From c803413c52e2c91e48e275aaadff6d1e26a45771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Thu, 17 Oct 2024 11:54:05 +0200 Subject: [PATCH] Move kolla_toolbox to high level client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move from APIClient to PodmanClient and DockerClient clients. Signed-off-by: Roman Krček Change-Id: I6c5734b6fff1bd42929851a263620bb4d959ac25 --- ansible/library/kolla_toolbox.py | 360 +++++++++++----------- tests/kolla-toolbox-testsuite.yml | 89 ------ tests/test-kolla-toolbox.yml | 12 - tests/test_kolla_toolbox.py | 479 ++++++++++++++++++++++++++++++ 4 files changed, 665 insertions(+), 275 deletions(-) delete mode 100644 tests/kolla-toolbox-testsuite.yml delete mode 100644 tests/test-kolla-toolbox.yml create mode 100644 tests/test_kolla_toolbox.py diff --git a/ansible/library/kolla_toolbox.py b/ansible/library/kolla_toolbox.py index d0493a24f4..d368eb67d7 100644 --- a/ansible/library/kolla_toolbox.py +++ b/ansible/library/kolla_toolbox.py @@ -13,13 +13,10 @@ # limitations under the License. import json -import re +import traceback from ansible.module_utils.basic import AnsibleModule -from ast import literal_eval -from shlex import split - DOCUMENTATION = ''' --- module: kolla_toolbox @@ -34,6 +31,7 @@ options: - Name of container engine to use required: True type: str + choices: ['docker', 'podman'] module_name: description: - The module name to invoke @@ -43,12 +41,12 @@ options: description: - The module args use by the module required: False - type: str or dict + type: dict module_extra_vars: description: - The extra variables used by the module required: False - type: str or dict + type: dict user: description: - The user to execute Ansible inside kolla_toolbox with @@ -56,27 +54,29 @@ options: type: str api_version: description: - - The version of the API for docker-py to use when contacting Docker + - The version of the API for client to use when contacting container API required: False type: str default: auto timeout: description: - - The default timeout for docker-py client when contacting Docker API + - The default timeout for client when contacting container API required: False type: int default: 180 -author: Jeffrey Zhang +authors: Jeffrey Zhang, Roman Krček ''' EXAMPLES = ''' - hosts: controller tasks: - - name: Ensure the direct absent + - name: Ensure the directory is removed kolla_toolbox: - container_engine: docker + container_engine: podman module_name: file - module_args: path=/tmp/a state=absent + module_args: + path: /tmp/a + state: absent - name: Create mysql database kolla_toolbox: container_engine: docker @@ -86,7 +86,7 @@ EXAMPLES = ''' login_user: root login_password: admin name: testdb - - name: Creating default user role + - name: Create default user role kolla_toolbox: container_engine: docker module_name: openstack.cloud.identity_role @@ -103,177 +103,189 @@ EXAMPLES = ''' ''' -JSON_REG = re.compile(r'^(?P\w+) \| (?P\w+)!? =>(?P.*)$', - re.MULTILINE | re.DOTALL) -NON_JSON_REG = re.compile((r'^(?P\w+) \| (?P\w+)!? \| ' - r'rc=(?P\d+) >>\n(?P.*)\n$'), - re.MULTILINE | re.DOTALL) +class KollaToolboxWorker(): + def __init__(self, module, client, container_errors) -> None: + self.module = module + self.client = client + self.container_errors = container_errors + self.result = dict() - -def gen_commandline(params): - command = ['ansible', 'localhost'] - if params.get('module_name'): - command.extend(['-m', params.get('module_name')]) - if params.get('module_args'): - try: - module_args = literal_eval(params.get('module_args')) - except SyntaxError: - if not isinstance(params.get('module_args'), str): - raise - - # account for string arguments - module_args = split(params.get('module_args')) - if isinstance(module_args, dict): - module_args = ' '.join("{}='{}'".format(key, value) - for key, value in module_args.items()) - if isinstance(module_args, list): - module_args = ' '.join(module_args) - command.extend(['-a', module_args]) - if params.get('module_extra_vars'): - extra_vars = params.get('module_extra_vars') - if isinstance(extra_vars, dict): - extra_vars = json.dumps(extra_vars) - command.extend(['--extra-vars', extra_vars]) - return command - - -def get_docker_client(): - import docker - return docker.APIClient - - -def use_docker(module): - client = get_docker_client()( - version=module.params.get('api_version'), - timeout=module.params.get('timeout')) - command_line = gen_commandline(module.params) - kolla_toolbox = client.containers(filters=dict(name='kolla_toolbox', - status='running')) - if not kolla_toolbox: - module.fail_json(msg='kolla_toolbox container is not running.') - - kolla_toolbox = kolla_toolbox[0] - kwargs = {} - if 'user' in module.params: - kwargs['user'] = module.params['user'] - - # Use the JSON output formatter, so that we can parse it. - environment = {"ANSIBLE_STDOUT_CALLBACK": "json", - "ANSIBLE_LOAD_CALLBACK_PLUGINS": "True"} - job = client.exec_create(kolla_toolbox, command_line, - environment=environment, **kwargs) - json_output, error = client.exec_start(job, demux=True) - if error: - module.log(msg='Inner module stderr: %s' % error) - - try: - output = json.loads(json_output) - except Exception: - module.fail_json( - msg='Can not parse the inner module output: %s' % json_output) - - # Expected format is the following: - # { - # "plays": [ - # { - # "tasks": [ - # { - # "hosts": { - # "localhost": { - # - # } - # } - # } - # ] - # { - # ] - # } - try: - ret = output['plays'][0]['tasks'][0]['hosts']['localhost'] - except (KeyError, IndexError): - module.fail_json( - msg='Ansible JSON output has unexpected format: %s' % output) - - # Remove Ansible's internal variables from returned fields. - ret.pop('_ansible_no_log', None) - return ret - - -def get_kolla_toolbox(): - from podman import PodmanClient - - with PodmanClient(base_url="http+unix:/run/podman/podman.sock") as client: - for cont in client.containers.list(all=True): - cont.reload() - if cont.name == 'kolla_toolbox' and cont.status == 'running': - return cont - - -def use_podman(module): - from podman.errors.exceptions import APIError - - try: - kolla_toolbox = get_kolla_toolbox() - if not kolla_toolbox: - module.fail_json(msg='kolla_toolbox container is not running.') - - kwargs = {} - if 'user' in module.params: - kwargs['user'] = module.params['user'] - environment = {"ANSIBLE_STDOUT_CALLBACK": "json", - "ANSIBLE_LOAD_CALLBACK_PLUGINS": "True"} - command_line = gen_commandline(module.params) - - _, raw_output = kolla_toolbox.exec_run( - command_line, - environment=environment, - tty=True, - **kwargs + def _get_toolbox_container(self): + """Get the kolla_toolbox container object, if up and running.""" + cont = self.client.containers.list( + filters=dict(name='kolla_toolbox', status='running') ) - except APIError as e: - module.fail_json(msg=f'Encountered Podman API error: {e.explanation}') + if not cont: + self.module.fail_json( + msg='kolla_toolbox container is missing or not running!' + ) + return cont[0] - try: - json_output = raw_output.decode('utf-8') - output = json.loads(json_output) - except Exception: - module.fail_json( - msg='Can not parse the inner module output: %s' % json_output) + 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 - try: - ret = output['plays'][0]['tasks'][0]['hosts']['localhost'] - except (KeyError, IndexError): - module.fail_json( - msg='Ansible JSON output has unexpected format: %s' % output) + 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')) - # Remove Ansible's internal variables from returned fields. - ret.pop('_ansible_no_log', None) + 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 ret + 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: + self.module.fail_json( + msg='Container engine client encountered API error: ' + f'{e.explanation}' + ) + return output_raw + + def _process_container_output(self, output_raw: bytes) -> dict: + """Convert raw bytes output from container.exec_run into dictionary.""" + try: + output_json = json.loads(output_raw.decode('utf-8')) + except json.JSONDecodeError as e: + self.module.fail_json( + msg=f'Parsing kolla_toolbox JSON output failed: {e}' + ) + + # Expected format for the output is the following: + # { + # "plays": [ + # { + # "tasks": [ + # { + # "hosts": { + # "localhost": { + # + # } + # } + # } + # ] + # { + # ] + # } + + try: + result = output_json['plays'][0]['tasks'][0]['hosts']['localhost'] + result.pop('_ansible_no_log', None) + except KeyError: + self.module.fail_json( + msg=f'Ansible JSON output has unexpected format: {output_json}' + ) + + return result + + def main(self) -> None: + """Run command inside the kolla_toolbox container with defined args.""" + + kolla_toolbox = self._get_toolbox_container() + command = self._generate_command() + environment = {'ANSIBLE_STDOUT_CALLBACK': 'json', + 'ANSIBLE_LOAD_CALLBACK_PLUGINS': 'True'} + + output_raw = self._run_command(kolla_toolbox, + command, + environment=environment, + tty=True, + user=self.module.params.get('user')) + + self.result = self._process_container_output(output_raw) + + +def create_container_client(module: AnsibleModule): + """Return container engine client based on the parameters.""" + container_engine = module.params.get('container_engine') + api_version = module.params.get('api_version') + timeout = module.params.get('timeout') + + if container_engine == 'docker': + try: + import docker + import docker.errors as container_errors + except ImportError: + module.fail_json( + msg='The docker library could not be imported!' + ) + client = docker.DockerClient( + base_url='http+unix:/var/run/docker.sock', + version=api_version, + timeout=timeout) + else: + try: + import podman + import podman.errors as container_errors + except ImportError: + module.fail_json( + msg='The podman library could not be imported!' + ) + # NOTE(r-krcek): PodmanClient has a glitch in which when you pass + # 'auto' to api_version, it literally creates an url of /vauto + # for API calls, instead of actually finding the compatible version + # like /v5.0.0, this leads to 404 Error when accessing the API. + if api_version == 'auto': + client = podman.PodmanClient( + base_url='http+unix:/run/podman/podman.sock', + timeout=timeout) + else: + client = podman.PodmanClient( + base_url='http+unix:/run/podman/podman.sock', + version=api_version, + timeout=timeout) + return client, container_errors + + +def create_ansible_module() -> AnsibleModule: + argument_spec = dict( + container_engine=dict(type='str', + choices=['podman', 'docker'], + required=True), + module_name=dict(type='str', required=True), + module_args=dict(type='dict', default=dict()), + module_extra_vars=dict(type='dict', default=dict()), + api_version=dict(type='str', default='auto'), + timeout=dict(type='int', default=180), + user=dict(type='str'), + ) + + return AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) def main(): - specs = dict( - container_engine=dict(required=True, type='str'), - module_name=dict(required=True, type='str'), - module_args=dict(type='str'), - module_extra_vars=dict(type='json'), - api_version=dict(required=False, type='str', default='auto'), - timeout=dict(required=False, type='int', default=180), - user=dict(required=False, type='str'), - ) - module = AnsibleModule(argument_spec=specs, bypass_checks=True) + module = create_ansible_module() + client, container_errors = create_container_client(module) + ktbw = KollaToolboxWorker(module, client, container_errors) - container_engine = module.params.get('container_engine').lower() - if container_engine == 'docker': - result = use_docker(module) - elif container_engine == 'podman': - result = use_podman(module) - else: - module.fail_json(msg='Missing or invalid container engine.') - - module.exit_json(**result) + try: + ktbw.main() + module.exit_json(**ktbw.result) + except Exception: + module.fail_json(changed=True, + msg=repr(traceback.format_exc())) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/tests/kolla-toolbox-testsuite.yml b/tests/kolla-toolbox-testsuite.yml deleted file mode 100644 index 23a3fb08fd..0000000000 --- a/tests/kolla-toolbox-testsuite.yml +++ /dev/null @@ -1,89 +0,0 @@ ---- -- name: Test successful & unchanged - kolla_toolbox: - common_options: - container_engine: "{{ item }}" - module_name: debug - module_args: - msg: hi - register: result - -- name: Assert result is successful - assert: - that: result is successful - -- name: Assert result is not changed - assert: - that: result is not changed - -- name: Test successful & changed - kolla_toolbox: - common_options: - container_engine: "{{ item }}" - module_name: command - module_args: - echo hi - register: result - -- name: Assert result is successful - assert: - that: result is successful - -- name: Assert result is changed - assert: - that: result is changed - -- name: Test unsuccessful - kolla_toolbox: - common_options: - container_engine: "{{ item }}" - module_name: command - module_args: - foo - register: result - ignore_errors: true - -- name: Assert result is failed - assert: - that: result is failed - -- name: Test invalid module parameters - kolla_toolbox: - common_options: - container_engine: "{{ item }}" - module_name: debug - module_args: - foo: bar - register: result - ignore_errors: true - -- name: Assert result is failed - assert: - that: result is failed - -- name: Setup for Test successful & changed (JSON format) - kolla_toolbox: - common_options: - container_engine: "{{ item }}" - module_name: file - module_args: - path: /tmp/foo - state: absent - -- name: Test successful & changed (JSON format) - kolla_toolbox: - common_options: - container_engine: "{{ item }}" - module_name: file - module_args: - path: /tmp/foo - state: directory - register: result - -- name: Assert result is successful - assert: - that: result is successful - -- name: Assert result is changed - assert: - that: result is changed diff --git a/tests/test-kolla-toolbox.yml b/tests/test-kolla-toolbox.yml deleted file mode 100644 index 566d53220e..0000000000 --- a/tests/test-kolla-toolbox.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -- name: Test the kolla_toolbox module - hosts: localhost - gather_facts: false - vars: - container_engines: - - "docker" - - "podman" - tasks: - - name: Test kolla-toolbox for each container engine - include_tasks: kolla-toolbox-testsuite.yml - with_items: "{{ container_engines }}" diff --git a/tests/test_kolla_toolbox.py b/tests/test_kolla_toolbox.py new file mode 100644 index 0000000000..112bd403c8 --- /dev/null +++ b/tests/test_kolla_toolbox.py @@ -0,0 +1,479 @@ +# Copyright 2024 Tietoevry +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import builtins +import json +import os +import sys + +from ansible.module_utils import basic +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_bytes +from importlib.machinery import SourceFileLoader +from oslotest import base +from unittest import mock + +# Import kolla_toolbox module using SourceFileLoader +this_dir = os.path.dirname(sys.modules[__name__].__file__) +ansible_dir = os.path.join(this_dir, '..', 'ansible') +kolla_toolbox_file = os.path.join(ansible_dir, 'library', 'kolla_toolbox.py') + +kolla_toolbox = SourceFileLoader('kolla_toolbox', + kolla_toolbox_file).load_module() + + +def set_module_args(args): + """Prepare arguments so they will be picked up during module creation.""" + + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(BaseException): + """Exception to be raised by module.exit_json and caught by a test case.""" + + def __init__(self, kwargs): + super().__init__(kwargs) + self.result = kwargs + + +class AnsibleFailJson(BaseException): + """Exception to be raised by module.fail_json and caught by a test case.""" + + def __init__(self, kwargs): + super().__init__(kwargs) + self.result = kwargs + + +class MockAPIError(Exception): + """Mock exception to be raised to simulate engine client APIError.""" + + def __init__(self, message, explanation=None): + super().__init__(message) + self.explanation = explanation + + +class TestKollaToolboxModule(base.BaseTestCase): + """Base class for the module's tests. + + Sets up methods that patch over the module's fail_json and exit_json, + so that they dont just call sys.exit() and instead they return + value of the result. + """ + + def setUp(self): + super().setUp() + + self.fail_json_patch = mock.patch( + 'ansible.module_utils.basic.AnsibleModule.fail_json', + side_effect=self.fail_json) + self.exit_json_patch = mock.patch( + 'ansible.module_utils.basic.AnsibleModule.exit_json', + side_effect=self.exit_json) + + self.fail_json_mock = self.fail_json_patch.start() + self.exit_json_mock = self.exit_json_patch.start() + + def tearDown(self): + super().tearDown() + self.fail_json_patch.stop() + self.exit_json_patch.stop() + + def exit_json(self, *args, **kwargs): + raise AnsibleExitJson(kwargs) + + def fail_json(self, *args, **kwargs): + raise AnsibleFailJson(kwargs) + + +class TestKollaToolboxMethods(TestKollaToolboxModule): + """Class focused on testing the methods of KollaToolboxWorker.""" + + def setUp(self): + super().setUp() + + # Mock container client + self.mock_container_client = mock.MagicMock() + self.mock_container_errors = mock.MagicMock() + self.mock_container_errors.APIError = MockAPIError + + # Mock Ansible module + 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 + + # Fake Kolla Toolbox Worker + self.fake_ktbw = kolla_toolbox.KollaToolboxWorker( + self.mock_ansible_module, + self.mock_container_client, + self.mock_container_errors) + + def test_ktb_container_missing_or_not_running(self): + self.mock_container_client.containers.list.return_value = [] + + error = self.assertRaises(AnsibleFailJson, + self.fake_ktbw._get_toolbox_container) + self.assertIn("kolla_toolbox container is missing or not running!", + error.result["msg"]) + + def test_get_ktb_container_success(self): + ktb_container = mock.MagicMock() + other_container = mock.MagicMock() + self.mock_container_client.containers.list.return_value = [ + ktb_container, other_container] + + ktb_container_returned = self.fake_ktbw._get_toolbox_container() + + 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'"] + } + ] + + for args in module_args: + formatted_args = self.fake_ktbw._format_module_args( + args['module_args']) + + self.assertEqual(args['expected_output'], formatted_args) + + @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' + } + + mock_params = mock.MagicMock() + mock_params.get.side_effect = lambda key: fake_module_params.get(key) + self.mock_ansible_module.params = mock_params + + mock_formatter.side_effect = [ + ["path='/some/folder'", "state='absent'"], + ['variable=\'{"key": "pair", "list": ["item1", "item2"]}\''] + ] + + expected_command = ['ansible', 'localhost', '-m', 'file', + '-a', "path='/some/folder' state='absent'", + '-e', 'variable=\'{"key": "pair", ' + '"list": ["item1", "item2"]}\'', + '--check'] + + generated_command = self.fake_ktbw._generate_command() + + 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_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 + + 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_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') + + self.assertEqual(exec_return_value[1], command_output) + self.assertIsInstance(command_output, bytes) + ktb_container.exec_run.assert_called_once_with('some_command') + + def test_process_container_output_invalid_json(self): + invalid_json = b'this is no json' + + error = self.assertRaises(AnsibleFailJson, + self.fake_ktbw._process_container_output, + invalid_json) + self.assertIn('Parsing kolla_toolbox JSON output failed', + 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') + + error = self.assertRaises(AnsibleFailJson, + self.fake_ktbw._process_container_output, + encoded_json) + self.assertIn('Ansible JSON output has unexpected format', + error.result['msg']) + + 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' + } + generated_module_output = self.fake_ktbw._process_container_output( + container_encoded_json) + + self.assertNotIn('_ansible_no_log', generated_module_output) + self.assertEqual(expected_output, generated_module_output) + + +class TestModuleInteraction(TestKollaToolboxModule): + """Class focused on testing user input data from playbook.""" + + def test_create_ansible_module_missing_required_module_name(self): + set_module_args({ + 'container_engine': 'docker' + }) + + error = self.assertRaises(AnsibleFailJson, + kolla_toolbox.create_ansible_module) + self.assertIn('missing required arguments: module_name', + error.result['msg']) + + def test_create_ansible_module_missing_required_container_engine(self): + set_module_args({ + 'module_name': 'url' + }) + + error = self.assertRaises(AnsibleFailJson, + kolla_toolbox.create_ansible_module) + self.assertIn('missing required arguments: container_engine', + error.result['msg']) + + def test_create_ansible_module_invalid_container_engine(self): + set_module_args({ + 'module_name': 'url', + 'container_engine': 'podmano' + }) + + error = self.assertRaises(AnsibleFailJson, + kolla_toolbox.create_ansible_module) + self.assertIn( + 'value of container_engine must be one of: podman, docker', + error.result['msg'] + ) + + def test_create_ansible_module_success(self): + args = { + 'container_engine': 'docker', + 'module_name': 'file', + 'module_args': { + 'path': '/some/folder', + 'state': 'absent' + }, + 'module_extra_vars': { + 'variable': { + 'key': 'pair', + 'list': ['item1', 'item2'] + } + }, + 'user': 'root', + 'timeout': 180, + 'api_version': '1.5' + } + set_module_args(args) + + module = kolla_toolbox.create_ansible_module() + + self.assertIsInstance(module, AnsibleModule) + self.assertEqual(args, module.params) + + +class TestContainerEngineClientIntraction(TestKollaToolboxModule): + """Class focused on testing container engine client creation.""" + + def setUp(self): + super().setUp() + self.module_to_mock_import = '' + self.original_import = builtins.__import__ + + def mock_import_error(self, name, globals, locals, fromlist, level): + """Mock import function to raise ImportError for a specific module.""" + + if name == self.module_to_mock_import: + raise ImportError(f'No module named {name}') + return self.original_import(name, globals, locals, fromlist, level) + + def test_podman_client_params(self): + set_module_args({ + 'module_name': 'ping', + 'container_engine': 'podman', + 'api_version': '1.47', + 'timeout': 155 + }) + + module = kolla_toolbox.create_ansible_module() + mock_podman = mock.MagicMock() + mock_podman_errors = mock.MagicMock() + import_dict = {'podman': mock_podman, + 'podman.errors': mock_podman_errors} + + with mock.patch.dict('sys.modules', import_dict): + kolla_toolbox.create_container_client(module) + mock_podman.PodmanClient.assert_called_with( + base_url='http+unix:/run/podman/podman.sock', + version='1.47', + timeout=155 + ) + + def test_docker_client_params(self): + set_module_args({ + 'module_name': 'ping', + 'container_engine': 'docker', + 'api_version': '1.47', + 'timeout': 155 + }) + + module = kolla_toolbox.create_ansible_module() + mock_docker = mock.MagicMock() + mock_docker_errors = mock.MagicMock() + import_dict = {'docker': mock_docker, + 'docker.errors': mock_docker_errors} + + with mock.patch.dict('sys.modules', import_dict): + kolla_toolbox.create_container_client(module) + mock_docker.DockerClient.assert_called_with( + base_url='http+unix:/var/run/docker.sock', + version='1.47', + timeout=155 + ) + + def test_create_container_client_podman_not_called_with_auto(self): + set_module_args({ + 'module_name': 'ping', + 'container_engine': 'podman', + 'api_version': 'auto', + 'timeout': 90 + }) + + module = kolla_toolbox.create_ansible_module() + mock_podman = mock.MagicMock() + mock_podman_errors = mock.MagicMock() + import_dict = {'podman': mock_podman, + 'podman.errors': mock_podman_errors} + + with mock.patch.dict('sys.modules', import_dict): + kolla_toolbox.create_container_client(module) + mock_podman.PodmanClient.assert_called_with( + base_url='http+unix:/run/podman/podman.sock', + timeout=90 + ) + + def test_create_container_client_podman_importerror(self): + set_module_args({ + 'module_name': 'ping', + 'container_engine': 'podman' + }) + self.module_to_mock_import = 'podman' + module = kolla_toolbox.create_ansible_module() + + with mock.patch('builtins.__import__', + side_effect=self.mock_import_error): + error = self.assertRaises(AnsibleFailJson, + kolla_toolbox.create_container_client, + module) + self.assertIn('The podman library could not be imported!', + error.result['msg']) + + def test_create_container_client_docker_importerror(self): + set_module_args({ + 'module_name': 'ping', + 'container_engine': 'docker' + }) + + self.module_to_mock_import = 'docker' + module = kolla_toolbox.create_ansible_module() + + with mock.patch('builtins.__import__', + side_effect=self.mock_import_error): + error = self.assertRaises(AnsibleFailJson, + kolla_toolbox.create_container_client, + module) + self.assertIn('The docker library could not be imported!', + error.result['msg'])