diff --git a/changelogs/fragments/environment_cli.yml b/changelogs/fragments/environment_cli.yml new file mode 100644 index 00000000000..659521541ed --- /dev/null +++ b/changelogs/fragments/environment_cli.yml @@ -0,0 +1,2 @@ +minor_changes: + - CLI - Tools that execute tasks can now pass one or more ``-E`` options to define key-value pairs (k=v or YAML/JSON mapping) to be added to the ``environment`` task keyword for all hosts. diff --git a/lib/ansible/cli/arguments/option_helpers.py b/lib/ansible/cli/arguments/option_helpers.py index 1932e2efc59..52a20af3145 100644 --- a/lib/ansible/cli/arguments/option_helpers.py +++ b/lib/ansible/cli/arguments/option_helpers.py @@ -19,13 +19,14 @@ import yaml import ansible from ansible import constants as C from ansible._internal import _templating +from ansible._internal._datatag._tags import TrustedAsTemplate, Origin from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.common.yaml import HAS_LIBYAML, yaml_load from ansible.release import __version__ +from ansible.parsing.splitter import parse_kv +from ansible.parsing.utils.yaml import from_yaml from ansible.utils.path import unfrackpath -from ansible._internal._datatag._tags import TrustedAsTemplate, Origin - # # Special purpose OptionParsers @@ -233,6 +234,46 @@ def maybe_unfrack_path(beacon): return inner +def parse_env_vars(value: str) -> dict[str, str]: + """ + Parse command line args to set the 'environment' keyword + """ + + if value.startswith('{'): + data = from_yaml(value) + + elif value.startswith('@'): + filename = unfrackpath(value[1:]) + try: + with open(filename, errors='strict') as f: + data = from_yaml(f.read(), file_name=filename) + except OSError as e: + raise ValueError(f"Cannot access environment file {filename!r}") from e + except ValueError as e: + raise ValueError(f"Cannot decode file {filename!r}") from e + + elif '=' in value: + data = parse_kv(value) + + else: + raise ValueError(f"Unable to parse environment option, not YAML/JSON nor k=v pairs nor a file: {value!r}") + + if not isinstance(data, dict): + raise TypeError(f"Error while parsing environment values, expected a dictionary, got a {type(data)!r}") + + final = {} + for k, v in data.items(): + if not isinstance(k, str): + raise TypeError(f"Environment key is required to be a string, but {k!r} is a {type(k)!r} instead.") + try: + final[k] = TrustedAsTemplate().tag(str(v)) + except UnicodeError as e: + raise ValueError(f"Environment values are required to be strings, {k!r}'s value could not be converted.") from e + + # TODO: add Origin + return final + + def _git_repo_info(repo_path): """ returns a string containing git branch, commit id and commit date """ result = None @@ -502,6 +543,8 @@ def add_runtask_options(parser): """Add options for commands that run a task""" parser.add_argument('-e', '--extra-vars', dest="extra_vars", action="append", type=maybe_unfrack_path('@'), help="set additional variables as key=value or YAML/JSON, if filename prepend with @", default=[]) + parser.add_argument('-E', '--environment', dest='environment', action='append', default=[], type=parse_env_vars, + help="Set environment variables (key=value or YAML/JSON formatted or @filename.yml) when executing a task on the target.") def add_tasknoplay_options(parser): diff --git a/lib/ansible/cli/pull.py b/lib/ansible/cli/pull.py index 05b520b5e7c..51251cb92a8 100755 --- a/lib/ansible/cli/pull.py +++ b/lib/ansible/cli/pull.py @@ -271,6 +271,8 @@ class PullCLI(CLI): repo_opts, limit_opts) for ev in context.CLIARGS['extra_vars']: cmd += ' -e %s' % shlex.quote(ev) + for env_var in context.CLIARGS.get('environment', []): + cmd += ' ' + shlex.join(('-E', str(dict(env_var)))) # Nap? if context.CLIARGS['sleep']: @@ -318,6 +320,8 @@ class PullCLI(CLI): for ev in context.CLIARGS['extra_vars']: cmd += ' -e %s' % shlex.quote(ev) + for env_var in context.CLIARGS.get('environment', []): + cmd += ' -E %s' % shlex.quote(str(dict(env_var))) if context.CLIARGS['become_ask_pass']: cmd += ' --ask-become-pass' diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index f2bf62f5eb9..4d2b7c8d896 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -2032,6 +2032,17 @@ TARGET_LOG_INFO: vars: - name: ansible_target_log_info version_added: "2.17" +TASK_ENVIRONMENT: + name: Task environment + description: + - Environment variables to be provided for the task upon execution on the target. + - This can ONLY affects module execution, it does not affect any other type of plugin nor Ansible itself nor its configuration. + - This is not a recommended way to pass in confidential data. + type: list + keyword: + - name: environment + cli: + - name: environment TASK_TIMEOUT: name: Task Timeout default: 0 diff --git a/lib/ansible/playbook/play.py b/lib/ansible/playbook/play.py index 5cb639f851b..4e6189b065d 100644 --- a/lib/ansible/playbook/play.py +++ b/lib/ansible/playbook/play.py @@ -20,8 +20,7 @@ from __future__ import annotations import functools as _functools import pathlib as _pathlib -from ansible import constants as C -from ansible import context +from ansible import context, constants as C from ansible.errors import AnsibleError from ansible.errors import AnsibleParserError, AnsibleAssertionError, AnsibleValueOmittedError from ansible.module_utils.common.collections import is_sequence @@ -173,6 +172,16 @@ class Play(Base, Taggable, CollectionSearch): ds['remote_user'] = ds['user'] del ds['user'] + # prepend possible CLI environment vars (tuple of immutable dicts), these are not a default values + cli = C.config.get_config_value('TASK_ENVIRONMENT') + if cli: + play_env = ds.get('environment') + ds['environment'] = [dict(d) for d in cli] # from ImmutableDict + if play_env: + if not isinstance(play_env, list): + play_env = [play_env] + ds['environment'].extend(play_env) + return super(Play, self).preprocess_data(ds) def _load(self, attr: str, ds: object) -> list[Block]: diff --git a/test/integration/targets/adhoc/runme.sh b/test/integration/targets/adhoc/runme.sh index 0235b6ca3ff..43b7f0ae2f2 100755 --- a/test/integration/targets/adhoc/runme.sh +++ b/test/integration/targets/adhoc/runme.sh @@ -28,3 +28,7 @@ ansible localhost -m assert -a '{"that": "ansible_facts.distribution is defined" ansible --flush-cache localhost -m debug -a "msg={{ ansible_facts }}" | grep '"msg": {}' # test meta end_play ansible localhost -m include_role -a name=end_play +# test environment variable setting with -E option (basic KEY=VALUE) +ansible localhost -m gather_facts -a 'gather_subset="env"' -E 'TEST_ENV_VAR=ansible_test_value' | grep '"TEST_ENV_VAR": "ansible_test_value"' +# test environment variable setting with -E option (YAML/JSON format) +ansible localhost -m gather_facts -a 'gather_subset="env"' -E '{"TEST_JSON_VAR": "json_test_value"}' | grep '"TEST_JSON_VAR": "json_test_value"' diff --git a/test/integration/targets/ansible-console/runme.sh b/test/integration/targets/ansible-console/runme.sh index 13d2517c5a2..114b7f53c06 100755 --- a/test/integration/targets/ansible-console/runme.sh +++ b/test/integration/targets/ansible-console/runme.sh @@ -14,3 +14,8 @@ if grep -q "ERROR" err.txt; then echo "Failed to execute end_play" exit 1 fi + +# test environment variable setting with -E option (basic KEY=VALUE) +echo 'gather_facts gather_subset=env' | ansible-console localhost -E 'TEST_CONSOLE_VAR=console_test_value' | grep '"TEST_CONSOLE_VAR": "console_test_value"' +# test environment variable setting with -E option (JSON format) +echo 'gather_facts gather_subset=env' | ansible-console localhost -E '{"TEST_CONSOLE_JSON": "console_json_value"}' | grep '"TEST_CONSOLE_JSON": "console_json_value"' diff --git a/test/integration/targets/ansible-pull/pull-integration-test/env_var_test.yml b/test/integration/targets/ansible-pull/pull-integration-test/env_var_test.yml new file mode 100644 index 00000000000..78c0fd8e29c --- /dev/null +++ b/test/integration/targets/ansible-pull/pull-integration-test/env_var_test.yml @@ -0,0 +1,13 @@ +--- +- hosts: localhost + gather_facts: false + module_defaults: + gather_facts: + gather_subset: ['env'] + tasks: + - name: Verify all environment variables are accessible via ansible_env + assert: + that: + - ansible_env.TEST_PULL is defined + - ansible_env.TEST_PULL == "pull_test_value" + fail_msg: "Environment variables not accessible via -E option in ansible-pull" diff --git a/test/integration/targets/ansible-pull/runme.sh b/test/integration/targets/ansible-pull/runme.sh index 2f1d81b54a3..460643347d8 100755 --- a/test/integration/targets/ansible-pull/runme.sh +++ b/test/integration/targets/ansible-pull/runme.sh @@ -142,3 +142,11 @@ pass_tests if [ "${ORIG_CONFIG}" != "" ]; then export ANSIBLE_CONFIG="${ORIG_CONFIG}" fi + +# test environment variable setting with -E option (basic KEY=VALUE) +ansible-pull -d "${pull_dir}" -U "${repo_dir}" env_var_test.yml \ + -e 'ansble_python_interpreter={{ansible_playbook_python}}' -E 'TEST_PULL=pull_test_value' "$@" + +# test environment variable setting with -E option (JSON format) +ansible-pull -d "${pull_dir}" -U "${repo_dir}" env_var_test.yml \ + -e 'ansble_python_interpreter={{ansible_playbook_python}}' -E '{"TEST_PULL": "pull_test_value"}' "$@" diff --git a/test/integration/targets/playbook/env_var_test.yml b/test/integration/targets/playbook/env_var_test.yml new file mode 100644 index 00000000000..1405cf0d938 --- /dev/null +++ b/test/integration/targets/playbook/env_var_test.yml @@ -0,0 +1,10 @@ +--- +- hosts: localhost + gather_facts: yes + tasks: + - name: Verify all environment variables are accessible via ansible_env + assert: + that: + - ansible_env.TEST_PLAYBOOK is defined + - ansible_env.TEST_PLAYBOOK == "playbook_test_value" + fail_msg: "Environment variables not accessible via -E option" diff --git a/test/integration/targets/playbook/playbook_env_vars.yml b/test/integration/targets/playbook/playbook_env_vars.yml new file mode 100644 index 00000000000..b0361b0622b --- /dev/null +++ b/test/integration/targets/playbook/playbook_env_vars.yml @@ -0,0 +1 @@ +TEST_PLAYBOOK: playbook_test_value diff --git a/test/integration/targets/playbook/runme.sh b/test/integration/targets/playbook/runme.sh index bf4f1769b54..76e43802017 100755 --- a/test/integration/targets/playbook/runme.sh +++ b/test/integration/targets/playbook/runme.sh @@ -90,3 +90,12 @@ ansible-playbook -i ../../inventory vars_files_null.yml -v "$@" # test vars_files: filename.yml ansible-playbook -i ../../inventory vars_files_string.yml -v "$@" + +# test environment variable setting with -E option (basic KEY=VALUE) +ansible-playbook -i ../../inventory env_var_test.yml -E '@playbook_env_vars.yml' "$@" + +# test environment variable setting with -E option (basic KEY=VALUE) +ansible-playbook -i ../../inventory env_var_test.yml -E 'TEST_PLAYBOOK=playbook_test_value' "$@" + +# test environment variable setting with -E option (JSON format) +ansible-playbook -i ../../inventory env_var_test.yml -E '{"TEST_PLAYBOOK": "playbook_test_value"}' "$@"