aoh

A tool for running Ansible playbooks with `curl <url> | python` commands
git clone https://git.ashermorgan.net/aoh/
Log | Files | Refs | README

commit 92d1489c5aaa3f58f99a8916b668ccea19e2be74
parent ebbefecb220c420f638e0194e398bf5b56e6c8c3
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Fri, 18 Sep 2026 18:13:29 -0700

Replace config playbook option with env option

This option is more general and allows for more flexibility. Setting
env.ANSIBLE_CONFIG is equivalent to setting the old config option.

Diffstat:
MREADME.md | 12+++++++-----
Maoh/playbook.py | 43++++++++++++++++++++++---------------------
Maoh/runner.py | 38++++++++++++++++++++++----------------
Maoh/security.py | 2+-
Ddemo/ansible.cfg | 2--
Mdemo/playbooks.yml | 12+++++++-----
Dtests/e2e/pipelining.cfg | 2--
Mtests/e2e/playbooks.yml | 6++++--
Mtests/e2e/run.sh | 4++++
Mtests/unit/test_playbook.py | 2+-
10 files changed, 68 insertions(+), 55 deletions(-)

diff --git a/README.md b/README.md @@ -151,10 +151,6 @@ main.yml: # The playbook name # $AOH_PLAYBOOKS_FILE. Defaults to the playbook name. path: my-playbook.yml - # The path to an associated Ansible config file, if one exists. Relative paths - # are interpreted as relative to $AOH_PLAYBOOKS_FILE. - config: ansible.cfg - # The hostname to assign to clients. If set to null, clients are assigned the # hostname that they report for themselves, which could be spoofed. Defaults # to null. @@ -166,6 +162,10 @@ main.yml: # The playbook name - group1 - group2 + # A dictionary of environment variables to pass to ansible-playbook. + env: + ANSIBLE_PIPELINING: 'true' + # Whether to restrict play execution to the AoH client only using # ansible-playbook's --limit option. If enabled, the --limit option must not # be present in allow_opts or extra_args. Defaults to true. @@ -204,7 +204,9 @@ main.yml: # The playbook name jinja_args: false # A list of additional arguments to pass to ansible-playbook. These are not - # subject to the allow_opts/block_opts/jinja_args restrictions. + # subject to the allow_opts/block_opts/jinja_args restrictions. Note that the + # --inventory option is not supported and the env.ANSIBLE_INVENTORY playbook + # option should be used instead for specifying inventory files. extra_args: - '--diff' diff --git a/aoh/playbook.py b/aoh/playbook.py @@ -13,14 +13,14 @@ class PlaybookFileError(Exception): class Playbook: - def __init__(self, name, dict): + def __init__(self, name, raw): """Parse a playbook configuration from a dictionary.""" _types = { 'path': str, - 'config': str, 'host': str, 'groups': list, + 'env': dict, 'limit': bool, 'password': bool, 'output': bool, @@ -32,34 +32,35 @@ class Playbook: } for key, _type in _types.items(): - if key in dict: - if type(dict[key]) != _type: + if key in raw: + if type(raw[key]) != _type: raise PlaybookFileError(f'"{key}" field must be of type ' f'{_type}') - if _type is list and any(type(x) != str for x in dict[key]): + if _type is list and any(type(x) != str for x in raw[key]): raise PlaybookFileError(f'"{key}" elements must be of type ' 'str') + if _type is dict and any(type(x) != str for x in + raw[key].values()): + raise PlaybookFileError(f'"{key}" values must be of type ' + 'str') + self.name = name - self.path = dict.get('path', name) - self.config = dict.get('config', None) - self.host = dict.get('host') - self.groups = dict.get('groups', []) - self.limit = dict.get('limit', True) - self.password = dict.get('password', False) - self.output = dict.get('output', True) - self.allow_opts = dict.get('allow_opts', []) - self.block_opts = dict.get('block_opts', []) - self.jinja_args = dict.get('jinja_args', False) - self.extra_args = dict.get('extra_args', []) - self.web_description = dict.get('web_description') + self.path = raw.get('path', name) + self.host = raw.get('host') + self.groups = raw.get('groups', []) + self.env = raw.get('env', {}) + self.limit = raw.get('limit', True) + self.password = raw.get('password', False) + self.output = raw.get('output', True) + self.allow_opts = raw.get('allow_opts', []) + self.block_opts = raw.get('block_opts', []) + self.jinja_args = raw.get('jinja_args', False) + self.extra_args = raw.get('extra_args', []) + self.web_description = raw.get('web_description') self.path = os.path.abspath(os.path.join(_PLAYBOOKS_DIR, self.path)) - if self.config: - self.config = os.path.abspath(os.path.join(_PLAYBOOKS_DIR, - self.config)) - if self.limit: if '--limit' in self.allow_opts or '-l' in self.allow_opts: diff --git a/aoh/runner.py b/aoh/runner.py @@ -25,19 +25,23 @@ class RunnerError(Exception): """Raised for AoH runner errors.""" -def get_config_values(config, keys): +def get_config_values(playbook, keys): """Query Ansible config option values.""" with tempfile.TemporaryDirectory(prefix='aoh-') as dir: + env = playbook.env.copy() + env['ANSIBLE_FORCE_COLOR'] = '0' + env['ANSIBLE_NOCOLOR'] = '1' + raw_config = ansible_runner.get_ansible_config( 'dump', - config, + envvars=env, private_data_dir=dir, quiet=True, )[0] if not raw_config: - raise RunnerError(f'Failed to dump Ansible config from {config}') + raise RunnerError('Failed to dump Ansible config') result = {} for key in keys: @@ -107,22 +111,24 @@ class Runner: f.writelines(f'[{g}]\n{self.host}\n' for g in self.playbook.groups) config = get_config_values( - self.playbook.config, + self.playbook, ['DEFAULT_CONNECTION_PLUGIN_PATH', 'DEFAULT_HOST_LIST'] ) - env = { - 'ANSIBLE_AOH_DIR': self._DIR, - 'ANSIBLE_CONNECTION_PLUGINS': ':'.join( - [_CONNECTION_PLUGIN_DIR] + - config['DEFAULT_CONNECTION_PLUGIN_PATH'] - ), - 'ANSIBLE_CONFIG': self.playbook.config, - ('ANSIBLE_FORCE_COLOR' if self.color else 'NO_COLOR'): '1', - 'ANSIBLE_INVENTORY': ','.join( - [f'{self._DIR}/inventory.ini'] + config['DEFAULT_HOST_LIST'] - ), - } + # Assemble env vars, from low-priority values to high-priority values + env = {} + env['ANSIBLE_FORCE_COLOR' if self.color else 'NO_COLOR'] = '1' + for key, val in self.playbook.env.items(): + env[key] = val + env['ANSIBLE_AOH_DIR'] = self._DIR + env['ANSIBLE_CONNECTION_PLUGINS'] = ':'.join( + [_CONNECTION_PLUGIN_DIR] + + config['DEFAULT_CONNECTION_PLUGIN_PATH'] + ) + env['ANSIBLE_INVENTORY'] = ','.join( + [f'{self._DIR}/inventory.ini'] + config['DEFAULT_HOST_LIST'] + ) + pw_prompt_answers = {} for pw_type in self.passwords: pw_prompt_answers[_PASSWORD_PROMPTS[pw_type]] = \ diff --git a/aoh/security.py b/aoh/security.py @@ -94,7 +94,7 @@ def get_required_passwords(playbook, args): if opt in opts: pw_types.add(pw_type) - opts = get_config_values(playbook.config, _CONFIG_PASSWORD_OPTS.keys()) + opts = get_config_values(playbook, _CONFIG_PASSWORD_OPTS.keys()) for opt, pw_type in _CONFIG_PASSWORD_OPTS.items(): if opts[opt]: pw_types.add(pw_type) diff --git a/demo/ansible.cfg b/demo/ansible.cfg @@ -1,2 +0,0 @@ -[connection] -pipelining = true diff --git a/demo/playbooks.yml b/demo/playbooks.yml @@ -4,10 +4,6 @@ main.yml: # The playbook name # $AOH_PLAYBOOKS_FILE. Defaults to the playbook name. path: my-playbook.yml - # The path to an associated Ansible config file, if one exists. Relative paths - # are interpreted as relative to $AOH_PLAYBOOKS_FILE. - config: ansible.cfg - # The hostname to assign to clients. If set to null, clients are assigned the # hostname that they report for themselves, which could be spoofed. Defaults # to null. @@ -19,6 +15,10 @@ main.yml: # The playbook name - group1 - group2 + # A dictionary of environment variables to pass to ansible-playbook. + env: + ANSIBLE_PIPELINING: 'true' + # Whether to restrict play execution to the AoH client only using # ansible-playbook's --limit option. If enabled, the --limit option must not # be present in allow_opts or extra_args. Defaults to true. @@ -57,7 +57,9 @@ main.yml: # The playbook name jinja_args: false # A list of additional arguments to pass to ansible-playbook. These are not - # subject to the allow_opts/block_opts/jinja_args restrictions. + # subject to the allow_opts/block_opts/jinja_args restrictions. Note that the + # --inventory option is not supported and the env.ANSIBLE_INVENTORY playbook + # option should be used instead for specifying inventory files. extra_args: - '--diff' diff --git a/tests/e2e/pipelining.cfg b/tests/e2e/pipelining.cfg @@ -1,2 +0,0 @@ -[connection] -pipelining = true diff --git a/tests/e2e/playbooks.yml b/tests/e2e/playbooks.yml @@ -3,11 +3,13 @@ test1.yml: test2.yml: path: test_connection.yml - config: pipelining.cfg + env: + ANSIBLE_PIPELINING: 'true' test3.yml: path: test_become_password_handling.yml test4.yml: path: test_become_password_handling.yml - config: pipelining.cfg + env: + ANSIBLE_PIPELINING: 'true' diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh @@ -47,3 +47,7 @@ for i in {1..4}; do exit 1 fi done + +echo ================================ +echo All tests passed. +echo ================================ diff --git a/tests/unit/test_playbook.py b/tests/unit/test_playbook.py @@ -13,9 +13,9 @@ class TestPlaybookGetPlaybook(unittest.TestCase): self.assertEqual(pb.name, 'main.yml') self.assertEqual(pb.path, os.path.abspath('main.yml')) - self.assertEqual(pb.config, None) self.assertEqual(pb.host, None) self.assertEqual(pb.groups, []) + self.assertEqual(pb.env, {}) self.assertEqual(pb.limit, True) self.assertEqual(pb.password, False) self.assertEqual(pb.output, True)