aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ansible/library/kolla_toolbox.py398
-rw-r--r--tests/test_kolla_toolbox.py670
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):