Vendor Galaxy Roles and Collections

This commit is contained in:
Stefan Bethke 2026-02-06 22:07:16 +01:00
commit 2aed20393f
3553 changed files with 387444 additions and 2 deletions

View 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,
)

View file

@ -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.
"""

View 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
'''

View file

@ -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,
}

View 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,
}

View 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

View file

@ -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))

View 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

View 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]
"""

View file

@ -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()

View file

@ -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)

View 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