diff options
| -rw-r--r-- | ansible/library/kolla_toolbox.py | 398 | ||||
| -rw-r--r-- | tests/test_kolla_toolbox.py | 670 |
2 files changed, 783 insertions, 285 deletions
diff --git a/ansible/library/kolla_toolbox.py b/ansible/library/kolla_toolbox.py index 4062fcb6e..2749f3b4b 100644 --- a/ansible/library/kolla_toolbox.py +++ b/ansible/library/kolla_toolbox.py @@ -12,8 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections +import io import json -import re +import secrets +import tarfile import traceback from ansible.module_utils.basic import AnsibleModule @@ -24,7 +27,7 @@ module: kolla_toolbox short_description: > Module for invoking ansible module in kolla_toolbox container. description: - - A module targerting at invoking ansible module in kolla_toolbox + - A module targeting at invoking ansible module in kolla_toolbox container as used by Kolla project. options: container_engine: @@ -103,6 +106,129 @@ EXAMPLES = ''' domain_name: "default" ''' +# NOTE(mnasiadka): Full path to Python inside the kolla_toolbox container +_PYTHON = '/opt/ansible/bin/python3' + +# NOTE(mnasiadka): Base directory for ansible-runner private_data_dir trees +_PDD_BASEDIR = '/var/lib/ansible' + +# NOTE(mnasiadka): Script run inside kolla_toolbox to extract the terminal +# ansible-runner vent from job_events and print it as JSON +_PARSE_SCRIPT = """\ +import json, os, sys +pdd = %r +events_dir = None +for root, dirs, files in os.walk(pdd): + if "job_events" in dirs: + events_dir = os.path.join(root, "job_events") + break +if not events_dir: + print(json.dumps({"failed": True, + "msg": "no job_events dir under " + pdd})) + sys.exit(1) +terminal = {"runner_on_ok", "runner_on_failed", + "runner_on_unreachable", "runner_on_async_failed", + "runner_on_skipped"} +for fname in sorted(os.listdir(events_dir)): + path = os.path.join(events_dir, fname) + with open(path) as f: + event = json.load(f) + event_type = event.get("event", "") + if event_type in terminal: + res = event.get("event_data", {}).get("res", {}) + res.pop("_ansible_no_log", None) + res["_runner_status"] = event_type + print(json.dumps(res)) + sys.exit(0) + lifecycle_events = {"runner_on_start", "runner_on_no_hosts", + "runner_on_async_poll", "runner_on_async_ok"} + if event_type.startswith("runner_on_") and ( + event_type not in lifecycle_events): + print(json.dumps({"failed": True, + "msg": "unhandled runner event: " + + event_type})) + sys.exit(1) +print(json.dumps({"failed": True, + "msg": "no terminal event found"})) +sys.exit(1) +""" + + +# NOTE(mnasiadka): Docker SDK exec_run returns a namedtuple with .exit_code +# and .output attributes, while Podman SDK returns a +# plain (exit_code, output) tuple. +_ExecResult = collections.namedtuple( + '_ExecResult', ['exit_code', 'output']) + + +def _build_playbook(module_name, module_args, extra_vars, check_mode): + """Return a JSON playbook string for *module_name* / *module_args*. + + Both module_args and extra_vars are embedded as dicts so they live + on disk inside the container and never appear on any CLI. + """ + play = { + 'name': 'kolla_toolbox', + 'hosts': 'localhost', + 'gather_facts': False, + 'tasks': [{'name': 'kolla_toolbox task', + module_name: module_args}], + } + if extra_vars: + play['vars'] = extra_vars + if check_mode: + play['check_mode'] = True + return json.dumps([play]) + + +def _make_tar(files): + """Build an in-memory tar from {relative_path: str|bytes}. + + Returns a BytesIO at position 0, ready for put_archive(base_dir). + Paths are relative to the put_archive destination. + Parent directory entries are created automatically. + """ + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode='w') as tf: + seen_dirs = set() + for rel_path, content in files.items(): + parts = rel_path.split('/') + for depth in range(1, len(parts)): + dir_path = '/'.join(parts[:depth]) + if dir_path not in seen_dirs: + dir_info = tarfile.TarInfo(name=dir_path) + dir_info.type = tarfile.DIRTYPE + dir_info.mode = 0o755 + tf.addfile(dir_info) + seen_dirs.add(dir_path) + encoded = (content if isinstance(content, bytes) + else content.encode('utf-8')) + info = tarfile.TarInfo(name=rel_path) + info.size = len(encoded) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(encoded)) + buf.seek(0) + return buf + + +def _exec_run(container, cmd, **kwargs): + """Normalise exec_run return value across Docker and Podman SDKs. + + Docker SDK returns an ExecResult namedtuple with .exit_code and + .output attributes. Podman SDK returns a plain (exit_code, output) + tuple. This wrapper always returns an _ExecResult namedtuple. + + tty=True is forced so output is a plain byte stream with no + multiplexed frame headers, avoiding the need to parse the + Docker/Podman mux format. + """ + kwargs['tty'] = True + + result = container.exec_run(cmd, **kwargs) + if isinstance(result, tuple): + return _ExecResult(*result) + return _ExecResult(result.exit_code, result.output) + class KollaToolboxWorker(): def __init__(self, module, client, container_errors) -> None: @@ -122,113 +248,172 @@ class KollaToolboxWorker(): ) return cont[0] - def _format_module_args(self, module_args: dict) -> list: - """Format dict of module parameters into list of 'key=value' pairs.""" - pairs = list() - for key, value in module_args.items(): - if isinstance(value, dict): - value_json = json.dumps(value) - pairs.append(f"{key}='{value_json}'") - else: - pairs.append(f"{key}='{value}'") - return pairs - - def _generate_command(self) -> list: - """Generate the command that will be executed inside kolla_toolbox.""" - args_formatted = self._format_module_args( - self.module.params.get('module_args')) - extra_vars_formatted = self._format_module_args( - self.module.params.get('module_extra_vars')) - - command = ['ansible', 'localhost'] - command.extend(['-m', self.module.params.get('module_name')]) - if args_formatted: - command.extend(['-a', ' '.join(args_formatted)]) - if extra_vars_formatted: - command.extend(['-e', ' '.join(extra_vars_formatted)]) - if self.module.check_mode: - command.append('--check') - - return command - - def _run_command(self, kolla_toolbox, command, *args, **kwargs) -> bytes: - try: - _, output_raw = kolla_toolbox.exec_run(command, - *args, - **kwargs) - except self.container_errors.APIError as e: + def _push_private_data_dir(self, kolla_toolbox, pdd, user): + """Push the ansible-runner private_data_dir tree into container. + + Creates pdd (and pdd/tmp for Ansible's temp files) as root, then + chowns both to *user* if specified so ansible-runner can write + there even when the user has no shell (e.g. rabbitmq, nova). + + Pushes inventory and playbook via a single put_archive() call — + one tar stream through the socket, no secrets in any argv. + """ + params = self.module.params + + playbook_json = _build_playbook( + params['module_name'], + params.get('module_args') or {}, + params.get('module_extra_vars') or {}, + self.module.check_mode, + ) + + # NOTE(mnasiadka): Build and run the setup script as root so it + # can always write to _PDD_BASEDIR. + # Chown pdd to the effective exec user + # so ansible-runner can create artifacts/. + chown_user = user if user else 'ansible' + if chown_user == 'root': + setup = ( + 'import os\n' + 'for p in [%r, %r + "/tmp"]:\n' + ' os.makedirs(p, exist_ok=True)\n' + ) % (pdd, pdd) + else: + setup = ( + 'import os, pwd\n' + 'pw = pwd.getpwnam(%r)\n' + 'for p in [%r, %r + "/tmp"]:\n' + ' os.makedirs(p, exist_ok=True)\n' + ' os.chown(p, pw.pw_uid, pw.pw_gid)\n' + ) % (chown_user, pdd, pdd) + + mkdir_result = _exec_run( + kolla_toolbox, [_PYTHON, '-c', setup], user='root') + if mkdir_result.exit_code != 0: self.module.fail_json( - msg='Container engine client encountered API error: ' - f'{e.explanation}' + msg='Failed to create pdd %s in kolla_toolbox: %s' + % (pdd, mkdir_result.output.decode(errors='replace')) ) - return output_raw - def _process_container_output(self, output_raw: bytes) -> dict: - """Convert raw bytes output from container.exec_run into dictionary.""" - try: - # Extract deprecation warning (if any) - warning_match = re.search(rb'\[DEPRECATION WARNING\].*', - output_raw) - if warning_match: - self.module.warn(warning_match.group(0).decode('utf-8')) - - # Extract JSON block (including curly braces and content) - json_match = re.search(rb'\{.*\}', output_raw, re.DOTALL) - if json_match: - output_json = json.loads(json_match.group(0)) - else: - self.module.fail_json( - msg=('Parsing kolla_toolbox JSON output failed - ' - 'no JSON block in output') - ) + inventory = ( + 'localhost' + ' ansible_connection=local' + ' ansible_remote_tmp={pdd}/tmp' + ' ansible_local_tmp={pdd}/tmp' + ' ansible_local_temp={pdd}/tmp' + ' ansible_python_interpreter={python}\n' + ).format(pdd=pdd, python=_PYTHON) + + kolla_toolbox.put_archive(pdd, _make_tar({ + 'inventory/hosts': inventory, + 'project/main.json': playbook_json, + })) + + def _parse_runner_result(self, kolla_toolbox, pdd, user): + """Extract the module result from ansible-runner job_events. + + Runs _PARSE_SCRIPT inside the container via exec_run: it walks + job_events, finds the terminal event, and prints result as JSON. + No tar parsing, no path-prefix assumptions. + """ + parse_kwargs = {} + if user: + parse_kwargs['user'] = user + + result = _exec_run( + kolla_toolbox, [_PYTHON, '-c', _PARSE_SCRIPT % pdd], + **parse_kwargs + ) - except json.JSONDecodeError as e: + if result.exit_code != 0: self.module.fail_json( - msg=f'Parsing kolla_toolbox JSON output failed: {e}' + msg='Failed to read ansible-runner result (exit %d): %s' + % (result.exit_code, + result.output.decode(errors='replace')) ) - # Expected format for the output is the following: - # { - # "plays": [ - # { - # "tasks": [ - # { - # "hosts": { - # "localhost": { - # <module result> - # } - # } - # } - # ] - # { - # ] - # } - try: - result = output_json['plays'][0]['tasks'][0]['hosts']['localhost'] - result.pop('_ansible_no_log', None) - except KeyError: + res = json.loads(result.output.decode('utf-8')) + except json.JSONDecodeError: self.module.fail_json( - msg=f'Ansible JSON output has unexpected format: {output_json}' + msg='Could not parse ansible-runner result output: %r' + % result.output + ) + + status = res.pop('_runner_status', 'runner_on_ok') + res.pop('_ansible_no_log', None) + if res.get('failed') or status not in ['runner_on_ok', + 'runner_on_skipped']: + msg = res.pop( + 'msg', + 'Module execution failed inside kolla_toolbox' ) + self.module.fail_json(msg=msg, **res) - return result + return res def main(self) -> None: - """Run command inside the kolla_toolbox container with defined args.""" - + """Run the requested module inside the kolla_toolbox container. + + 1. Generate a unique pdd name on the controller (no exec). + 2. Push inventory + playbook via put_archive (secrets travel as + tar content, never in argv). + 3. Run ansible-runner CLI (only pdd path in argv, no secrets). + 4. Extract result via _PARSE_SCRIPT exec (walks job_events, + prints terminal event as JSON — no tar, no path assumptions). + 5. Clean up pdd unconditionally in finally. + """ kolla_toolbox = self._get_toolbox_container() - command = self._generate_command() - environment = {'ANSIBLE_STDOUT_CALLBACK': 'json', - 'ANSIBLE_LOAD_CALLBACK_PLUGINS': 'True'} + user = self.module.params.get('user') - output_raw = self._run_command(kolla_toolbox, - command, - environment=environment, - tty=True, - user=self.module.params.get('user')) + check_runner = _exec_run( + kolla_toolbox, ['test', '-x', '/opt/ansible/bin/ansible-runner']) + if check_runner.exit_code != 0: + self.module.fail_json( + msg="The 'ansible-runner' binary was not found in the " + "kolla_toolbox container. Please ensure the container " + "image is up to date and includes ansible-runner." + ) - self.result = self._process_container_output(output_raw) + # NOTE(mnasiadka): Generate a unique pdd name without creating + # anything locally. + pdd = _PDD_BASEDIR + '/kolla_runner.' + secrets.token_urlsafe(8) + + try: + self._push_private_data_dir(kolla_toolbox, pdd, user) + + runner_env = {} + if self.module._diff: + runner_env['ANSIBLE_DIFF_MODE'] = '1' + + runner_result = _exec_run( + kolla_toolbox, + ['/opt/ansible/bin/ansible-runner', 'run', pdd, + '--playbook', 'main.json', + '--rotate-artifacts', '1'], + user=user, + environment=runner_env + ) + # exit 2 = task failed/unreachable; handled via event below. + # Anything else is a runner-level failure. + if runner_result.exit_code not in (0, 2): + self.module.fail_json( + msg='ansible-runner exited with code %d: %s' + % (runner_result.exit_code, + runner_result.output.decode(errors='replace')) + ) + + self.result = self._parse_runner_result( + kolla_toolbox, pdd, user) + + finally: + _exec_run( + kolla_toolbox, + [_PYTHON, '-c', + 'import shutil; shutil.rmtree(%r, ignore_errors=True)' + % pdd], + user='root', + ) def create_container_client(module: AnsibleModule): @@ -273,7 +458,26 @@ def create_container_client(module: AnsibleModule): return client, container_errors -def create_ansible_module() -> AnsibleModule: +class KollaAnsibleModule(AnsibleModule): + """AnsibleModule subclass that redacts known sensitive keys + + Overrides _log_invocation() to add sensitive values to + no_log_values before the "Invoked with ..." syslog message + is written, allowing the rest of module_args to remain + visible for debugging. + """ + + _NO_LOG_KEYS = frozenset({'auth', 'login_password', 'password'}) + + def _log_invocation(self): + for param in ('module_args', 'module_extra_vars'): + for key, value in (self.params.get(param) or {}).items(): + if key in self._NO_LOG_KEYS and value is not None: + self.no_log_values.add(str(value)) + super()._log_invocation() + + +def create_ansible_module() -> KollaAnsibleModule: argument_spec = dict( container_engine=dict(type='str', choices=['podman', 'docker'], @@ -286,8 +490,8 @@ def create_ansible_module() -> AnsibleModule: user=dict(type='str'), ) - return AnsibleModule(argument_spec=argument_spec, - supports_check_mode=True) + return KollaAnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) def main(): @@ -300,7 +504,7 @@ def main(): module.exit_json(**ktbw.result) except Exception: module.fail_json(changed=True, - msg=repr(traceback.format_exc())) + msg=traceback.format_exc()) if __name__ == '__main__': diff --git a/tests/test_kolla_toolbox.py b/tests/test_kolla_toolbox.py index e170964db..89eafdebb 100644 --- a/tests/test_kolla_toolbox.py +++ b/tests/test_kolla_toolbox.py @@ -14,9 +14,11 @@ import builtins import contextlib +import io import json import os import sys +import tarfile from ansible.module_utils import basic from ansible.module_utils.basic import AnsibleModule @@ -103,6 +105,28 @@ class TestKollaToolboxModule(base.BaseTestCase): raise AnsibleFailJson(kwargs) +def _tar_contents(tar_bytes): + """Return {member_name: bytes} for all files in a tar stream.""" + result = {} + with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode='r') as tf: + for member in tf.getmembers(): + if member.isfile(): + result[member.name] = tf.extractfile(member).read() + return result + + +def _make_exec_result(exit_code, output, as_tuple=False): + """Return an exec result compatible with _exec_run normalisation. + + Use as_tuple=True to simulate the Podman SDK plain-tuple return. + """ + raw = (output if isinstance(output, bytes) + else output.encode('utf-8')) + if as_tuple: + return (exit_code, raw) + return kolla_toolbox._ExecResult(exit_code, raw) + + class TestKollaToolboxMethods(TestKollaToolboxModule): """Class focused on testing the methods of KollaToolboxWorker.""" @@ -118,6 +142,7 @@ class TestKollaToolboxMethods(TestKollaToolboxModule): self.mock_ansible_module = mock.MagicMock() self.mock_ansible_module.fail_json.side_effect = self.fail_json self.mock_ansible_module.exit_json.side_effect = self.exit_json + self.mock_ansible_module.check_mode = False # Fake Kolla Toolbox Worker self.fake_ktbw = kolla_toolbox.KollaToolboxWorker( @@ -143,217 +168,486 @@ class TestKollaToolboxMethods(TestKollaToolboxModule): self.assertEqual(ktb_container, ktb_container_returned) - def test_format_module_args(self): - module_args = [ - { - 'module_args': {}, - 'expected_output': [] - }, - { - 'module_args': { - 'path': '/some/folder', - 'state': 'absent'}, - 'expected_output': ["path='/some/folder'", "state='absent'"] - } - ] + def test_exec_run_normalises_docker_namedtuple(self): + """Docker SDK returns namedtuple — _exec_run must pass it through.""" + container = mock.MagicMock() + container.exec_run.return_value = kolla_toolbox._ExecResult(0, b'ok') - for args in module_args: - formatted_args = self.fake_ktbw._format_module_args( - args['module_args']) + result = kolla_toolbox._exec_run(container, ['echo']) - self.assertEqual(args['expected_output'], formatted_args) + self.assertEqual(0, result.exit_code) + self.assertEqual(b'ok', result.output) - @mock.patch('kolla_toolbox.KollaToolboxWorker._format_module_args') - def test_generate_correct_ktb_command(self, mock_formatter): - fake_module_params = { - 'module_args': { - 'path': '/some/folder', - 'state': 'absent' - }, - 'module_extra_vars': { - 'variable': { - 'key': 'pair', - 'list': ['item1', 'item2'] - } - }, - 'user': 'root', - 'module_name': 'file' + def test_exec_run_normalises_podman_tuple(self): + """Podman SDK returns plain tuple — _exec_run must normalise it.""" + container = mock.MagicMock() + container.exec_run.return_value = (0, b'ok') + + result = kolla_toolbox._exec_run(container, ['echo']) + + self.assertEqual(0, result.exit_code) + self.assertEqual(b'ok', result.output) + + +class TestBuildPlaybook(base.BaseTestCase): + """Tests for the _build_playbook helper.""" + + def test_module_args_embedded_as_dict_not_string(self): + """args must be a dict in the playbook, not in proc argv""" + module_args = {'password': 'S3cr3t', 'user': 'admin'} # nosec B105 + result = json.loads( + kolla_toolbox._build_playbook( + 'community.mysql.mysql_user', module_args, {}, False)) + + task = result[0]['tasks'][0] + task_args = task['community.mysql.mysql_user'] + # Must be a dict, not a string + self.assertIsInstance(task_args, dict) + self.assertEqual(module_args, task_args) + # The password must not appear as a bare string anywhere in the + # serialised playbook outside of the JSON-encoded dict value + playbook_str = json.dumps(result) + # It appears once, inside the dict — not as a CLI token + self.assertNotIn("password='S3cr3t'", playbook_str) + + def test_extra_vars_under_play_vars(self): + extra_vars = {'db_host': 'localhost', 'db_port': 3306} + result = json.loads( + kolla_toolbox._build_playbook('ping', {}, extra_vars, False)) + + self.assertEqual(extra_vars, result[0]['vars']) + + def test_empty_extra_vars_not_in_play(self): + result = json.loads( + kolla_toolbox._build_playbook('ping', {}, {}, False)) + + self.assertNotIn('vars', result[0]) + + def test_check_mode_sets_play_check_mode(self): + result = json.loads( + kolla_toolbox._build_playbook('ping', {}, {}, True)) + + self.assertTrue(result[0]['check_mode']) + + def test_check_mode_false_not_in_play(self): + result = json.loads( + kolla_toolbox._build_playbook('ping', {}, {}, False)) + + self.assertNotIn('check_mode', result[0]) + + def test_module_name_is_task_key(self): + result = json.loads( + kolla_toolbox._build_playbook( + 'community.mysql.mysql_db', {}, {}, False)) + + task = result[0]['tasks'][0] + self.assertIn('community.mysql.mysql_db', task) + + +class TestMakeTar(base.BaseTestCase): + """Tests for the _make_tar helper.""" + + def test_files_present_with_correct_content(self): + files = { + 'inventory/hosts': 'localhost\n', + 'project/main.json': '{"play": 1}', } + tar_bytes = kolla_toolbox._make_tar(files).read() + contents = _tar_contents(tar_bytes) - mock_params = mock.MagicMock() - mock_params.get.side_effect = lambda key: fake_module_params.get(key) - self.mock_ansible_module.params = mock_params + self.assertEqual(b'localhost\n', contents['inventory/hosts']) + self.assertEqual(b'{"play": 1}', contents['project/main.json']) - mock_formatter.side_effect = [ - ["path='/some/folder'", "state='absent'"], - ['variable=\'{"key": "pair", "list": ["item1", "item2"]}\''] - ] + def test_parent_directories_created(self): + files = {'a/b/c/file.txt': 'data'} + tar_bytes = kolla_toolbox._make_tar(files).read() - expected_command = ['ansible', 'localhost', '-m', 'file', - '-a', "path='/some/folder' state='absent'", - '-e', 'variable=\'{"key": "pair", ' - '"list": ["item1", "item2"]}\'', - '--check'] + with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode='r') as tf: + names = tf.getnames() - generated_command = self.fake_ktbw._generate_command() + self.assertIn('a', names) + self.assertIn('a/b', names) + self.assertIn('a/b/c', names) + self.assertIn('a/b/c/file.txt', names) - self.assertEqual(expected_command, generated_command) - mock_formatter.assert_has_calls([ - mock.call(fake_module_params['module_args']), - mock.call(fake_module_params['module_extra_vars']) - ]) + def test_bytes_content_not_double_encoded(self): + files = {'file.bin': b'\x00\x01\x02'} + tar_bytes = kolla_toolbox._make_tar(files).read() + contents = _tar_contents(tar_bytes) - def test_run_command_raises_apierror(self): - ktb_container = mock.MagicMock() - api_error = self.mock_container_errors.APIError( - 'API error occurred', explanation='Error explanation') - ktb_container.exec_run.side_effect = api_error + self.assertEqual(b'\x00\x01\x02', contents['file.bin']) - error = self.assertRaises(AnsibleFailJson, - self.fake_ktbw._run_command, - ktb_container, - 'some_command') - self.assertIn('Container engine client encountered API error', - error.result['msg']) + def test_returned_bytesio_at_position_zero(self): + buf = kolla_toolbox._make_tar({'f': 'x'}) + self.assertEqual(0, buf.tell()) - def test_run_command_success(self): - exec_return_value = (0, b'data') - ktb_container = mock.MagicMock() - ktb_container.exec_run.return_value = exec_return_value - self.mock_container_client.containers.list.return_value = [ - ktb_container] - command_output = self.fake_ktbw._run_command( - ktb_container, 'some_command') +class TestPushPrivateDataDir(TestKollaToolboxModule): + """Tests for KollaToolboxWorker._push_private_data_dir.""" - self.assertEqual(exec_return_value[1], command_output) - self.assertIsInstance(command_output, bytes) - ktb_container.exec_run.assert_called_once_with('some_command') + def setUp(self): + super().setUp() + self.mock_ansible_module = mock.MagicMock() + self.mock_ansible_module.fail_json.side_effect = self.fail_json + self.mock_ansible_module.check_mode = False + self.mock_ansible_module.params = { + 'module_name': 'community.mysql.mysql_user', + 'module_args': {'user': 'admin', + 'password': 'S3cr3t'}, # nosec B105 + 'module_extra_vars': {}, + } + self.mock_container_errors = mock.MagicMock() + self.fake_ktbw = kolla_toolbox.KollaToolboxWorker( + self.mock_ansible_module, + mock.MagicMock(), + self.mock_container_errors) - def test_process_container_output_invalid_json(self): - invalid_json = b'this is no json' + self.mock_container = mock.MagicMock() + self.mock_container.exec_run.return_value = _make_exec_result( + 0, b'') # makedirs + self.pdd = '/var/lib/ansible/kolla_runner.test1' + + def _get_put_archive_tar(self): + """Return the tar bytes passed to put_archive.""" + call_args = self.mock_container.put_archive.call_args + tar_arg = call_args.args[1] # second positional arg + return tar_arg.read() + + def test_secrets_not_in_exec_argv(self): + """module_args values must not appear in any exec_run argv.""" + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, None) + + for call in self.mock_container.exec_run.call_args_list: + argv = call[0][0] # first positional arg is the command list + argv_str = ' '.join(str(a) for a in argv) + self.assertNotIn('S3cr3t', argv_str) + self.assertNotIn('admin', argv_str) + + def test_module_args_in_tar_as_dict(self): + """module_args must be a dict in the pushed playbook, not a string.""" + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, None) + + tar_bytes = self._get_put_archive_tar() + contents = _tar_contents(tar_bytes) + playbook = json.loads(contents['project/main.json']) + task_args = playbook[0]['tasks'][0]['community.mysql.mysql_user'] + + self.assertIsInstance(task_args, dict) + self.assertEqual('S3cr3t', task_args['password']) + + def test_inventory_contains_required_vars(self): + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, None) + + tar_bytes = self._get_put_archive_tar() + contents = _tar_contents(tar_bytes) + inventory = contents['inventory/hosts'].decode() + + self.assertIn('ansible_connection=local', inventory) + self.assertIn('ansible_remote_tmp=', inventory) + self.assertIn('ansible_local_tmp=', inventory) + self.assertIn('ansible_python_interpreter=', inventory) + self.assertIn(kolla_toolbox._PYTHON, inventory) + + def test_setup_exec_chown_to_ansible_when_user_is_none(self): + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, None) + + setup_call = self.mock_container.exec_run.call_args_list[0] + script = setup_call[0][0][2] # python3 -c <script> + self.assertIn('chown', script) + self.assertIn('ansible', script) + + def test_setup_exec_no_chown_when_user_is_root(self): + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, 'root') + + setup_call = self.mock_container.exec_run.call_args_list[0] + script = setup_call[0][0][2] + self.assertNotIn('chown', script) + + def test_setup_exec_chown_for_non_root_user(self): + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, 'rabbitmq') + + setup_call = self.mock_container.exec_run.call_args_list[0] + script = setup_call[0][0][2] + self.assertIn('chown', script) + self.assertIn('rabbitmq', script) + + def test_fail_json_when_setup_exec_fails(self): + self.mock_container.exec_run.return_value = _make_exec_result( + 1, b'Permission denied') # makedirs + + error = self.assertRaises( + AnsibleFailJson, + self.fake_ktbw._push_private_data_dir, + self.mock_container, self.pdd, None) + + self.assertIn('Failed to create pdd', error.result['msg']) + # put_archive must not be called after setup failure + self.mock_container.put_archive.assert_not_called() + + def test_put_archive_called_with_pdd(self): + self.fake_ktbw._push_private_data_dir( + self.mock_container, self.pdd, None) + + self.mock_container.put_archive.assert_called_once() + call_path = self.mock_container.put_archive.call_args.args[0] + self.assertEqual(self.pdd, call_path) + + +class TestParseRunnerResult(TestKollaToolboxModule): + """Tests for KollaToolboxWorker._parse_runner_result.""" - error = self.assertRaises(AnsibleFailJson, - self.fake_ktbw._process_container_output, - invalid_json) - self.assertIn('Parsing kolla_toolbox JSON output failed', + def setUp(self): + super().setUp() + self.mock_ansible_module = mock.MagicMock() + self.mock_ansible_module.fail_json.side_effect = self.fail_json + self.fake_ktbw = kolla_toolbox.KollaToolboxWorker( + self.mock_ansible_module, + mock.MagicMock(), + mock.MagicMock()) + self.mock_container = mock.MagicMock() + self.pdd = '/var/lib/ansible/kolla_runner.test1' + + def _result_output(self, event_type, res): + res['_runner_status'] = event_type + return json.dumps(res).encode('utf-8') + + def test_returns_result_on_runner_on_ok(self): + expected = {'changed': False, 'ping': 'pong'} + self.mock_container.exec_run.return_value = _make_exec_result( + # _PARSE_SCRIPT + 0, self._result_output('runner_on_ok', expected.copy())) + + result = self.fake_ktbw._parse_runner_result( + self.mock_container, self.pdd, None) + + self.assertEqual(expected, result) + + def test_fail_json_on_runner_on_failed(self): + self.mock_container.exec_run.return_value = _make_exec_result( + 0, self._result_output( + 'runner_on_failed', {'msg': 'Access denied'})) # _PARSE_SCRIPT + + error = self.assertRaises( + AnsibleFailJson, + self.fake_ktbw._parse_runner_result, + self.mock_container, self.pdd, None) + + self.assertIn('Access denied', error.result['msg']) + + def test_fail_json_on_runner_on_unreachable(self): + self.mock_container.exec_run.return_value = _make_exec_result( + 0, self._result_output( + 'runner_on_unreachable', + {'msg': 'Host unreachable'})) # _PARSE_SCRIPT + + error = self.assertRaises( + AnsibleFailJson, + self.fake_ktbw._parse_runner_result, + self.mock_container, self.pdd, None) + + self.assertIn('Host unreachable', error.result['msg']) + + def test_ansible_no_log_stripped(self): + res = {'changed': True, '_ansible_no_log': True, 'key': 'val'} + self.mock_container.exec_run.return_value = _make_exec_result( + 0, self._result_output('runner_on_ok', res)) # _PARSE_SCRIPT + + result = self.fake_ktbw._parse_runner_result( + self.mock_container, self.pdd, None) + + self.assertNotIn('_ansible_no_log', result) + self.assertEqual('val', result['key']) + + def test_fail_json_on_nonzero_exit_code(self): + self.mock_container.exec_run.return_value = _make_exec_result( + 1, b'something went wrong') # _PARSE_SCRIPT + + error = self.assertRaises( + AnsibleFailJson, + self.fake_ktbw._parse_runner_result, + self.mock_container, self.pdd, None) + + self.assertIn('Failed to read ansible-runner result', error.result['msg']) - def test_process_container_output_invalid_structure(self): - wrong_output_json = { - 'plays': [ - { - 'tasks': [ - { - 'wrong': { - 'control_node': { - 'pong': 'ping' - } - } - } - ] - } - ] - } - encoded_json = json.dumps(wrong_output_json).encode('utf-8') + def test_fail_json_on_invalid_json_output(self): + self.mock_container.exec_run.return_value = _make_exec_result( + 0, b'not valid json') # _PARSE_SCRIPT - error = self.assertRaises(AnsibleFailJson, - self.fake_ktbw._process_container_output, - encoded_json) - self.assertIn('Ansible JSON output has unexpected format', + error = self.assertRaises( + AnsibleFailJson, + self.fake_ktbw._parse_runner_result, + self.mock_container, self.pdd, None) + + self.assertIn('Could not parse ansible-runner result output', error.result['msg']) - def test_process_container_output_deprecation(self): - container_output_deprecation = "[DEPRECATION WARNING] Some deprecation" - container_output_json = { - 'custom_stats': {}, - 'global_custom_stats': {}, - 'plays': [ - { - 'tasks': [ - { - 'hosts': { - 'localhost': { - '_ansible_no_log': False, - 'action': 'ping', - 'changed': False, - 'invocation': { - 'module_args': { - 'data': 'pong' - } - }, - 'ping': 'pong' - } - }, - } - ] - } - ], - } - container_encoded_json = json.dumps(container_output_json) - container_output = (str(container_output_deprecation) + - str(container_encoded_json)).encode('utf-8') - - expected_output = { - 'action': 'ping', - 'changed': False, - 'invocation': { - 'module_args': { - 'data': 'pong' - } - }, - 'ping': 'pong' - } - generated_module_output = self.fake_ktbw._process_container_output( - container_output) - - self.assertNotIn('_ansible_no_log', generated_module_output) - self.assertEqual(expected_output, generated_module_output) - - def test_process_container_output_success(self): - container_output_json = { - 'custom_stats': {}, - 'global_custom_stats': {}, - 'plays': [ - { - 'tasks': [ - { - 'hosts': { - 'localhost': { - '_ansible_no_log': False, - 'action': 'ping', - 'changed': False, - 'invocation': { - 'module_args': { - 'data': 'pong' - } - }, - 'ping': 'pong' - } - }, - } - ] - } - ], - } - container_encoded_json = json.dumps( - container_output_json).encode('utf-8') - - expected_output = { - 'action': 'ping', - 'changed': False, - 'invocation': { - 'module_args': { - 'data': 'pong' - } - }, - 'ping': 'pong' + def test_user_passed_to_exec_run_when_specified(self): + res = {'changed': False} + self.mock_container.exec_run.return_value = _make_exec_result( + 0, self._result_output('runner_on_ok', res)) # _PARSE_SCRIPT + + self.fake_ktbw._parse_runner_result( + self.mock_container, self.pdd, 'rabbitmq') + + call_kwargs = self.mock_container.exec_run.call_args.kwargs + self.assertEqual('rabbitmq', call_kwargs.get('user')) + + def test_user_not_passed_to_exec_run_when_none(self): + res = {'changed': False} + self.mock_container.exec_run.return_value = _make_exec_result( + 0, self._result_output('runner_on_ok', res)) # _PARSE_SCRIPT + + self.fake_ktbw._parse_runner_result( + self.mock_container, self.pdd, None) + + call_kwargs = self.mock_container.exec_run.call_args.kwargs + self.assertNotIn('user', call_kwargs) + + +class TestKollaToolboxWorkerMain(TestKollaToolboxModule): + """Tests for KollaToolboxWorker.main.""" + + def setUp(self): + super().setUp() + self.mock_ansible_module = mock.MagicMock() + self.mock_ansible_module.fail_json.side_effect = self.fail_json + self.mock_ansible_module.check_mode = False + self.mock_ansible_module.params = { + 'module_name': 'ping', + 'module_args': {}, + 'module_extra_vars': {}, + 'user': None, } - generated_module_output = self.fake_ktbw._process_container_output( - container_encoded_json) + self.mock_container_client = mock.MagicMock() + self.mock_container_errors = mock.MagicMock() + self.fake_ktbw = kolla_toolbox.KollaToolboxWorker( + self.mock_ansible_module, + self.mock_container_client, + self.mock_container_errors) + + self.mock_container = mock.MagicMock() + self.mock_container_client.containers.list.return_value = [ + self.mock_container] + # Default: setup exec succeeds, runner exits 0, parse returns ok + ok_result = json.dumps( + {'changed': False, '_runner_status': 'runner_on_ok'} + ).encode() + self.mock_container.exec_run.side_effect = [ + _make_exec_result(0, b''), # test -x ansible-runner + _make_exec_result(0, b''), # makedirs + _make_exec_result(0, b''), # ansible-runner + _make_exec_result(0, ok_result), # _PARSE_SCRIPT + _make_exec_result(0, b''), # cleanup + ] - self.assertNotIn('_ansible_no_log', generated_module_output) - self.assertEqual(expected_output, generated_module_output) + def test_pdd_under_pdd_basedir(self): + """pdd must be under _PDD_BASEDIR, not /tmp.""" + pdd_used = [] + + def capture_push(container, pdd, user): + pdd_used.append(pdd) + # Simulate success without real put_archive + container.put_archive = mock.MagicMock() + + with mock.patch.object(self.fake_ktbw, + '_push_private_data_dir', + side_effect=capture_push), \ + mock.patch.object(self.fake_ktbw, + '_parse_runner_result', + return_value={'changed': False}): + self.fake_ktbw.main() + + self.assertTrue( + pdd_used[0].startswith('/var/lib/ansible')) + + def test_cleanup_called_on_success(self): + ok_result = json.dumps( + {'changed': False, '_runner_status': 'runner_on_ok'} + ).encode() + self.mock_container.exec_run.side_effect = [ + _make_exec_result(0, b''), # test -x ansible-runner + _make_exec_result(0, b''), # makedirs + _make_exec_result(0, b''), # ansible-runner + _make_exec_result(0, ok_result), # _PARSE_SCRIPT + _make_exec_result(0, b''), # cleanup + ] + + with mock.patch.object(self.fake_ktbw, + '_parse_runner_result', + return_value={'changed': False}): + self.fake_ktbw.main() + + # Last exec_run call must be the shutil.rmtree cleanup + last_call = self.mock_container.exec_run.call_args_list[-1] + last_cmd = last_call[0][0] + self.assertIn('shutil', last_cmd[-1]) + self.assertIn('rmtree', last_cmd[-1]) + + def test_cleanup_called_on_failure(self): + """finally block must run cleanup even when ansible-runner fails.""" + self.mock_container.exec_run.side_effect = [ + _make_exec_result(0, b''), # test -x ansible-runner + _make_exec_result(0, b''), # makedirs + _make_exec_result(1, b'runner crashed'), # ansible-runner + _make_exec_result(0, b''), # cleanup + ] + + self.assertRaises(AnsibleFailJson, self.fake_ktbw.main) + + last_call = self.mock_container.exec_run.call_args_list[-1] + last_cmd = last_call[0][0] + self.assertIn('rmtree', last_cmd[-1]) + + def test_fail_json_on_runner_exit_code_other_than_0_or_2(self): + self.mock_container.exec_run.side_effect = [ + _make_exec_result(0, b''), # test -x ansible-runner + _make_exec_result(0, b''), # makedirs + _make_exec_result(4, b'runner internal error'), # ansible-runner + _make_exec_result(0, b''), # cleanup + ] + + error = self.assertRaises(AnsibleFailJson, self.fake_ktbw.main) + self.assertIn('ansible-runner exited with code 4', + error.result['msg']) + + def test_runner_exit_code_2_is_not_fatal(self): + """Exit code 2 means task failed — we handle it via the event.""" + failed_result = json.dumps( + {'changed': False, 'msg': 'task failed', + '_runner_status': 'runner_on_failed'} + ).encode() + self.mock_container.exec_run.side_effect = [ + _make_exec_result(0, b''), # test -x ansible-runner + _make_exec_result(0, b''), # makedirs + _make_exec_result(2, b''), # ansible-runner + _make_exec_result(0, failed_result), # _PARSE_SCRIPT + _make_exec_result(0, b''), # cleanup + ] + + with mock.patch.object( + self.fake_ktbw, + '_parse_runner_result', + side_effect=AnsibleFailJson({'msg': 'task failed'})): + error = self.assertRaises(AnsibleFailJson, self.fake_ktbw.main) + self.assertIn('task failed', error.result['msg']) + + def test_fail_json_when_ansible_runner_not_found(self): + """test -x failing means ansible-runner is missing.""" + self.mock_container.exec_run.side_effect = [ + _make_exec_result(1, b''), # test -x ansible-runner fails + _make_exec_result(0, b''), # cleanup + ] + + error = self.assertRaises(AnsibleFailJson, + self.fake_ktbw.main) + self.assertIn('ansible-runner', error.result['msg']) class TestModuleInteraction(TestKollaToolboxModule): |
