# Copyright 2016 99cloud Inc. # # 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. from distutils.version import StrictVersion import json import re from ansible.module_utils.ansible_release import __version__ as ansible_version from ansible.module_utils.basic import AnsibleModule from ast import literal_eval from shlex import split DOCUMENTATION = ''' --- 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 container as used by Kolla project. options: container_engine: description: - Name of container engine to use required: True type: str module_name: description: - The module name to invoke required: True type: str module_args: description: - The module args use by the module required: False type: str or dict module_extra_vars: description: - The extra variables used by the module required: False type: str or dict user: description: - The user to execute Ansible inside kolla_toolbox with required: False type: str api_version: description: - The version of the API for docker-py to use when contacting Docker required: False type: str default: auto timeout: description: - The default timeout for docker-py client when contacting Docker API required: False type: int default: 180 author: Jeffrey Zhang ''' EXAMPLES = ''' - hosts: controller tasks: - name: Ensure the direct absent kolla_toolbox: container_engine: docker module_name: file module_args: path=/tmp/a state=absent - name: Create mysql database kolla_toolbox: container_engine: docker module_name: mysql_db module_args: login_host: 192.168.1.10 login_user: root login_password: admin name: testdb - name: Creating default user role kolla_toolbox: container_engine: docker module_name: os_keystone_role module_args: name: member auth: "{{ '{{ openstack_keystone_auth }}' }}" module_extra_vars: openstack_keystone_auth: auth_url: http://127.0.0.1:5000 username: admin password: password project_name: "admin" domain_name: "default" ''' 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) def gen_commandline(params): command = ['ansible', 'localhost'] if params.get('module_name'): command.extend(['-m', params.get('module_name')]) if params.get('module_args'): if StrictVersion(ansible_version) < StrictVersion('2.11.0'): module_args = params.get('module_args') else: 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 docker_supports_environment_in_exec(client): docker_version = StrictVersion(client.api_version) return docker_version >= StrictVersion('1.25') 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'] # NOTE(mgoddard): Docker 1.12 has API version 1.24, and was installed by # kolla-ansible bootstrap-servers on Rocky and earlier releases. This API # version does not have support for specifying environment variables for # exec jobs, which is necessary to use the Ansible JSON output formatter. # While we continue to support this version of Docker, fall back to the old # regex-based method for API version 1.24 and earlier. # TODO(mgoddard): Remove this conditional (keep the if) when we require # Docker API version 1.25+. if docker_supports_environment_in_exec(client): # 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 = client.exec_start(job) 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) else: job = client.exec_create(kolla_toolbox, command_line, **kwargs) output = client.exec_start(job) for exp in [JSON_REG, NON_JSON_REG]: m = exp.match(output) if m: inner_output = m.groupdict().get('stdout') status = m.groupdict().get('status') break else: module.fail_json( msg='Can not parse the inner module output: %s' % output) ret = dict() try: ret = json.loads(inner_output) except ValueError: # Some modules (e.g. command) do not produce a JSON output. # Instead, check the status, and assume changed on success. ret['stdout'] = inner_output if status != "SUCCESS": ret['failed'] = True else: # No way to know whether changed - assume yes. ret['changed'] = True 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 ) except APIError as e: module.fail_json(msg=f'Encountered Podman API error: {e.explanation}') 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) 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 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) 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) if __name__ == "__main__": main()