forked from CCCHH/ansible-infra
Vendor Galaxy Roles and Collections
This commit is contained in:
parent
c1e1897cda
commit
2aed20393f
3553 changed files with 387444 additions and 2 deletions
110
ansible_collections/community/sops/plugins/action/load_vars.py
Normal file
110
ansible_collections/community/sops/plugins/action/load_vars.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence, Mapping
|
||||
|
||||
from ansible.module_utils.common.text.converters import to_native
|
||||
from ansible.utils.display import Display
|
||||
|
||||
from ansible_collections.community.sops.plugins.module_utils.sops import Sops, get_sops_argument_spec
|
||||
|
||||
from ansible_collections.community.sops.plugins.plugin_utils.action_module import ActionModuleBase, ArgumentSpec
|
||||
|
||||
try:
|
||||
from ansible.template import trust_as_template as _trust_as_template
|
||||
HAS_DATATAGGING = True
|
||||
except ImportError:
|
||||
HAS_DATATAGGING = False
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
def _make_safe(value):
|
||||
if HAS_DATATAGGING and isinstance(value, str):
|
||||
return _trust_as_template(value)
|
||||
return value
|
||||
|
||||
|
||||
class ActionModule(ActionModuleBase):
|
||||
|
||||
def _load(self, filename, module):
|
||||
def get_option_value(argument_name):
|
||||
return module.params.get(argument_name)
|
||||
|
||||
output = Sops.decrypt(filename, display=display, get_option_value=get_option_value)
|
||||
|
||||
data = self._loader.load(output, file_name=filename, show_content=False)
|
||||
if not data:
|
||||
data = dict()
|
||||
if not isinstance(data, dict):
|
||||
# Should not happen with sops-encrypted files
|
||||
raise Exception('{0} must be stored as a dictionary/hash'.format(to_native(filename)))
|
||||
return data
|
||||
|
||||
def _evaluate(self, value):
|
||||
if isinstance(value, str):
|
||||
# must come *before* Sequence, as strings are also instances of Sequence
|
||||
return self._templar.template(_make_safe(value))
|
||||
if isinstance(value, Sequence):
|
||||
return [self._evaluate(v) for v in value]
|
||||
if isinstance(value, Mapping):
|
||||
return dict((k, self._evaluate(v)) for k, v in value.items())
|
||||
return value
|
||||
|
||||
def _make_safe(self, value):
|
||||
if isinstance(value, str):
|
||||
# must come *before* Sequence, as strings are also instances of Sequence
|
||||
return _make_safe(value)
|
||||
if isinstance(value, Sequence):
|
||||
return [self._make_safe(v) for v in value]
|
||||
if isinstance(value, Mapping):
|
||||
return dict((k, self._make_safe(v)) for k, v in value.items())
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def setup_module():
|
||||
argument_spec = ArgumentSpec(
|
||||
argument_spec=dict(
|
||||
file=dict(type='path', required=True),
|
||||
name=dict(type='str'),
|
||||
expressions=dict(type='str', default='ignore', choices=['ignore', 'evaluate-on-load', 'lazy-evaluation']),
|
||||
),
|
||||
)
|
||||
argument_spec.argument_spec.update(get_sops_argument_spec())
|
||||
return argument_spec, {}
|
||||
|
||||
def run_module(self, module):
|
||||
expressions = module.params['expressions']
|
||||
if expressions == 'lazy-evaluation' and not HAS_DATATAGGING:
|
||||
module.fail_json(msg='expressions=lazy-evaluation requires ansible-core 2.19+ with Data Tagging support.')
|
||||
|
||||
data = dict()
|
||||
files = []
|
||||
try:
|
||||
filename = self._find_needle('vars', module.params['file'])
|
||||
data.update(self._load(filename, module))
|
||||
files.append(filename)
|
||||
except Exception as e:
|
||||
module.fail_json(msg=to_native(e))
|
||||
|
||||
name = module.params['name']
|
||||
if name is None:
|
||||
value = data
|
||||
else:
|
||||
value = dict()
|
||||
value[name] = data
|
||||
|
||||
if expressions == 'evaluate-on-load':
|
||||
value = self._evaluate(value)
|
||||
|
||||
if expressions == 'lazy-evaluation':
|
||||
value = self._make_safe(value)
|
||||
|
||||
module.exit_json(
|
||||
ansible_included_var_files=files,
|
||||
ansible_facts=value,
|
||||
_ansible_no_log=True,
|
||||
)
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
class ModuleDocFragment(object):
|
||||
|
||||
# Standard documentation fragment
|
||||
DOCUMENTATION = r"""
|
||||
options: {}
|
||||
attributes:
|
||||
check_mode:
|
||||
description: Can run in C(check_mode) and return changed status prediction without modifying target.
|
||||
diff_mode:
|
||||
description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode.
|
||||
idempotent:
|
||||
description:
|
||||
- When run twice in a row outside check mode, with the same arguments, the second invocation indicates no change.
|
||||
- This assumes that the system controlled/queried by the module has not changed in a relevant way.
|
||||
"""
|
||||
|
||||
# Should be used together with the standard fragment
|
||||
IDEMPOTENT_NOT_MODIFY_STATE = r"""
|
||||
options: {}
|
||||
attributes:
|
||||
idempotent:
|
||||
support: full
|
||||
details:
|
||||
- This action does not modify state.
|
||||
"""
|
||||
|
||||
# Should be used together with the standard fragment
|
||||
INFO_MODULE = r'''
|
||||
options: {}
|
||||
attributes:
|
||||
check_mode:
|
||||
support: full
|
||||
details:
|
||||
- This action does not modify state.
|
||||
diff_mode:
|
||||
support: N/A
|
||||
details:
|
||||
- This action does not modify state.
|
||||
'''
|
||||
|
||||
FACTS = r"""
|
||||
options: {}
|
||||
attributes:
|
||||
facts:
|
||||
description: Action returns an C(ansible_facts) dictionary that will update existing host facts.
|
||||
"""
|
||||
|
||||
# Should be used together with the standard fragment and the FACTS fragment
|
||||
FACTS_MODULE = r'''
|
||||
options: {}
|
||||
attributes:
|
||||
check_mode:
|
||||
support: full
|
||||
details:
|
||||
- This action does not modify state.
|
||||
diff_mode:
|
||||
support: N/A
|
||||
details:
|
||||
- This action does not modify state.
|
||||
facts:
|
||||
support: full
|
||||
'''
|
||||
|
||||
FILES = r"""
|
||||
options: {}
|
||||
attributes:
|
||||
safe_file_operations:
|
||||
description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption.
|
||||
"""
|
||||
|
||||
FLOW = r"""
|
||||
options: {}
|
||||
attributes:
|
||||
action:
|
||||
description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller.
|
||||
async:
|
||||
description: Supports being used with the C(async) keyword.
|
||||
"""
|
||||
318
ansible_collections/community/sops/plugins/doc_fragments/sops.py
Normal file
318
ansible_collections/community/sops/plugins/doc_fragments/sops.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) 2020 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
class ModuleDocFragment(object):
|
||||
DOCUMENTATION = r"""
|
||||
requirements:
|
||||
- A binary executable C(sops) (U(https://github.com/getsops/sops)) must exist either in E(PATH) or configured as O(sops_binary).
|
||||
options:
|
||||
sops_binary:
|
||||
description:
|
||||
- Path to the SOPS binary.
|
||||
- By default uses C(sops).
|
||||
type: path
|
||||
version_added: 1.0.0
|
||||
age_key:
|
||||
description:
|
||||
- One or more age private keys that can be used to decrypt encrypted files.
|
||||
- Will be set as the E(SOPS_AGE_KEY) environment variable when calling SOPS.
|
||||
- Requires SOPS 3.7.1+.
|
||||
type: str
|
||||
version_added: 1.4.0
|
||||
age_keyfile:
|
||||
description:
|
||||
- The file containing the age private keys that SOPS can use to decrypt encrypted files.
|
||||
- Will be set as the E(SOPS_AGE_KEY_FILE) environment variable when calling SOPS.
|
||||
- By default, SOPS looks for C(sops/age/keys.txt) inside your user configuration directory.
|
||||
- Requires SOPS 3.7.0+.
|
||||
type: path
|
||||
version_added: 1.4.0
|
||||
age_ssh_private_keyfile:
|
||||
description:
|
||||
- The file containing the SSH private key that SOPS can use to decrypt encrypted files.
|
||||
- Will be set as the E(SOPS_AGE_SSH_PRIVATE_KEY_FILE) environment variable when calling SOPS.
|
||||
- By default, SOPS looks for C(~/.ssh/id_ed25519) and falls back to C(~/.ssh/id_rsa).
|
||||
- Requires SOPS 3.10.0+.
|
||||
type: path
|
||||
version_added: 1.4.0
|
||||
aws_profile:
|
||||
description:
|
||||
- The AWS profile to use for requests to AWS.
|
||||
- This corresponds to the SOPS C(--aws-profile) option.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
aws_access_key_id:
|
||||
description:
|
||||
- The AWS access key ID to use for requests to AWS.
|
||||
- Sets the environment variable E(AWS_ACCESS_KEY_ID) for the SOPS call.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
aws_secret_access_key:
|
||||
description:
|
||||
- The AWS secret access key to use for requests to AWS.
|
||||
- Sets the environment variable E(AWS_SECRET_ACCESS_KEY) for the SOPS call.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
aws_session_token:
|
||||
description:
|
||||
- The AWS session token to use for requests to AWS.
|
||||
- Sets the environment variable E(AWS_SESSION_TOKEN) for the SOPS call.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
config_path:
|
||||
description:
|
||||
- Path to the SOPS configuration file.
|
||||
- If not set, SOPS will recursively search for the config file starting at the file that is encrypted or decrypted.
|
||||
- This corresponds to the SOPS C(--config) option.
|
||||
type: path
|
||||
version_added: 1.0.0
|
||||
enable_local_keyservice:
|
||||
description:
|
||||
- Tell SOPS to use local key service.
|
||||
- When set to V(false), this corresponds to the SOPS C(--enable-local-keyservice=false) option.
|
||||
type: bool
|
||||
default: true
|
||||
version_added: 1.0.0
|
||||
keyservice:
|
||||
description:
|
||||
- Specify key services to use next to the local one.
|
||||
- A key service must be specified in the form C(protocol://address), for example C(tcp://myserver.com:5000).
|
||||
- This corresponds to the SOPS C(--keyservice) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
"""
|
||||
|
||||
ANSIBLE_VARIABLES = r'''
|
||||
options:
|
||||
sops_binary:
|
||||
vars:
|
||||
- name: sops_binary
|
||||
age_key:
|
||||
vars:
|
||||
- name: sops_age_key
|
||||
age_keyfile:
|
||||
vars:
|
||||
- name: sops_age_keyfile
|
||||
age_ssh_private_keyfile:
|
||||
vars:
|
||||
- name: sops_age_ssh_private_keyfile
|
||||
aws_profile:
|
||||
vars:
|
||||
- name: sops_aws_profile
|
||||
aws_access_key_id:
|
||||
vars:
|
||||
- name: sops_aws_access_key_id
|
||||
aws_secret_access_key:
|
||||
vars:
|
||||
- name: sops_aws_secret_access_key
|
||||
aws_session_token:
|
||||
vars:
|
||||
- name: sops_session_token
|
||||
- name: sops_aws_session_token
|
||||
version_added: 1.2.0
|
||||
config_path:
|
||||
vars:
|
||||
- name: sops_config_path
|
||||
enable_local_keyservice:
|
||||
vars:
|
||||
- name: sops_enable_local_keyservice
|
||||
keyservice:
|
||||
vars:
|
||||
- name: sops_keyservice
|
||||
'''
|
||||
|
||||
ANSIBLE_ENV = r'''
|
||||
options:
|
||||
sops_binary:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_BINARY
|
||||
version_added: 1.2.0
|
||||
age_key:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AGE_KEY
|
||||
age_keyfile:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AGE_KEYFILE
|
||||
age_ssh_private_keyfile:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AGE_SSH_PRIVATE_KEYFILE
|
||||
aws_profile:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AWS_PROFILE
|
||||
version_added: 1.2.0
|
||||
aws_access_key_id:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AWS_ACCESS_KEY_ID
|
||||
version_added: 1.2.0
|
||||
aws_secret_access_key:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AWS_SECRET_ACCESS_KEY
|
||||
version_added: 1.2.0
|
||||
aws_session_token:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_AWS_SESSION_TOKEN
|
||||
version_added: 1.2.0
|
||||
config_path:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_CONFIG_PATH
|
||||
version_added: 1.2.0
|
||||
enable_local_keyservice:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_ENABLE_LOCAL_KEYSERVICE
|
||||
version_added: 1.2.0
|
||||
keyservice:
|
||||
env:
|
||||
- name: ANSIBLE_SOPS_KEYSERVICE
|
||||
version_added: 1.2.0
|
||||
'''
|
||||
|
||||
ANSIBLE_INI = r'''
|
||||
options:
|
||||
sops_binary:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: binary
|
||||
version_added: 1.2.0
|
||||
# We do not provide an INI key for
|
||||
# age_key
|
||||
# to make sure that secrets cannot be provided in ansible.ini. Use environment variables or another mechanism for that.
|
||||
age_keyfile:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: age_keyfile
|
||||
age_ssh_private_keyfile:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: age_ssh_private_keyfile
|
||||
aws_profile:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: aws_profile
|
||||
version_added: 1.2.0
|
||||
aws_access_key_id:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: aws_access_key_id
|
||||
version_added: 1.2.0
|
||||
# We do not provide an INI key for
|
||||
# aws_secret_access_key
|
||||
# to make sure that secrets cannot be provided in ansible.ini. Use environment variables or another mechanism for that.
|
||||
aws_session_token:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: aws_session_token
|
||||
version_added: 1.2.0
|
||||
config_path:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: config_path
|
||||
version_added: 1.2.0
|
||||
enable_local_keyservice:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: enable_local_keyservice
|
||||
version_added: 1.2.0
|
||||
keyservice:
|
||||
ini:
|
||||
- section: community.sops
|
||||
key: keyservice
|
||||
version_added: 1.2.0
|
||||
'''
|
||||
|
||||
ENCRYPT_SPECIFIC = r'''
|
||||
options:
|
||||
age:
|
||||
description:
|
||||
- Age fingerprints to use.
|
||||
- This corresponds to the SOPS C(--age) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.4.0
|
||||
kms:
|
||||
description:
|
||||
- List of KMS ARNs to use.
|
||||
- This corresponds to the SOPS C(--kms) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
gcp_kms:
|
||||
description:
|
||||
- GCP KMS resource IDs to use.
|
||||
- This corresponds to the SOPS C(--gcp-kms) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
azure_kv:
|
||||
description:
|
||||
- Azure Key Vault URLs to use.
|
||||
- This corresponds to the SOPS C(--azure-kv) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
hc_vault_transit:
|
||||
description:
|
||||
- HashiCorp Vault key URIs to use.
|
||||
- For example, C(https://vault.example.org:8200/v1/transit/keys/dev).
|
||||
- This corresponds to the SOPS C(--hc-vault-transit) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
pgp:
|
||||
description:
|
||||
- PGP fingerprints to use.
|
||||
- This corresponds to the SOPS C(--pgp) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
unencrypted_suffix:
|
||||
description:
|
||||
- Override the unencrypted key suffix.
|
||||
- This corresponds to the SOPS C(--unencrypted-suffix) option.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
encrypted_suffix:
|
||||
description:
|
||||
- Override the encrypted key suffix.
|
||||
- When set to an empty string, all keys will be encrypted that are not explicitly
|
||||
marked by O(unencrypted_suffix).
|
||||
- This corresponds to the SOPS C(--encrypted-suffix) option.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
unencrypted_regex:
|
||||
description:
|
||||
- Set the unencrypted key suffix.
|
||||
- When specified, only keys matching the regular expression will be left unencrypted.
|
||||
- This corresponds to the SOPS C(--unencrypted-regex) option.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
encrypted_regex:
|
||||
description:
|
||||
- Set the encrypted key suffix.
|
||||
- When specified, only keys matching the regular expression will be encrypted.
|
||||
- This corresponds to the SOPS C(--encrypted-regex) option.
|
||||
type: str
|
||||
version_added: 1.0.0
|
||||
encryption_context:
|
||||
description:
|
||||
- List of KMS encryption context pairs of format C(key:value).
|
||||
- This corresponds to the SOPS C(--encryption-context) option.
|
||||
type: list
|
||||
elements: str
|
||||
version_added: 1.0.0
|
||||
shamir_secret_sharing_threshold:
|
||||
description:
|
||||
- The number of distinct keys required to retrieve the data key with
|
||||
L(Shamir's Secret Sharing, https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing).
|
||||
- If not set here and in the SOPS config file, will default to V(0).
|
||||
- This corresponds to the SOPS C(--shamir-secret-sharing-threshold) option.
|
||||
type: int
|
||||
version_added: 1.0.0
|
||||
'''
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Copyright (c) 2022, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
name: _latest_version
|
||||
short_description: "[INTERNAL] Get latest version from a list of versions"
|
||||
version_added: 1.4.0
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
description:
|
||||
- B(This is an internal tool and must only be used from roles in this collection!) If you use it from outside this collection,
|
||||
be warned that its behavior can change and it can be removed at any time, even in bugfix releases!
|
||||
- Given a list of version numbers, returns the largest of them.
|
||||
options:
|
||||
_input:
|
||||
description:
|
||||
- A list of strings. Every string must be a version number.
|
||||
type: list
|
||||
elements: string
|
||||
required: true
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Print latest version
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ versions | community.sops._latest_version }}"
|
||||
vars:
|
||||
versions:
|
||||
- 1.0.0
|
||||
- 1.0.0rc1
|
||||
- 1.1.0
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
_value:
|
||||
description:
|
||||
- The latest version from the input.
|
||||
- Returns the empty string if the input was empty.
|
||||
type: string
|
||||
"""
|
||||
|
||||
from ansible.module_utils.compat.version import LooseVersion
|
||||
|
||||
|
||||
def pick_latest_version(version_list):
|
||||
'''Pick latest version from a list of versions.'''
|
||||
# Remove all prereleases (versions with '+' or '-' in them)
|
||||
version_list = [v for v in version_list if '-' not in v and '+' not in v]
|
||||
if not version_list:
|
||||
return ''
|
||||
return sorted(version_list, key=LooseVersion, reverse=True)[0]
|
||||
|
||||
|
||||
class FilterModule:
|
||||
'''Helper filters.'''
|
||||
def filters(self):
|
||||
return {
|
||||
'_latest_version': pick_latest_version,
|
||||
}
|
||||
173
ansible_collections/community/sops/plugins/filter/decrypt.py
Normal file
173
ansible_collections/community/sops/plugins/filter/decrypt.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# Copyright (c) 2021, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
name: decrpyt
|
||||
short_description: Decrypt SOPS-encrypted data
|
||||
version_added: 1.1.0
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
description:
|
||||
- Decrypt SOPS-encrypted data.
|
||||
- Allows to decrypt data that has been provided by an arbitrary source.
|
||||
- Note that due to Ansible lazy-evaluating expressions, it is better to use M(ansible.builtin.set_fact) to store the result
|
||||
of an evaluation in a fact to avoid recomputing the value every time the expression is used.
|
||||
options:
|
||||
_input:
|
||||
description:
|
||||
- The data to decrypt.
|
||||
type: string
|
||||
required: true
|
||||
rstrip:
|
||||
description:
|
||||
- Whether to remove trailing newlines and spaces.
|
||||
type: bool
|
||||
default: true
|
||||
input_type:
|
||||
description:
|
||||
- Tell SOPS how to interpret the encrypted data.
|
||||
- There is no auto-detection since we do not have a filename. By default SOPS is told to treat the input as YAML. If
|
||||
that is wrong, please set this option to the correct value.
|
||||
- The value V(ini) is available since community.sops 1.9.0.
|
||||
type: str
|
||||
choices:
|
||||
- binary
|
||||
- json
|
||||
- yaml
|
||||
- dotenv
|
||||
- ini
|
||||
default: yaml
|
||||
output_type:
|
||||
description:
|
||||
- Tell SOPS how to interpret the decrypted file.
|
||||
- Please note that the output is always text or bytes, depending on the value of O(decode_output). To parse the resulting
|
||||
JSON or YAML, use corresponding filters such as P(ansible.builtin.from_json#filter) and P(ansible.builtin.from_yaml#filter).
|
||||
- The value V(ini) is available since community.sops 1.9.0.
|
||||
type: str
|
||||
choices:
|
||||
- binary
|
||||
- json
|
||||
- yaml
|
||||
- dotenv
|
||||
- ini
|
||||
default: yaml
|
||||
decode_output:
|
||||
description:
|
||||
- Whether to decode the output to bytes.
|
||||
- When O(output_type=binary), and the file is not known to contain UTF-8 encoded text, this should better be set to
|
||||
V(false) to prevent mangling the data with UTF-8 decoding.
|
||||
type: bool
|
||||
default: true
|
||||
extends_documentation_fragment:
|
||||
- community.sops.sops
|
||||
seealso:
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: lookup
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: vars
|
||||
- module: community.sops.load_vars
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Decrypt file fetched from URL
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Fetch file from URL
|
||||
ansible.builtin.uri:
|
||||
url: https://raw.githubusercontent.com/getsops/sops/master/functional-tests/res/comments.enc.yaml
|
||||
return_content: true
|
||||
register: encrypted_content
|
||||
|
||||
- name: Show encrypted data
|
||||
debug:
|
||||
msg: "{{ encrypted_content.content | ansible.builtin.from_yaml }}"
|
||||
|
||||
- name: Decrypt data and decode decrypted YAML
|
||||
set_fact:
|
||||
decrypted_data: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
|
||||
|
||||
- name: Show decrypted data
|
||||
debug:
|
||||
msg: "{{ decrypted_data }}"
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
_value:
|
||||
description:
|
||||
- Decrypted data as text (O(decode_output=true), default) or binary string (O(decode_output=false)).
|
||||
type: string
|
||||
"""
|
||||
|
||||
from ansible.errors import AnsibleFilterError
|
||||
from ansible.module_utils.common.text.converters import to_bytes, to_native
|
||||
from ansible.utils.display import Display
|
||||
|
||||
from ansible_collections.community.sops.plugins.module_utils.sops import Sops, SopsError
|
||||
|
||||
|
||||
_VALID_TYPES = set(['binary', 'json', 'yaml', 'dotenv', 'ini'])
|
||||
|
||||
|
||||
def decrypt_filter(data, input_type='yaml', output_type='yaml', sops_binary='sops', rstrip=True, decode_output=True,
|
||||
aws_profile=None, aws_access_key_id=None, aws_secret_access_key=None, aws_session_token=None,
|
||||
config_path=None, enable_local_keyservice=True, keyservice=None, age_key=None, age_keyfile=None, age_ssh_private_keyfile=None):
|
||||
'''Decrypt sops-encrypted data.'''
|
||||
|
||||
# Check parameters
|
||||
if input_type not in _VALID_TYPES:
|
||||
raise AnsibleFilterError('input_type must be one of {expected}; got "{value}"'.format(
|
||||
expected=', '.join(sorted(_VALID_TYPES)), value=input_type))
|
||||
if output_type not in _VALID_TYPES:
|
||||
raise AnsibleFilterError('output_type must be one of {expected}; got "{value}"'.format(
|
||||
expected=', '.join(sorted(_VALID_TYPES)), value=output_type))
|
||||
|
||||
# Create option value querier
|
||||
def get_option_value(argument_name):
|
||||
if argument_name == 'sops_binary':
|
||||
return sops_binary
|
||||
if argument_name == 'age_key':
|
||||
return age_key
|
||||
if argument_name == 'age_keyfile':
|
||||
return age_keyfile
|
||||
if argument_name == 'age_ssh_private_keyfile':
|
||||
return age_ssh_private_keyfile
|
||||
if argument_name == 'aws_profile':
|
||||
return aws_profile
|
||||
if argument_name == 'aws_access_key_id':
|
||||
return aws_access_key_id
|
||||
if argument_name == 'aws_secret_access_key':
|
||||
return aws_secret_access_key
|
||||
if argument_name == 'aws_session_token':
|
||||
return aws_session_token
|
||||
if argument_name == 'config_path':
|
||||
return config_path
|
||||
if argument_name == 'enable_local_keyservice':
|
||||
return enable_local_keyservice
|
||||
if argument_name == 'keyservice':
|
||||
return keyservice
|
||||
raise AssertionError('internal error: should not be reached')
|
||||
|
||||
# Decode
|
||||
data = to_bytes(data)
|
||||
try:
|
||||
output = Sops.decrypt(
|
||||
None, content=data, display=Display(), rstrip=rstrip, decode_output=decode_output,
|
||||
input_type=input_type, output_type=output_type, get_option_value=get_option_value)
|
||||
except SopsError as e:
|
||||
raise AnsibleFilterError(to_native(e))
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class FilterModule:
|
||||
'''Ansible jinja2 filters'''
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
'decrypt': decrypt_filter,
|
||||
}
|
||||
166
ansible_collections/community/sops/plugins/lookup/sops.py
Normal file
166
ansible_collections/community/sops/plugins/lookup/sops.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Copyright 2018 Edoardo Tenani <e.tenani@arduino.cc> (@endorama)
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
name: sops
|
||||
author: Edoardo Tenani (@endorama) <e.tenani@arduino.cc>
|
||||
short_description: Read SOPS-encrypted file contents
|
||||
version_added: '0.1.0'
|
||||
description:
|
||||
- This lookup returns the contents from a file on the Ansible controller's file system.
|
||||
- This lookup requires the C(sops) executable to be available in the controller PATH.
|
||||
options:
|
||||
_terms:
|
||||
description: Path(s) of files to read.
|
||||
required: true
|
||||
type: list
|
||||
elements: str
|
||||
rstrip:
|
||||
description: Whether to remove trailing newlines and spaces.
|
||||
type: bool
|
||||
default: true
|
||||
base64:
|
||||
description:
|
||||
- Base64-encodes the parsed result.
|
||||
- Use this if you want to store binary data in Ansible variables.
|
||||
type: bool
|
||||
default: false
|
||||
input_type:
|
||||
description:
|
||||
- Tell SOPS how to interpret the encrypted file.
|
||||
- By default, SOPS will chose the input type from the file extension. If it detects the wrong type for a file, this
|
||||
could result in decryption failing.
|
||||
- The value V(ini) is available since community.sops 1.9.0.
|
||||
type: str
|
||||
choices:
|
||||
- binary
|
||||
- json
|
||||
- yaml
|
||||
- dotenv
|
||||
- ini
|
||||
output_type:
|
||||
description:
|
||||
- Tell SOPS how to interpret the decrypted file.
|
||||
- By default, SOPS will chose the output type from the file extension. If it detects the wrong type for a file, this
|
||||
could result in decryption failing.
|
||||
- The value V(ini) is available since community.sops 1.9.0.
|
||||
type: str
|
||||
choices:
|
||||
- binary
|
||||
- json
|
||||
- yaml
|
||||
- dotenv
|
||||
- ini
|
||||
empty_on_not_exist:
|
||||
description:
|
||||
- When set to V(true), will not raise an error when a file cannot be found, but return an empty string instead.
|
||||
type: bool
|
||||
default: false
|
||||
extract:
|
||||
description:
|
||||
- Tell SOPS to extract a specific key from a JSON or YAML file.
|
||||
- Expects a string with the same 'querystring' syntax as SOPS' C(--encrypt) option, for example V(["somekey"][0]).
|
||||
- B(Note:) Escape quotes appropriately.
|
||||
type: str
|
||||
version_added: 1.9.0
|
||||
extends_documentation_fragment:
|
||||
- community.sops.sops
|
||||
- community.sops.sops.ansible_variables
|
||||
- community.sops.sops.ansible_env
|
||||
- community.sops.sops.ansible_ini
|
||||
notes:
|
||||
- This lookup does not understand 'globbing' - use the P(ansible.builtin.fileglob#lookup) lookup instead.
|
||||
seealso:
|
||||
- plugin: community.sops.decrypt
|
||||
plugin_type: filter
|
||||
description: The decrypt filter can be used to descrypt SOPS-encrypted in-memory data.
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: vars
|
||||
description: The sops vars plugin can be used to load SOPS-encrypted host or group variables.
|
||||
- module: community.sops.load_vars
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Output secrets to screen (BAD IDEA!)
|
||||
ansible.builtin.debug:
|
||||
msg: "Content: {{ lookup('community.sops.sops', item) }}"
|
||||
loop:
|
||||
- sops-encrypted-file.enc.yaml
|
||||
|
||||
- name: Add SSH private key
|
||||
ansible.builtin.copy:
|
||||
# Note that rstrip=false is necessary for some SSH versions to be able to use the key
|
||||
content: "{{ lookup('community.sops.sops', user + '-id_rsa', rstrip=false) }}"
|
||||
dest: /home/{{ user }}/.ssh/id_rsa
|
||||
owner: "{{ user }}"
|
||||
group: "{{ user }}"
|
||||
mode: "0600"
|
||||
no_log: true # avoid content to be written to log
|
||||
|
||||
- name: The file file.json is a YAML file, which contains the encryption of binary data
|
||||
ansible.builtin.debug:
|
||||
msg: "Content: {{ lookup('community.sops.sops', 'file.json', input_type='yaml', output_type='binary') }}"
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
_raw:
|
||||
description: Decrypted file content.
|
||||
type: list
|
||||
elements: str
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from ansible.errors import AnsibleLookupError
|
||||
from ansible.plugins.lookup import LookupBase
|
||||
from ansible.module_utils.common.text.converters import to_native
|
||||
from ansible_collections.community.sops.plugins.module_utils.sops import Sops, SopsError
|
||||
|
||||
from ansible.utils.display import Display
|
||||
display = Display()
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
|
||||
def run(self, terms, variables=None, **kwargs):
|
||||
self.set_options(var_options=variables, direct=kwargs)
|
||||
rstrip = self.get_option('rstrip')
|
||||
use_base64 = self.get_option('base64')
|
||||
input_type = self.get_option('input_type')
|
||||
output_type = self.get_option('output_type')
|
||||
empty_on_not_exist = self.get_option('empty_on_not_exist')
|
||||
extract = self.get_option('extract')
|
||||
|
||||
ret = []
|
||||
|
||||
def get_option_value(argument_name):
|
||||
return self.get_option(argument_name)
|
||||
|
||||
for term in terms:
|
||||
display.debug("Sops lookup term: %s" % term)
|
||||
lookupfile = self.find_file_in_search_path(variables, 'files', term, ignore_missing=empty_on_not_exist)
|
||||
display.vvvv(u"Sops lookup using %s as file" % lookupfile)
|
||||
|
||||
if not lookupfile:
|
||||
if empty_on_not_exist:
|
||||
ret.append('')
|
||||
continue
|
||||
raise AnsibleLookupError("could not locate file in lookup: %s" % to_native(term))
|
||||
|
||||
try:
|
||||
output = Sops.decrypt(
|
||||
lookupfile, display=display, rstrip=rstrip, decode_output=(not use_base64),
|
||||
input_type=input_type, output_type=output_type, get_option_value=get_option_value, extract=extract)
|
||||
except SopsError as e:
|
||||
raise AnsibleLookupError(to_native(e))
|
||||
|
||||
if use_base64:
|
||||
output = to_native(base64.b64encode(output))
|
||||
|
||||
ret.append(output)
|
||||
|
||||
return ret
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Copyright (c), Yanis Guenane <yanis+ansible@guenane.org>, 2016
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
||||
# This is taken from community.crypto
|
||||
|
||||
def write_file(module, content):
|
||||
'''
|
||||
Writes content into destination file as securely as possible.
|
||||
Uses file arguments from module.
|
||||
'''
|
||||
# Find out parameters for file
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
# Create tempfile name
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(prefix=b'.ansible_tmp')
|
||||
try:
|
||||
os.close(tmp_fd)
|
||||
except Exception:
|
||||
pass
|
||||
module.add_cleanup_file(tmp_name) # if we fail, let Ansible try to remove the file
|
||||
try:
|
||||
try:
|
||||
# Create tempfile
|
||||
file = os.open(tmp_name, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.write(file, content)
|
||||
os.close(file)
|
||||
except Exception as e:
|
||||
try:
|
||||
os.remove(tmp_name)
|
||||
except Exception:
|
||||
pass
|
||||
module.fail_json(msg='Error while writing result into temporary file: {0}'.format(e))
|
||||
# Update destination to wanted permissions
|
||||
if os.path.exists(file_args['path']):
|
||||
module.set_fs_attributes_if_different(file_args, False)
|
||||
# Move tempfile to final destination
|
||||
module.atomic_move(os.path.abspath(tmp_name), os.path.abspath(file_args['path']))
|
||||
# Try to update permissions again
|
||||
module.set_fs_attributes_if_different(file_args, False)
|
||||
except Exception as e:
|
||||
try:
|
||||
os.remove(tmp_name)
|
||||
except Exception:
|
||||
pass
|
||||
module.fail_json(msg='Error while writing result: {0}'.format(e))
|
||||
436
ansible_collections/community/sops/plugins/module_utils/sops.py
Normal file
436
ansible_collections/community/sops/plugins/module_utils/sops.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
# Copyright (c), Edoardo Tenani <e.tenani@arduino.cc>, 2018-2020
|
||||
# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause)
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from ansible.module_utils.common.text.converters import to_text, to_native
|
||||
|
||||
# Since this is used both by plugins and modules, we need subprocess in case the `module` parameter is not used
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
|
||||
# From https://github.com/getsops/sops/blob/master/cmd/sops/codes/codes.go
|
||||
# Should be manually updated
|
||||
SOPS_ERROR_CODES = {
|
||||
1: "ErrorGeneric",
|
||||
2: "CouldNotReadInputFile",
|
||||
3: "CouldNotWriteOutputFile",
|
||||
4: "ErrorDumpingTree",
|
||||
5: "ErrorReadingConfig",
|
||||
6: "ErrorInvalidKMSEncryptionContextFormat",
|
||||
7: "ErrorInvalidSetFormat",
|
||||
8: "ErrorConflictingParameters",
|
||||
21: "ErrorEncryptingMac",
|
||||
23: "ErrorEncryptingTree",
|
||||
24: "ErrorDecryptingMac",
|
||||
25: "ErrorDecryptingTree",
|
||||
49: "CannotChangeKeysFromNonExistentFile",
|
||||
51: "MacMismatch",
|
||||
52: "MacNotFound",
|
||||
61: "ConfigFileNotFound",
|
||||
85: "KeyboardInterrupt",
|
||||
91: "InvalidTreePathFormat",
|
||||
100: "NoFileSpecified",
|
||||
128: "CouldNotRetrieveKey",
|
||||
111: "NoEncryptionKeyFound",
|
||||
200: "FileHasNotBeenModified",
|
||||
201: "NoEditorFound",
|
||||
202: "FailedToCompareVersions",
|
||||
203: "FileAlreadyEncrypted"
|
||||
}
|
||||
|
||||
_SOPS_VERSION = re.compile(r'^sops ([0-9]+)\.([0-9]+)\.([0-9]+)')
|
||||
|
||||
|
||||
def _add_argument(arguments_pre, arguments_post, *args, **kwargs):
|
||||
pre = kwargs.pop('pre', False)
|
||||
(arguments_pre if pre else arguments_post).extend(args)
|
||||
|
||||
|
||||
def _create_single_arg(argument_name, pre=False):
|
||||
def f(value, arguments_pre, arguments_post, env, version):
|
||||
_add_argument(arguments_pre, arguments_post, argument_name, to_native(value), pre=pre)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _create_comma_separated(argument_name, pre=False):
|
||||
def f(value, arguments_pre, arguments_post, env, version):
|
||||
value = ','.join([to_native(v) for v in value])
|
||||
_add_argument(arguments_pre, arguments_post, argument_name, value, pre=pre)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _create_repeated(argument_name, pre=False):
|
||||
def f(value, arguments_pre, arguments_post, env, version):
|
||||
for v in value:
|
||||
_add_argument(arguments_pre, arguments_post, argument_name, to_native(v), pre=pre)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _create_boolean(argument_name, pre=False, invert=False):
|
||||
def f(value, arguments_pre, arguments_post, env, version):
|
||||
if value ^ invert:
|
||||
_add_argument(arguments_pre, arguments_post, argument_name, pre=pre)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _create_env_variable(argument_name):
|
||||
def f(value, arguments_pre, arguments_post, env, version):
|
||||
env[argument_name] = value
|
||||
|
||||
return f
|
||||
|
||||
|
||||
GENERAL_OPTIONS = {
|
||||
'age_key': _create_env_variable('SOPS_AGE_KEY'),
|
||||
'age_keyfile': _create_env_variable('SOPS_AGE_KEY_FILE'),
|
||||
'age_ssh_private_keyfile': _create_env_variable('SOPS_AGE_SSH_PRIVATE_KEY_FILE'),
|
||||
'aws_profile': _create_single_arg('--aws-profile'),
|
||||
'aws_access_key_id': _create_env_variable('AWS_ACCESS_KEY_ID'),
|
||||
'aws_secret_access_key': _create_env_variable('AWS_SECRET_ACCESS_KEY'),
|
||||
'aws_session_token': _create_env_variable('AWS_SESSION_TOKEN'),
|
||||
'config_path': _create_single_arg('--config', pre=True),
|
||||
'enable_local_keyservice': _create_boolean('--enable-local-keyservice=false', invert=True),
|
||||
'keyservice': _create_repeated('--keyservice'),
|
||||
}
|
||||
|
||||
|
||||
ENCRYPT_OPTIONS = {
|
||||
'age': _create_comma_separated('--age'),
|
||||
'kms': _create_comma_separated('--kms'),
|
||||
'gcp_kms': _create_comma_separated('--gcp-kms'),
|
||||
'azure_kv': _create_comma_separated('--azure-kv'),
|
||||
'hc_vault_transit': _create_comma_separated('--hc-vault-transit'),
|
||||
'pgp': _create_comma_separated('--pgp'),
|
||||
'unencrypted_suffix': _create_single_arg('--unencrypted-suffix'),
|
||||
'encrypted_suffix': _create_single_arg('--encrypted-suffix'),
|
||||
'unencrypted_regex': _create_single_arg('--unencrypted-regex'),
|
||||
'encrypted_regex': _create_single_arg('--encrypted-regex'),
|
||||
'encryption_context': _create_comma_separated('--encryption-context'),
|
||||
'shamir_secret_sharing_threshold': _create_single_arg('--shamir-secret-sharing-threshold'),
|
||||
}
|
||||
|
||||
|
||||
class SopsError(Exception):
|
||||
''' Extend Exception class with sops specific information '''
|
||||
|
||||
def __init__(self, filename, exit_code, message, decryption=True, operation=None):
|
||||
if operation is None:
|
||||
operation = 'decrypt' if decryption else 'encrypt'
|
||||
if exit_code in SOPS_ERROR_CODES:
|
||||
exception_name = SOPS_ERROR_CODES[exit_code]
|
||||
message = "error with file %s: %s exited with code %d: %s" % (
|
||||
filename, exception_name, exit_code, to_native(message))
|
||||
else:
|
||||
message = "could not %s file %s; Unknown sops error code: %s; message: %s" % (
|
||||
operation, filename, exit_code, to_native(message))
|
||||
super(SopsError, self).__init__(message)
|
||||
|
||||
|
||||
SopsFileStatus = collections.namedtuple('SopsFileStatus', ['encrypted'])
|
||||
|
||||
|
||||
class SopsRunner(object):
|
||||
def _add_options(self, command_pre, command_post, env, get_option_value, options):
|
||||
if get_option_value is None:
|
||||
return
|
||||
for option, f in options.items():
|
||||
v = get_option_value(option)
|
||||
if v is not None:
|
||||
f(v, command_pre, command_post, env, self.version)
|
||||
|
||||
def _debug(self, message):
|
||||
if self.display:
|
||||
self.display.vvvv(message)
|
||||
elif self.module:
|
||||
self.module.debug(message)
|
||||
|
||||
def _warn(self, message):
|
||||
if self.display:
|
||||
self.display.warning(message)
|
||||
elif self.module:
|
||||
self.module.warn(message)
|
||||
|
||||
def __init__(self, binary, module=None, display=None):
|
||||
self.binary = binary
|
||||
self.module = module
|
||||
self.display = display
|
||||
|
||||
self.version = (3, 7, 3) # if --disable-version-check is not supported, this is version 3.7.3 or older
|
||||
self.version_string = '(before 3.8.0)'
|
||||
|
||||
exit_code, output, err = self._run_command([self.binary, '--version', '--disable-version-check'])
|
||||
if exit_code == 0:
|
||||
m = _SOPS_VERSION.match(output.decode('utf-8'))
|
||||
if m:
|
||||
self.version = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
self.version_string = '%d.%d.%d' % self.version
|
||||
self._debug('SOPS version detected as %s' % (self.version, ))
|
||||
else:
|
||||
self._warn('Cannot extract SOPS version from: %s' % repr(output))
|
||||
else:
|
||||
self._debug('Cannot detect SOPS version efficiently, likely a version before 3.8.0')
|
||||
|
||||
def _run_command(self, command, env=None, data=None, cwd=None):
|
||||
if self.module:
|
||||
return self.module.run_command(command, environ_update=env, cwd=cwd, encoding=None, data=data, binary_data=True)
|
||||
|
||||
process = Popen(command, stdin=None if data is None else PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd, env=env)
|
||||
output, err = process.communicate(input=data)
|
||||
return process.returncode, output, err
|
||||
|
||||
def decrypt(self, encrypted_file, content=None,
|
||||
decode_output=True, rstrip=True, input_type=None, output_type=None, get_option_value=None, extract=None):
|
||||
# Run sops directly, python module is deprecated
|
||||
command = [self.binary]
|
||||
command_post = []
|
||||
env = os.environ.copy()
|
||||
self._add_options(command, command_post, env, get_option_value, GENERAL_OPTIONS)
|
||||
if self.version >= (3, 9, 0):
|
||||
command.append("decrypt")
|
||||
command.extend(command_post)
|
||||
if input_type is not None:
|
||||
command.extend(["--input-type", input_type])
|
||||
if output_type is not None:
|
||||
command.extend(["--output-type", output_type])
|
||||
if self.version < (3, 9, 0):
|
||||
command.append("--decrypt")
|
||||
if extract is not None:
|
||||
command.extend(["--extract", extract])
|
||||
if content is not None:
|
||||
encrypted_file = '/dev/stdin'
|
||||
command.append(encrypted_file)
|
||||
|
||||
exit_code, output, err = self._run_command(command, env=env, data=content)
|
||||
|
||||
if decode_output:
|
||||
# output is binary, we want UTF-8 string
|
||||
output = to_text(output, errors='surrogate_or_strict')
|
||||
# the process output is the decrypted secret; be cautious
|
||||
|
||||
# sops logs always to stderr, as stdout is used for
|
||||
# file content
|
||||
if err:
|
||||
self._debug(u'Unexpected stderr:\n' + to_text(err, errors='surrogate_or_strict'))
|
||||
|
||||
if exit_code != 0:
|
||||
raise SopsError(encrypted_file, exit_code, err, decryption=True)
|
||||
|
||||
if rstrip:
|
||||
output = output.rstrip()
|
||||
|
||||
return output
|
||||
|
||||
def encrypt(self, data, cwd=None, input_type=None, output_type=None, filename=None, get_option_value=None):
|
||||
# Run sops directly, python module is deprecated
|
||||
command = [self.binary]
|
||||
command_post = []
|
||||
env = os.environ.copy()
|
||||
self._add_options(command, command_post, env, get_option_value, GENERAL_OPTIONS)
|
||||
self._add_options(command, command_post, env, get_option_value, ENCRYPT_OPTIONS)
|
||||
if self.version >= (3, 9, 0):
|
||||
command.append("encrypt")
|
||||
command.extend(command_post)
|
||||
if input_type is not None:
|
||||
command.extend(["--input-type", input_type])
|
||||
if output_type is not None:
|
||||
command.extend(["--output-type", output_type])
|
||||
if self.version < (3, 9, 0):
|
||||
command.append("--encrypt")
|
||||
if self.version >= (3, 9, 0) and filename:
|
||||
command.extend(["--filename-override", filename])
|
||||
command.append("/dev/stdin")
|
||||
|
||||
exit_code, output, err = self._run_command(command, env=env, data=data, cwd=cwd)
|
||||
|
||||
# sops logs always to stderr, as stdout is used for
|
||||
# file content
|
||||
if err:
|
||||
self._debug(u'Unexpected stderr:\n' + to_text(err, errors='surrogate_or_strict'))
|
||||
|
||||
if exit_code != 0:
|
||||
raise SopsError('to stdout', exit_code, err, decryption=False)
|
||||
|
||||
return output
|
||||
|
||||
def has_filestatus(self):
|
||||
return self.version >= (3, 9, 0)
|
||||
|
||||
def get_filestatus(self, path):
|
||||
command = [self.binary, 'filestatus', path]
|
||||
|
||||
exit_code, output, err = self._run_command(command)
|
||||
|
||||
# sops logs always to stderr, as stdout is used for
|
||||
# file content
|
||||
if err:
|
||||
self._debug(u'Unexpected stderr:\n' + to_text(err, errors='surrogate_or_strict'))
|
||||
|
||||
if exit_code != 0:
|
||||
raise SopsError(path, exit_code, err, operation='inspect')
|
||||
|
||||
try:
|
||||
result = json.loads(output)
|
||||
return SopsFileStatus(result['encrypted'])
|
||||
except Exception as exc:
|
||||
self._debug(u'Unexpected stdout:\n' + to_text(output, errors='surrogate_or_strict'))
|
||||
raise SopsError(path, 0, 'Cannot decode filestatus result: %s' % exc, operation='inspect')
|
||||
|
||||
|
||||
_SOPS_RUNNER_CACHE = dict()
|
||||
|
||||
|
||||
class Sops():
|
||||
''' Utility class to perform sops CLI actions '''
|
||||
|
||||
@staticmethod
|
||||
def get_sops_binary(get_option_value):
|
||||
cmd = get_option_value('sops_binary') if get_option_value else None
|
||||
if cmd is None:
|
||||
cmd = 'sops'
|
||||
return cmd
|
||||
|
||||
@staticmethod
|
||||
def get_sops_runner_from_binary(sops_binary, module=None, display=None):
|
||||
candidates = _SOPS_RUNNER_CACHE.get(sops_binary, [])
|
||||
for cand_module, cand_runner in candidates:
|
||||
if cand_runner is module:
|
||||
return cand_runner
|
||||
runner = SopsRunner(sops_binary, module=module, display=display)
|
||||
candidates.append((module, runner))
|
||||
_SOPS_RUNNER_CACHE[sops_binary] = candidates
|
||||
return runner
|
||||
|
||||
@staticmethod
|
||||
def get_sops_runner_from_options(get_option_value, module=None, display=None):
|
||||
return Sops.get_sops_runner_from_binary(Sops.get_sops_binary(get_option_value), module=module, display=display)
|
||||
|
||||
@staticmethod
|
||||
def decrypt(encrypted_file, content=None,
|
||||
display=None, decode_output=True, rstrip=True, input_type=None, output_type=None, get_option_value=None, module=None, extract=None):
|
||||
runner = Sops.get_sops_runner_from_options(get_option_value, module=module, display=display)
|
||||
return runner.decrypt(
|
||||
encrypted_file,
|
||||
content=content,
|
||||
decode_output=decode_output,
|
||||
rstrip=rstrip,
|
||||
input_type=input_type,
|
||||
output_type=output_type,
|
||||
get_option_value=get_option_value,
|
||||
extract=extract,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encrypt(data, display=None, cwd=None, input_type=None, output_type=None, get_option_value=None, module=None, filename=None):
|
||||
runner = Sops.get_sops_runner_from_options(get_option_value, module=module, display=display)
|
||||
return runner.encrypt(
|
||||
data,
|
||||
cwd=cwd,
|
||||
input_type=input_type,
|
||||
output_type=output_type,
|
||||
get_option_value=get_option_value,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
def get_sops_argument_spec(add_encrypt_specific=False):
|
||||
argument_spec = {
|
||||
'sops_binary': {
|
||||
'type': 'path',
|
||||
},
|
||||
'age_key': {
|
||||
'type': 'str',
|
||||
'no_log': True,
|
||||
},
|
||||
'age_keyfile': {
|
||||
'type': 'path',
|
||||
},
|
||||
'age_ssh_private_keyfile': {
|
||||
'type': 'path',
|
||||
},
|
||||
'aws_profile': {
|
||||
'type': 'str',
|
||||
},
|
||||
'aws_access_key_id': {
|
||||
'type': 'str',
|
||||
},
|
||||
'aws_secret_access_key': {
|
||||
'type': 'str',
|
||||
'no_log': True,
|
||||
},
|
||||
'aws_session_token': {
|
||||
'type': 'str',
|
||||
'no_log': True,
|
||||
},
|
||||
'config_path': {
|
||||
'type': 'path',
|
||||
},
|
||||
'enable_local_keyservice': {
|
||||
'type': 'bool',
|
||||
'default': True,
|
||||
},
|
||||
'keyservice': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
}
|
||||
if add_encrypt_specific:
|
||||
argument_spec.update({
|
||||
'age': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'kms': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'gcp_kms': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'azure_kv': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'hc_vault_transit': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'pgp': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'unencrypted_suffix': {
|
||||
'type': 'str',
|
||||
},
|
||||
'encrypted_suffix': {
|
||||
'type': 'str',
|
||||
},
|
||||
'unencrypted_regex': {
|
||||
'type': 'str',
|
||||
},
|
||||
'encrypted_regex': {
|
||||
'type': 'str',
|
||||
},
|
||||
'encryption_context': {
|
||||
'type': 'list',
|
||||
'elements': 'str',
|
||||
},
|
||||
'shamir_secret_sharing_threshold': {
|
||||
'type': 'int',
|
||||
'no_log': False,
|
||||
},
|
||||
})
|
||||
return argument_spec
|
||||
117
ansible_collections/community/sops/plugins/modules/load_vars.py
Normal file
117
ansible_collections/community/sops/plugins/modules/load_vars.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) Ansible Project
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
author: Felix Fontein (@felixfontein)
|
||||
module: load_vars
|
||||
short_description: Load SOPS-encrypted variables from files, dynamically within a task
|
||||
version_added: '0.1.0'
|
||||
description:
|
||||
- Loads SOPS-encrypted YAML/JSON variables dynamically from a file during task runtime.
|
||||
- To assign included variables to a different host than C(inventory_hostname), use C(delegate_to) and set C(delegate_facts=true).
|
||||
options:
|
||||
file:
|
||||
description:
|
||||
- The file name from which variables should be loaded.
|
||||
- If the path is relative, it will look for the file in C(vars/) subdirectory of a role or relative to playbook.
|
||||
type: path
|
||||
name:
|
||||
description:
|
||||
- The name of a variable into which assign the included vars.
|
||||
- If omitted (V(null)) they will be made top level vars.
|
||||
type: str
|
||||
expressions:
|
||||
description:
|
||||
- This option controls how Jinja2 expressions in values in the loaded file are handled.
|
||||
- If set to V(ignore), expressions will not be evaluated, but treated as regular strings.
|
||||
- If set to V(evaluate-on-load), expressions will be evaluated on execution of this module, in other words, when the
|
||||
file is loaded.
|
||||
- If set to V(lazy-evaluation), expressions will be lazily evaluated. This requires ansible-core 2.19 or newer
|
||||
and is the same behavior than M(ansible.builtin.include_vars). V(lazy-evaluation) has been added in community.sops 2.2.0.
|
||||
type: str
|
||||
default: ignore
|
||||
choices:
|
||||
- ignore
|
||||
- evaluate-on-load
|
||||
- lazy-evaluation
|
||||
extends_documentation_fragment:
|
||||
- community.sops.sops
|
||||
- community.sops.attributes
|
||||
- community.sops.attributes.facts
|
||||
- community.sops.attributes.flow
|
||||
attributes:
|
||||
action:
|
||||
support: full
|
||||
async:
|
||||
support: none
|
||||
details:
|
||||
- This action runs completely on the controller.
|
||||
check_mode:
|
||||
support: full
|
||||
diff_mode:
|
||||
support: N/A
|
||||
details:
|
||||
- This action does not modify state.
|
||||
facts:
|
||||
support: full
|
||||
idempotent:
|
||||
support: N/A
|
||||
details:
|
||||
- The action has no C(changed) state.
|
||||
seealso:
|
||||
- module: ansible.builtin.set_fact
|
||||
- module: ansible.builtin.include_vars
|
||||
- ref: playbooks_delegation
|
||||
description: More information related to task delegation.
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: lookup
|
||||
description: The sops lookup can be used decrypt SOPS-encrypted files.
|
||||
- plugin: community.sops.decrypt
|
||||
plugin_type: filter
|
||||
description: The decrypt filter can be used to descrypt SOPS-encrypted in-memory data.
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: vars
|
||||
description: The sops vars plugin can be used to load SOPS-encrypted host or group variables.
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Include variables of stuff.sops.yaml into the 'stuff' variable
|
||||
community.sops.load_vars:
|
||||
file: stuff.sops.yaml
|
||||
name: stuff
|
||||
expressions: evaluate-on-load # interpret Jinja2 expressions in stuf.sops.yaml on load-time!
|
||||
|
||||
- name: Conditionally decide to load in variables into 'plans' when x is 0, otherwise do not
|
||||
community.sops.load_vars:
|
||||
file: contingency_plan.sops.yaml
|
||||
name: plans
|
||||
expressions: ignore # do not interpret possible Jinja2 expressions
|
||||
when: x == 0
|
||||
|
||||
- name: Load variables into the global namespace
|
||||
community.sops.load_vars:
|
||||
file: contingency_plan.sops.yaml
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
ansible_facts:
|
||||
description: Variables that were included and their values.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: {'variable': 'value'}
|
||||
ansible_included_var_files:
|
||||
description: A list of files that were successfully included.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: [/path/to/file.sops.yaml]
|
||||
"""
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
author: Felix Fontein (@felixfontein)
|
||||
module: sops_encrypt
|
||||
short_description: Encrypt data with SOPS
|
||||
version_added: '0.1.0'
|
||||
description:
|
||||
- Allows to encrypt binary data (Base64 encoded), text data, JSON or YAML data with SOPS.
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- The SOPS encrypt file.
|
||||
type: path
|
||||
required: true
|
||||
force:
|
||||
description:
|
||||
- Force rewriting the encrypted file.
|
||||
type: bool
|
||||
default: false
|
||||
content_text:
|
||||
description:
|
||||
- The data to encrypt. Must be a Unicode text.
|
||||
- Please note that the module might not be idempotent if the text can be parsed as JSON or YAML.
|
||||
- Exactly one of O(content_text), O(content_binary), O(content_json), and O(content_yaml) must be specified.
|
||||
type: str
|
||||
content_binary:
|
||||
description:
|
||||
- The data to encrypt. Must be L(Base64 encoded,https://en.wikipedia.org/wiki/Base64) binary data.
|
||||
- Please note that the module might not be idempotent if the data can be parsed as JSON or YAML.
|
||||
- Exactly one of O(content_text), O(content_binary), O(content_json), and O(content_yaml) must be specified.
|
||||
type: str
|
||||
content_json:
|
||||
description:
|
||||
- The data to encrypt. Must be a JSON dictionary.
|
||||
- Exactly one of O(content_text), O(content_binary), O(content_json), and O(content_yaml) must be specified.
|
||||
type: dict
|
||||
content_yaml:
|
||||
description:
|
||||
- The data to encrypt. Must be a YAML dictionary.
|
||||
- Please note that Ansible only allows to pass data that can be represented as a JSON dictionary.
|
||||
- Exactly one of O(content_text), O(content_binary), O(content_json), and O(content_yaml) must be specified.
|
||||
type: dict
|
||||
extends_documentation_fragment:
|
||||
- ansible.builtin.files
|
||||
- community.sops.sops
|
||||
- community.sops.sops.encrypt_specific
|
||||
- community.sops.attributes
|
||||
- community.sops.attributes.files
|
||||
attributes:
|
||||
check_mode:
|
||||
support: full
|
||||
diff_mode:
|
||||
support: none
|
||||
safe_file_operations:
|
||||
support: full
|
||||
idempotent:
|
||||
support: full
|
||||
seealso:
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: lookup
|
||||
description: The sops lookup can be used decrypt SOPS-encrypted files.
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Encrypt a secret text
|
||||
community.sops.sops_encrypt:
|
||||
path: text-data.sops
|
||||
content_text: This is a secret text.
|
||||
|
||||
- name: Encrypt the contents of a file
|
||||
community.sops.sops_encrypt:
|
||||
path: binary-data.sops
|
||||
content_binary: "{{ lookup('ansible.builtin.file', '/path/to/file', rstrip=false) | b64encode }}"
|
||||
|
||||
- name: Encrypt some datastructure as YAML
|
||||
community.sops.sops_encrypt:
|
||||
path: stuff.sops.yaml
|
||||
content_yaml: "{{ result }}"
|
||||
"""
|
||||
|
||||
RETURN = r"""#"""
|
||||
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils.common.text.converters import to_text
|
||||
|
||||
from ansible_collections.community.sops.plugins.module_utils.io import write_file
|
||||
from ansible_collections.community.sops.plugins.module_utils.sops import Sops, SopsError, get_sops_argument_spec
|
||||
|
||||
try:
|
||||
import yaml
|
||||
YAML_IMP_ERR = None
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
YAML_IMP_ERR = traceback.format_exc()
|
||||
HAS_YAML = False
|
||||
yaml = None
|
||||
|
||||
|
||||
def get_data_type(module):
|
||||
if module.params['content_text'] is not None:
|
||||
return 'binary'
|
||||
if module.params['content_binary'] is not None:
|
||||
return 'binary'
|
||||
if module.params['content_json'] is not None:
|
||||
return 'json'
|
||||
if module.params['content_yaml'] is not None:
|
||||
return 'yaml'
|
||||
module.fail_json(msg='Internal error: unknown content type')
|
||||
|
||||
|
||||
def compare_encoded_content(module, binary_data, content):
|
||||
if module.params['content_text'] is not None:
|
||||
return content == module.params['content_text'].encode('utf-8')
|
||||
if module.params['content_binary'] is not None:
|
||||
return content == binary_data
|
||||
if module.params['content_json'] is not None:
|
||||
# Compare JSON
|
||||
try:
|
||||
return json.loads(content) == module.params['content_json']
|
||||
except Exception:
|
||||
# Treat parsing errors as content not equal
|
||||
return False
|
||||
if module.params['content_yaml'] is not None:
|
||||
# Compare YAML
|
||||
try:
|
||||
return yaml.safe_load(content) == module.params['content_yaml']
|
||||
except Exception:
|
||||
# Treat parsing errors as content not equal
|
||||
return False
|
||||
module.fail_json(msg='Internal error: unknown content type')
|
||||
|
||||
|
||||
def get_encoded_type_content(module, binary_data):
|
||||
if module.params['content_text'] is not None:
|
||||
return 'binary', module.params['content_text'].encode('utf-8')
|
||||
if module.params['content_binary'] is not None:
|
||||
return 'binary', binary_data
|
||||
if module.params['content_json'] is not None:
|
||||
return 'json', json.dumps(module.params['content_json']).encode('utf-8')
|
||||
if module.params['content_yaml'] is not None:
|
||||
return 'yaml', yaml.safe_dump(module.params['content_yaml']).encode('utf-8')
|
||||
module.fail_json(msg='Internal error: unknown content type')
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = dict(
|
||||
path=dict(type='path', required=True),
|
||||
force=dict(type='bool', default=False),
|
||||
content_text=dict(type='str', no_log=True),
|
||||
content_binary=dict(type='str', no_log=True),
|
||||
content_json=dict(type='dict', no_log=True),
|
||||
content_yaml=dict(type='dict', no_log=True),
|
||||
)
|
||||
argument_spec.update(get_sops_argument_spec(add_encrypt_specific=True))
|
||||
module = AnsibleModule(
|
||||
argument_spec=argument_spec,
|
||||
mutually_exclusive=[
|
||||
('content_text', 'content_binary', 'content_json', 'content_yaml'),
|
||||
],
|
||||
required_one_of=[
|
||||
('content_text', 'content_binary', 'content_json', 'content_yaml'),
|
||||
],
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
)
|
||||
|
||||
# Check YAML
|
||||
if module.params['content_yaml'] is not None and not HAS_YAML:
|
||||
module.fail_json(msg=missing_required_lib('pyyaml'), exception=YAML_IMP_ERR)
|
||||
|
||||
# Decode binary data
|
||||
binary_data = None
|
||||
if module.params['content_binary'] is not None:
|
||||
try:
|
||||
binary_data = base64.b64decode(module.params['content_binary'])
|
||||
except Exception as e:
|
||||
module.fail_json(msg='Cannot decode Base64 encoded data: {0}'.format(e))
|
||||
|
||||
path = module.params['path']
|
||||
directory = os.path.dirname(path) or None
|
||||
changed = False
|
||||
|
||||
def get_option_value(argument_name):
|
||||
return module.params.get(argument_name)
|
||||
|
||||
try:
|
||||
if module.params['force'] or not os.path.exists(path):
|
||||
# Simply encrypt
|
||||
changed = True
|
||||
else:
|
||||
# Change detection: check if encrypted data equals new data
|
||||
decrypted_content = Sops.decrypt(
|
||||
path, decode_output=False, output_type=get_data_type(module), rstrip=False,
|
||||
get_option_value=get_option_value, module=module,
|
||||
)
|
||||
if not compare_encoded_content(module, binary_data, decrypted_content):
|
||||
changed = True
|
||||
|
||||
if changed and not module.check_mode:
|
||||
input_type, input_data = get_encoded_type_content(module, binary_data)
|
||||
output_type = None
|
||||
if path.endswith('.json'):
|
||||
output_type = 'json'
|
||||
elif path.endswith(('.yml', '.yaml')):
|
||||
output_type = 'yaml'
|
||||
data = Sops.encrypt(
|
||||
data=input_data, cwd=directory, input_type=input_type, output_type=output_type,
|
||||
filename=os.path.relpath(path, directory) if directory is not None else path,
|
||||
get_option_value=get_option_value, module=module,
|
||||
)
|
||||
write_file(module, data)
|
||||
except SopsError as e:
|
||||
module.fail_json(msg=to_text(e))
|
||||
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
changed = module.set_fs_attributes_if_different(file_args, changed)
|
||||
|
||||
module.exit_json(changed=changed)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
# Copyright (c) 2012-2013 Michael DeHaan <michael.dehaan@gmail.com>
|
||||
# Copyright (c) 2016 Toshio Kuratomi <tkuratomi@ansible.com>
|
||||
# Copyright (c) 2019 Ansible Project
|
||||
# Copyright (c) 2020 Felix Fontein <felix@fontein.de>
|
||||
# Copyright (c) 2021 Ansible Project
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# Parts taken from ansible.module_utils.basic and ansible.module_utils.common.warnings.
|
||||
|
||||
# NOTE: THIS IS ONLY FOR ACTION PLUGINS!
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import abc
|
||||
import copy
|
||||
import traceback
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.module_utils.basic import SEQUENCETYPE, remove_values
|
||||
from collections.abc import Mapping
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
from ansible.module_utils.common.arg_spec import ArgumentSpecValidator
|
||||
from ansible.module_utils.errors import UnsupportedError
|
||||
|
||||
try:
|
||||
from ansible.module_utils.common.validation import (
|
||||
safe_eval,
|
||||
)
|
||||
except ImportError:
|
||||
safe_eval = None
|
||||
|
||||
|
||||
class _ModuleExitException(Exception):
|
||||
def __init__(self, result):
|
||||
super().__init__()
|
||||
self.result = result
|
||||
|
||||
|
||||
class AnsibleActionModule:
|
||||
def __init__(self, action_plugin, argument_spec, bypass_checks=False,
|
||||
mutually_exclusive=None, required_together=None,
|
||||
required_one_of=None, supports_check_mode=False,
|
||||
required_if=None, required_by=None):
|
||||
# Internal data
|
||||
self.__action_plugin = action_plugin
|
||||
self.__warnings = []
|
||||
self.__deprecations = []
|
||||
|
||||
# AnsibleModule data
|
||||
self._name = self.__action_plugin._task.action
|
||||
self.argument_spec = argument_spec
|
||||
self.supports_check_mode = supports_check_mode
|
||||
self.check_mode = self.__action_plugin._play_context.check_mode
|
||||
self.bypass_checks = bypass_checks
|
||||
self.no_log = self.__action_plugin._play_context.no_log
|
||||
|
||||
self.mutually_exclusive = mutually_exclusive
|
||||
self.required_together = required_together
|
||||
self.required_one_of = required_one_of
|
||||
self.required_if = required_if
|
||||
self.required_by = required_by
|
||||
self._diff = self.__action_plugin._play_context.diff
|
||||
self._verbosity = self.__action_plugin._display.verbosity
|
||||
|
||||
self.aliases = {}
|
||||
self._legal_inputs = []
|
||||
self._options_context = list()
|
||||
|
||||
self.params = copy.deepcopy(self.__action_plugin._task.args)
|
||||
self.no_log_values = set()
|
||||
self._validator = ArgumentSpecValidator(
|
||||
self.argument_spec,
|
||||
self.mutually_exclusive,
|
||||
self.required_together,
|
||||
self.required_one_of,
|
||||
self.required_if,
|
||||
self.required_by,
|
||||
)
|
||||
self._validation_result = self._validator.validate(self.params)
|
||||
self.params.update(self._validation_result.validated_parameters)
|
||||
self.no_log_values.update(self._validation_result._no_log_values)
|
||||
|
||||
try:
|
||||
error = self._validation_result.errors[0]
|
||||
except IndexError:
|
||||
error = None
|
||||
|
||||
# We cannot use ModuleArgumentSpecValidator directly since it uses mechanisms for reporting
|
||||
# warnings and deprecations that do not work in plugins. This is a copy of that code adjusted
|
||||
# for our use-case:
|
||||
for d in self._validation_result._deprecations:
|
||||
# Before ansible-core 2.14.2, deprecations were always for aliases:
|
||||
if 'name' in d:
|
||||
self.deprecate(
|
||||
"Alias '{name}' is deprecated. See the module docs for more information".format(name=d['name']),
|
||||
version=d.get('version'), date=d.get('date'), collection_name=d.get('collection_name'))
|
||||
# Since ansible-core 2.14.2, a message is present that can be directly printed:
|
||||
if 'msg' in d:
|
||||
self.deprecate(d['msg'], version=d.get('version'), date=d.get('date'), collection_name=d.get('collection_name'))
|
||||
|
||||
for w in self._validation_result._warnings:
|
||||
self.warn('Both option {option} and its alias {alias} are set.'.format(option=w['option'], alias=w['alias']))
|
||||
|
||||
# Fail for validation errors, even in check mode
|
||||
if error:
|
||||
msg = self._validation_result.errors.msg
|
||||
if isinstance(error, UnsupportedError):
|
||||
msg = "Unsupported parameters for ({name}) {kind}: {msg}".format(name=self._name, kind='module', msg=msg)
|
||||
|
||||
self.fail_json(msg=msg)
|
||||
|
||||
def safe_eval(self, value, locals=None, include_exceptions=False):
|
||||
if safe_eval is None:
|
||||
raise ValueError("safe_eval is not available since ansible-core 2.21")
|
||||
return safe_eval(value, locals, include_exceptions)
|
||||
|
||||
def warn(self, warning):
|
||||
# Copied from ansible.module_utils.common.warnings:
|
||||
if isinstance(warning, str):
|
||||
self.__warnings.append(warning)
|
||||
else:
|
||||
raise TypeError("warn requires a string not a %s" % type(warning))
|
||||
|
||||
def deprecate(self, msg, version=None, date=None, collection_name=None):
|
||||
if version is not None and date is not None:
|
||||
raise AssertionError("implementation error -- version and date must not both be set")
|
||||
|
||||
# Copied from ansible.module_utils.common.warnings:
|
||||
if isinstance(msg, str):
|
||||
# For compatibility, we accept that neither version nor date is set,
|
||||
# and treat that the same as if version would haven been set
|
||||
if date is not None:
|
||||
self.__deprecations.append({'msg': msg, 'date': date, 'collection_name': collection_name})
|
||||
else:
|
||||
self.__deprecations.append({'msg': msg, 'version': version, 'collection_name': collection_name})
|
||||
else:
|
||||
raise TypeError("deprecate requires a string not a %s" % type(msg))
|
||||
|
||||
def _return_formatted(self, kwargs):
|
||||
if 'invocation' not in kwargs:
|
||||
kwargs['invocation'] = {'module_args': self.params}
|
||||
|
||||
if 'warnings' in kwargs:
|
||||
if isinstance(kwargs['warnings'], list):
|
||||
for w in kwargs['warnings']:
|
||||
self.warn(w)
|
||||
else:
|
||||
self.warn(kwargs['warnings'])
|
||||
|
||||
if self.__warnings:
|
||||
kwargs['warnings'] = self.__warnings
|
||||
|
||||
if 'deprecations' in kwargs:
|
||||
if isinstance(kwargs['deprecations'], list):
|
||||
for d in kwargs['deprecations']:
|
||||
if isinstance(d, SEQUENCETYPE) and len(d) == 2:
|
||||
self.deprecate(d[0], version=d[1])
|
||||
elif isinstance(d, Mapping):
|
||||
self.deprecate(d['msg'], version=d.get('version'), date=d.get('date'),
|
||||
collection_name=d.get('collection_name'))
|
||||
else:
|
||||
self.deprecate(d) # pylint: disable=ansible-deprecated-no-version
|
||||
else:
|
||||
self.deprecate(kwargs['deprecations']) # pylint: disable=ansible-deprecated-no-version
|
||||
|
||||
if self.__deprecations:
|
||||
kwargs['deprecations'] = self.__deprecations
|
||||
|
||||
kwargs = remove_values(kwargs, self.no_log_values)
|
||||
raise _ModuleExitException(kwargs)
|
||||
|
||||
def exit_json(self, **kwargs):
|
||||
result = dict(kwargs)
|
||||
if 'failed' not in result:
|
||||
result['failed'] = False
|
||||
self._return_formatted(result)
|
||||
|
||||
def fail_json(self, msg, **kwargs):
|
||||
result = dict(kwargs)
|
||||
result['failed'] = True
|
||||
result['msg'] = msg
|
||||
self._return_formatted(result)
|
||||
|
||||
|
||||
class ActionModuleBase(ActionBase, metaclass=abc.ABCMeta):
|
||||
@abc.abstractmethod
|
||||
def setup_module(self):
|
||||
"""Return pair (ArgumentSpec, kwargs)."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def run_module(self, module):
|
||||
"""Run module code"""
|
||||
module.fail_json(msg='Not implemented.')
|
||||
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
if task_vars is None:
|
||||
task_vars = dict()
|
||||
|
||||
result = super().run(tmp, task_vars)
|
||||
del tmp # tmp no longer has any effect
|
||||
|
||||
try:
|
||||
argument_spec, kwargs = self.setup_module()
|
||||
module = argument_spec.create_ansible_module_helper(AnsibleActionModule, (self, ), **kwargs)
|
||||
self.run_module(module)
|
||||
raise AnsibleError('Internal error: action module did not call module.exit_json()')
|
||||
except _ModuleExitException as mee:
|
||||
result.update(mee.result)
|
||||
return result
|
||||
except Exception as dummy:
|
||||
result['failed'] = True
|
||||
result['msg'] = 'MODULE FAILURE'
|
||||
result['exception'] = traceback.format_exc()
|
||||
return result
|
||||
|
||||
|
||||
class ArgumentSpec:
|
||||
def __init__(self, argument_spec, mutually_exclusive=None, required_together=None, required_one_of=None, required_if=None, required_by=None):
|
||||
self.argument_spec = argument_spec
|
||||
self.mutually_exclusive = mutually_exclusive or []
|
||||
self.required_together = required_together or []
|
||||
self.required_one_of = required_one_of or []
|
||||
self.required_if = required_if or []
|
||||
self.required_by = required_by or {}
|
||||
|
||||
def create_ansible_module_helper(self, clazz, args, **kwargs):
|
||||
return clazz(
|
||||
*args,
|
||||
argument_spec=self.argument_spec,
|
||||
mutually_exclusive=self.mutually_exclusive,
|
||||
required_together=self.required_together,
|
||||
required_one_of=self.required_one_of,
|
||||
required_if=self.required_if,
|
||||
required_by=self.required_by,
|
||||
**kwargs)
|
||||
246
ansible_collections/community/sops/plugins/vars/sops.py
Normal file
246
ansible_collections/community/sops/plugins/vars/sops.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# Copyright (c) 2018 Edoardo Tenani <e.tenani@arduino.cc> (@endorama)
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
name: sops
|
||||
author: Edoardo Tenani (@endorama) <e.tenani@arduino.cc>
|
||||
short_description: Loading SOPS-encrypted vars files
|
||||
version_added: '0.1.0'
|
||||
description:
|
||||
- Load encrypted YAML files into corresponding groups/hosts in C(group_vars/) and C(host_vars/) directories.
|
||||
- Files are encrypted prior to reading, making this plugin an effective companion to P(ansible.builtin.host_group_vars#vars)
|
||||
plugin.
|
||||
- Files are restricted to V(.sops.yaml), V(.sops.yml), V(.sops.json) extensions, unless configured otherwise with O(valid_extensions).
|
||||
- Hidden files are ignored.
|
||||
options:
|
||||
valid_extensions:
|
||||
default: [".sops.yml", ".sops.yaml", ".sops.json"]
|
||||
description:
|
||||
- Check all of these extensions when looking for 'variable' files.
|
||||
- These files must be SOPS encrypted YAML or JSON files.
|
||||
- By default the plugin will produce errors when encountering files matching these extensions that are not SOPS encrypted.
|
||||
This behavior can be controlled with the O(handle_unencrypted_files) option.
|
||||
type: list
|
||||
elements: string
|
||||
ini:
|
||||
- key: valid_extensions
|
||||
section: community.sops
|
||||
version_added: 1.7.0
|
||||
env:
|
||||
- name: ANSIBLE_VARS_SOPS_PLUGIN_VALID_EXTENSIONS
|
||||
version_added: 1.7.0
|
||||
stage:
|
||||
version_added: 0.2.0
|
||||
ini:
|
||||
- key: vars_stage
|
||||
section: community.sops
|
||||
env:
|
||||
- name: ANSIBLE_VARS_SOPS_PLUGIN_STAGE
|
||||
cache:
|
||||
description:
|
||||
- Whether to cache decrypted files or not.
|
||||
- If the cache is disabled, the files will be decrypted for almost every task. This is very slow!
|
||||
- Only disable caching if you modify the variable files during a playbook run and want the updated result to be available
|
||||
from the next task on.
|
||||
- 'Note that setting O(stage=inventory) has the same effect as setting O(cache=true): the variables will be loaded only
|
||||
once (during inventory loading) and the vars plugin will not be called for every task.'
|
||||
type: bool
|
||||
default: true
|
||||
version_added: 0.2.0
|
||||
ini:
|
||||
- key: vars_cache
|
||||
section: community.sops
|
||||
env:
|
||||
- name: ANSIBLE_VARS_SOPS_PLUGIN_CACHE
|
||||
disable_vars_plugin_temporarily:
|
||||
description:
|
||||
- Temporarily disable this plugin.
|
||||
- Useful if ansible-inventory is supposed to be run without decrypting secrets (in AWX for instance).
|
||||
type: bool
|
||||
default: false
|
||||
version_added: 1.3.0
|
||||
env:
|
||||
- name: SOPS_ANSIBLE_AWX_DISABLE_VARS_PLUGIN_TEMPORARILY
|
||||
handle_unencrypted_files:
|
||||
description:
|
||||
- How to handle files that match the extensions in O(valid_extensions) that are not SOPS encrypted.
|
||||
- The default value V(error) will produce an error.
|
||||
- The value V(skip) will simply skip these files. This requires SOPS 3.9.0 or later.
|
||||
- The value V(warn) will skip these files and emit a warning. This requires SOPS 3.9.0 or later.
|
||||
- B(Note) that this will not help if the store SOPS uses cannot parse the file, for example because it is no valid JSON/YAML/...
|
||||
file despite its file extension. For extensions other than the default ones SOPS uses the binary store, which tries
|
||||
to parse the file as JSON.
|
||||
type: string
|
||||
choices:
|
||||
- skip
|
||||
- warn
|
||||
- error
|
||||
default: error
|
||||
version_added: 1.8.0
|
||||
ini:
|
||||
- key: handle_unencrypted_files
|
||||
section: community.sops
|
||||
env:
|
||||
- name: ANSIBLE_VARS_SOPS_PLUGIN_HANDLE_UNENCRYPTED_FILES
|
||||
extends_documentation_fragment:
|
||||
- ansible.builtin.vars_plugin_staging
|
||||
- community.sops.sops
|
||||
- community.sops.sops.ansible_env
|
||||
- community.sops.sops.ansible_ini
|
||||
seealso:
|
||||
- plugin: community.sops.sops
|
||||
plugin_type: lookup
|
||||
description: The sops lookup can be used decrypt SOPS-encrypted files.
|
||||
- plugin: community.sops.decrypt
|
||||
plugin_type: filter
|
||||
description: The decrypt filter can be used to decrypt SOPS-encrypted in-memory data.
|
||||
- module: community.sops.load_vars
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence, Mapping
|
||||
|
||||
from ansible.errors import AnsibleParserError
|
||||
from ansible.inventory.host import Host
|
||||
from ansible.inventory.group import Group
|
||||
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
|
||||
from ansible.plugins.vars import BaseVarsPlugin
|
||||
from ansible.utils.display import Display
|
||||
from ansible.utils.vars import combine_vars
|
||||
from ansible_collections.community.sops.plugins.module_utils.sops import Sops, SopsError
|
||||
|
||||
try:
|
||||
from ansible.template import trust_as_template as _trust_as_template
|
||||
HAS_DATATAGGING = True
|
||||
except ImportError:
|
||||
HAS_DATATAGGING = False
|
||||
|
||||
|
||||
display = Display()
|
||||
|
||||
FOUND = {}
|
||||
DECRYPTED = {}
|
||||
|
||||
|
||||
def _make_safe(value):
|
||||
if isinstance(value, str):
|
||||
# must come *before* Sequence, as strings are also instances of Sequence
|
||||
if HAS_DATATAGGING and isinstance(value, str):
|
||||
return _trust_as_template(value)
|
||||
return value
|
||||
if isinstance(value, Sequence):
|
||||
return [_make_safe(v) for v in value]
|
||||
if isinstance(value, Mapping):
|
||||
return dict((k, _make_safe(v)) for k, v in value.items())
|
||||
return value
|
||||
|
||||
|
||||
class VarsModule(BaseVarsPlugin):
|
||||
|
||||
def get_vars(self, loader, path, entities, cache=None):
|
||||
''' parses the inventory file '''
|
||||
|
||||
if not isinstance(entities, list):
|
||||
entities = [entities]
|
||||
|
||||
super().get_vars(loader, path, entities)
|
||||
|
||||
def get_option_value(argument_name):
|
||||
return self.get_option(argument_name)
|
||||
|
||||
if cache is None:
|
||||
cache = self.get_option('cache')
|
||||
|
||||
if self.get_option('disable_vars_plugin_temporarily'):
|
||||
return {}
|
||||
|
||||
valid_extensions = self.get_option('valid_extensions')
|
||||
handle_unencrypted_files = self.get_option('handle_unencrypted_files')
|
||||
|
||||
data = {}
|
||||
for entity in entities:
|
||||
if isinstance(entity, Host):
|
||||
subdir = 'host_vars'
|
||||
elif isinstance(entity, Group):
|
||||
subdir = 'group_vars'
|
||||
else:
|
||||
raise AnsibleParserError("Supplied entity must be Host or Group, got %s instead" % (type(entity)))
|
||||
|
||||
# avoid 'chroot' type inventory hostnames /path/to/chroot
|
||||
if not entity.name.startswith(os.path.sep):
|
||||
try:
|
||||
found_files = []
|
||||
# load vars
|
||||
b_opath = os.path.realpath(to_bytes(os.path.join(self._basedir, subdir)))
|
||||
opath = to_text(b_opath)
|
||||
key = '%s.%s' % (entity.name, opath)
|
||||
self._display.vvvv("key: %s" % (key))
|
||||
if cache and key in FOUND:
|
||||
found_files = FOUND[key]
|
||||
else:
|
||||
# no need to do much if path does not exist for basedir
|
||||
if os.path.exists(b_opath):
|
||||
if os.path.isdir(b_opath):
|
||||
self._display.debug("\tprocessing dir %s" % opath)
|
||||
# NOTE: iterating without extension allow retrieving files recursively
|
||||
# A filter is then applied by iterating on all results and filtering by
|
||||
# extension.
|
||||
# See:
|
||||
# - https://github.com/ansible-collections/community.sops/pull/6
|
||||
found_files = loader.find_vars_files(opath, entity.name, extensions=valid_extensions, allow_dir=False)
|
||||
found_files.extend([file_path for file_path in loader.find_vars_files(opath, entity.name)
|
||||
if any(to_text(file_path).endswith(extension) for extension in valid_extensions)])
|
||||
FOUND[key] = found_files
|
||||
else:
|
||||
self._display.warning("Found %s that is not a directory, skipping: %s" % (subdir, opath))
|
||||
|
||||
for found in found_files:
|
||||
if cache and found in DECRYPTED:
|
||||
file_content = DECRYPTED[found]
|
||||
else:
|
||||
sops_runner = Sops.get_sops_runner_from_options(get_option_value, display=display)
|
||||
if handle_unencrypted_files != 'error' and not sops_runner.has_filestatus():
|
||||
raise AnsibleParserError(
|
||||
'Cannot use handle_unencrypted_files=%s with SOPS %s' % (handle_unencrypted_files, sops_runner.version_string)
|
||||
)
|
||||
try:
|
||||
file_content = sops_runner.decrypt(found, get_option_value=get_option_value)
|
||||
except SopsError as exc:
|
||||
skip = False
|
||||
if sops_runner.has_filestatus():
|
||||
# Check whether sops thinks the file might be encrypted. If it thinks it is not,
|
||||
# skip it. Otherwise, re-raise the original error
|
||||
try:
|
||||
file_status = sops_runner.get_filestatus(found)
|
||||
if not file_status.encrypted:
|
||||
if handle_unencrypted_files == 'skip':
|
||||
self._display.vvvv("SOPS vars plugin: skipping unencrypted file %s" % found)
|
||||
skip = True
|
||||
elif handle_unencrypted_files == 'warn':
|
||||
self._display.warning("SOPS vars plugin: skipping unencrypted file %s" % found)
|
||||
skip = True
|
||||
elif handle_unencrypted_files == 'error':
|
||||
raise AnsibleParserError("SOPS vars plugin: file %s is not encrypted" % found)
|
||||
except SopsError as status_exc:
|
||||
# The filestatus operation can fail for example if sops cannot parse the file
|
||||
# as JSON/YAML. In that case, also re-raise the original error
|
||||
self._display.warning("SOPS vars plugin: cannot obtain file status of %s: %s" % (found, status_exc))
|
||||
if skip:
|
||||
continue
|
||||
raise
|
||||
DECRYPTED[found] = file_content
|
||||
new_data = _make_safe(loader.load(file_content))
|
||||
if new_data: # ignore empty files
|
||||
data = combine_vars(data, new_data)
|
||||
|
||||
except AnsibleParserError:
|
||||
raise
|
||||
except SopsError as e:
|
||||
raise AnsibleParserError(to_native(e))
|
||||
except Exception as e:
|
||||
raise AnsibleParserError('Unexpected error in the SOPS vars plugin: %s' % to_native(e))
|
||||
|
||||
return data
|
||||
Loading…
Add table
Add a link
Reference in a new issue