#!/usr/bin/python # # Copyright 2016 Red Hat | Ansible # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . DOCUMENTATION = ''' --- module: docker_container short_description: manage docker containers description: - Manage the life cycle of docker containers. - Supports check mode. Run with --check and --diff to view config difference and list of actions to be taken. version_added: "2.1.0" options: blkio_weight: description: - Block IO (relative weight), between 10 and 1000. default: null required: false capabilities: description: - List of capabilities to add to the container. default: null required: false cleanup: description: - Use with I(detach) to remove the container after successful execution. default: false required: false version_added: 2.2 command: description: - Command or list of commands to execute in the container when it starts. default: null required: false cpu_period: description: - Limit CPU CFS (Completely Fair Scheduler) period default: 0 required: false cpu_quota: description: - Limit CPU CFS (Completely Fair Scheduler) quota default: 0 required: false cpuset_cpus: description: - CPUs in which to allow execution C(1,3) or C(1-3). default: null required: false cpuset_mems: description: - Memory nodes (MEMs) in which to allow execution C(0-3) or C(0,1) default: null required: false cpu_shares: description: - CPU shares (relative weight). default: null required: false detach: description: - Enable detached mode to leave the container running in background. If disabled, the task will reflect the status of the container run (failed if the command failed). default: true required: false devices: description: - "List of host device bindings to add to the container. Each binding is a mapping expressed in the format: ::" default: null required: false dns_servers: description: - List of custom DNS servers. default: null required: false dns_search_domains: description: - List of custom DNS search domains. default: null required: false env: description: - Dictionary of key,value pairs. default: null required: false env_file: version_added: "2.2" description: - Path to a file containing environment variables I(FOO=BAR). - If variable also present in C(env), then C(env) value will override. - Requires docker-py >= 1.4.0. default: null required: false entrypoint: description: - String or list of commands that overwrite the default ENTRYPOINT of the image. default: null required: false etc_hosts: description: - Dict of host-to-IP mappings, where each host name is a key in the dictionary. Each host name will be added to the container's /etc/hosts file. default: null required: false exposed_ports: description: - List of additional container ports to expose for port mappings or links. If the port is already exposed using EXPOSE in a Dockerfile, it does not need to be xposed again. default: null required: false aliases: - exposed force_kill: description: - Use the kill command when stopping a running container. default: false required: false groups: description: - List of additional group names and/or IDs that the container process will run as. default: null required: false hostname: description: - Container hostname. default: null required: false ignore_image: description: - When C(state) is I(present) or I(started) the module compares the configuration of an existing container to requested configuration. The evaluation includes the image version. If the image vesion in the registry does not match the container, the container will be recreated. Stop this behavior by setting C(ignore_image) to I(True). default: false required: false image: description: - Repository path and tag used to create the container. If an image is not found or pull is true, the image will be pulled from the registry. If no tag is included, 'latest' will be used. default: null required: false interactive: description: - Keep stdin open after a container is launched, even if not attached. default: false required: false ipc_mode: description: - Set the IPC mode for the container. Can be one of 'container:' to reuse another container's IPC namespace or 'host' to use the host's IPC namespace within the container. default: null required: false keep_volumes: description: - Retain volumes associated with a removed container. default: true required: false kill_signal: description: - Override default signal used to kill a running container. default null: required: false kernel_memory: description: - "Kernel memory limit (format: []). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M." default: 0 required: false labels: description: - Dictionary of key value pairs. default: null required: false links: description: - List of name aliases for linked containers in the format C(container_name:alias) default: null required: false log_driver: description: - Specify the logging driver. choices: - json-file - syslog - journald - gelf - fluentd - awslogs - splunk default: json-file required: false log_options: description: - Dictionary of options specific to the chosen log_driver. See https://docs.docker.com/engine/admin/logging/overview/ for details. required: false default: null mac_address: description: - Container MAC address (e.g. 92:d0:c6:0a:29:33) default: null required: false memory: description: - "Memory limit (format: []). Number is a positive integer. Unit can be one of b, k, m, or g" default: 0 required: false memory_reservation: description: - "Memory soft limit (format: []). Number is a positive integer. Unit can be one of b, k, m, or g" default: 0 required: false memory_swap: description: - Total memory limit (memory + swap, format:[]). Number is a positive integer. Unit can be one of b, k, m, or g. default: 0 required: false memory_swappiness: description: - Tune a container's memory swappiness behavior. Accepts an integer between 0 and 100. default: 0 required: false name: description: - Assign a name to a new container or match an existing container. - When identifying an existing container name may be a name or a long or short container ID. required: true network_mode: description: - Connect the container to a network. choices: - bridge - container: - host - none default: null required: false networks: description: - List of networks the container belongs to. - Each network is a dict with keys C(name), C(ipv4_address), C(ipv6_address), C(links), C(aliases). - For each network C(name) is required, all other keys are optional. - If included, C(links) or C(aliases) are lists. - For examples of the data structure and usage see EXAMPLES below. - To remove a container from one or more networks, use the C(purge_networks) option. default: null required: false version_added: "2.2" oom_killer: description: - Whether or not to disable OOM Killer for the container. default: false required: false paused: description: - Use with the started state to pause running processes inside the container. default: false required: false pid_mode: description: - Set the PID namespace mode for the container. Currenly only supports 'host'. default: null required: false privileged: description: - Give extended privileges to the container. default: false required: false published_ports: description: - List of ports to publish from the container to the host. - "Use docker CLI syntax: C(8000), C(9000:8000), or C(0.0.0.0:9000:8000), where 8000 is a container port, 9000 is a host port, and 0.0.0.0 is a host interface." - Container ports must be exposed either in the Dockerfile or via the C(expose) option. - A value of ALL will publish all exposed container ports to random host ports, ignoring any other mappings. aliases: - ports required: false default: null pull: description: - If true, always pull the latest version of an image. Otherwise, will only pull an image when missing. default: false required: false purge_networks: description: - Remove the container from ALL networks not included in C(networks) parameter. - Any default networks such as I(bridge), if not found in C(networks), will be removed as well. default: false required: false version_added: "2.2" read_only: description: - Mount the container's root file system as read-only. default: false required: false recreate: description: - Use with present and started states to force the re-creation of an existing container. default: false required: false restart: description: - Use with started state to force a matching container to be stopped and restarted. default: false required: false restart_policy: description: - Container restart policy. Place quotes around I(no) option. choices: - always - no - on-failure - unless-stopped default: on-failure required: false restart_retries: description: - Use with restart policy to control maximum number of restart attempts. default: 0 required: false shm_size: description: - Size of `/dev/shm`. The format is ``. `number` must be greater than `0`. Unit is optional and can be `b` (bytes), `k` (kilobytes), `m` (megabytes), or `g` (gigabytes). - Ommitting the unit defaults to bytes. If you omit the size entirely, the system uses `64m`. default: null required: false security_opts: description: - List of security options in the form of C("label:user:User") default: null required: false state: description: - 'I(absent) - A container matching the specified name will be stopped and removed. Use force_kill to kill the container rather than stopping it. Use keep_volumes to retain volumes associated with the removed container.' - 'I(present)" - Asserts the existence of a container matching the name and any provided configuration parameters. If no container matches the name, a container will be created. If a container matches the name but the provided configuration does not match, the container will be updated, if it can be. If it cannot be updated, it will be removed and re-created with the requested config. Image version will be taken into account when comparing configuration. To ignore image version use the ignore_image option. Use the recreate option to force the re-creation of the matching container. Use force_kill to kill the container rather than stopping it. Use keep_volumes to retain volumes associated with a removed container.' - 'I(started) - Asserts there is a running container matching the name and any provided configuration. If no container matches the name, a container will be created and started. If a container matching the name is found but the configuration does not match, the container will be updated, if it can be. If it cannot be updated, it will be removed and a new container will be created with the requested configuration and started. Image version will be taken into account when comparing configuration. To ignore image version use the ignore_image option. Use recreate to always re-create a matching container, even if it is running. Use restart to force a matching container to be stopped and restarted. Use force_kill to kill a container rather than stopping it. Use keep_volumes to retain volumes associated with a removed container.' - 'I(stopped) - Asserts that the container is first I(present), and then if the container is running moves it to a stopped state. Use force_kill to kill a container rather than stopping it.' required: false default: started choices: - absent - present - stopped - started stop_signal: description: - Override default signal used to stop the container. default: null required: false stop_timeout: description: - Number of seconds to wait for the container to stop before sending SIGKILL. required: false default: null trust_image_content: description: - If true, skip image verification. default: false requried: false tty: description: - Allocate a psuedo-TTY. default: false required: false ulimits: description: - "List of ulimit options. A ulimit is specified as C(nofile:262144:262144)" default: null required: false user: description: - Sets the username or UID used and optionally the groupname or GID for the specified command. - "Can be [ user | user:group | uid | uid:gid | user:gid | uid:group ]" default: null required: false uts: description: - Set the UTS namespace mode for the container. default: null required: false volumes: description: - List of volumes to mount within the container. - "Use docker CLI-style syntax: C(/host:/container[:mode])" - You can specify a read mode for the mount with either C(ro) or C(rw). - SELinux hosts can additionally use C(z) or C(Z) to use a shared or private label for the volume. default: null required: false volume_driver: description: - The container volume driver. default: none required: false volumes_from: description: - List of container names or Ids to get volumes from. default: null required: false extends_documentation_fragment: - docker author: - "Cove Schneider (@cove)" - "Joshua Conner (@joshuaconner)" - "Pavel Antonov (@softzilla)" - "Thomas Steinbach (@ThomasSteinbach)" - "Philippe Jandot (@zfil)" - "Daan Oosterveld (@dusdanig)" - "James Tanner (@jctanner)" - "Chris Houseknecht (@chouseknecht)" requirements: - "python >= 2.6" - "docker-py >= 1.7.0" - "Docker API >= 1.20" ''' EXAMPLES = ''' - name: Create a data container docker_container: name: mydata image: busybox volumes: - /data - name: Re-create a redis container docker_container: name: myredis image: redis command: redis-server --appendonly yes state: present recreate: yes exposed_ports: - 6379 volumes_from: - mydata - name: Restart a container docker_container: name: myapplication image: someuser/appimage state: started restart: yes links: - "myredis:aliasedredis" devices: - "/dev/sda:/dev/xvda:rwm" ports: - "8080:9000" - "127.0.0.1:8081:9001/udp" env: SECRET_KEY: ssssh - name: Container present docker_container: name: mycontainer state: present image: ubuntu:14.04 command: sleep infinity - name: Stop a contianer docker_container: name: mycontainer state: stopped - name: Start 4 load-balanced containers docker_container: name: "container{{ item }}" recreate: yes image: someuser/anotherappimage command: sleep 1d with_sequence: count=4 - name: remove container docker_container: name: ohno state: absent - name: Syslogging output docker_container: name: myservice image: busybox log_driver: syslog log_options: syslog-address: tcp://my-syslog-server:514 syslog-facility: daemon syslog-tag: myservice - name: Create db container and connect to network docker_container: name: db_test image: "postgres:latest" networks: - name: "{{ docker_network_name }}" - name: Start container, connect to network and link docker_container: name: sleeper image: ubuntu:14.04 networks: - name: TestingNet ipv4_address: "172.1.1.100" aliases: - sleepyzz links: - db_test:db - name: TestingNet2 - name: Start a container with a command docker_container: name: sleepy image: ubuntu:14.04 command: sleep infinity - name: Add container to networks docker_container: docker_container: name: sleepy networks: - name: TestingNet ipv4_address: 172.1.1.18 links: - sleeper - name: TestingNet2 ipv4_address: 172.1.10.20 - name: Update network with aliases docker_container: name: sleepy networks: - name: TestingNet aliases: - sleepyz - zzzz - name: Remove container from one network docker_container: name: sleepy networks: - name: TestingNet2 purge_networks: yes - name: Remove container from all networks docker_container: name: sleepy purge_networks: yes ''' RETURN = ''' ansible_docker_container: description: - Facts representing the current state of the container. Matches the docker inspection output. - Note that facts are not part of registered vars but accessible directly. - Empty if C(state) is I(absent) - If detached is I(False), will include Output attribute containing any output from container run. returned: always type: dict sample: '{ "AppArmorProfile": "", "Args": [], "Config": { "AttachStderr": false, "AttachStdin": false, "AttachStdout": false, "Cmd": [ "/usr/bin/supervisord" ], "Domainname": "", "Entrypoint": null, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "ExposedPorts": { "443/tcp": {}, "80/tcp": {} }, "Hostname": "8e47bf643eb9", "Image": "lnmp_nginx:v1", "Labels": {}, "OnBuild": null, "OpenStdin": false, "StdinOnce": false, "Tty": false, "User": "", "Volumes": { "/tmp/lnmp/nginx-sites/logs/": {} }, ... }' ''' import re from ansible.module_utils.docker_common import * try: from docker import utils from docker.utils.types import Ulimit except: # missing docker-py handled in ansible.module_utils.docker pass REQUIRES_CONVERSION_TO_BYTES = [ 'memory', 'memory_reservation', 'memory_swap', 'shm_size' ] VOLUME_PERMISSIONS = ('rw', 'ro', 'z', 'Z') class TaskParameters(DockerBaseClass): ''' Access and parse module parameters ''' def __init__(self, client): super(TaskParameters, self).__init__() self.client = client self.blkio_weight = None self.capabilities = None self.cleanup = None self.command = None self.cpu_period = None self.cpu_quota = None self.cpuset_cpus = None self.cpuset_mems = None self.cpu_shares = None self.detach = None self.devices = None self.dns_servers = None self.dns_opts = None self.dns_search_domains = None self.env = None self.env_file = None self.entrypoint = None self.etc_hosts = None self.exposed_ports = None self.force_kill = None self.groups = None self.hostname = None self.image = None self.interactive = None self.ipc_mode = None self.keep_volumes = None self.kernel_memory = None self.kill_signal = None self.labels = None self.links = None self.log_driver = None self.log_options = None self.mac_address = None self.memory = None self.memory_reservation = None self.memory_swap = None self.memory_swappiness = None self.name = None self.network_mode = None self.networks = None self.oom_killer = None self.paused = None self.pid_mode = None self.privileged = None self.purge_networks = None self.pull = None self.read_only = None self.recreate = None self.restart = None self.restart_retries = None self.restart_policy = None self.shm_size = None self.security_opts = None self.state = None self.stop_signal = None self.stop_timeout = None self.trust_image_content = None self.tty = None self.user = None self.uts = None self.volumes = None self.volume_binds = dict() self.volumes_from = None self.volume_driver = None for key, value in client.module.params.items(): setattr(self, key, value) for param_name in REQUIRES_CONVERSION_TO_BYTES: if client.module.params.get(param_name): try: setattr(self, param_name, human_to_bytes(client.module.params.get(param_name))) except ValueError as exc: self.fail("Failed to convert %s to bytes: %s" % (param_name, exc)) if 'all' in (port.lower() if isinstance(port, basestring) else port for port in self.published_ports): self.publish_all_ports = True self.published_ports = None self.ports = None else: self.publish_all_ports = False self.published_ports = self._parse_publish_ports() self.ports = self._parse_exposed_ports(self.published_ports) self.log("expose ports:") self.log(self.ports, pretty_print=True) self.links = self._parse_links(self.links) if self.volumes: self.volumes = self._expand_host_paths() self.env = self._get_environment() self.ulimits = self._parse_ulimits() self.log_config = self._parse_log_config() self.exp_links = None self.volume_binds = self._get_volume_binds(self.volumes) self.log("volumes:") self.log(self.volumes, pretty_print=True) self.log("volume binds:") self.log(self.volume_binds, pretty_print=True) if self.networks: for network in self.networks: if not network.get('name'): self.fail("Parameter error: network must have a name attribute.") network['id'] = self._get_network_id(network['name']) if not network['id']: self.fail("Parameter error: network named %s could not be found. Does it exist?" % network['name']) if network.get('links'): network['links'] = self._parse_links(network['links']) def fail(self, msg): self.client.module.fail_json(msg=msg) @property def update_parameters(self): ''' Returns parameters used to update a container ''' update_parameters = dict( blkio_weight='blkio_weight', cpu_period='cpu_period', cpu_quota='cpu_quota', cpu_shares='cpu_shares', cpuset_cpus='cpuset_cpus', mem_limit='memory', mem_reservation='mem_reservation', memswap_limit='memory_swap', kernel_memory='kernel_memory' ) result = dict() for key, value in update_parameters.iteritems(): if getattr(self, value, None) is not None: result[key] = getattr(self, value) return result @property def create_parameters(self): ''' Returns parameters used to create a container ''' create_params = dict( command='command', hostname='hostname', user='user', detach='detach', stdin_open='interactive', tty='tty', ports='ports', environment='env', name='name', entrypoint='entrypoint', cpu_shares='cpu_shares', mac_address='mac_address', labels='labels', stop_signal='stop_signal', volume_driver='volume_driver', ) result = dict( host_config=self._host_config(), volumes=self._get_mounts(), ) for key, value in create_params.items(): if getattr(self, value, None) is not None: result[key] = getattr(self, value) return result def _expand_host_paths(self): new_vols = [] for vol in self.volumes: if ':' in vol: if len(vol.split(':')) == 3: host, container, mode = vol.split(':') if re.match(r'[\.~]', host): host = os.path.abspath(host) new_vols.append("%s:%s:%s" % (host, container, mode)) continue elif len(vol.split(':')) == 2: parts = vol.split(':') if parts[1] not in VOLUME_PERMISSIONS and re.match(r'[\.~]', parts[0]): host = os.path.abspath(parts[0]) new_vols.append("%s:%s:rw" % (host, parts[1])) continue new_vols.append(vol) return new_vols def _get_mounts(self): ''' Return a list of container mounts. :return: ''' result = [] if self.volumes: for vol in self.volumes: if ':' in vol: if len(vol.split(':')) == 3: host, container, _ = vol.split(':') result.append(container) continue if len(vol.split(':')) == 2: parts = vol.split(':') if parts[1] not in VOLUME_PERMISSIONS: result.append(parts[1]) continue result.append(vol) self.log("mounts:") self.log(result, pretty_print=True) return result def _host_config(self): ''' Returns parameters used to create a HostConfig object ''' host_config_params=dict( port_bindings='published_ports', publish_all_ports='publish_all_ports', links='links', privileged='privileged', dns='dns_servers', dns_search='dns_search_domains', binds='volume_binds', volumes_from='volumes_from', network_mode='network_mode', cap_add='capabilities', extra_hosts='etc_hosts', read_only='read_only', ipc_mode='ipc_mode', security_opt='security_opts', ulimits='ulimits', log_config='log_config', mem_limit='memory', memswap_limit='memory_swap', mem_swappiness='memory_swappiness', shm_size='shm_size', group_add='groups', devices='devices', pid_mode='pid_mode' ) params = dict() for key, value in host_config_params.iteritems(): if getattr(self, value, None) is not None: params[key] = getattr(self, value) if self.restart_policy: params['restart_policy'] = dict(Name=self.restart_policy, MaximumRetryCount=self.restart_retries) return self.client.create_host_config(**params) def _parse_publish_ports(self): ''' Parse ports from docker CLI syntax ''' if self.published_ports is None: return None if 'all' in self.published_ports: return 'all' binds = {} for port in self.published_ports: parts = str(port).split(':') container_port = parts[-1] if '/' not in container_port: container_port = int(parts[-1]) p_len = len(parts) if p_len == 1: bind = ('0.0.0.0',) elif p_len == 2: bind = ('0.0.0.0', int(parts[0])) elif p_len == 3: bind = (parts[0], int(parts[1])) if parts[1] else (parts[0],) if container_port in binds: old_bind = binds[container_port] if isinstance(old_bind, list): old_bind.append(bind) else: binds[container_port] = [binds[container_port], bind] else: binds[container_port] = bind return binds @staticmethod def _get_volume_binds(volumes): ''' Extract host bindings, if any, from list of volume mapping strings. :return: dictionary of bind mappings ''' result = dict() if volumes: for vol in volumes: host = None if ':' in vol: if len(vol.split(':')) == 3: host, container, mode = vol.split(':') if len(vol.split(':')) == 2: parts = vol.split(':') if parts[1] not in VOLUME_PERMISSIONS: host, container, mode = (vol.split(':') + ['rw']) if host is not None: result[host] = dict( bind=container, mode=mode ) return result def _parse_exposed_ports(self, published_ports): ''' Parse exposed ports from docker CLI-style ports syntax. ''' exposed = [] if self.exposed_ports: for port in self.exposed_ports: port = str(port).strip() protocol = 'tcp' match = re.search(r'(/.+$)', port) if match: protocol = match.group(1) exposed.append((port, protocol)) if published_ports: # Any published port should also be exposed for publish_port in published_ports: match = False if isinstance(publish_port, basestring) and '/' in publish_port: port, protocol = publish_port.split('/') port = int(port) else: protocol = 'tcp' port = int(publish_port) for exposed_port in exposed: if isinstance(exposed_port[0], basestring) and '-' in exposed_port[0]: start_port, end_port = exposed_port[0].split('-') if int(start_port) <= port <= int(end_port): match = True elif exposed_port[0] == port: match = True if not match: exposed.append((port, protocol)) return exposed @staticmethod def _parse_links(links): ''' Turn links into a dictionary ''' if links is None: return None result = {} for link in links: parsed_link = link.split(':', 1) if len(parsed_link) == 2: result[parsed_link[0]] = parsed_link[1] else: result[parsed_link[0]] = parsed_link[0] return result def _parse_ulimits(self): ''' Turn ulimits into an array of Ulimit objects ''' if self.ulimits is None: return None results = [] for limit in self.ulimits: limits = dict() pieces = limit.split(':') if len(pieces) >= 2: limits['name'] = pieces[0] limits['soft'] = int(pieces[1]) limits['hard'] = int(pieces[1]) if len(pieces) == 3: limits['hard'] = int(pieces[2]) try: results.append(Ulimit(**limits)) except ValueError as exc: self.fail("Error parsing ulimits value %s - %s" % (limit, exc)) return results def _parse_log_config(self): ''' Create a LogConfig object ''' if self.log_driver is None: return None options = dict( Type=self.log_driver, Config = dict() ) if self.log_options is not None: options['Config'] = self.log_options try: return LogConfig(**options) except ValueError as exc: self.fail('Error parsing logging options - %s' % (exc)) def _get_environment(self): """ If environment file is combined with explicit environment variables, the explicit environment variables take precedence. """ final_env = {} if self.env_file: parsed_env_file = utils.parse_env_file(self.env_file) for name, value in parsed_env_file.iteritems(): final_env[name] = str(value) if self.env: for name, value in self.env.iteritems(): final_env[name] = str(value) return final_env def _get_network_id(self, network_name): network_id = None try: for network in self.client.networks(names=[network_name]): if network['Name'] == network_name: network_id = network['Id'] break except Exception as exc: self.fail("Error getting network id for %s - %s" % (network_name, str(exc))) return network_id class Container(DockerBaseClass): def __init__(self, container, parameters): super(Container, self).__init__() self.raw = container self.Id = None self.container = container if container: self.Id = container['Id'] self.Image = container['Image'] self.log(self.container, pretty_print=True) self.parameters = parameters self.parameters.expected_links = None self.parameters.expected_ports = None self.parameters.expected_exposed = None self.parameters.expected_volumes = None self.parameters.expected_ulimits = None self.parameters.expected_etc_hosts = None self.parameters.expected_env = None def fail(self, msg): self.parameters.client.module.fail_json(msg=msg) @property def exists(self): return True if self.container else False @property def running(self): if self.container and self.container.get('State'): if self.container['State'].get('Running') and not self.container['State'].get('Ghost', False): return True return False def has_different_configuration(self, image): ''' Diff parameters vs existing container config. Returns tuple: (True | False, List of differences) ''' self.log('Starting has_different_configuration') self.parameters.expected_entrypoint = self._get_expected_entrypoint(image) self.parameters.expected_links = self._get_expected_links() self.parameters.expected_ports = self._get_expected_ports() self.parameters.expected_exposed = self._get_expected_exposed(image) self.parameters.expected_volumes = self._get_expected_volumes(image) self.parameters.expected_binds = self._get_expected_binds(image) self.parameters.expected_ulimits = self._get_expected_ulimits(self.parameters.ulimits) self.parameters.expected_etc_hosts = self._convert_simple_dict_to_list('etc_hosts') self.parameters.expected_env = self._get_expected_env(image) self.parameters.expected_cmd = self._get_expected_cmd() if not self.container.get('HostConfig'): self.fail("has_config_diff: Error parsing container properties. HostConfig missing.") if not self.container.get('Config'): self.fail("has_config_diff: Error parsing container properties. Config missing.") if not self.container.get('NetworkSettings'): self.fail("has_config_diff: Error parsing container properties. NetworkSettings missing.") host_config = self.container['HostConfig'] log_config = host_config.get('LogConfig', dict()) restart_policy = host_config.get('RestartPolicy', dict()) config = self.container['Config'] network = self.container['NetworkSettings'] # The previous version of the docker module ignored the detach state by # assuming if the container was running, it must have been detached. detach = not (config.get('AttachStderr') and config.get('AttachStdout')) # Map parameters to container inspect results config_mapping = dict( image=config.get('Image'), expected_cmd=config.get('Cmd'), hostname=config.get('Hostname'), user=config.get('User'), detach=detach, interactive=config.get('OpenStdin'), capabilities=host_config.get('CapAdd'), devices=host_config.get('Devices'), dns_servers=host_config.get('Dns'), dns_opts=host_config.get('DnsOptions'), dns_search_domains=host_config.get('DnsSearch'), expected_env=(config.get('Env') or []), expected_entrypoint=config.get('Entrypoint'), expected_etc_hosts=host_config['ExtraHosts'], expected_exposed=[re.sub(r'/.+$', '', p) for p in config.get('ExposedPorts', dict()).keys()], groups=host_config.get('GroupAdd'), ipc_mode=host_config.get("IpcMode"), labels=config.get('Labels'), expected_links=host_config.get('Links'), log_driver=log_config.get('Type'), log_options=log_config.get('Config'), mac_address=network.get('MacAddress'), memory_swappiness=host_config.get('MemorySwappiness'), network_mode=host_config.get('NetworkMode'), oom_killer=host_config.get('OomKillDisable'), pid_mode=host_config.get('PidMode'), privileged=host_config.get('Privileged'), expected_ports=host_config.get('PortBindings'), read_only=host_config.get('ReadonlyRootfs'), restart_policy=restart_policy.get('Name'), restart_retries=restart_policy.get('MaximumRetryCount'), # Cannot test shm_size, as shm_size is not included in container inspection results. # shm_size=host_config.get('ShmSize'), security_opts=host_config.get("SecuriytOpt"), stop_signal=config.get("StopSignal"), tty=config.get('Tty'), expected_ulimits=host_config.get('Ulimits'), uts=host_config.get('UTSMode'), expected_volumes=config.get('Volumes'), expected_binds=host_config.get('Binds'), volumes_from=host_config.get('VolumesFrom'), volume_driver=host_config.get('VolumeDriver') ) differences = [] for key, value in config_mapping.iteritems(): self.log('check differences %s %s vs %s' % (key, getattr(self.parameters, key), str(value))) if getattr(self.parameters, key, None) is not None: if isinstance(getattr(self.parameters, key), list) and isinstance(value, list): if len(getattr(self.parameters, key)) > 0 and isinstance(getattr(self.parameters, key)[0], dict): # compare list of dictionaries self.log("comparing list of dict: %s" % key) match = self._compare_dictionary_lists(getattr(self.parameters, key), value) else: # compare two lists. Is list_a in list_b? self.log("comparing lists: %s" % key) set_a = set(getattr(self.parameters, key)) set_b = set(value) match = (set_a <= set_b) elif isinstance(getattr(self.parameters, key), dict) and isinstance(value, dict): # compare two dicts self.log("comparing two dicts: %s" % key) match = self._compare_dicts(getattr(self.parameters, key), value) else: # primitive compare self.log("primitive compare: %s" % key) match = (getattr(self.parameters, key) == value) if not match: # no match. record the differences item = dict() item[key] = dict( parameter=getattr(self.parameters, key), container=value ) differences.append(item) has_differences = True if len(differences) > 0 else False return has_differences, differences def _compare_dictionary_lists(self, list_a, list_b): ''' If all of list_a exists in list_b, return True ''' if not isinstance(list_a, list) or not isinstance(list_b, list): return False matches = 0 for dict_a in list_a: for dict_b in list_b: if self._compare_dicts(dict_a, dict_b): matches += 1 break result = (matches == len(list_a)) return result def _compare_dicts(self, dict_a, dict_b): ''' If dict_a in dict_b, return True ''' if not isinstance(dict_a, dict) or not isinstance(dict_b, dict): return False for key, value in dict_a.iteritems(): if isinstance(value, dict): match = self._compare_dicts(value, dict_b.get(key)) elif isinstance(value, list): if len(value) > 0 and isinstance(value[0], dict): match = self._compare_dictionary_lists(value, dict_b.get(key)) else: set_a = set(value) set_b = set(dict_b.get(key)) match = (set_a == set_b) else: match = (value == dict_b.get(key)) if not match: return False return True def has_different_resource_limits(self): ''' Diff parameters and container resource limits ''' if not self.container.get('HostConfig'): self.fail("limits_differ_from_container: Error parsing container properties. HostConfig missing.") host_config = self.container['HostConfig'] config_mapping = dict( cpu_period=host_config.get('CpuPeriod'), cpu_quota=host_config.get('CpuQuota'), cpuset_cpus=host_config.get('CpusetCpus'), cpuset_mems=host_config.get('CpusetMems'), cpu_shares=host_config.get('CpuShares'), kernel_memory=host_config.get("KernelMemory"), memory=host_config.get('Memory'), memory_reservation=host_config.get('MemoryReservation'), memory_swap=host_config.get('MemorySwap'), ) differences = [] for key, value in config_mapping.iteritems(): if getattr(self.parameters, key, None) and getattr(self.parameters, key) != value: # no match. record the differences item = dict() item[key] = dict( parameter=getattr(self.parameters, key), container=value ) differences.append(item) different = (len(differences) > 0) return different, differences def has_network_differences(self): ''' Check if the container is connected to requested networks with expected options: links, aliases, ipv4, ipv6 ''' different = False differences = [] if not self.parameters.networks: return different, differences if not self.container.get('NetworkSettings'): self.fail("has_missing_networks: Error parsing container properties. NetworkSettings missing.") connected_networks = self.container['NetworkSettings']['Networks'] for network in self.parameters.networks: if connected_networks.get(network['name'], None) is None: different = True differences.append(dict( parameter=network, container=None )) else: diff = False if network.get('ipv4_address') and network['ipv4_address'] != connected_networks[network['name']].get('IPAddress'): diff = True if network.get('ipv6_address') and network['ipv6_address'] != connected_networks[network['name']].get('GlobalIPv6Address'): diff = True if network.get('aliases') and not connected_networks[network['name']].get('Aliases'): diff = True if network.get('aliases') and connected_networks[network['name']].get('Aliases'): if set(network.get('aliases')) != set(connected_networks[network['name']].get('Aliases')): diff = True if network.get('links') and not connected_networks[network['name']].get('Links'): diff = True if network.get('links') and connected_networks[network['name']].get('Links'): expected_links = [] for link, alias in network['links'].iteritems(): expected_links.append("%s:%s" % (link, alias)) if set(expected_links) != set(connected_networks[network['name']].get('Links', [])): diff = True if diff: different = True differences.append(dict( parameter=network, container=dict( name=network['name'], ipv4_address=connected_networks[network['name']].get('IPAddress'), ipv6_address=connected_networks[network['name']].get('GlobalIPv6Address'), aliases=connected_networks[network['name']].get('Aliases'), links=connected_networks[network['name']].get('Links') ) )) return different, differences def has_extra_networks(self): ''' Check if the container is connected to non-requested networks ''' extra_networks = [] extra = False if not self.container.get('NetworkSettings'): self.fail("has_extra_networks: Error parsing container properties. NetworkSettings missing.") connected_networks = self.container['NetworkSettings'].get('Networks') if connected_networks: for network, network_config in connected_networks.iteritems(): keep = False if self.parameters.networks: for expected_network in self.parameters.networks: if expected_network['name'] == network: keep = True if not keep: extra = True extra_networks.append(dict(name=network, id=network_config['NetworkID'])) return extra, extra_networks def _get_expected_entrypoint(self, image): self.log('_get_expected_entrypoint') if isinstance(self.parameters.entrypoint, list): entrypoint = self.parameters.entrypoint else: entrypoint = [] if image and image['ContainerConfig'].get('Entrypoint'): entrypoint = list(set(entrypoint + image['ContainerConfig'].get('Entrypoint'))) if len(entrypoint) == 0: return None return entrypoint def _get_expected_ports(self): if not self.parameters.published_ports: return None expected_bound_ports = {} for container_port, config in self.parameters.published_ports.iteritems(): if isinstance(container_port, int): container_port = "%s/tcp" % container_port if len(config) == 1: expected_bound_ports[container_port] = [{'HostIp': "0.0.0.0", 'HostPort': ""}] elif isinstance(config[0], tuple): expected_bound_ports[container_port] = [] for host_ip, host_port in config: expected_bound_ports[container_port].append({ 'HostIp': host_ip, 'HostPort': str(host_port)}) else: expected_bound_ports[container_port] = [{'HostIp': config[0], 'HostPort': str(config[1])}] return expected_bound_ports def _get_expected_links(self): if self.parameters.links is None: return None self.log('parameter links:') self.log(self.parameters.links, pretty_print=True) exp_links = [] for link, alias in self.parameters.links.iteritems(): exp_links.append("/%s:%s/%s" % (link, ('/' + self.parameters.name), alias)) return exp_links def _get_expected_binds(self, image): self.log('_get_expected_binds') image_vols = [] if image: image_vols = self._get_image_binds(image['ContainerConfig'].get('Volumes')) param_vols = [] if self.parameters.volumes: for vol in self.parameters.volumes: host = None if ':' in vol: if len(vol.split(':')) == 3: host, container, mode = vol.split(':') if len(vol.split(':')) == 2: parts = vol.split(':') if parts[1] not in VOLUME_PERMISSIONS: host, container, mode = vol.split(':') + ['rw'] if host: param_vols.append("%s:%s:%s" % (host, container, mode)) result = list(set(image_vols + param_vols)) self.log("expected_binds:") self.log(result, pretty_print=True) return result def _get_image_binds(self, volumes): ''' Convert array of binds to array of strings with format host_path:container_path:mode :param volumes: array of bind dicts :return: array of strings ''' results = [] if isinstance(volumes, dict): results += self._get_bind_from_dict(volumes) elif isinstance(volumes, list): for vol in volumes: results += self._get_bind_from_dict(vol) return results @staticmethod def _get_bind_from_dict(volume_dict): results = [] if volume_dict: for host_path, config in volume_dict.items(): if isinstance(config, dict) and config.get('bind'): container_path = config.get('bind') mode = config.get('mode', 'rw') results.append("%s:%s:%s" % (host_path, container_path, mode)) return results def _get_expected_volumes(self, image): self.log('_get_expected_volumes') expected_vols = dict() if image and image['ContainerConfig'].get('Volumes'): expected_vols.update(image['ContainerConfig'].get('Volumes')) if self.parameters.volumes: for vol in self.parameters.volumes: container = None if ':' in vol: if len(vol.split(':')) == 3: host, container, mode = vol.split(':') if len(vol.split(':')) == 2: parts = vol.split(':') if parts[1] not in VOLUME_PERMISSIONS: host, container, mode = vol.split(':') + ['rw'] new_vol = dict() if container: new_vol[container] = dict() else: new_vol[vol] = dict() expected_vols.update(new_vol) if not expected_vols: expected_vols = None self.log("expected_volumes:") self.log(expected_vols, pretty_print=True) return expected_vols def _get_expected_env(self, image): self.log('_get_expected_env') expected_env = dict() if image and image['ContainerConfig'].get('Env'): for env_var in image['ContainerConfig']['Env']: parts = env_var.split('=', 1) expected_env[parts[0]] = parts[1] if self.parameters.env: expected_env.update(self.parameters.env) param_env = [] for key, value in expected_env.items(): param_env.append("%s=%s" % (key, value)) return param_env def _get_expected_exposed(self, image): self.log('_get_expected_exposed') image_ports = [] if image: image_ports = [re.sub(r'/.+$', '', p) for p in (image['ContainerConfig'].get('ExposedPorts') or {}).keys()] param_ports = [] if self.parameters.ports: param_ports = [str(p[0]) for p in self.parameters.ports] result = list(set(image_ports + param_ports)) self.log(result, pretty_print=True) return result def _get_expected_ulimits(self, config_ulimits): self.log('_get_expected_ulimits') if config_ulimits is None: return None results = [] for limit in config_ulimits: results.append(dict( Name=limit.name, Soft=limit.soft, Hard=limit.hard )) return results def _get_expected_cmd(self): self.log('_get_expected_cmd') if not self.parameters.command: return None return shlex.split(self.parameters.command) def _convert_simple_dict_to_list(self, param_name, join_with=':'): if getattr(self.parameters, param_name, None) is None: return None results = [] for key, value in getattr(self.parameters, param_name).iteritems(): results.append("%s%s%s" % (key, join_with, value)) return results class ContainerManager(DockerBaseClass): ''' Perform container management tasks ''' def __init__(self, client): super(ContainerManager, self).__init__() self.client = client self.parameters = TaskParameters(client) self.check_mode = self.client.check_mode self.results = {'changed': False, 'actions': []} self.diff = {} self.facts = {} state = self.parameters.state if state in ('stopped', 'started', 'present'): self.present(state) elif state == 'absent': self.absent() if not self.check_mode: try: del self.results['actions'] except: pass if self.client.module._diff: self.results['diff'] = self.diff if self.facts: self.results['ansible_facts'] = {'ansible_docker_container': self.facts} def present(self, state): container = self._get_container(self.parameters.name) image = self._get_image() if not container.exists: # New container self.log('No container found') new_container = self.container_create(self.parameters.image, self.parameters.create_parameters) if new_container: container = new_container else: # Existing container different, differences = container.has_different_configuration(image) image_different = self._image_is_different(image, container) if image_different or different or self.parameters.recreate: self.diff['differences'] = differences if image_different: self.diff['image_different'] = True self.log("differences") self.log(differences, pretty_print=True) if container.running: self.container_stop(container.Id) self.container_remove(container.Id) new_container = self.container_create(self.parameters.image, self.parameters.create_parameters) if new_container: container = new_container if container and container.exists: container = self.update_limits(container) container = self.update_networks(container) if state == 'started' and not container.running: container = self.container_start(container.Id) elif state == 'started' and self.parameters.restart: self.container_stop(container.Id) container = self.container_start(container.Id) elif state == 'stopped' and container.running: self.container_stop(container.Id) container = self._get_container(container.Id) self.facts = container.raw def absent(self): container = self._get_container(self.parameters.name) if container.exists: if container.running: self.container_stop(container.Id) self.container_remove(container.Id) def fail(self, msg, **kwargs): self.client.module.fail_json(msg=msg, **kwargs) def _get_container(self, container): ''' Expects container ID or Name. Returns a container object ''' return Container(self.client.get_container(container), self.parameters) def _get_image(self): if not self.parameters.image: self.log('No image specified') return None repository, tag = utils.parse_repository_tag(self.parameters.image) if not tag: tag = "latest" image = self.client.find_image(repository, tag) if not self.check_mode: if not image or self.parameters.pull: self.log("Pull the image.") image = self.client.pull_image(repository, tag) self.results['actions'].append(dict(pulled_image="%s:%s" % (repository, tag))) self.results['changed'] = True self.log("image") self.log(image, pretty_print=True) return image def _image_is_different(self, image, container): if image and image.get('Id'): if container and container.Image: if image.get('Id') != container.Image: return True return False def update_limits(self, container): limits_differ, different_limits = container.has_different_resource_limits() if limits_differ: self.log("limit differences:") self.log(different_limits, pretty_print=True) if limits_differ and not self.check_mode: self.container_update(container.Id, self.parameters.update_parameters) return self._get_container(container.Id) return container def update_networks(self, container): has_network_differences, network_differences = container.has_network_differences() updated_container = container if has_network_differences: if self.diff.get('differences'): self.diff['differences'].append(dict(network_differences=network_differences)) else: self.diff['differences'] = [dict(network_differences=network_differences)] self.results['changed'] = True updated_container = self._add_networks(container, network_differences) if self.parameters.purge_networks: has_extra_networks, extra_networks = container.has_extra_networks() if has_extra_networks: if self.diff.get('differences'): self.diff['differences'].append(dict(purge_networks=extra_networks)) else: self.diff['differences'] = [dict(purge_networks=extra_networks)] self.results['changed'] = True updated_container = self._purge_networks(container, extra_networks) return updated_container def _add_networks(self, container, differences): for diff in differences: # remove the container from the network, if connected if diff.get('container'): self.results['actions'].append(dict(removed_from_network=diff['parameter']['name'])) if not self.check_mode: try: self.client.disconnect_container_from_network(container.Id, diff['parameter']['id']) except Exception as exc: self.fail("Error disconnecting container from network %s - %s" % (diff['parameter']['name'], str(exc))) # connect to the network params = dict( ipv4_address=diff['parameter'].get('ipv4_address', None), ipv6_address=diff['parameter'].get('ipv6_address', None), links=diff['parameter'].get('links', None), aliases=diff['parameter'].get('aliases', None) ) self.results['actions'].append(dict(added_to_network=diff['parameter']['name'], network_parameters=params)) if not self.check_mode: try: self.log("Connecting conainer to network %s" % diff['parameter']['id']) self.log(params, pretty_print=True) self.client.connect_container_to_network(container.Id, diff['parameter']['id'], **params) except Exception as exc: self.fail("Error connecting container to network %s - %s" % (diff['parameter']['name'], str(exc))) return self._get_container(container.Id) def _purge_networks(self, container, networks): for network in networks: self.results['actions'].append(dict(removed_from_network=network['name'])) if not self.check_mode and network.get('id'): try: self.client.disconnect_container_from_network(container.Id, network['id']) except Exception as exc: self.fail("Error disconnecting container from network %s - %s" % (network['name'], str(exc))) return self._get_container(container.Id) def container_create(self, image, create_parameters): self.log("create container") self.log("image: %s parameters:" % image) self.log(create_parameters, pretty_print=True) self.results['actions'].append(dict(created="Created container", create_parameters=create_parameters)) self.results['changed'] = True new_container = None if not self.check_mode: try: new_container = self.client.create_container(image, **create_parameters) except Exception as exc: self.fail("Error creating container: %s" % str(exc)) return self._get_container(new_container['Id']) return new_container def container_start(self, container_id): self.log("start container %s" % (container_id)) self.results['actions'].append(dict(started=container_id)) self.results['changed'] = True if not self.check_mode: try: self.client.start(container=container_id) except Exception as exc: self.fail("Error starting container %s: %s" % (container_id, str(exc))) if not self.parameters.detach: status = self.client.wait(container_id) output = self.client.logs(container_id, stdout=True, stderr=True, stream=False, timestamps=False) if status != 0: self.fail(output, status=status) if self.parameters.cleanup: self.container_remove(container_id, force=True) insp = self._get_container(container_id) if insp.raw: insp.raw['Output'] = output else: insp.raw = dict(Output=output) return insp return self._get_container(container_id) def container_remove(self, container_id, link=False, force=False): volume_state = (not self.parameters.keep_volumes) self.log("remove container container:%s v:%s link:%s force%s" % (container_id, volume_state, link, force)) self.results['actions'].append(dict(removed=container_id, volume_state=volume_state, link=link, force=force)) self.results['changed'] = True response = None if not self.check_mode: try: response = self.client.remove_container(container_id, v=volume_state, link=link, force=force) except Exception as exc: self.fail("Error removing container %s: %s" % (container_id, str(exc))) return response def container_update(self, container_id, update_parameters): if update_parameters: self.log("update container %s" % (container_id)) self.log(update_parameters, pretty_print=True) self.results['actions'].append(dict(updated=container_id, update_parameters=update_parameters)) self.results['changed'] = True if not self.check_mode and callable(getattr(self.client, 'update_container')): try: self.client.update_container(container_id, **update_parameters) except Exception as exc: self.fail("Error updating container %s: %s" % (container_id, str(exc))) return self._get_container(container_id) def container_kill(self, container_id): self.results['actions'].append(dict(killed=container_id, signal=self.parameters.kill_signal)) self.results['changed'] = True response = None if not self.check_mode: try: if self.parameters.kill_signal: response = self.client.kill(container_id, signal=self.parameters.kill_signal) else: response = self.client.kill(container_id) except Exception as exc: self.fail("Error killing container %s: %s" % (container_id, exc)) return response def container_stop(self, container_id): if self.parameters.force_kill: self.container_kill(container_id) return self.results['actions'].append(dict(stopped=container_id, timeout=self.parameters.stop_timeout)) self.results['changed'] = True response = None if not self.check_mode: try: if self.parameters.stop_timeout: response = self.client.stop(container_id, timeout=self.parameters.stop_timeout) else: response = self.client.stop(container_id) except Exception as exc: self.fail("Error stopping container %s: %s" % (container_id, str(exc))) return response def main(): argument_spec = dict( blkio_weight=dict(type='int'), capabilities=dict(type='list'), cleanup=dict(type='bool', default=False), command=dict(type='str'), cpu_period=dict(type='int'), cpu_quota=dict(type='int'), cpuset_cpus=dict(type='str'), cpuset_mems=dict(type='str'), cpu_shares=dict(type='int'), detach=dict(type='bool', default=True), devices=dict(type='list'), dns_servers=dict(type='list'), dns_opts=dict(type='list'), dns_search_domains=dict(type='list'), env=dict(type='dict'), env_file=dict(type='path'), entrypoint=dict(type='list'), etc_hosts=dict(type='dict'), exposed_ports=dict(type='list', aliases=['exposed', 'expose']), force_kill=dict(type='bool', default=False, aliases=['forcekill']), groups=dict(type='list'), hostname=dict(type='str'), image=dict(type='str'), interactive=dict(type='bool', default=False), ipc_mode=dict(type='str'), keep_volumes=dict(type='bool', default=True), kernel_memory=dict(type='str'), kill_signal=dict(type='str'), labels=dict(type='dict'), links=dict(type='list'), log_driver=dict(type='str', choices=['json-file', 'syslog', 'journald', 'gelf', 'fluentd', 'awslogs', 'splunk'], default='json-file'), log_options=dict(type='dict', aliases=['log_opt']), mac_address=dict(type='str'), memory=dict(type='str', default='0'), memory_reservation=dict(type='str'), memory_swap=dict(type='str'), memory_swappiness=dict(type='int'), name=dict(type='str', required=True), network_mode=dict(type='str'), networks=dict(type='list'), oom_killer=dict(type='bool'), paused=dict(type='bool', default=False), pid_mode=dict(type='str'), privileged=dict(type='bool', default=False), published_ports=dict(type='list', aliases=['ports']), pull=dict(type='bool', default=False), purge_networks=dict(type='bool', deault=False), read_only=dict(type='bool', default=False), recreate=dict(type='bool', default=False), restart=dict(type='bool', default=False), restart_policy=dict(type='str', choices=['no', 'on-failure', 'always', 'unless-stopped']), restart_retries=dict(type='int', default=0), shm_size=dict(type='str'), security_opts=dict(type='list'), state=dict(type='str', choices=['absent', 'present', 'started', 'stopped'], default='started'), stop_signal=dict(type='str'), stop_timeout=dict(type='int'), trust_image_content=dict(type='bool', default=False), tty=dict(type='bool', default=False), ulimits=dict(type='list'), user=dict(type='str'), uts=dict(type='str'), volumes=dict(type='list'), volumes_from=dict(type='list'), volume_driver=dict(type='str'), ) required_if = [ ('state', 'present', ['image']) ] client = AnsibleDockerClient( argument_spec=argument_spec, required_if=required_if, supports_check_mode=True ) cm = ContainerManager(client) client.module.exit_json(**cm.results) # import module snippets from ansible.module_utils.basic import * if __name__ == '__main__': main()