Skip to content
Snippets Groups Projects
kolla_docker.py 19.9 KiB
Newer Older
  • Learn to ignore specific revisions
  • Sam Yaple's avatar
    Sam Yaple committed
    #!/usr/bin/python
    
    # Copyright 2015 Sam Yaple
    #
    # 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.
    
    DOCUMENTATION = '''
    ---
    module: kolla_docker
    
    short_description: Module for controlling Docker
    
    Sam Yaple's avatar
    Sam Yaple committed
    description:
    
         - A module targeting at controlling Docker as used by Kolla.
    
    Sam Yaple's avatar
    Sam Yaple committed
    options:
    
      common_options:
    
    Sam Yaple's avatar
    Sam Yaple committed
        description:
    
          - A dict containing common params such as login info
        required: False
        type: dict
        default: dict()
      action:
        description:
          - The action the module should take
    
    Sam Yaple's avatar
    Sam Yaple committed
        required: True
    
        type: str
        choices:
    
          - compare_image
    
          - create_volume
          - pull_image
          - remove_container
          - remove_volume
          - start_container
    
          - stop_container
    
      api_version:
        description:
          - The version of the api for docker-py to use when contacting docker
        required: False
        type: str
        default: auto
      auth_email:
        description:
          - The email address used to authenticate
        required: False
        type: str
      auth_password:
        description:
          - The password used to authenticate
        required: False
        type: str
      auth_registry:
        description:
          - The registry to authenticate to
        required: False
        type: str
      auth_username:
        description:
          - The username used to authenticate
        required: False
        type: str
      detach:
        description:
          - Detach from the container after it is created
        required: False
        default: True
        type: bool
      name:
        description:
          - Name of the container or volume to manage
        required: False
        type: str
      environment:
        description:
          - The environment to set for the container
        required: False
        type: dict
      image:
        description:
          - Name of the docker image
        required: False
        type: str
    
    SamYaple's avatar
    SamYaple committed
      labels:
        description:
          - List of labels to apply to container
        required: False
        type: dict
        default: dict()
    
      pid_mode:
        description:
          - Set docker pid namespace
        required: False
        type: str
        default: None
        choices:
          - host
      privileged:
        description:
          - Set the container to privileged
        required: False
        default: False
        type: bool
      remove_on_exit:
        description:
          - When not detaching from container, remove on successful exit
        required: False
        default: True
    
    Sam Yaple's avatar
    Sam Yaple committed
        type: bool
    
      restart_policy:
        description:
          - Determine what docker does when the container exits
        required: False
        type: str
        choices:
          - never
          - on-failure
          - always
      restart_retries:
        description:
          - How many times to attempt a restart if restart_policy is set
        type: int
        default: 10
      volumes:
        description:
          - Set volumes for docker to use
        required: False
        type: list
      volumes_from:
        description:
          - Name or id of container(s) to use volumes from
        required: True
        type: list
    
    Sam Yaple's avatar
    Sam Yaple committed
    author: Sam Yaple
    '''
    
    EXAMPLES = '''
    - hosts: kolla_docker
      tasks:
        - name: Start container
          kolla_docker:
    
            image: ubuntu
            name: test_container
            action: start_container
        - name: Remove container
          kolla_docker:
            name: test_container
            action: remove_container
        - name: Pull image without starting container
          kolla_docker:
            action: pull_container
            image: private-registry.example.com:5000/ubuntu
        - name: Create named volume
            action: create_volume
            name: name_of_volume
        - name: Remove named volume
            action: remove_volume
            name: name_of_volume
    
    Sam Yaple's avatar
    Sam Yaple committed
    '''
    
    import os
    
    import docker
    
    
    class DockerWorker(object):
    
        def __init__(self, module):
            self.module = module
            self.params = self.module.params
            self.changed = False
    
            # TLS not fully implemented
            # tls_config = self.generate_tls()
    
            options = {
                'version': self.params.get('api_version')
            }
    
            self.dc = docker.Client(**options)
    
        def generate_tls(self):
            tls = {'verify': self.params.get('tls_verify')}
            tls_cert = self.params.get('tls_cert'),
            tls_key = self.params.get('tls_key'),
            tls_cacert = self.params.get('tls_cacert')
    
            if tls['verify']:
                if tlscert:
                    self.check_file(tls['tls_cert'])
                    self.check_file(tls['tls_key'])
                    tls['client_cert'] = (tls_cert, tls_key)
                if tlscacert:
                    self.check_file(tls['tls_cacert'])
                    tls['verify'] = tls_cacert
    
            return docker.tls.TLSConfig(**tls)
    
        def check_file(self, path):
            if not os.path.isfile(path):
                self.module.fail_json(
                    failed=True,
                    msg='There is no file at "{}"'.format(path)
                )
            if not os.access(path, os.R_OK):
                self.module.fail_json(
                    failed=True,
                    msg='Permission denied for file at "{}"'.format(path)
                )
    
        def check_image(self):
            find_image = ':'.join(self.parse_image())
            for image in self.dc.images():
                for image_name in image['RepoTags']:
                    if image_name == find_image:
                        return image
    
        def check_volume(self):
    
            for vol in self.dc.volumes()['Volumes'] or list():
    
    Sam Yaple's avatar
    Sam Yaple committed
                if vol['Name'] == self.params.get('name'):
                    return vol
    
        def check_container(self):
            find_name = '/{}'.format(self.params.get('name'))
            for cont in self.dc.containers(all=True):
                if find_name in cont['Names']:
                    return cont
    
    
        def get_container_info(self):
    
    Sam Yaple's avatar
    Sam Yaple committed
            container = self.check_container()
            if not container:
    
                return None
            return self.dc.inspect_container(self.params.get('name'))
    
        def check_container_differs(self):
            container_info = self.get_container_info()
    
    Sam Yaple's avatar
    Sam Yaple committed
            return (
                self.compare_image(container_info) or
    
    SamYaple's avatar
    SamYaple committed
                self.compare_labels(container_info) or
    
    Sam Yaple's avatar
    Sam Yaple committed
                self.compare_privileged(container_info) or
                self.compare_pid_mode(container_info) or
                self.compare_volumes(container_info) or
                self.compare_volumes_from(container_info) or
                self.compare_environment(container_info)
            )
    
        def compare_pid_mode(self, container_info):
            new_pid_mode = self.params.get('pid_mode')
            current_pid_mode = container_info['HostConfig'].get('PidMode')
            if not current_pid_mode:
                current_pid_mode = None
    
            if new_pid_mode != current_pid_mode:
                return True
    
        def compare_privileged(self, container_info):
            new_privileged = self.params.get('privileged')
            current_privileged = container_info['HostConfig']['Privileged']
            if new_privileged != current_privileged:
                return True
    
    
        def compare_image(self, container_info=None):
            container_info = container_info or self.get_container_info()
            if not container_info:
                return True
    
    Sam Yaple's avatar
    Sam Yaple committed
            new_image = self.check_image()
            current_image = container_info['Image']
            if new_image['Id'] != current_image:
                return True
    
    
    SamYaple's avatar
    SamYaple committed
        def compare_labels(self, container_info):
            new_labels = self.params.get('labels')
            current_labels = container_info['Config'].get('Labels', dict())
            image_labels = self.check_image().get('Labels', dict())
            for k, v in image_labels.iteritems():
                if k in new_labels:
                    if v != new_labels[k]:
                        return True
                else:
                    del current_labels[k]
    
            if new_labels != current_labels:
                return True
    
    
    Sam Yaple's avatar
    Sam Yaple committed
        def compare_volumes_from(self, container_info):
            new_vols_from = self.params.get('volumes_from')
            current_vols_from = container_info['HostConfig'].get('VolumesFrom')
            if not new_vols_from:
                new_vols_from = list()
            if not current_vols_from:
                current_vols_from = list()
    
            if set(current_vols_from).symmetric_difference(set(new_vols_from)):
                return True
    
        def compare_volumes(self, container_info):
            volumes, binds = self.generate_volumes()
            current_vols = container_info['Config'].get('Volumes')
            current_binds = container_info['HostConfig'].get('Binds')
            if not volumes:
                volumes = list()
            if not current_vols:
                current_vols = list()
            if not current_binds:
                current_binds = list()
    
            if set(volumes).symmetric_difference(set(current_vols)):
                return True
    
            new_binds = list()
            if binds:
                for k, v in binds.iteritems():
                    new_binds.append("{}:{}:{}".format(k, v['bind'], v['mode']))
    
            if set(new_binds).symmetric_difference(set(current_binds)):
                return True
    
        def compare_environment(self, container_info):
            if self.params.get('environment'):
                current_env = dict()
                for kv in container_info['Config'].get('Env', list()):
                    k, v = kv.split('=', 1)
                    current_env.update({k: v})
    
                for k, v in self.params.get('environment').iteritems():
                    if k not in current_env:
                        return True
                    if current_env[k] != v:
                        return True
    
        def parse_image(self):
            full_image = self.params.get('image')
    
            if '/' in full_image:
                registry, image = full_image.split('/', 1)
            else:
                image = full_image
    
            if ':' in image:
                return full_image.rsplit(':', 1)
            else:
                return full_image, 'latest'
    
        def pull_image(self):
            if self.params.get('auth_username'):
                self.dc.login(
                    username=self.params.get('auth_username'),
                    password=self.params.get('auth_password'),
                    registry=self.params.get('auth_registry'),
                    email=self.params.get('auth_email')
                )
    
            image, tag = self.parse_image()
    
    
    Sam Yaple's avatar
    Sam Yaple committed
                json.loads(line.strip()) for line in self.dc.pull(
                    repository=image, tag=tag, stream=True
                )
            ]
    
    
            for status in reversed(statuses):
    
                if 'error' in status:
                    if status['error'].endswith('not found'):
                        self.module.fail_json(
                            msg="The requested image does not exist: {}:{}".format(
                                image, tag),
                            failed=True
                        )
                    else:
                        self.module.fail_json(
                            msg="Unknown error message: {}".format(
                                status['error']),
                            failed=True
                        )
    
    
                if status and status.get('status'):
                    # NOTE(SamYaple): This allows us to use v1 and v2 docker
                    # registries.  Eventually docker will stop supporting v1
                    # registries and when that happens we can remove this.
    
                    if 'legacy registry' in status['status']:
    
                    elif 'Downloaded newer image for' in status['status']:
    
                    elif 'Image is up to date for' in status['status']:
    
                        return
                    else:
                        self.module.fail_json(
    
                            msg="Unknown status message: {}".format(
                                status['status']),
    
    Sam Yaple's avatar
    Sam Yaple committed
    
        def remove_container(self):
            if self.check_container():
                self.changed = True
                self.dc.remove_container(
                    container=self.params.get('name'),
                    force=True
                )
    
        def generate_volumes(self):
            volumes = self.params.get('volumes')
            if not volumes:
                return None, None
    
            vol_list = list()
            vol_dict = dict()
    
            for vol in volumes:
                if ':' not in vol:
                    vol_list.append(vol)
                    continue
    
                split_vol = vol.split(':')
    
                if (len(split_vol) == 2
                   and ('/' not in split_vol[0] or '/' in split_vol[1])):
                    split_vol.append('rw')
    
                vol_list.append(split_vol[1])
                vol_dict.update({
                    split_vol[0]: {
                        'bind': split_vol[1],
                        'mode': split_vol[2]
                    }
                })
    
            return vol_list, vol_dict
    
        def build_host_config(self, binds):
            options = {
                'network_mode': 'host',
                'pid_mode': self.params.get('pid_mode'),
                'privileged': self.params.get('privileged'),
                'volumes_from': self.params.get('volumes_from')
            }
    
            if self.params.get('restart_policy') in ['on-failure', 'always']:
                options['restart_policy'] = {
                    'Name': self.params.get('restart_policy'),
                    'MaximumRetryCount': self.params.get('restart_retries')
                }
    
            if binds:
                options['binds'] = binds
    
            return self.dc.create_host_config(**options)
    
        def build_container_options(self):
            volumes, binds = self.generate_volumes()
            return {
                'detach': self.params.get('detach'),
                'environment': self.params.get('environment'),
                'host_config': self.build_host_config(binds),
    
    SamYaple's avatar
    SamYaple committed
                'labels': self.params.get('labels'),
    
    Sam Yaple's avatar
    Sam Yaple committed
                'image': self.params.get('image'),
                'name': self.params.get('name'),
                'volumes': volumes,
                'tty': True
            }
    
        def create_container(self):
            self.changed = True
            options = self.build_container_options()
            self.dc.create_container(**options)
    
        def start_container(self):
            if not self.check_image():
                self.pull_image()
    
            container = self.check_container()
            if container and self.check_container_differs():
                self.remove_container()
                container = self.check_container()
    
            if not container:
                self.create_container()
                container = self.check_container()
    
            if not container['Status'].startswith('Up '):
                self.changed = True
                self.dc.start(container=self.params.get('name'))
    
            # We do not want to detach so we wait around for container to exit
            if not self.params.get('detach'):
                rc = self.dc.wait(self.params.get('name'))
                if rc != 0:
                    self.module.fail_json(
                        failed=True,
                        changed=True,
                        msg="Container exited with non-zero return code"
                    )
                if self.params.get('remove_on_exit'):
                    self.remove_container()
    
    
        def stop_container(self):
            name = self.params.get('name')
            container = self.check_container()
            if not container['Status'].startswith('Exited '):
                self.changed = True
                self.dc.stop(name)
    
    
    Sam Yaple's avatar
    Sam Yaple committed
        def create_volume(self):
            if not self.check_volume():
                self.changed = True
                self.dc.create_volume(name=self.params.get('name'), driver='local')
    
        def remove_volume(self):
            if self.check_volume():
                self.changed = True
                try:
                    self.dc.remove_volume(name=self.params.get('name'))
                except docker.errors.APIError as e:
                    if e.response.status_code == 409:
                        self.module.fail_json(
                            failed=True,
                            msg="Volume named '{}' is currently in-use".format(
                                self.params.get('name')
                            )
                        )
                    raise
    
    
    def generate_module():
        argument_spec = dict(
            common_options=dict(required=False, type='dict', default=dict()),
    
            action=dict(requried=True, type='str', choices=['compare_image',
                                                            'create_volume',
    
    Sam Yaple's avatar
    Sam Yaple committed
                                                            'pull_image',
                                                            'remove_container',
                                                            'remove_volume',
    
                                                            'start_container',
                                                            'stop_container']),
    
    Sam Yaple's avatar
    Sam Yaple committed
            api_version=dict(required=False, type='str', default='auto'),
            auth_email=dict(required=False, type='str'),
            auth_password=dict(required=False, type='str'),
            auth_registry=dict(required=False, type='str'),
            auth_username=dict(required=False, type='str'),
            detach=dict(required=False, type='bool', default=True),
    
    SamYaple's avatar
    SamYaple committed
            labels=dict(required=False, type='dict', default=dict()),
    
            name=dict(required=False, type='str'),
    
    Sam Yaple's avatar
    Sam Yaple committed
            environment=dict(required=False, type='dict'),
            image=dict(required=False, type='str'),
            pid_mode=dict(required=False, type='str', choices=['host']),
            privileged=dict(required=False, type='bool', default=False),
            remove_on_exit=dict(required=False, type='bool', default=True),
            restart_policy=dict(required=False, type='str', choices=['no',
                                                                     'never',
                                                                     'on-failure',
                                                                     'always']),
            restart_retries=dict(required=False, type='int', default=10),
            tls_verify=dict(required=False, type='bool', default=False),
            tls_cert=dict(required=False, type='str'),
            tls_key=dict(required=False, type='str'),
            tls_cacert=dict(required=False, type='str'),
            volumes=dict(required=False, type='list'),
            volumes_from=dict(required=False, type='list')
        )
        required_together = [
            ['tls_cert', 'tls_key']
        ]
        return AnsibleModule(
            argument_spec=argument_spec,
            required_together=required_together
        )
    
    
    def generate_nested_module():
        module = generate_module()
    
        # We unnest the common dict and the update it with the other options
        new_args = module.params.get('common_options')
        new_args.update(module._load_params()[0])
        module.params = new_args
    
        # Override ARGS to ensure new args are used
        global MODULE_ARGS
        global MODULE_COMPLEX_ARGS
        MODULE_ARGS = ''
        MODULE_COMPLEX_ARGS = json.dumps(module.params)
    
        # Reprocess the args now that the common dict has been unnested
        return generate_module()
    
    
    def main():
        module = generate_nested_module()
    
        # TODO(SamYaple): Replace with required_if when Ansible 2.0 lands
        if (module.params.get('action') in ['pull_image', 'start_container']
           and not module.params.get('image')):
            self.module.fail_json(
                msg="missing required arguments: image",
                failed=True
            )
    
        # TODO(SamYaple): Replace with required_if when Ansible 2.0 lands
        if (module.params.get('action') != 'pull_image'
           and not module.params.get('name')):
            self.module.fail_json(
                msg="missing required arguments: name",
                failed=True
            )
    
    Sam Yaple's avatar
    Sam Yaple committed
    
        try:
            dw = DockerWorker(module)
    
            # TODO(inc0): We keep it bool to have ansible deal with cosistent
            # types. If we ever add method that will have to return some
            # meaningful data, we need to refactor all methods to return dicts.
            result = bool(getattr(dw, module.params.get('action'))())
            module.exit_json(changed=dw.changed, result=result)
    
    Sam Yaple's avatar
    Sam Yaple committed
        except Exception as e:
            module.exit_json(failed=True, changed=True, msg=repr(e))
    
    # import module snippets
    from ansible.module_utils.basic import *  # noqa
    if __name__ == '__main__':
        main()