#!/usr/bin/python # # (c) 2017, Dario Zanzico (git@dariozanzico.com) # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type ANSIBLE_METADATA = {'status': ['preview'], 'supported_by': 'community', 'metadata_version': '1.1'} DOCUMENTATION = ''' --- module: docker_swarm_service author: "Dario Zanzico (@dariko), Jason Witkowski (@jwitko)" short_description: docker swarm service description: | Manage docker services. Allows live altering of already defined services version_added: "2.7" options: name: required: true description: - Service name image: type: str required: true description: - Service image path and tag. Maps docker service IMAGE parameter. resolve_image: type: bool required: false default: true description: - If the current image digest should be resolved from registry and updated if changed. version_added: 2.8 state: required: true default: present description: - Service state. choices: - present - absent args: required: false default: [] description: - List comprised of the command and the arguments to be run inside - the container command: required: false description: - Command to execute when the container starts. A command may be either a string or a list or a list of strings. version_added: 2.8 constraints: required: false default: [] description: - List of the service constraints. - Maps docker service --constraint option. placement_preferences: required: false type: list description: - List of the placement preferences as key value pairs. - Maps docker service C(--placement-pref) option. version_added: 2.8 hostname: required: false default: "" description: - Container hostname - Maps docker service --hostname option. - Requires API version >= 1.25 tty: required: false type: bool default: False description: - Allocate a pseudo-TTY - Maps docker service --tty option. - Requires API version >= 1.25 dns: required: false default: [] description: - List of custom DNS servers. - Maps docker service --dns option. - Requires API version >= 1.25 dns_search: required: false default: [] description: - List of custom DNS search domains. - Maps docker service --dns-search option. - Requires API version >= 1.25 dns_options: required: false default: [] description: - List of custom DNS options. - Maps docker service --dns-option option. - Requires API version >= 1.25 force_update: required: false type: bool default: False description: - Force update even if no changes require it. - Maps to docker service update --force option. - Requires API version >= 1.25 labels: required: false type: dict description: - Dictionary of key value pairs. - Maps docker service --label option. container_labels: required: false type: dict description: - Dictionary of key value pairs. - Maps docker service --container-label option. endpoint_mode: type: str description: - Service endpoint mode. - Maps docker service --endpoint-mode option. choices: - vip - dnsrr env: required: false default: [] description: - List of the service environment variables. - Maps docker service --env option. log_driver: required: false default: json-file description: - Configure the logging driver for a service log_driver_options: required: false default: [] description: - Options for service logging driver limit_cpu: required: false default: 0.000 description: - Service CPU limit. 0 equals no limit. - Maps docker service --limit-cpu option. reserve_cpu: required: false default: 0.000 description: - Service CPU reservation. 0 equals no reservation. - Maps docker service --reserve-cpu option. limit_memory: required: false default: 0 description: - "Service memory limit (format: C([])). Number is a positive integer. Unit can be C(B) (byte), C(K) (kibibyte, 1024B), C(M) (mebibyte), C(G) (gibibyte), C(T) (tebibyte), or C(P) (pebibyte)." - 0 equals no limit. - Omitting the unit defaults to bytes. - Maps docker service --limit-memory option. reserve_memory: required: false default: 0 description: - "Service memory reservation (format: C([])). Number is a positive integer. Unit can be C(B) (byte), C(K) (kibibyte, 1024B), C(M) (mebibyte), C(G) (gibibyte), C(T) (tebibyte), or C(P) (pebibyte)." - 0 equals no reservation. - Omitting the unit defaults to bytes. - Maps docker service --reserve-memory option. mode: required: false default: replicated description: - Service replication mode. - Maps docker service --mode option. mounts: required: false description: - List of dictionaries describing the service mounts. - Every item must be a dictionary exposing the keys source, target, type (defaults to 'bind'), readonly (defaults to false) - Maps docker service --mount option. default: [] secrets: required: false description: - List of dictionaries describing the service secrets. - Every item must be a dictionary exposing the keys secret_id, secret_name, filename, uid (defaults to 0), gid (defaults to 0), mode (defaults to 0o444) - Maps docker service --secret option. - Requires API version >= 1.25 default: [] configs: required: false description: - List of dictionaries describing the service configs. - Every item must be a dictionary exposing the keys config_id, config_name, filename, uid (defaults to 0), gid (defaults to 0), mode (defaults to 0o444) - Maps docker service --config option. - Requires API version >= 1.30 default: null networks: required: false default: [] description: - List of the service networks names. - Maps docker service --network option. publish: type: list required: false default: [] description: - List of dictionaries describing the service published ports. - Only used with api_version >= 1.25 suboptions: published_port: type: int required: true description: - The port to make externally available. target_port: type: int required: true description: - The port inside the container to expose. protocol: type: str required: false default: tcp description: - What protocol to use. choices: - tcp - udp mode: type: str required: false description: - What publish mode to use. - Requires API version >= 1.32 and docker python library >= 3.0.0 choices: - ingress - host replicas: required: false default: -1 description: - Number of containers instantiated in the service. Valid only if ``mode=='replicated'``. - If set to -1, and service is not present, service replicas will be set to 1. - If set to -1, and service is present, service replicas will be unchanged. - Maps docker service --replicas option. restart_policy: required: false default: none description: - Restart condition of the service. - Maps docker service --restart-condition option. choices: - none - on-failure - any restart_policy_attempts: required: false default: 0 description: - Maximum number of service restarts. - Maps docker service --restart-max-attempts option. restart_policy_delay: required: false default: 0 description: - Delay between restarts. - Maps docker service --restart-delay option. restart_policy_window: required: false default: 0 description: - Restart policy evaluation window. - Maps docker service --restart-window option. update_delay: required: false default: 10 description: - Rolling update delay - Maps docker service --update-delay option update_parallelism: required: false default: 1 description: - Rolling update parallelism - Maps docker service --update-parallelism option update_failure_action: required: false default: continue description: - Action to take in case of container failure - Maps to docker service --update-failure-action option choices: - continue - pause update_monitor: required: false default: 5000000000 description: - Time to monitor updated tasks for failures, in nanoseconds. - Maps to docker service --update-monitor option update_max_failure_ratio: required: false default: 0.00 description: - Fraction of tasks that may fail during an update before the failure action is invoked - Maps to docker service --update-max-failure-ratio update_order: required: false default: null description: - Specifies the order of operations when rolling out an updated task. - Maps to docker service --update-order - Requires API version >= 1.29 user: type: str required: false description: - Sets the username or UID used for the specified command. - Before Ansible 2.8, the default value for this option was C(root). The default has been removed so that the user defined in the image is used if no user is specified here. extends_documentation_fragment: - docker requirements: - "docker-py >= 2.0" - "Please note that the L(docker-py,https://pypi.org/project/docker-py/) Python module has been superseded by L(docker,https://pypi.org/project/docker/) (see L(here,https://github.com/docker/docker-py/issues/1310) for details). Version 2.1.0 or newer is only available with the C(docker) module." - "Docker API >= 1.24" notes: - "Images will only resolve to the latest digest when using Docker API >= 1.30 and docker-py >= 3.2.0. When using older versions use C(force_update: true) to trigger the swarm to resolve a new image." ''' RETURN = ''' ansible_swarm_service: returned: always type: dict description: - Dictionary of variables representing the current state of the service. Matches the module parameters format. - Note that facts are not part of registered vars but accessible directly. sample: '{ "args": [ "sleep", "3600" ], "constraints": [], "container_labels": {}, "endpoint_mode": "vip", "env": [ "ENVVAR1=envvar1" ], "force_update": False, "image": "alpine", "labels": {}, "limit_cpu": 0.0, "limit_memory": 0, "log_driver": "json-file", "log_driver_options": {}, "mode": "replicated", "mounts": [ { "source": "/tmp/", "target": "/remote_tmp/", "type": "bind" } ], "secrets": [], "configs": [], "networks": [], "publish": [], "replicas": 1, "reserve_cpu": 0.0, "reserve_memory": 0, "restart_policy": "any", "restart_policy_attempts": 5, "restart_policy_delay": 0, "restart_policy_window": 30, "update_delay": 10, "update_parallelism": 1, "update_failure_action": "continue", "update_monitor": 5000000000 "update_max_failure_ratio": 0, "update_order": "stop-first" }' changes: returned: always description: - List of changed service attributes if a service has been altered, [] otherwhise type: list sample: ['container_labels', 'replicas'] rebuilt: returned: always description: - True if the service has been recreated (removed and created) type: bool sample: True ''' EXAMPLES = ''' - name: define myservice docker_swarm_service: name: myservice image: "alpine" args: - "sleep" - "3600" mounts: - source: /tmp/ target: /remote_tmp/ type: bind env: - "ENVVAR1=envvar1" log_driver: fluentd log_driver_options: fluentd-address: "127.0.0.1:24224" fluentd-async-connect: true tag: "{{.Name}}/{{.ID}}" restart_policy: any restart_policy_attempts: 5 restart_policy_window: 30 register: dss_out1 - name: change myservice.env docker_swarm_service: name: myservice image: "alpine" args: - "sleep" - "7200" mounts: - source: /tmp/ target: /remote_tmp/ type: bind env: - "ENVVAR1=envvar1" restart_policy: any restart_policy_attempts: 5 restart_policy_window: 30 register: dss_out2 - name: test for changed myservice facts fail: msg: unchanged service when: "{{ dss_out1 == dss_out2 }}" - name: change myservice.image docker_swarm_service: name: myservice image: "alpine:edge" args: - "sleep" - "7200" mounts: - source: /tmp/ target: /remote_tmp/ type: bind env: - "ENVVAR1=envvar1" restart_policy: any restart_policy_attempts: 5 restart_policy_window: 30 register: dss_out3 - name: test for changed myservice facts fail: msg: unchanged service when: "{{ dss_out2 == dss_out3 }}" - name: remove mount docker_swarm_service: name: myservice image: "alpine:edge" args: - "sleep" - "7200" env: - "ENVVAR1=envvar1" restart_policy: any restart_policy_attempts: 5 restart_policy_window: 30 register: dss_out4 - name: test for changed myservice facts fail: msg: unchanged service when: "{{ dss_out3 == dss_out4 }}" - name: keep service as it is docker_swarm_service: name: myservice image: "alpine:edge" args: - "sleep" - "7200" env: - "ENVVAR1=envvar1" restart_policy: any restart_policy_attempts: 5 restart_policy_window: 30 register: dss_out5 - name: test for changed service facts fail: msg: changed service when: "{{ dss_out5 != dss_out5 }}" - name: remove myservice docker_swarm_service: name: myservice state: absent - name: set placement preferences docker_swarm_service: name: myservice image: alpine:edge placement_preferences: - spread: "node.labels.mylabel" ''' import time import shlex import operator from ansible.module_utils.docker_common import ( AnsibleDockerClient, DifferenceTracker, DockerBaseClass, ) from ansible.module_utils.basic import human_to_bytes from ansible.module_utils.six import string_types from ansible.module_utils._text import to_text try: from distutils.version import LooseVersion from docker import types from docker.utils import parse_repository_tag from docker.errors import DockerException except Exception: # missing docker-py handled in ansible.module_utils.docker pass class DockerService(DockerBaseClass): def __init__(self): super(DockerService, self).__init__() self.image = "" self.command = None self.args = [] self.endpoint_mode = None self.dns = [] self.hostname = "" self.tty = False self.dns_search = [] self.dns_options = [] self.env = [] self.force_update = None self.log_driver = "json-file" self.log_driver_options = {} self.labels = {} self.container_labels = {} self.limit_cpu = 0.000 self.limit_memory = 0 self.reserve_cpu = 0.000 self.reserve_memory = 0 self.mode = "replicated" self.user = None self.mounts = [] self.configs = [] self.secrets = [] self.constraints = [] self.networks = [] self.publish = [] self.constraints = [] self.placement_preferences = None self.replicas = -1 self.service_id = False self.service_version = False self.restart_policy = None self.restart_policy_attempts = None self.restart_policy_delay = None self.restart_policy_window = None self.update_delay = None self.update_parallelism = 1 self.update_failure_action = "continue" self.update_monitor = 5000000000 self.update_max_failure_ratio = 0.00 self.update_order = None def get_facts(self): return { 'image': self.image, 'mounts': self.mounts, 'configs': self.configs, 'networks': self.networks, 'command': self.command, 'args': self.args, 'tty': self.tty, 'dns': self.dns, 'dns_search': self.dns_search, 'dns_options': self.dns_options, 'hostname': self.hostname, 'env': self.env, 'force_update': self.force_update, 'log_driver': self.log_driver, 'log_driver_options': self.log_driver_options, 'publish': self.publish, 'constraints': self.constraints, 'placement_preferences': self.placement_preferences, 'labels': self.labels, 'container_labels': self.container_labels, 'mode': self.mode, 'replicas': self.replicas, 'endpoint_mode': self.endpoint_mode, 'restart_policy': self.restart_policy, 'limit_cpu': self.limit_cpu, 'limit_memory': self.limit_memory, 'reserve_cpu': self.reserve_cpu, 'reserve_memory': self.reserve_memory, 'restart_policy_delay': self.restart_policy_delay, 'restart_policy_attempts': self.restart_policy_attempts, 'restart_policy_window': self.restart_policy_window, 'update_delay': self.update_delay, 'update_parallelism': self.update_parallelism, 'update_failure_action': self.update_failure_action, 'update_monitor': self.update_monitor, 'update_max_failure_ratio': self.update_max_failure_ratio, 'update_order': self.update_order} @staticmethod def from_ansible_params(ap, old_service, image_digest): s = DockerService() s.image = image_digest s.constraints = ap['constraints'] s.placement_preferences = ap['placement_preferences'] s.args = ap['args'] s.endpoint_mode = ap['endpoint_mode'] s.dns = ap['dns'] s.dns_search = ap['dns_search'] s.dns_options = ap['dns_options'] s.hostname = ap['hostname'] s.tty = ap['tty'] s.env = ap['env'] s.log_driver = ap['log_driver'] s.log_driver_options = ap['log_driver_options'] s.labels = ap['labels'] s.container_labels = ap['container_labels'] s.limit_cpu = ap['limit_cpu'] s.reserve_cpu = ap['reserve_cpu'] s.mode = ap['mode'] s.networks = ap['networks'] s.restart_policy = ap['restart_policy'] s.restart_policy_attempts = ap['restart_policy_attempts'] s.restart_policy_delay = ap['restart_policy_delay'] s.restart_policy_window = ap['restart_policy_window'] s.update_delay = ap['update_delay'] s.update_parallelism = ap['update_parallelism'] s.update_failure_action = ap['update_failure_action'] s.update_monitor = ap['update_monitor'] s.update_max_failure_ratio = ap['update_max_failure_ratio'] s.update_order = ap['update_order'] s.user = ap['user'] s.command = ap['command'] if isinstance(s.command, string_types): s.command = shlex.split(s.command) elif isinstance(s.command, list): invalid_items = [ (index, item) for index, item in enumerate(s.command) if not isinstance(item, string_types) ] if invalid_items: errors = ', '.join( [ '%s (%s) at index %s' % (item, type(item), index) for index, item in invalid_items ] ) raise Exception( 'All items in a command list need to be strings. ' 'Check quoting. Invalid items: %s.' % errors ) s.command = ap['command'] elif s.command is not None: raise ValueError( 'Invalid type for command %s (%s). ' 'Only string or list allowed. Check quoting.' % (s.command, type(s.command)) ) if ap['force_update']: s.force_update = int(str(time.time()).replace('.', '')) if ap['replicas'] == -1: if old_service: s.replicas = old_service.replicas else: s.replicas = 1 else: s.replicas = ap['replicas'] for param_name in ['reserve_memory', 'limit_memory']: if ap.get(param_name): try: setattr(s, param_name, human_to_bytes(ap[param_name])) except ValueError as exc: raise Exception("Failed to convert %s to bytes: %s" % (param_name, exc)) s.publish = [] for param_p in ap['publish']: service_p = {} service_p['protocol'] = param_p['protocol'] service_p['mode'] = param_p['mode'] service_p['published_port'] = int(param_p['published_port']) service_p['target_port'] = int(param_p['target_port']) if service_p['protocol'] not in ['tcp', 'udp']: raise ValueError("got publish.protocol '%s', valid values:'tcp', 'udp'" % service_p['protocol']) if service_p['mode'] not in [None, 'ingress', 'host']: raise ValueError("got publish.mode '%s', valid values:'ingress', 'host'" % service_p['mode']) s.publish.append(service_p) s.mounts = [] for param_m in ap['mounts']: service_m = {} service_m['readonly'] = bool(param_m.get('readonly', False)) service_m['type'] = param_m.get('type', 'bind') service_m['source'] = param_m['source'] service_m['target'] = param_m['target'] s.mounts.append(service_m) s.configs = None if ap['configs'] is not None: s.configs = [] for param_m in ap['configs']: service_c = {} service_c['config_id'] = param_m['config_id'] service_c['config_name'] = str(param_m['config_name']) service_c['filename'] = param_m.get('filename', service_c['config_name']) service_c['uid'] = int(param_m.get('uid', "0")) service_c['gid'] = int(param_m.get('gid', "0")) service_c['mode'] = param_m.get('mode', 0o444) s.configs.append(service_c) s.secrets = [] for param_m in ap['secrets']: service_s = {} service_s['secret_id'] = param_m['secret_id'] service_s['secret_name'] = str(param_m['secret_name']) service_s['filename'] = param_m.get('filename', service_s['secret_name']) service_s['uid'] = int(param_m.get('uid', "0")) service_s['gid'] = int(param_m.get('gid', "0")) service_s['mode'] = param_m.get('mode', 0o444) s.secrets.append(service_s) return s def compare(self, os): differences = DifferenceTracker() needs_rebuild = False force_update = False if self.endpoint_mode is not None and self.endpoint_mode != os.endpoint_mode: differences.add('endpoint_mode', parameter=self.endpoint_mode, active=os.endpoint_mode) if self.env != os.env: differences.add('env', parameter=self.env, active=os.env) if self.log_driver != os.log_driver: differences.add('log_driver', parameter=self.log_driver, active=os.log_driver) if self.log_driver_options != os.log_driver_options: differences.add('log_opt', parameter=self.log_driver_options, active=os.log_driver_options) if self.mode != os.mode: needs_rebuild = True differences.add('mode', parameter=self.mode, active=os.mode) if self.mounts != os.mounts: differences.add('mounts', parameter=self.mounts, active=os.mounts) if self.configs is not None and self.configs != os.configs: differences.add('configs', parameter=self.configs, active=os.configs) if self.secrets != os.secrets: differences.add('secrets', parameter=self.secrets, active=os.secrets) if self.networks != os.networks: differences.add('networks', parameter=self.networks, active=os.networks) needs_rebuild = True if self.replicas != os.replicas: differences.add('replicas', parameter=self.replicas, active=os.replicas) if self.command is not None and self.command != os.command: differences.add('command', parameter=self.command, active=os.command) if self.args != os.args: differences.add('args', parameter=self.args, active=os.args) if self.constraints != os.constraints: differences.add('constraints', parameter=self.constraints, active=os.constraints) if self.placement_preferences is not None and self.placement_preferences != os.placement_preferences: differences.add('placement_preferences', parameter=self.placement_preferences, active=os.placement_preferences) if self.labels != os.labels: differences.add('labels', parameter=self.labels, active=os.labels) if self.limit_cpu != os.limit_cpu: differences.add('limit_cpu', parameter=self.limit_cpu, active=os.limit_cpu) if self.limit_memory != os.limit_memory: differences.add('limit_memory', parameter=self.limit_memory, active=os.limit_memory) if self.reserve_cpu != os.reserve_cpu: differences.add('reserve_cpu', parameter=self.reserve_cpu, active=os.reserve_cpu) if self.reserve_memory != os.reserve_memory: differences.add('reserve_memory', parameter=self.reserve_memory, active=os.reserve_memory) if self.container_labels != os.container_labels: differences.add('container_labels', parameter=self.container_labels, active=os.container_labels) if self.has_publish_changed(os.publish): differences.add('publish', parameter=self.publish, active=os.publish) if self.restart_policy != os.restart_policy: differences.add('restart_policy', parameter=self.restart_policy, active=os.restart_policy) if self.restart_policy_attempts != os.restart_policy_attempts: differences.add('restart_policy_attempts', parameter=self.restart_policy_attempts, active=os.restart_policy_attempts) if self.restart_policy_delay != os.restart_policy_delay: differences.add('restart_policy_delay', parameter=self.restart_policy_delay, active=os.restart_policy_delay) if self.restart_policy_window != os.restart_policy_window: differences.add('restart_policy_window', parameter=self.restart_policy_window, active=os.restart_policy_window) if self.update_delay != os.update_delay: differences.add('update_delay', parameter=self.update_delay, active=os.update_delay) if self.update_parallelism != os.update_parallelism: differences.add('update_parallelism', parameter=self.update_parallelism, active=os.update_parallelism) if self.update_failure_action != os.update_failure_action: differences.add('update_failure_action', parameter=self.update_failure_action, active=os.update_failure_action) if self.update_monitor != os.update_monitor: differences.add('update_monitor', parameter=self.update_monitor, active=os.update_monitor) if self.update_max_failure_ratio != os.update_max_failure_ratio: differences.add('update_max_failure_ratio', parameter=self.update_max_failure_ratio, active=os.update_max_failure_ratio) if self.update_order is not None and self.update_order != os.update_order: differences.add('update_order', parameter=self.update_order, active=os.update_order) has_image_changed, change = self.has_image_changed(os.image) if has_image_changed: differences.add('image', parameter=self.image, active=change) if self.user and self.user != os.user: differences.add('user', parameter=self.user, active=os.user) if self.dns != os.dns: differences.add('dns', parameter=self.dns, active=os.dns) if self.dns_search != os.dns_search: differences.add('dns_search', parameter=self.dns_search, active=os.dns_search) if self.dns_options != os.dns_options: differences.add('dns_options', parameter=self.dns_options, active=os.dns_options) if self.hostname != os.hostname: differences.add('hostname', parameter=self.hostname, active=os.hostname) if self.tty != os.tty: differences.add('tty', parameter=self.tty, active=os.tty) if self.force_update: force_update = True return not differences.empty or force_update, differences, needs_rebuild, force_update def has_publish_changed(self, old_publish): if len(self.publish) != len(old_publish): return True publish_sorter = operator.itemgetter('published_port', 'target_port', 'protocol') publish = sorted(self.publish, key=publish_sorter) old_publish = sorted(old_publish, key=publish_sorter) for publish_item, old_publish_item in zip(publish, old_publish): ignored_keys = set() if not publish_item.get('mode'): ignored_keys.add('mode') # Create copies of publish_item dicts where keys specified in ignored_keys are left out filtered_old_publish_item = dict( (k, v) for k, v in old_publish_item.items() if k not in ignored_keys ) filtered_publish_item = dict( (k, v) for k, v in publish_item.items() if k not in ignored_keys ) if filtered_publish_item != filtered_old_publish_item: return True return False def has_image_changed(self, old_image): if '@' not in self.image: old_image = old_image.split('@')[0] return self.image != old_image, old_image def __str__(self): return str({ 'mode': self.mode, 'env': self.env, 'endpoint_mode': self.endpoint_mode, 'mounts': self.mounts, 'configs': self.configs, 'secrets': self.secrets, 'networks': self.networks, 'replicas': self.replicas}) def generate_docker_py_service_description(self, name, docker_networks): mounts = [] for mount_config in self.mounts: mounts.append( types.Mount(target=mount_config['target'], source=mount_config['source'], type=mount_config['type'], read_only=mount_config['readonly']) ) configs = None if self.configs: configs = [] for config_config in self.configs: configs.append( types.ConfigReference( config_id=config_config['config_id'], config_name=config_config['config_name'], filename=config_config.get('filename'), uid=config_config.get('uid'), gid=config_config.get('gid'), mode=config_config.get('mode') ) ) secrets = [] for secret_config in self.secrets: secrets.append( types.SecretReference( secret_id=secret_config['secret_id'], secret_name=secret_config['secret_name'], filename=secret_config.get('filename'), uid=secret_config.get('uid'), gid=secret_config.get('gid'), mode=secret_config.get('mode') ) ) dns_config = types.DNSConfig( nameservers=self.dns, search=self.dns_search, options=self.dns_options ) cspec = types.ContainerSpec( image=self.image, command=self.command, args=self.args, hostname=self.hostname, env=self.env, user=self.user, labels=self.container_labels, mounts=mounts, secrets=secrets, tty=self.tty, dns_config=dns_config, configs=configs ) log_driver = types.DriverConfig(name=self.log_driver, options=self.log_driver_options) placement = types.Placement( constraints=self.constraints, preferences=[ {key.title(): {"SpreadDescriptor": value}} for preference in self.placement_preferences for key, value in preference.items() ] if self.placement_preferences else None, ) restart_policy = types.RestartPolicy( condition=self.restart_policy, delay=self.restart_policy_delay, max_attempts=self.restart_policy_attempts, window=self.restart_policy_window) resources = types.Resources( cpu_limit=int(self.limit_cpu * 1000000000.0), mem_limit=self.limit_memory, cpu_reservation=int(self.reserve_cpu * 1000000000.0), mem_reservation=self.reserve_memory ) update_policy = types.UpdateConfig( parallelism=self.update_parallelism, delay=self.update_delay, failure_action=self.update_failure_action, monitor=self.update_monitor, max_failure_ratio=self.update_max_failure_ratio, order=self.update_order ) task_template = types.TaskTemplate( container_spec=cspec, log_driver=log_driver, restart_policy=restart_policy, placement=placement, resources=resources, force_update=self.force_update) if self.mode == 'global': self.replicas = None mode = types.ServiceMode(self.mode, replicas=self.replicas) networks = [] for network_name in self.networks: network_id = None try: network_id = list(filter(lambda n: n['name'] == network_name, docker_networks))[0]['id'] except Exception: pass if network_id: networks.append({'Target': network_id}) else: raise Exception("no docker networks named: %s" % network_name) ports = {} for port in self.publish: if port.get('mode'): ports[int(port['published_port'])] = (int(port['target_port']), port['protocol'], port['mode']) else: ports[int(port['published_port'])] = (int(port['target_port']), port['protocol']) endpoint_spec = types.EndpointSpec(mode=self.endpoint_mode, ports=ports) return update_policy, task_template, networks, endpoint_spec, mode, self.labels # def fail(self, msg): # self.parameters.client.module.fail_json(msg=msg) # # @property # def exists(self): # return True if self.service else False class DockerServiceManager(): def get_networks_names_ids(self): return [{'name': n['Name'], 'id': n['Id']} for n in self.client.networks()] def get_service(self, name): # The Docker API allows filtering services by name but the filter looks # for a substring match, not an exact match. (Filtering for "foo" would # return information for services "foobar" and "foobuzz" even if the # service "foo" doesn't exist.) Avoid incorrectly determining that a # service is present by filtering the list of services returned from the # Docker API so that the name must be an exact match. raw_data = [ service for service in self.client.services(filters={'name': name}) if service['Spec']['Name'] == name ] if len(raw_data) == 0: return None raw_data = raw_data[0] networks_names_ids = self.get_networks_names_ids() ds = DockerService() task_template_data = raw_data['Spec']['TaskTemplate'] update_config_data = raw_data['Spec']['UpdateConfig'] ds.image = task_template_data['ContainerSpec']['Image'] ds.user = task_template_data['ContainerSpec'].get('User', None) ds.env = task_template_data['ContainerSpec'].get('Env', []) ds.command = task_template_data['ContainerSpec'].get('Command') ds.args = task_template_data['ContainerSpec'].get('Args', []) ds.update_delay = update_config_data['Delay'] ds.update_parallelism = update_config_data['Parallelism'] ds.update_failure_action = update_config_data['FailureAction'] ds.update_monitor = update_config_data['Monitor'] ds.update_max_failure_ratio = update_config_data['MaxFailureRatio'] if 'Order' in update_config_data: ds.update_order = update_config_data['Order'] dns_config = task_template_data['ContainerSpec'].get('DNSConfig', None) if dns_config: if 'Nameservers' in dns_config.keys(): ds.dns = dns_config['Nameservers'] if 'Search' in dns_config.keys(): ds.dns_search = dns_config['Search'] if 'Options' in dns_config.keys(): ds.dns_options = dns_config['Options'] ds.hostname = task_template_data['ContainerSpec'].get('Hostname', '') ds.tty = task_template_data['ContainerSpec'].get('TTY', False) if 'Placement' in task_template_data.keys(): placement = task_template_data['Placement'] ds.constraints = placement.get('Constraints', []) placement_preferences = [] for preference in placement.get('Preferences', []): placement_preferences.append( dict( (key.lower(), value["SpreadDescriptor"]) for key, value in preference.items() ) ) ds.placement_preferences = placement_preferences or None restart_policy_data = task_template_data.get('RestartPolicy', None) if restart_policy_data: ds.restart_policy = restart_policy_data.get('Condition') ds.restart_policy_delay = restart_policy_data.get('Delay') ds.restart_policy_attempts = restart_policy_data.get('MaxAttempts') ds.restart_policy_window = restart_policy_data.get('Window') raw_data_endpoint_spec = raw_data['Spec'].get('EndpointSpec') if raw_data_endpoint_spec: ds.endpoint_mode = raw_data_endpoint_spec.get('Mode') for port in raw_data_endpoint_spec.get('Ports', []): ds.publish.append({ 'protocol': port['Protocol'], 'mode': port.get('PublishMode', None), 'published_port': int(port['PublishedPort']), 'target_port': int(port['TargetPort'])}) if 'Resources' in task_template_data.keys(): if 'Limits' in task_template_data['Resources'].keys(): if 'NanoCPUs' in task_template_data['Resources']['Limits'].keys(): ds.limit_cpu = float(task_template_data['Resources']['Limits']['NanoCPUs']) / 1000000000 if 'MemoryBytes' in task_template_data['Resources']['Limits'].keys(): ds.limit_memory = int(task_template_data['Resources']['Limits']['MemoryBytes']) if 'Reservations' in task_template_data['Resources'].keys(): if 'NanoCPUs' in task_template_data['Resources']['Reservations'].keys(): ds.reserve_cpu = float(task_template_data['Resources']['Reservations']['NanoCPUs']) / 1000000000 if 'MemoryBytes' in task_template_data['Resources']['Reservations'].keys(): ds.reserve_memory = int( task_template_data['Resources']['Reservations']['MemoryBytes']) ds.labels = raw_data['Spec'].get('Labels', {}) if 'LogDriver' in task_template_data.keys(): ds.log_driver = task_template_data['LogDriver'].get('Name', 'json-file') ds.log_driver_options = task_template_data['LogDriver'].get('Options', {}) ds.container_labels = task_template_data['ContainerSpec'].get('Labels', {}) mode = raw_data['Spec']['Mode'] if 'Replicated' in mode.keys(): ds.mode = to_text('replicated', encoding='utf-8') ds.replicas = mode['Replicated']['Replicas'] elif 'Global' in mode.keys(): ds.mode = 'global' else: raise Exception("Unknown service mode: %s" % mode) for mount_data in raw_data['Spec']['TaskTemplate']['ContainerSpec'].get('Mounts', []): ds.mounts.append({ 'source': mount_data['Source'], 'type': mount_data['Type'], 'target': mount_data['Target'], 'readonly': mount_data.get('ReadOnly', False)}) for config_data in raw_data['Spec']['TaskTemplate']['ContainerSpec'].get('Configs', []): ds.configs.append({ 'config_id': config_data['ConfigID'], 'config_name': config_data['ConfigName'], 'filename': config_data['File'].get('Name'), 'uid': int(config_data['File'].get('UID')), 'gid': int(config_data['File'].get('GID')), 'mode': config_data['File'].get('Mode') }) for secret_data in raw_data['Spec']['TaskTemplate']['ContainerSpec'].get('Secrets', []): ds.secrets.append({ 'secret_id': secret_data['SecretID'], 'secret_name': secret_data['SecretName'], 'filename': secret_data['File'].get('Name'), 'uid': int(secret_data['File'].get('UID')), 'gid': int(secret_data['File'].get('GID')), 'mode': secret_data['File'].get('Mode') }) networks_names_ids = self.get_networks_names_ids() for raw_network_data in raw_data['Spec']['TaskTemplate'].get('Networks', raw_data['Spec'].get('Networks', [])): network_name = [network_name_id['name'] for network_name_id in networks_names_ids if network_name_id['id'] == raw_network_data['Target']] if len(network_name) == 0: ds.networks.append(raw_network_data['Target']) else: ds.networks.append(network_name[0]) ds.service_version = raw_data['Version']['Index'] ds.service_id = raw_data['ID'] return ds def update_service(self, name, old_service, new_service): update_policy, task_template, networks, endpoint_spec, mode, labels = new_service.generate_docker_py_service_description( name, self.get_networks_names_ids()) self.client.update_service( old_service.service_id, old_service.service_version, name=name, endpoint_spec=endpoint_spec, networks=networks, mode=mode, update_config=update_policy, task_template=task_template, labels=labels) def create_service(self, name, service): update_policy, task_template, networks, endpoint_spec, mode, labels = service.generate_docker_py_service_description( name, self.get_networks_names_ids()) self.client.create_service( name=name, endpoint_spec=endpoint_spec, mode=mode, networks=networks, update_config=update_policy, task_template=task_template, labels=labels) def remove_service(self, name): self.client.remove_service(name) def get_image_digest(self, name, resolve=True): if ( not name or not resolve or self.client.docker_py_version < LooseVersion('3.2') or self.client.docker_api_version < LooseVersion('1.30') ): return name repo, tag = parse_repository_tag(name) if not tag: tag = 'latest' name = repo + ':' + tag distribution_data = self.client.inspect_distribution(name) digest = distribution_data['Descriptor']['digest'] return '%s@%s' % (name, digest) def __init__(self, client): self.client = client self.diff_tracker = DifferenceTracker() def run(self): module = self.client.module image = module.params['image'] try: image_digest = self.get_image_digest( name=image, resolve=module.params['resolve_image'] ) except DockerException as e: return module.fail_json( msg="Error looking for an image named %s: %s" % (image, e)) try: current_service = self.get_service(module.params['name']) except Exception as e: return module.fail_json( msg="Error looking for service named %s: %s" % (module.params['name'], e)) try: new_service = DockerService.from_ansible_params( module.params, current_service, image_digest ) except Exception as e: return module.fail_json( msg="Error parsing module parameters: %s" % e) changed = False msg = 'noop' rebuilt = False differences = DifferenceTracker() facts = {} if current_service: if module.params['state'] == 'absent': if not module.check_mode: self.remove_service(module.params['name']) msg = 'Service removed' changed = True else: changed, differences, need_rebuild, force_update = new_service.compare(current_service) if changed: self.diff_tracker.merge(differences) if need_rebuild: if not module.check_mode: self.remove_service(module.params['name']) self.create_service(module.params['name'], new_service) msg = 'Service rebuilt' rebuilt = True else: if not module.check_mode: self.update_service(module.params['name'], current_service, new_service) msg = 'Service updated' rebuilt = False else: if force_update: if not module.check_mode: self.update_service(module.params['name'], current_service, new_service) msg = 'Service forcefully updated' rebuilt = False changed = True else: msg = 'Service unchanged' facts = new_service.get_facts() else: if module.params['state'] == 'absent': msg = 'Service absent' else: if not module.check_mode: service_id = self.create_service(module.params['name'], new_service) msg = 'Service created' changed = True facts = new_service.get_facts() return msg, changed, rebuilt, differences.get_legacy_docker_diffs(), facts def _detect_publish_mode_usage(client): for publish_def in client.module.params['publish']: if publish_def.get('mode'): return True return False def main(): argument_spec = dict( name=dict(required=True), image=dict(type='str'), state=dict(default="present", choices=['present', 'absent']), mounts=dict(default=[], type='list'), configs=dict(default=None, type='list'), secrets=dict(default=[], type='list'), networks=dict(default=[], type='list'), command=dict(default=None, type='raw'), args=dict(default=[], type='list'), env=dict(default=[], type='list'), force_update=dict(default=False, type='bool'), log_driver=dict(default="json-file", type='str'), log_driver_options=dict(default={}, type='dict'), publish=dict(default=[], type='list', elements='dict', options=dict( published_port=dict(type='int', required=True), target_port=dict(type='int', required=True), protocol=dict(default='tcp', type='str', required=False, choices=('tcp', 'udp')), mode=dict(type='str', required=False, choices=('ingress', 'host')), )), constraints=dict(default=[], type='list'), placement_preferences=dict(default=None, type='list'), tty=dict(default=False, type='bool'), dns=dict(default=[], type='list'), dns_search=dict(default=[], type='list'), dns_options=dict(default=[], type='list'), hostname=dict(default="", type='str'), labels=dict(default={}, type='dict'), container_labels=dict(default={}, type='dict'), mode=dict(default="replicated"), replicas=dict(default=-1, type='int'), endpoint_mode=dict(default=None, choices=['vip', 'dnsrr']), restart_policy=dict(default='none', choices=['none', 'on-failure', 'any']), limit_cpu=dict(default=0, type='float'), limit_memory=dict(default=0, type='str'), reserve_cpu=dict(default=0, type='float'), reserve_memory=dict(default=0, type='str'), resolve_image=dict(default=True, type='bool'), restart_policy_delay=dict(default=0, type='int'), restart_policy_attempts=dict(default=0, type='int'), restart_policy_window=dict(default=0, type='int'), update_delay=dict(default=10, type='int'), update_parallelism=dict(default=1, type='int'), update_failure_action=dict(default='continue', choices=['continue', 'pause']), update_monitor=dict(default=5000000000, type='int'), update_max_failure_ratio=dict(default=0, type='float'), update_order=dict(default=None, type='str'), user=dict(type='str')) option_minimal_versions = dict( dns=dict(docker_py_version='2.6.0', docker_api_version='1.25'), dns_options=dict(docker_py_version='2.6.0', docker_api_version='1.25'), dns_search=dict(docker_py_version='2.6.0', docker_api_version='1.25'), force_update=dict(docker_py_version='2.1.0', docker_api_version='1.25'), hostname=dict(docker_py_version='2.2.0', docker_api_version='1.25'), tty=dict(docker_py_version='2.4.0', docker_api_version='1.25'), secrets=dict(docker_py_version='2.1.0', docker_api_version='1.25'), configs=dict(docker_py_version='2.6.0', docker_api_version='1.30'), update_order=dict(docker_py_version='2.7.0', docker_api_version='1.29'), # specials publish_mode=dict( docker_py_version='3.0.0', docker_api_version='1.25', detect_usage=_detect_publish_mode_usage, usage_msg='set publish.mode' ) ) required_if = [ ('state', 'present', ['image']) ] client = AnsibleDockerClient( argument_spec=argument_spec, required_if=required_if, supports_check_mode=True, min_docker_version='2.0.0', min_docker_api_version='1.24', option_minimal_versions=option_minimal_versions, ) dsm = DockerServiceManager(client) msg, changed, rebuilt, changes, facts = dsm.run() results = dict( msg=msg, changed=changed, rebuilt=rebuilt, changes=changes, ansible_docker_service=facts, ) if client.module._diff: before, after = dsm.diff_tracker.get_before_after() results['diff'] = dict(before=before, after=after) client.module.exit_json(**results) if __name__ == '__main__': main()