#!/usr/bin/python # # Copyright (c) 2017, Dario Zanzico (git@dariozanzico.com) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import annotations DOCUMENTATION = r""" module: docker_swarm_service author: - "Dario Zanzico (@dariko)" - "Jason Witkowski (@jwitko)" - "Hannes Ljungberg (@hannseman)" - "Piotr Wojciechowski (@wojciechowskipiotr)" short_description: docker swarm service description: - Manages docker services through a swarm manager node. - This modules does not support updating services in a stack. extends_documentation_fragment: - community.docker._docker - community.docker._docker.docker_py_2_documentation - community.docker._attributes - community.docker._attributes.actiongroup_docker attributes: check_mode: support: full diff_mode: support: full idempotent: support: full options: args: description: - List arguments to be passed to the container. - Corresponds to the C(ARG) parameter of C(docker service create). type: list elements: str command: description: - Command to execute when the container starts. - A command may be either a string or a list or a list of strings. - Corresponds to the C(COMMAND) parameter of C(docker service create). type: raw configs: description: - List of dictionaries describing the service configs. - Corresponds to the C(--config) option of C(docker service create). - Requires API version >= 1.30. type: list elements: dict suboptions: config_id: description: - Config's ID. type: str config_name: description: - Config's name as defined at its creation. type: str required: true filename: description: - Name of the file containing the config. Defaults to the O(configs[].config_name) if not specified. type: str uid: description: - UID of the config file's owner. type: str gid: description: - GID of the config file's group. type: str mode: description: - File access mode inside the container. Must be an octal number (like V(0644) or V(0444)). type: int container_labels: description: - Dictionary of key value pairs. - Corresponds to the C(--container-label) option of C(docker service create). type: dict sysctls: description: - Dictionary of key, value pairs. version_added: 3.10.0 type: dict dns: description: - List of custom DNS servers. - Corresponds to the C(--dns) option of C(docker service create). type: list elements: str dns_search: description: - List of custom DNS search domains. - Corresponds to the C(--dns-search) option of C(docker service create). type: list elements: str dns_options: description: - List of custom DNS options. - Corresponds to the C(--dns-option) option of C(docker service create). type: list elements: str endpoint_mode: description: - Service endpoint mode. - Corresponds to the C(--endpoint-mode) option of C(docker service create). type: str choices: - vip - dnsrr env: description: - List or dictionary of the service environment variables. - If passed a list each items need to be in the format of C(KEY=VALUE). - If passed a dictionary values which might be parsed as numbers, booleans or other types by the YAML parser must be quoted (for example V("true")) in order to avoid data loss. - Corresponds to the C(--env) option of C(docker service create). type: raw env_files: description: - List of paths to files, present on the target, containing environment variables C(FOO=BAR). - The order of the list is significant in determining the value assigned to a variable that shows up more than once. - If variable also present in O(env), then O(env) value will override. type: list elements: path force_update: description: - Force update even if no changes require it. - Corresponds to the C(--force) option of C(docker service update). type: bool default: false groups: description: - List of additional group names and/or IDs that the container process will run as. - Corresponds to the C(--group) option of C(docker service update). type: list elements: str healthcheck: description: - Configure a check that is run to determine whether or not containers for this service are "healthy". See the docs for the L(HEALTHCHECK Dockerfile instruction,https://docs.docker.com/engine/reference/builder/#healthcheck) for details on how healthchecks work. - 'O(healthcheck.interval), O(healthcheck.timeout), and O(healthcheck.start_period) are specified as durations. They accept duration as a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' type: dict suboptions: test: description: - Command to run to check health. - Must be either a string or a list. If it is a list, the first item must be one of V(NONE), V(CMD) or V(CMD-SHELL). type: raw interval: description: - Time between running the check. type: str timeout: description: - Maximum time to allow one check to run. type: str retries: description: - Consecutive failures needed to report unhealthy. It accept integer value. type: int start_period: description: - Start period for the container to initialize before starting health-retries countdown. type: str hostname: description: - Container hostname. - Corresponds to the C(--hostname) option of C(docker service create). type: str 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. - Corresponds to the C(--host) option of C(docker service create). type: dict image: description: - Service image path and tag. - Corresponds to the C(IMAGE) parameter of C(docker service create). type: str init: description: - Use an init inside each service container to forward signals and reap processes. - Corresponds to the C(--init) option of C(docker service create). - Requires API version >= 1.37. type: bool labels: description: - Dictionary of key value pairs. - Corresponds to the C(--label) option of C(docker service create). type: dict limits: description: - Configures service resource limits. suboptions: cpus: description: - Service CPU limit. V(0) equals no limit. - Corresponds to the C(--limit-cpu) option of C(docker service create). type: float memory: description: - Service memory limit in format C([]). Number is a positive integer. Unit can be V(B) (byte), V(K) (kibibyte, 1024B), V(M) (mebibyte), V(G) (gibibyte), V(T) (tebibyte), or V(P) (pebibyte). - V(0) equals no limit. - Omitting the unit defaults to bytes. - Corresponds to the C(--limit-memory) option of C(docker service create). type: str type: dict logging: description: - Logging configuration for the service. suboptions: driver: description: - Configure the logging driver for a service. - Corresponds to the C(--log-driver) option of C(docker service create). type: str options: description: - Options for service logging driver. - Corresponds to the C(--log-opt) option of C(docker service create). type: dict type: dict mode: description: - Service replication mode. - Service will be removed and recreated when changed. - Corresponds to the C(--mode) option of C(docker service create). - The value V(replicated-job) was added in community.docker 4.7.0, and requires API version >= 1.41 and Docker SDK for Python >= 6.0.0. type: str default: replicated choices: - replicated - global - replicated-job mounts: description: - List of dictionaries describing the service mounts. - Corresponds to the C(--mount) option of C(docker service create). type: list elements: dict suboptions: source: description: - Mount source (for example a volume name or a host path). - Must be specified if O(mounts[].type) is not V(tmpfs). type: str target: description: - Container path. type: str required: true type: description: - The mount type. - Note that V(npipe) is only supported by Docker for Windows. Also note that V(npipe) was added in Ansible 2.9. type: str default: bind choices: - bind - volume - tmpfs - npipe readonly: description: - Whether the mount should be read-only. type: bool labels: description: - Volume labels to apply. type: dict propagation: description: - The propagation mode to use. - Can only be used when O(mounts[].type=bind). type: str choices: - shared - slave - private - rshared - rslave - rprivate no_copy: description: - Disable copying of data from a container when a volume is created. - Can only be used when O(mounts[].type=volume). type: bool driver_config: description: - Volume driver configuration. - Can only be used when O(mounts[].type=volume). suboptions: name: description: - Name of the volume-driver plugin to use for the volume. type: str options: description: - Options as key-value pairs to pass to the driver for this volume. type: dict type: dict tmpfs_size: description: - Size of the tmpfs mount in format C([]). Number is a positive integer. Unit can be V(B) (byte), V(K) (kibibyte, 1024B), V(M) (mebibyte), V(G) (gibibyte), V(T) (tebibyte), or V(P) (pebibyte). - Can only be used when O(mounts[].type=tmpfs). type: str tmpfs_mode: description: - File mode of the tmpfs in octal. - Can only be used when O(mounts[].type=tmpfs). type: int name: description: - Service name. - Corresponds to the C(--name) option of C(docker service create). type: str required: true networks: description: - List of the service networks names or dictionaries. - When passed dictionaries valid sub-options are C(name), which is required, and C(aliases) and C(options). - Prior to API version 1.29, updating and removing networks is not supported. If changes are made the service will then be removed and recreated. - Corresponds to the C(--network) option of C(docker service create). type: list elements: raw placement: description: - Configures service placement preferences and constraints. suboptions: constraints: description: - List of the service constraints. - Corresponds to the C(--constraint) option of C(docker service create). type: list elements: str preferences: description: - List of the placement preferences as key value pairs. - Corresponds to the C(--placement-pref) option of C(docker service create). - Requires API version >= 1.27. type: list elements: dict replicas_max_per_node: description: - Maximum number of tasks per node. - Corresponds to the C(--replicas_max_per_node) option of C(docker service create). - Requires API version >= 1.40. type: int version_added: 1.3.0 type: dict publish: description: - List of dictionaries describing the service published ports. - Corresponds to the C(--publish) option of C(docker service create). type: list elements: dict suboptions: published_port: description: - The port to make externally available. type: int required: false target_port: description: - The port inside the container to expose. type: int required: true protocol: description: - What protocol to use. type: str default: tcp choices: - tcp - udp mode: description: - What publish mode to use. - Requires API version >= 1.32. type: str choices: - ingress - host read_only: description: - Mount the containers root filesystem as read only. - Corresponds to the C(--read-only) option of C(docker service create). type: bool replicas: description: - Number of containers instantiated in the service. Valid only if O(mode=replicated) or O(mode=replicated-job). - If set to V(-1), and service is not present, service replicas will be set to V(1). - If set to V(-1), and service is present, service replicas will be unchanged. - Corresponds to the C(--replicas) option of C(docker service create). type: int default: -1 reservations: description: - Configures service resource reservations. suboptions: cpus: description: - Service CPU reservation. V(0) equals no reservation. - Corresponds to the C(--reserve-cpu) option of C(docker service create). type: float memory: description: - Service memory reservation in format C([]). Number is a positive integer. Unit can be V(B) (byte), V(K) (kibibyte, 1024B), V(M) (mebibyte), V(G) (gibibyte), V(T) (tebibyte), or V(P) (pebibyte). - V(0) equals no reservation. - Omitting the unit defaults to bytes. - Corresponds to the C(--reserve-memory) option of C(docker service create). type: str type: dict resolve_image: description: - If the current image digest should be resolved from registry and updated if changed. - Requires API version >= 1.30. type: bool default: false restart_config: description: - Configures if and how to restart containers when they exit. suboptions: condition: description: - Restart condition of the service. - Corresponds to the C(--restart-condition) option of C(docker service create). type: str choices: - none - on-failure - any delay: description: - Delay between restarts. - 'Accepts a a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--restart-delay) option of C(docker service create). type: str max_attempts: description: - Maximum number of service restarts. - Corresponds to the C(--restart-condition) option of C(docker service create). type: int window: description: - Restart policy evaluation window. - 'Accepts a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--restart-window) option of C(docker service create). type: str type: dict rollback_config: description: - Configures how the service should be rolled back in case of a failing update. suboptions: parallelism: description: - The number of containers to rollback at a time. If set to 0, all containers rollback simultaneously. - Corresponds to the C(--rollback-parallelism) option of C(docker service create). - Requires API version >= 1.28. type: int delay: description: - Delay between task rollbacks. - 'Accepts a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--rollback-delay) option of C(docker service create). - Requires API version >= 1.28. type: str failure_action: description: - Action to take in case of rollback failure. - Corresponds to the C(--rollback-failure-action) option of C(docker service create). - Requires API version >= 1.28. type: str choices: - continue - pause monitor: description: - Duration after each task rollback to monitor for failure. - 'Accepts a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--rollback-monitor) option of C(docker service create). - Requires API version >= 1.28. type: str max_failure_ratio: description: - Fraction of tasks that may fail during a rollback. - Corresponds to the C(--rollback-max-failure-ratio) option of C(docker service create). - Requires API version >= 1.28. type: float order: description: - Specifies the order of operations during rollbacks. - Corresponds to the C(--rollback-order) option of C(docker service create). - Requires API version >= 1.29. type: str type: dict secrets: description: - List of dictionaries describing the service secrets. - Corresponds to the C(--secret) option of C(docker service create). type: list elements: dict suboptions: secret_id: description: - Secret's ID. type: str secret_name: description: - Secret's name as defined at its creation. type: str required: true filename: description: - Name of the file containing the secret. Defaults to the O(secrets[].secret_name) if not specified. - Corresponds to the C(target) key of C(docker service create --secret). type: str uid: description: - UID of the secret file's owner. type: str gid: description: - GID of the secret file's group. type: str mode: description: - File access mode inside the container. Must be an octal number (like V(0644) or V(0444)). type: int state: description: - V(absent) - A service matching the specified name will be removed and have its tasks stopped. - V(present) - Asserts the existence of a service matching the name and provided configuration parameters. Unspecified configuration parameters will be set to docker defaults. type: str default: present choices: - present - absent stop_grace_period: description: - Time to wait before force killing a container. - 'Accepts a duration as a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--stop-grace-period) option of C(docker service create). type: str stop_signal: description: - Override default signal used to stop the container. - Corresponds to the C(--stop-signal) option of C(docker service create). type: str tty: description: - Allocate a pseudo-TTY. - Corresponds to the C(--tty) option of C(docker service create). type: bool update_config: description: - Configures how the service should be updated. Useful for configuring rolling updates. suboptions: parallelism: description: - Rolling update parallelism. - Corresponds to the C(--update-parallelism) option of C(docker service create). type: int delay: description: - Rolling update delay. - 'Accepts a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--update-delay) option of C(docker service create). type: str failure_action: description: - Action to take in case of container failure. - Corresponds to the C(--update-failure-action) option of C(docker service create). - Usage of V(rollback) requires API version >= 1.29. type: str choices: - continue - pause - rollback monitor: description: - Time to monitor updated tasks for failures. - 'Accepts a string in a format that look like: V(5h34m56s), V(1m30s), and so on. The supported units are V(us), V(ms), V(s), V(m) and V(h).' - Corresponds to the C(--update-monitor) option of C(docker service create). type: str max_failure_ratio: description: - Fraction of tasks that may fail during an update before the failure action is invoked. - Corresponds to the C(--update-max-failure-ratio) option of C(docker service create). type: float order: description: - Specifies the order of operations when rolling out an updated task. - Corresponds to the C(--update-order) option of C(docker service create). - Requires API version >= 1.29. type: str type: dict user: description: - Sets the username or UID used for the specified command. - Before Ansible 2.8, the default value for this option was V(root). - The default has been removed so that the user defined in the image is used if no user is specified here. - Corresponds to the C(--user) option of C(docker service create). type: str working_dir: description: - Path to the working directory. - Corresponds to the C(--workdir) option of C(docker service create). type: str cap_add: description: - List of capabilities to add to the container. - Requires API version >= 1.41. type: list elements: str version_added: 2.2.0 cap_drop: description: - List of capabilities to drop from the container. - Requires API version >= 1.41. type: list elements: str version_added: 2.2.0 requirements: - "L(Docker SDK for Python,https://docker-py.readthedocs.io/en/stable/) >= 2.0.2" - "Docker API >= 1.25" notes: - Images will only resolve to the latest digest when using Docker API >= 1.30 and Docker SDK for Python >= 3.2.0. When using older versions use O(force_update=true) to trigger the swarm to resolve a new image. """ RETURN = r""" 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. - Note that before Ansible 2.7.9, the return variable was documented as C(ansible_swarm_service), while the module actually returned a variable called C(ansible_docker_service). The variable was renamed to RV(swarm_service) in both code and documentation for Ansible 2.7.9 and Ansible 2.8.0. In Ansible 2.7.x, the old name C(ansible_docker_service) can still be used. sample: '{ "args": [ "3600" ], "cap_add": null, "cap_drop": [ "ALL" ], "command": [ "sleep" ], "configs": null, "constraints": [ "node.role == manager", "engine.labels.operatingsystem == ubuntu 14.04" ], "container_labels": null, "sysctls": null, "dns": null, "dns_options": null, "dns_search": null, "endpoint_mode": null, "env": [ "ENVVAR1=envvar1", "ENVVAR2=envvar2" ], "force_update": null, "groups": null, "healthcheck": { "interval": 90000000000, "retries": 3, "start_period": 30000000000, "test": [ "CMD", "curl", "--fail", "http://nginx.host.com" ], "timeout": 10000000000 }, "healthcheck_disabled": false, "hostname": null, "hosts": null, "image": "alpine:latest@sha256:b3dbf31b77fd99d9c08f780ce6f5282aba076d70a513a8be859d8d3a4d0c92b8", "labels": { "com.example.department": "Finance", "com.example.description": "Accounting webapp" }, "limit_cpu": 0.5, "limit_memory": 52428800, "log_driver": "fluentd", "log_driver_options": { "fluentd-address": "127.0.0.1:24224", "fluentd-async-connect": "true", "tag": "myservice" }, "mode": "replicated", "mounts": [ { "readonly": false, "source": "/tmp/", "target": "/remote_tmp/", "type": "bind", "labels": null, "propagation": null, "no_copy": null, "driver_config": null, "tmpfs_size": null, "tmpfs_mode": null } ], "networks": null, "placement_preferences": [ { "spread": "node.labels.mylabel" } ], "publish": null, "read_only": null, "replicas": 1, "replicas_max_per_node": 1, "reserve_cpu": 0.25, "reserve_memory": 20971520, "restart_policy": "on-failure", "restart_policy_attempts": 3, "restart_policy_delay": 5000000000, "restart_policy_window": 120000000000, "secrets": null, "stop_grace_period": null, "stop_signal": null, "tty": null, "update_delay": 10000000000, "update_failure_action": null, "update_max_failure_ratio": null, "update_monitor": null, "update_order": "stop-first", "update_parallelism": 2, "user": null, "working_dir": null }' changes: returned: always description: - List of changed service attributes if a service has been altered, [] otherwise. type: list elements: str sample: ['container_labels', 'replicas'] rebuilt: returned: always description: - True if the service has been recreated (removed and created). type: bool sample: true """ EXAMPLES = r""" --- - name: Set command and arguments community.docker.docker_swarm_service: name: myservice image: alpine command: sleep args: - "3600" - name: Set a bind mount community.docker.docker_swarm_service: name: myservice image: alpine mounts: - source: /tmp/ target: /remote_tmp/ type: bind - name: Set service labels community.docker.docker_swarm_service: name: myservice image: alpine labels: com.example.description: "Accounting webapp" com.example.department: "Finance" - name: Set environment variables community.docker.docker_swarm_service: name: myservice image: alpine env: ENVVAR1: envvar1 ENVVAR2: envvar2 env_files: - envs/common.env - envs/apps/web.env - name: Set fluentd logging community.docker.docker_swarm_service: name: myservice image: alpine logging: driver: fluentd options: fluentd-address: "127.0.0.1:24224" fluentd-async-connect: "true" tag: myservice - name: Set restart policies community.docker.docker_swarm_service: name: myservice image: alpine restart_config: condition: on-failure delay: 5s max_attempts: 3 window: 120s - name: Set update config community.docker.docker_swarm_service: name: myservice image: alpine update_config: parallelism: 2 delay: 10s order: stop-first - name: Set rollback config community.docker.docker_swarm_service: name: myservice image: alpine update_config: failure_action: rollback rollback_config: parallelism: 2 delay: 10s order: stop-first - name: Set placement preferences community.docker.docker_swarm_service: name: myservice image: alpine:edge placement: preferences: - spread: node.labels.mylabel constraints: - node.role == manager - engine.labels.operatingsystem == ubuntu 14.04 replicas_max_per_node: 2 - name: Set configs community.docker.docker_swarm_service: name: myservice image: alpine:edge configs: - config_name: myconfig_name filename: "/tmp/config.txt" - name: Set networks community.docker.docker_swarm_service: name: myservice image: alpine:edge networks: - mynetwork - name: Set networks as a dictionary community.docker.docker_swarm_service: name: myservice image: alpine:edge networks: - name: "mynetwork" aliases: - "mynetwork_alias" options: foo: bar - name: Set secrets community.docker.docker_swarm_service: name: myservice image: alpine:edge secrets: - secret_name: mysecret_name filename: "/run/secrets/secret.txt" - name: Start service with healthcheck community.docker.docker_swarm_service: name: myservice image: nginx:1.13 healthcheck: # Check if nginx server is healthy by curl'ing the server. # If this fails or timeouts, the healthcheck fails. test: ["CMD", "curl", "--fail", "http://nginx.host.com"] interval: 1m30s timeout: 10s retries: 3 start_period: 30s - name: Configure service resources community.docker.docker_swarm_service: name: myservice image: alpine:edge reservations: cpus: 0.25 memory: 20M limits: cpus: 0.50 memory: 50M - name: Remove service community.docker.docker_swarm_service: name: myservice state: absent """ import shlex import time import traceback from ansible.module_utils.basic import human_to_bytes from ansible.module_utils.common.text.converters import to_text from ansible_collections.community.docker.plugins.module_utils._common import ( AnsibleDockerClient, RequestException, ) from ansible_collections.community.docker.plugins.module_utils._util import ( DifferenceTracker, DockerBaseClass, clean_dict_booleans_for_docker_api, convert_duration_to_nanosecond, parse_healthcheck, sanitize_labels, ) from ansible_collections.community.docker.plugins.module_utils._version import ( LooseVersion, ) try: from docker import types from docker.errors import ( APIError, DockerException, NotFound, ) from docker.utils import ( format_environment, parse_env_file, parse_repository_tag, ) except ImportError: # missing Docker SDK for Python handled in ansible.module_utils.docker.common pass def get_docker_environment(env, env_files): """ Will return a list of "KEY=VALUE" items. Supplied env variable can be either a list or a dictionary. If environment files are combined with explicit environment variables, the explicit environment variables take precedence. """ env_dict = {} if env_files: for env_file in env_files: parsed_env_file = parse_env_file(env_file) for name, value in parsed_env_file.items(): env_dict[name] = str(value) if env is not None and isinstance(env, str): env = env.split(",") if env is not None and isinstance(env, dict): for name, value in env.items(): if not isinstance(value, str): raise ValueError( "Non-string value found for env option. " f"Ambiguous env options must be wrapped in quotes to avoid YAML parsing. Key: {name}" ) env_dict[name] = str(value) elif env is not None and isinstance(env, list): for item in env: try: name, value = item.split("=", 1) except ValueError: raise ValueError( "Invalid environment variable found in list, needs to be in format KEY=VALUE." ) env_dict[name] = value elif env is not None: raise ValueError( f"Invalid type for env {env} ({type(env)}). Only list or dict allowed." ) env_list = format_environment(env_dict) if not env_list: if env is not None or env_files is not None: return [] else: return None return sorted(env_list) def get_docker_networks(networks, network_ids): """ Validate a list of network names or a list of network dictionaries. Network names will be resolved to ids by using the network_ids mapping. """ if networks is None: return None parsed_networks = [] for network in networks: if isinstance(network, str): parsed_network = {"name": network} elif isinstance(network, dict): if "name" not in network: raise TypeError( '"name" is required when networks are passed as dictionaries.' ) name = network.pop("name") parsed_network = {"name": name} aliases = network.pop("aliases", None) if aliases is not None: if not isinstance(aliases, list): raise TypeError( '"aliases" network option is only allowed as a list' ) if not all(isinstance(alias, str) for alias in aliases): raise TypeError("Only strings are allowed as network aliases.") parsed_network["aliases"] = aliases options = network.pop("options", None) if options is not None: if not isinstance(options, dict): raise TypeError("Only dict is allowed as network options.") parsed_network["options"] = clean_dict_booleans_for_docker_api(options) # Check if any invalid keys left if network: invalid_keys = ", ".join(network.keys()) raise TypeError( f"{invalid_keys} are not valid keys for the networks option" ) else: raise TypeError( "Only a list of strings or dictionaries are allowed to be passed as networks." ) network_name = parsed_network.pop("name") try: parsed_network["id"] = network_ids[network_name] except KeyError as e: raise ValueError(f"Could not find a network named: {e}.") parsed_networks.append(parsed_network) return parsed_networks or [] def get_nanoseconds_from_raw_option(name, value): if value is None: return None elif isinstance(value, int): return value elif isinstance(value, str): try: return int(value) except ValueError: return convert_duration_to_nanosecond(value) else: raise ValueError( f"Invalid type for {name} {value} ({type(value)}). Only string or int allowed." ) def get_value(key, values, default=None): value = values.get(key) return value if value is not None else default def has_dict_changed(new_dict, old_dict): """ Check if new_dict has differences compared to old_dict while ignoring keys in old_dict which are None in new_dict. """ if new_dict is None: return False if not new_dict and old_dict: return True if not old_dict and new_dict: return True defined_options = dict( (option, value) for option, value in new_dict.items() if value is not None ) for option, value in defined_options.items(): old_value = old_dict.get(option) if not value and not old_value: continue if value != old_value: return True return False def has_list_changed(new_list, old_list, sort_lists=True, sort_key=None): """ Check two lists have differences. Sort lists by default. """ def sort_list(unsorted_list): """ Sort a given list. The list may contain dictionaries, so use the sort key to handle them. """ if unsorted_list and isinstance(unsorted_list[0], dict): if not sort_key: raise Exception("A sort key was not specified when sorting list") else: return sorted(unsorted_list, key=lambda k: k[sort_key]) # Either the list is empty or does not contain dictionaries try: return sorted(unsorted_list) except TypeError: return unsorted_list if new_list is None: return False old_list = old_list or [] if len(new_list) != len(old_list): return True if sort_lists: zip_data = zip(sort_list(new_list), sort_list(old_list)) else: zip_data = zip(new_list, old_list) for new_item, old_item in zip_data: is_same_type = type( # noqa: E721, pylint: disable=unidiomatic-typecheck new_item ) == type( # noqa: E721, pylint: disable=unidiomatic-typecheck old_item ) if not is_same_type: if isinstance(new_item, str) and isinstance(old_item, str): # Even though the types are different between these items, # they are both strings. Try matching on the same string type. try: new_item_type = type(new_item) old_item_casted = new_item_type(old_item) if new_item != old_item_casted: return True else: continue except UnicodeEncodeError: # Fallback to assuming the strings are different return True else: return True if isinstance(new_item, dict): if has_dict_changed(new_item, old_item): return True elif new_item != old_item: return True return False def have_networks_changed(new_networks, old_networks): """Special case list checking for networks to sort aliases""" if new_networks is None: return False old_networks = old_networks or [] if len(new_networks) != len(old_networks): return True zip_data = zip( sorted(new_networks, key=lambda k: k["id"]), sorted(old_networks, key=lambda k: k["id"]), ) for new_item, old_item in zip_data: new_item = dict(new_item) old_item = dict(old_item) # Sort the aliases if "aliases" in new_item: new_item["aliases"] = sorted(new_item["aliases"] or []) if "aliases" in old_item: old_item["aliases"] = sorted(old_item["aliases"] or []) if has_dict_changed(new_item, old_item): return True return False class DockerService(DockerBaseClass): def __init__(self, docker_api_version, docker_py_version): super().__init__() self.image = "" self.command = None self.args = None self.endpoint_mode = None self.dns = None self.healthcheck = None self.healthcheck_disabled = None self.hostname = None self.hosts = None self.tty = None self.dns_search = None self.dns_options = None self.env = None self.force_update = None self.groups = None self.log_driver = None self.log_driver_options = None self.labels = None self.container_labels = None self.sysctls = None self.limit_cpu = None self.limit_memory = None self.reserve_cpu = None self.reserve_memory = None self.mode = "replicated" self.user = None self.mounts = None self.configs = None self.secrets = None self.constraints = None self.replicas_max_per_node = None self.networks = None self.stop_grace_period = None self.stop_signal = None self.publish = None self.placement_preferences = None self.replicas = -1 self.service_id = False self.service_version = False self.read_only = None self.restart_policy = None self.restart_policy_attempts = None self.restart_policy_delay = None self.restart_policy_window = None self.rollback_config = None self.update_delay = None self.update_parallelism = None self.update_failure_action = None self.update_monitor = None self.update_max_failure_ratio = None self.update_order = None self.working_dir = None self.init = None self.cap_add = None self.cap_drop = None self.docker_api_version = docker_api_version self.docker_py_version = docker_py_version 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, "healthcheck": self.healthcheck, "healthcheck_disabled": self.healthcheck_disabled, "hostname": self.hostname, "hosts": self.hosts, "env": self.env, "force_update": self.force_update, "groups": self.groups, "log_driver": self.log_driver, "log_driver_options": self.log_driver_options, "publish": self.publish, "constraints": self.constraints, "replicas_max_per_node": self.replicas_max_per_node, "placement_preferences": self.placement_preferences, "labels": self.labels, "container_labels": self.container_labels, "sysctls": self.sysctls, "mode": self.mode, "replicas": self.replicas, "endpoint_mode": self.endpoint_mode, "restart_policy": self.restart_policy, "secrets": self.secrets, "stop_grace_period": self.stop_grace_period, "stop_signal": self.stop_signal, "limit_cpu": self.limit_cpu, "limit_memory": self.limit_memory, "read_only": self.read_only, "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, "rollback_config": self.rollback_config, "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, "user": self.user, "working_dir": self.working_dir, "init": self.init, "cap_add": self.cap_add, "cap_drop": self.cap_drop, } @property def can_update_networks(self): # Before Docker API 1.29 adding/removing networks was not supported return self.docker_api_version >= LooseVersion( "1.29" ) and self.docker_py_version >= LooseVersion("2.7") @property def can_use_task_template_networks(self): # In Docker API 1.25 attaching networks to TaskTemplate is preferred over Spec return self.docker_py_version >= LooseVersion("2.7") @staticmethod def get_restart_config_from_ansible_params(params): restart_config = params["restart_config"] or {} condition = get_value( "condition", restart_config, ) delay = get_value( "delay", restart_config, ) delay = get_nanoseconds_from_raw_option("restart_policy_delay", delay) max_attempts = get_value( "max_attempts", restart_config, ) window = get_value( "window", restart_config, ) window = get_nanoseconds_from_raw_option("restart_policy_window", window) return { "restart_policy": condition, "restart_policy_delay": delay, "restart_policy_attempts": max_attempts, "restart_policy_window": window, } @staticmethod def get_update_config_from_ansible_params(params): update_config = params["update_config"] or {} parallelism = get_value( "parallelism", update_config, ) delay = get_value( "delay", update_config, ) delay = get_nanoseconds_from_raw_option("update_delay", delay) failure_action = get_value( "failure_action", update_config, ) monitor = get_value( "monitor", update_config, ) monitor = get_nanoseconds_from_raw_option("update_monitor", monitor) max_failure_ratio = get_value( "max_failure_ratio", update_config, ) order = get_value( "order", update_config, ) return { "update_parallelism": parallelism, "update_delay": delay, "update_failure_action": failure_action, "update_monitor": monitor, "update_max_failure_ratio": max_failure_ratio, "update_order": order, } @staticmethod def get_rollback_config_from_ansible_params(params): if params["rollback_config"] is None: return None rollback_config = params["rollback_config"] or {} delay = get_nanoseconds_from_raw_option( "rollback_config.delay", rollback_config.get("delay") ) monitor = get_nanoseconds_from_raw_option( "rollback_config.monitor", rollback_config.get("monitor") ) return { "parallelism": rollback_config.get("parallelism"), "delay": delay, "failure_action": rollback_config.get("failure_action"), "monitor": monitor, "max_failure_ratio": rollback_config.get("max_failure_ratio"), "order": rollback_config.get("order"), } @staticmethod def get_logging_from_ansible_params(params): logging_config = params["logging"] or {} driver = get_value( "driver", logging_config, ) options = get_value( "options", logging_config, ) return { "log_driver": driver, "log_driver_options": options, } @staticmethod def get_limits_from_ansible_params(params): limits = params["limits"] or {} cpus = get_value( "cpus", limits, ) memory = get_value( "memory", limits, ) if memory is not None: try: memory = human_to_bytes(memory) except ValueError as exc: raise Exception(f"Failed to convert limit_memory to bytes: {exc}") return { "limit_cpu": cpus, "limit_memory": memory, } @staticmethod def get_reservations_from_ansible_params(params): reservations = params["reservations"] or {} cpus = get_value( "cpus", reservations, ) memory = get_value( "memory", reservations, ) if memory is not None: try: memory = human_to_bytes(memory) except ValueError as exc: raise Exception(f"Failed to convert reserve_memory to bytes: {exc}") return { "reserve_cpu": cpus, "reserve_memory": memory, } @staticmethod def get_placement_from_ansible_params(params): placement = params["placement"] or {} constraints = get_value("constraints", placement) preferences = placement.get("preferences") replicas_max_per_node = get_value("replicas_max_per_node", placement) return { "constraints": constraints, "placement_preferences": preferences, "replicas_max_per_node": replicas_max_per_node, } @classmethod def from_ansible_params( cls, ap, old_service, image_digest, secret_ids, config_ids, network_ids, client, ): s = DockerService(client.docker_api_version, client.docker_py_version) s.image = image_digest 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.healthcheck, s.healthcheck_disabled = parse_healthcheck(ap["healthcheck"]) s.hostname = ap["hostname"] s.hosts = ap["hosts"] s.tty = ap["tty"] s.labels = ap["labels"] sanitize_labels(s.labels, "labels", client) s.container_labels = ap["container_labels"] sanitize_labels(s.container_labels, "container_labels", client) s.sysctls = ap["sysctls"] s.mode = ap["mode"] s.stop_signal = ap["stop_signal"] s.user = ap["user"] s.working_dir = ap["working_dir"] s.read_only = ap["read_only"] s.init = ap["init"] s.cap_add = ap["cap_add"] s.cap_drop = ap["cap_drop"] s.networks = get_docker_networks(ap["networks"], network_ids) s.command = ap["command"] if isinstance(s.command, str): 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, str) ] if invalid_items: errors = ", ".join( [ f"{item} ({type(item)}) at index {index}" for index, item in invalid_items ] ) raise Exception( "All items in a command list need to be strings. " f"Check quoting. Invalid items: {errors}." ) s.command = ap["command"] elif s.command is not None: raise ValueError( f"Invalid type for command {s.command} ({type(s.command)}). " "Only string or list allowed. Check quoting." ) s.env = get_docker_environment(ap["env"], ap["env_files"]) s.rollback_config = cls.get_rollback_config_from_ansible_params(ap) update_config = cls.get_update_config_from_ansible_params(ap) for key, value in update_config.items(): setattr(s, key, value) restart_config = cls.get_restart_config_from_ansible_params(ap) for key, value in restart_config.items(): setattr(s, key, value) logging_config = cls.get_logging_from_ansible_params(ap) for key, value in logging_config.items(): setattr(s, key, value) limits = cls.get_limits_from_ansible_params(ap) for key, value in limits.items(): setattr(s, key, value) reservations = cls.get_reservations_from_ansible_params(ap) for key, value in reservations.items(): setattr(s, key, value) placement = cls.get_placement_from_ansible_params(ap) for key, value in placement.items(): setattr(s, key, value) if ap["stop_grace_period"] is not None: s.stop_grace_period = convert_duration_to_nanosecond( ap["stop_grace_period"] ) if ap["force_update"]: s.force_update = int(str(time.time()).replace(".", "")) if ap["groups"] is not None: # In case integers are passed as groups, we need to convert them to # strings as docker internally treats them as strings. s.groups = [str(g) for g in ap["groups"]] if ap["replicas"] == -1: if old_service: s.replicas = old_service.replicas else: s.replicas = 1 else: s.replicas = ap["replicas"] if ap["publish"] is not None: 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"] = param_p["published_port"] service_p["target_port"] = param_p["target_port"] s.publish.append(service_p) if ap["mounts"] is not None: s.mounts = [] for param_m in ap["mounts"]: service_m = {} service_m["readonly"] = param_m["readonly"] service_m["type"] = param_m["type"] if param_m["source"] is None and param_m["type"] != "tmpfs": raise ValueError( "Source must be specified for mounts which are not of type tmpfs" ) service_m["source"] = param_m["source"] or "" service_m["target"] = param_m["target"] service_m["labels"] = param_m["labels"] service_m["no_copy"] = param_m["no_copy"] service_m["propagation"] = param_m["propagation"] service_m["driver_config"] = param_m["driver_config"] service_m["tmpfs_mode"] = param_m["tmpfs_mode"] tmpfs_size = param_m["tmpfs_size"] if tmpfs_size is not None: try: tmpfs_size = human_to_bytes(tmpfs_size) except ValueError as exc: raise ValueError( f"Failed to convert tmpfs_size to bytes: {exc}" ) service_m["tmpfs_size"] = tmpfs_size s.mounts.append(service_m) if ap["configs"] is not None: s.configs = [] for param_m in ap["configs"]: service_c = {} config_name = param_m["config_name"] service_c["config_id"] = param_m["config_id"] or config_ids[config_name] service_c["config_name"] = config_name service_c["filename"] = param_m["filename"] or config_name service_c["uid"] = param_m["uid"] service_c["gid"] = param_m["gid"] service_c["mode"] = param_m["mode"] s.configs.append(service_c) if ap["secrets"] is not None: s.secrets = [] for param_m in ap["secrets"]: service_s = {} secret_name = param_m["secret_name"] service_s["secret_id"] = param_m["secret_id"] or secret_ids[secret_name] service_s["secret_name"] = secret_name service_s["filename"] = param_m["filename"] or secret_name service_s["uid"] = param_m["uid"] service_s["gid"] = param_m["gid"] service_s["mode"] = param_m["mode"] 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 has_list_changed(self.env, os.env): differences.add("env", parameter=self.env, active=os.env) if self.log_driver is not None and self.log_driver != os.log_driver: differences.add( "log_driver", parameter=self.log_driver, active=os.log_driver ) if self.log_driver_options is not None and self.log_driver_options != ( os.log_driver_options or {} ): 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 has_list_changed(self.mounts, os.mounts, sort_key="target"): differences.add("mounts", parameter=self.mounts, active=os.mounts) if has_list_changed(self.configs, os.configs, sort_key="config_name"): differences.add("configs", parameter=self.configs, active=os.configs) if has_list_changed(self.secrets, os.secrets, sort_key="secret_name"): differences.add("secrets", parameter=self.secrets, active=os.secrets) if have_networks_changed(self.networks, os.networks): differences.add("networks", parameter=self.networks, active=os.networks) needs_rebuild = not self.can_update_networks if self.replicas != os.replicas: differences.add("replicas", parameter=self.replicas, active=os.replicas) if has_list_changed(self.command, os.command, sort_lists=False): differences.add("command", parameter=self.command, active=os.command) if has_list_changed(self.args, os.args, sort_lists=False): differences.add("args", parameter=self.args, active=os.args) if has_list_changed(self.constraints, os.constraints): differences.add( "constraints", parameter=self.constraints, active=os.constraints ) if ( self.replicas_max_per_node is not None and self.replicas_max_per_node != os.replicas_max_per_node ): differences.add( "replicas_max_per_node", parameter=self.replicas_max_per_node, active=os.replicas_max_per_node, ) if has_list_changed( self.placement_preferences, os.placement_preferences, sort_lists=False ): differences.add( "placement_preferences", parameter=self.placement_preferences, active=os.placement_preferences, ) if has_list_changed(self.groups, os.groups): differences.add("groups", parameter=self.groups, active=os.groups) if self.labels is not None and self.labels != (os.labels or {}): differences.add("labels", parameter=self.labels, active=os.labels) if self.limit_cpu is not None and self.limit_cpu != os.limit_cpu: differences.add("limit_cpu", parameter=self.limit_cpu, active=os.limit_cpu) if self.limit_memory is not None and self.limit_memory != os.limit_memory: differences.add( "limit_memory", parameter=self.limit_memory, active=os.limit_memory ) if self.reserve_cpu is not None and self.reserve_cpu != os.reserve_cpu: differences.add( "reserve_cpu", parameter=self.reserve_cpu, active=os.reserve_cpu ) if self.reserve_memory is not None and self.reserve_memory != os.reserve_memory: differences.add( "reserve_memory", parameter=self.reserve_memory, active=os.reserve_memory, ) if self.container_labels is not None and self.container_labels != ( os.container_labels or {} ): differences.add( "container_labels", parameter=self.container_labels, active=os.container_labels, ) if self.sysctls is not None and self.sysctls != (os.sysctls or {}): differences.add("sysctls", parameter=self.sysctls, active=os.sysctls) if self.stop_signal is not None and self.stop_signal != os.stop_signal: differences.add( "stop_signal", parameter=self.stop_signal, active=os.stop_signal ) if ( self.stop_grace_period is not None and self.stop_grace_period != os.stop_grace_period ): differences.add( "stop_grace_period", parameter=self.stop_grace_period, active=os.stop_grace_period, ) if self.has_publish_changed(os.publish): differences.add("publish", parameter=self.publish, active=os.publish) if self.read_only is not None and self.read_only != os.read_only: differences.add("read_only", parameter=self.read_only, active=os.read_only) if self.restart_policy is not None and self.restart_policy != os.restart_policy: differences.add( "restart_policy", parameter=self.restart_policy, active=os.restart_policy, ) if ( self.restart_policy_attempts is not None and 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 is not None and 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 is not None and self.restart_policy_window != os.restart_policy_window ): differences.add( "restart_policy_window", parameter=self.restart_policy_window, active=os.restart_policy_window, ) if has_dict_changed(self.rollback_config, os.rollback_config): differences.add( "rollback_config", parameter=self.rollback_config, active=os.rollback_config, ) if self.update_delay is not None and self.update_delay != os.update_delay: differences.add( "update_delay", parameter=self.update_delay, active=os.update_delay ) if ( self.update_parallelism is not None and self.update_parallelism != os.update_parallelism ): differences.add( "update_parallelism", parameter=self.update_parallelism, active=os.update_parallelism, ) if ( self.update_failure_action is not None and 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 is not None and self.update_monitor != os.update_monitor: differences.add( "update_monitor", parameter=self.update_monitor, active=os.update_monitor, ) if ( self.update_max_failure_ratio is not None and 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 has_list_changed(self.dns, os.dns, sort_lists=False): differences.add("dns", parameter=self.dns, active=os.dns) if has_list_changed(self.dns_search, os.dns_search, sort_lists=False): differences.add( "dns_search", parameter=self.dns_search, active=os.dns_search ) if has_list_changed(self.dns_options, os.dns_options): differences.add( "dns_options", parameter=self.dns_options, active=os.dns_options ) if self.has_healthcheck_changed(os): differences.add( "healthcheck", parameter=self.healthcheck, active=os.healthcheck ) if self.hostname is not None and self.hostname != os.hostname: differences.add("hostname", parameter=self.hostname, active=os.hostname) if self.hosts is not None and self.hosts != (os.hosts or {}): differences.add("hosts", parameter=self.hosts, active=os.hosts) if self.tty is not None and self.tty != os.tty: differences.add("tty", parameter=self.tty, active=os.tty) if self.working_dir is not None and self.working_dir != os.working_dir: differences.add( "working_dir", parameter=self.working_dir, active=os.working_dir ) if self.force_update: force_update = True if self.init is not None and self.init != os.init: differences.add("init", parameter=self.init, active=os.init) if has_list_changed(self.cap_add, os.cap_add): differences.add("cap_add", parameter=self.cap_add, active=os.cap_add) if has_list_changed(self.cap_drop, os.cap_drop): differences.add("cap_drop", parameter=self.cap_drop, active=os.cap_drop) return ( not differences.empty or force_update, differences, needs_rebuild, force_update, ) def has_healthcheck_changed(self, old_publish): if self.healthcheck_disabled is False and self.healthcheck is None: return False if self.healthcheck_disabled: if old_publish.healthcheck is None: return False if old_publish.healthcheck.get("test") == ["NONE"]: return False return self.healthcheck != old_publish.healthcheck def has_publish_changed(self, old_publish): if self.publish is None: return False old_publish = old_publish or [] if len(self.publish) != len(old_publish): return True def publish_sorter(item): return ( item.get("published_port") or 0, item.get("target_port") or 0, item.get("protocol") or "", ) 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 build_container_spec(self): mounts = None if self.mounts is not None: mounts = [] for mount_config in self.mounts: mount_options = { "target": "target", "source": "source", "type": "type", "readonly": "read_only", "propagation": "propagation", "labels": "labels", "no_copy": "no_copy", "driver_config": "driver_config", "tmpfs_size": "tmpfs_size", "tmpfs_mode": "tmpfs_mode", } mount_args = {} for option, mount_arg in mount_options.items(): value = mount_config.get(option) if value is not None: mount_args[mount_arg] = value mounts.append(types.Mount(**mount_args)) configs = None if self.configs is not None: configs = [] for config_config in self.configs: config_args = { "config_id": config_config["config_id"], "config_name": config_config["config_name"], } filename = config_config.get("filename") if filename: config_args["filename"] = filename uid = config_config.get("uid") if uid: config_args["uid"] = uid gid = config_config.get("gid") if gid: config_args["gid"] = gid mode = config_config.get("mode") if mode: config_args["mode"] = mode configs.append(types.ConfigReference(**config_args)) secrets = None if self.secrets is not None: secrets = [] for secret_config in self.secrets: secret_args = { "secret_id": secret_config["secret_id"], "secret_name": secret_config["secret_name"], } filename = secret_config.get("filename") if filename: secret_args["filename"] = filename uid = secret_config.get("uid") if uid: secret_args["uid"] = uid gid = secret_config.get("gid") if gid: secret_args["gid"] = gid mode = secret_config.get("mode") if mode: secret_args["mode"] = mode secrets.append(types.SecretReference(**secret_args)) dns_config_args = {} if self.dns is not None: dns_config_args["nameservers"] = self.dns if self.dns_search is not None: dns_config_args["search"] = self.dns_search if self.dns_options is not None: dns_config_args["options"] = self.dns_options dns_config = types.DNSConfig(**dns_config_args) if dns_config_args else None container_spec_args = {} if self.command is not None: container_spec_args["command"] = self.command if self.args is not None: container_spec_args["args"] = self.args if self.env is not None: container_spec_args["env"] = self.env if self.user is not None: container_spec_args["user"] = self.user if self.container_labels is not None: container_spec_args["labels"] = self.container_labels if self.sysctls is not None: container_spec_args["sysctls"] = self.sysctls if self.healthcheck is not None: container_spec_args["healthcheck"] = types.Healthcheck(**self.healthcheck) elif self.healthcheck_disabled: container_spec_args["healthcheck"] = types.Healthcheck(test=["NONE"]) if self.hostname is not None: container_spec_args["hostname"] = self.hostname if self.hosts is not None: container_spec_args["hosts"] = self.hosts if self.read_only is not None: container_spec_args["read_only"] = self.read_only if self.stop_grace_period is not None: container_spec_args["stop_grace_period"] = self.stop_grace_period if self.stop_signal is not None: container_spec_args["stop_signal"] = self.stop_signal if self.tty is not None: container_spec_args["tty"] = self.tty if self.groups is not None: container_spec_args["groups"] = self.groups if self.working_dir is not None: container_spec_args["workdir"] = self.working_dir if secrets is not None: container_spec_args["secrets"] = secrets if mounts is not None: container_spec_args["mounts"] = mounts if dns_config is not None: container_spec_args["dns_config"] = dns_config if configs is not None: container_spec_args["configs"] = configs if self.init is not None: container_spec_args["init"] = self.init if self.cap_add is not None: container_spec_args["cap_add"] = self.cap_add if self.cap_drop is not None: container_spec_args["cap_drop"] = self.cap_drop return types.ContainerSpec(self.image, **container_spec_args) def build_placement(self): placement_args = {} if self.constraints is not None: placement_args["constraints"] = self.constraints if self.replicas_max_per_node is not None: placement_args["maxreplicas"] = self.replicas_max_per_node if self.placement_preferences is not None: placement_args["preferences"] = [ {key.title(): {"SpreadDescriptor": value}} for preference in self.placement_preferences for key, value in preference.items() ] return types.Placement(**placement_args) if placement_args else None def build_update_config(self): update_config_args = {} if self.update_parallelism is not None: update_config_args["parallelism"] = self.update_parallelism if self.update_delay is not None: update_config_args["delay"] = self.update_delay if self.update_failure_action is not None: update_config_args["failure_action"] = self.update_failure_action if self.update_monitor is not None: update_config_args["monitor"] = self.update_monitor if self.update_max_failure_ratio is not None: update_config_args["max_failure_ratio"] = self.update_max_failure_ratio if self.update_order is not None: update_config_args["order"] = self.update_order return types.UpdateConfig(**update_config_args) if update_config_args else None def build_log_driver(self): log_driver_args = {} if self.log_driver is not None: log_driver_args["name"] = self.log_driver if self.log_driver_options is not None: log_driver_args["options"] = self.log_driver_options return types.DriverConfig(**log_driver_args) if log_driver_args else None def build_restart_policy(self): restart_policy_args = {} if self.restart_policy is not None: restart_policy_args["condition"] = self.restart_policy if self.restart_policy_delay is not None: restart_policy_args["delay"] = self.restart_policy_delay if self.restart_policy_attempts is not None: restart_policy_args["max_attempts"] = self.restart_policy_attempts if self.restart_policy_window is not None: restart_policy_args["window"] = self.restart_policy_window return ( types.RestartPolicy(**restart_policy_args) if restart_policy_args else None ) def build_rollback_config(self): if self.rollback_config is None: return None rollback_config_options = [ "parallelism", "delay", "failure_action", "monitor", "max_failure_ratio", "order", ] rollback_config_args = {} for option in rollback_config_options: value = self.rollback_config.get(option) if value is not None: rollback_config_args[option] = value return ( types.RollbackConfig(**rollback_config_args) if rollback_config_args else None ) def build_resources(self): resources_args = {} if self.limit_cpu is not None: resources_args["cpu_limit"] = int(self.limit_cpu * 1000000000.0) if self.limit_memory is not None: resources_args["mem_limit"] = self.limit_memory if self.reserve_cpu is not None: resources_args["cpu_reservation"] = int(self.reserve_cpu * 1000000000.0) if self.reserve_memory is not None: resources_args["mem_reservation"] = self.reserve_memory return types.Resources(**resources_args) if resources_args else None def build_task_template(self, container_spec, placement=None): log_driver = self.build_log_driver() restart_policy = self.build_restart_policy() resources = self.build_resources() task_template_args = {} if placement is not None: task_template_args["placement"] = placement if log_driver is not None: task_template_args["log_driver"] = log_driver if restart_policy is not None: task_template_args["restart_policy"] = restart_policy if resources is not None: task_template_args["resources"] = resources if self.force_update: task_template_args["force_update"] = self.force_update if self.can_use_task_template_networks: networks = self.build_networks() if networks: task_template_args["networks"] = networks return types.TaskTemplate(container_spec=container_spec, **task_template_args) def build_service_mode(self): if self.mode == "global": self.replicas = None return types.ServiceMode(self.mode, replicas=self.replicas) def build_networks(self): networks = None if self.networks is not None: networks = [] for network in self.networks: docker_network = {"Target": network["id"]} if "aliases" in network: docker_network["Aliases"] = network["aliases"] if "options" in network: docker_network["DriverOpts"] = network["options"] networks.append(docker_network) return networks def build_endpoint_spec(self): endpoint_spec_args = {} if self.publish is not None: ports = [] for port in self.publish: port_spec = { "Protocol": port["protocol"], "TargetPort": port["target_port"], } if port.get("published_port"): port_spec["PublishedPort"] = port["published_port"] if port.get("mode"): port_spec["PublishMode"] = port["mode"] ports.append(port_spec) endpoint_spec_args["ports"] = ports if self.endpoint_mode is not None: endpoint_spec_args["mode"] = self.endpoint_mode return types.EndpointSpec(**endpoint_spec_args) if endpoint_spec_args else None def build_docker_service(self): container_spec = self.build_container_spec() placement = self.build_placement() task_template = self.build_task_template(container_spec, placement) update_config = self.build_update_config() rollback_config = self.build_rollback_config() service_mode = self.build_service_mode() endpoint_spec = self.build_endpoint_spec() service = {"task_template": task_template, "mode": service_mode} if update_config: service["update_config"] = update_config if rollback_config: service["rollback_config"] = rollback_config if endpoint_spec: service["endpoint_spec"] = endpoint_spec if self.labels: service["labels"] = self.labels if not self.can_use_task_template_networks: networks = self.build_networks() if networks: service["networks"] = networks return service class DockerServiceManager: def __init__(self, client): self.client = client self.retries = 2 self.diff_tracker = None def get_service(self, name): try: raw_data = self.client.inspect_service(name) except NotFound: return None ds = DockerService( self.client.docker_api_version, self.client.docker_py_version ) task_template_data = raw_data["Spec"]["TaskTemplate"] ds.image = task_template_data["ContainerSpec"]["Image"] ds.user = task_template_data["ContainerSpec"].get("User") 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.groups = task_template_data["ContainerSpec"].get("Groups") ds.stop_grace_period = task_template_data["ContainerSpec"].get( "StopGracePeriod" ) ds.stop_signal = task_template_data["ContainerSpec"].get("StopSignal") ds.working_dir = task_template_data["ContainerSpec"].get("Dir") ds.read_only = task_template_data["ContainerSpec"].get("ReadOnly") ds.cap_add = task_template_data["ContainerSpec"].get("CapabilityAdd") ds.cap_drop = task_template_data["ContainerSpec"].get("CapabilityDrop") ds.sysctls = task_template_data["ContainerSpec"].get("Sysctls") healthcheck_data = task_template_data["ContainerSpec"].get("Healthcheck") if healthcheck_data: options = { "Test": "test", "Interval": "interval", "Timeout": "timeout", "StartPeriod": "start_period", "Retries": "retries", } healthcheck = dict( (options[key], value) for key, value in healthcheck_data.items() if value is not None and key in options ) ds.healthcheck = healthcheck update_config_data = raw_data["Spec"].get("UpdateConfig") if update_config_data: ds.update_delay = update_config_data.get("Delay") ds.update_parallelism = update_config_data.get("Parallelism") ds.update_failure_action = update_config_data.get("FailureAction") ds.update_monitor = update_config_data.get("Monitor") ds.update_max_failure_ratio = update_config_data.get("MaxFailureRatio") ds.update_order = update_config_data.get("Order") rollback_config_data = raw_data["Spec"].get("RollbackConfig") if rollback_config_data: ds.rollback_config = { "parallelism": rollback_config_data.get("Parallelism"), "delay": rollback_config_data.get("Delay"), "failure_action": rollback_config_data.get("FailureAction"), "monitor": rollback_config_data.get("Monitor"), "max_failure_ratio": rollback_config_data.get("MaxFailureRatio"), "order": rollback_config_data.get("Order"), } dns_config = task_template_data["ContainerSpec"].get("DNSConfig") if dns_config: ds.dns = dns_config.get("Nameservers") ds.dns_search = dns_config.get("Search") ds.dns_options = dns_config.get("Options") ds.hostname = task_template_data["ContainerSpec"].get("Hostname") hosts = task_template_data["ContainerSpec"].get("Hosts") if hosts: hosts = [ ( list(reversed(host.split(":", 1))) if ":" in host else host.split(" ", 1) ) for host in hosts ] ds.hosts = dict((hostname, ip) for ip, hostname in hosts) ds.tty = task_template_data["ContainerSpec"].get("TTY") placement = task_template_data.get("Placement") if placement: ds.constraints = placement.get("Constraints") ds.replicas_max_per_node = placement.get("MaxReplicas") 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") 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") raw_data_ports = raw_data_endpoint_spec.get("Ports") if raw_data_ports: ds.publish = [] for port in raw_data_ports: ds.publish.append( { "protocol": port["Protocol"], "mode": port.get("PublishMode", None), "published_port": port.get("PublishedPort", None), "target_port": int(port["TargetPort"]), } ) raw_data_limits = task_template_data.get("Resources", {}).get("Limits") if raw_data_limits: raw_cpu_limits = raw_data_limits.get("NanoCPUs") if raw_cpu_limits: ds.limit_cpu = float(raw_cpu_limits) / 1000000000 raw_memory_limits = raw_data_limits.get("MemoryBytes") if raw_memory_limits: ds.limit_memory = int(raw_memory_limits) raw_data_reservations = task_template_data.get("Resources", {}).get( "Reservations" ) if raw_data_reservations: raw_cpu_reservations = raw_data_reservations.get("NanoCPUs") if raw_cpu_reservations: ds.reserve_cpu = float(raw_cpu_reservations) / 1000000000 raw_memory_reservations = raw_data_reservations.get("MemoryBytes") if raw_memory_reservations: ds.reserve_memory = int(raw_memory_reservations) ds.labels = raw_data["Spec"].get("Labels") ds.log_driver = task_template_data.get("LogDriver", {}).get("Name") ds.log_driver_options = task_template_data.get("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" elif "ReplicatedJob" in mode.keys(): ds.mode = to_text("replicated-job", encoding="utf-8") ds.replicas = mode["ReplicatedJob"]["TotalCompletions"] else: raise Exception(f"Unknown service mode: {mode}") raw_data_mounts = task_template_data["ContainerSpec"].get("Mounts") if raw_data_mounts: ds.mounts = [] for mount_data in raw_data_mounts: bind_options = mount_data.get("BindOptions", {}) volume_options = mount_data.get("VolumeOptions", {}) tmpfs_options = mount_data.get("TmpfsOptions", {}) driver_config = volume_options.get("DriverConfig", {}) driver_config = ( dict((key.lower(), value) for key, value in driver_config.items()) or None ) ds.mounts.append( { "source": mount_data.get("Source", ""), "type": mount_data["Type"], "target": mount_data["Target"], "readonly": mount_data.get("ReadOnly"), "propagation": bind_options.get("Propagation"), "no_copy": volume_options.get("NoCopy"), "labels": volume_options.get("Labels"), "driver_config": driver_config, "tmpfs_mode": tmpfs_options.get("Mode"), "tmpfs_size": tmpfs_options.get("SizeBytes"), } ) raw_data_configs = task_template_data["ContainerSpec"].get("Configs") if raw_data_configs: ds.configs = [] for config_data in raw_data_configs: ds.configs.append( { "config_id": config_data["ConfigID"], "config_name": config_data["ConfigName"], "filename": config_data["File"].get("Name"), "uid": config_data["File"].get("UID"), "gid": config_data["File"].get("GID"), "mode": config_data["File"].get("Mode"), } ) raw_data_secrets = task_template_data["ContainerSpec"].get("Secrets") if raw_data_secrets: ds.secrets = [] for secret_data in raw_data_secrets: ds.secrets.append( { "secret_id": secret_data["SecretID"], "secret_name": secret_data["SecretName"], "filename": secret_data["File"].get("Name"), "uid": secret_data["File"].get("UID"), "gid": secret_data["File"].get("GID"), "mode": secret_data["File"].get("Mode"), } ) raw_networks_data = task_template_data.get( "Networks", raw_data["Spec"].get("Networks") ) if raw_networks_data: ds.networks = [] for network_data in raw_networks_data: network = {"id": network_data["Target"]} if "Aliases" in network_data: network["aliases"] = network_data["Aliases"] if "DriverOpts" in network_data: network["options"] = network_data["DriverOpts"] ds.networks.append(network) ds.service_version = raw_data["Version"]["Index"] ds.service_id = raw_data["ID"] ds.init = task_template_data["ContainerSpec"].get("Init", False) return ds def update_service(self, name, old_service, new_service): service_data = new_service.build_docker_service() result = self.client.update_service( old_service.service_id, old_service.service_version, name=name, **service_data, ) # Prior to Docker SDK 4.0.0 no warnings were returned and will thus be ignored. # (see https://github.com/docker/docker-py/pull/2272) self.client.report_warnings(result, ["Warning"]) def create_service(self, name, service): service_data = service.build_docker_service() result = self.client.create_service(name=name, **service_data) self.client.report_warnings(result, ["Warning"]) def remove_service(self, name): self.client.remove_service(name) def get_image_digest(self, name, resolve=False): if not name or not resolve: 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 f"{name}@{digest}" def get_networks_names_ids(self): return dict( (network["Name"], network["Id"]) for network in self.client.networks() ) def get_missing_secret_ids(self): """ Resolve missing secret ids by looking them up by name """ secret_names = [ secret["secret_name"] for secret in self.client.module.params.get("secrets") or [] if secret["secret_id"] is None ] if not secret_names: return {} secrets = self.client.secrets(filters={"name": secret_names}) secrets = dict( (secret["Spec"]["Name"], secret["ID"]) for secret in secrets if secret["Spec"]["Name"] in secret_names ) for secret_name in secret_names: if secret_name not in secrets: self.client.fail(f'Could not find a secret named "{secret_name}"') return secrets def get_missing_config_ids(self): """ Resolve missing config ids by looking them up by name """ config_names = [ config["config_name"] for config in self.client.module.params.get("configs") or [] if config["config_id"] is None ] if not config_names: return {} configs = self.client.configs(filters={"name": config_names}) configs = dict( (config["Spec"]["Name"], config["ID"]) for config in configs if config["Spec"]["Name"] in config_names ) for config_name in config_names: if config_name not in configs: self.client.fail(f'Could not find a config named "{config_name}"') return configs def run(self): self.diff_tracker = DifferenceTracker() 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: self.client.fail(f"Error looking for an image named {image}: {e}") try: current_service = self.get_service(module.params["name"]) except Exception as e: self.client.fail( f"Error looking for service named {module.params['name']}: {e}" ) try: secret_ids = self.get_missing_secret_ids() config_ids = self.get_missing_config_ids() network_ids = self.get_networks_names_ids() new_service = DockerService.from_ansible_params( module.params, current_service, image_digest, secret_ids, config_ids, network_ids, self.client, ) except Exception as e: return self.client.fail(f"Error parsing module parameters: {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: 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 run_safe(self): while True: try: return self.run() except APIError as e: # Sometimes Version.Index will have changed between an inspect and # update. If this is encountered we'll retry the update. if self.retries > 0 and "update out of sequence" in str(e.explanation): self.retries -= 1 time.sleep(1) else: raise def _detect_publish_mode_usage(client): for publish_def in client.module.params["publish"] or []: if publish_def.get("mode"): return True return False def _detect_healthcheck_start_period(client): if client.module.params["healthcheck"]: return client.module.params["healthcheck"]["start_period"] is not None return False def _detect_mount_tmpfs_usage(client): for mount in client.module.params["mounts"] or []: if mount.get("type") == "tmpfs": return True if mount.get("tmpfs_size") is not None: return True if mount.get("tmpfs_mode") is not None: return True return False def _detect_update_config_failure_action_rollback(client): rollback_config_failure_action = (client.module.params["update_config"] or {}).get( "failure_action" ) return rollback_config_failure_action == "rollback" def main(): argument_spec = dict( name=dict(type="str", required=True), image=dict(type="str"), state=dict(type="str", default="present", choices=["present", "absent"]), mounts=dict( type="list", elements="dict", options=dict( source=dict(type="str"), target=dict(type="str", required=True), type=dict( type="str", default="bind", choices=["bind", "volume", "tmpfs", "npipe"], ), readonly=dict(type="bool"), labels=dict(type="dict"), propagation=dict( type="str", choices=[ "shared", "slave", "private", "rshared", "rslave", "rprivate", ], ), no_copy=dict(type="bool"), driver_config=dict( type="dict", options=dict(name=dict(type="str"), options=dict(type="dict")), ), tmpfs_size=dict(type="str"), tmpfs_mode=dict(type="int"), ), ), configs=dict( type="list", elements="dict", options=dict( config_id=dict(type="str"), config_name=dict(type="str", required=True), filename=dict(type="str"), uid=dict(type="str"), gid=dict(type="str"), mode=dict(type="int"), ), ), secrets=dict( type="list", elements="dict", no_log=False, options=dict( secret_id=dict(type="str", no_log=False), secret_name=dict(type="str", required=True, no_log=False), filename=dict(type="str"), uid=dict(type="str"), gid=dict(type="str"), mode=dict(type="int"), ), ), networks=dict(type="list", elements="raw"), command=dict(type="raw"), args=dict(type="list", elements="str"), env=dict(type="raw"), env_files=dict(type="list", elements="path"), force_update=dict(type="bool", default=False), groups=dict(type="list", elements="str"), logging=dict( type="dict", options=dict( driver=dict(type="str"), options=dict(type="dict"), ), ), publish=dict( type="list", elements="dict", options=dict( published_port=dict(type="int", required=False), target_port=dict(type="int", required=True), protocol=dict(type="str", default="tcp", choices=["tcp", "udp"]), mode=dict(type="str", choices=["ingress", "host"]), ), ), placement=dict( type="dict", options=dict( constraints=dict(type="list", elements="str"), preferences=dict(type="list", elements="dict"), replicas_max_per_node=dict(type="int"), ), ), tty=dict(type="bool"), dns=dict(type="list", elements="str"), dns_search=dict(type="list", elements="str"), dns_options=dict(type="list", elements="str"), healthcheck=dict( type="dict", options=dict( test=dict(type="raw"), interval=dict(type="str"), timeout=dict(type="str"), start_period=dict(type="str"), retries=dict(type="int"), ), ), hostname=dict(type="str"), hosts=dict(type="dict"), labels=dict(type="dict"), container_labels=dict(type="dict"), sysctls=dict(type="dict"), mode=dict( type="str", default="replicated", choices=["replicated", "global", "replicated-job"], ), replicas=dict(type="int", default=-1), endpoint_mode=dict(type="str", choices=["vip", "dnsrr"]), stop_grace_period=dict(type="str"), stop_signal=dict(type="str"), limits=dict( type="dict", options=dict( cpus=dict(type="float"), memory=dict(type="str"), ), ), read_only=dict(type="bool"), reservations=dict( type="dict", options=dict( cpus=dict(type="float"), memory=dict(type="str"), ), ), resolve_image=dict(type="bool", default=False), restart_config=dict( type="dict", options=dict( condition=dict(type="str", choices=["none", "on-failure", "any"]), delay=dict(type="str"), max_attempts=dict(type="int"), window=dict(type="str"), ), ), rollback_config=dict( type="dict", options=dict( parallelism=dict(type="int"), delay=dict(type="str"), failure_action=dict(type="str", choices=["continue", "pause"]), monitor=dict(type="str"), max_failure_ratio=dict(type="float"), order=dict(type="str"), ), ), update_config=dict( type="dict", options=dict( parallelism=dict(type="int"), delay=dict(type="str"), failure_action=dict( type="str", choices=["continue", "pause", "rollback"] ), monitor=dict(type="str"), max_failure_ratio=dict(type="float"), order=dict(type="str"), ), ), user=dict(type="str"), working_dir=dict(type="str"), init=dict(type="bool"), cap_add=dict(type="list", elements="str"), cap_drop=dict(type="list", elements="str"), ) option_minimal_versions = dict( dns=dict(docker_py_version="2.6.0"), dns_options=dict(docker_py_version="2.6.0"), dns_search=dict(docker_py_version="2.6.0"), endpoint_mode=dict(docker_py_version="3.0.0"), force_update=dict(docker_py_version="2.1.0"), healthcheck=dict(docker_py_version="2.6.0"), hostname=dict(docker_py_version="2.2.0"), hosts=dict(docker_py_version="2.6.0"), groups=dict(docker_py_version="2.6.0"), tty=dict(docker_py_version="2.4.0"), secrets=dict(docker_py_version="2.4.0"), configs=dict(docker_py_version="2.6.0", docker_api_version="1.30"), stop_signal=dict(docker_py_version="2.6.0", docker_api_version="1.28"), publish=dict(docker_py_version="3.0.0"), read_only=dict(docker_py_version="2.6.0", docker_api_version="1.28"), resolve_image=dict(docker_api_version="1.30", docker_py_version="3.2.0"), rollback_config=dict(docker_py_version="3.5.0", docker_api_version="1.28"), init=dict(docker_py_version="4.0.0", docker_api_version="1.37"), cap_add=dict(docker_py_version="5.0.3", docker_api_version="1.41"), cap_drop=dict(docker_py_version="5.0.3", docker_api_version="1.41"), sysctls=dict(docker_py_version="6.0.0", docker_api_version="1.40"), # specials publish_mode=dict( docker_py_version="3.0.0", detect_usage=_detect_publish_mode_usage, usage_msg="set publish.mode", ), healthcheck_start_period=dict( docker_py_version="2.6.0", docker_api_version="1.29", detect_usage=_detect_healthcheck_start_period, usage_msg="set healthcheck.start_period", ), update_config_max_failure_ratio=dict( docker_py_version="2.1.0", detect_usage=lambda c: (c.module.params["update_config"] or {}).get( "max_failure_ratio" ) is not None, usage_msg="set update_config.max_failure_ratio", ), update_config_failure_action=dict( docker_py_version="3.5.0", docker_api_version="1.28", detect_usage=_detect_update_config_failure_action_rollback, usage_msg="set update_config.failure_action.rollback", ), update_config_monitor=dict( docker_py_version="2.1.0", detect_usage=lambda c: (c.module.params["update_config"] or {}).get( "monitor" ) is not None, usage_msg="set update_config.monitor", ), update_config_order=dict( docker_py_version="2.7.0", docker_api_version="1.29", detect_usage=lambda c: (c.module.params["update_config"] or {}).get("order") is not None, usage_msg="set update_config.order", ), placement_config_preferences=dict( docker_py_version="2.4.0", docker_api_version="1.27", detect_usage=lambda c: (c.module.params["placement"] or {}).get( "preferences" ) is not None, usage_msg="set placement.preferences", ), placement_config_constraints=dict( docker_py_version="2.4.0", detect_usage=lambda c: (c.module.params["placement"] or {}).get( "constraints" ) is not None, usage_msg="set placement.constraints", ), placement_config_replicas_max_per_node=dict( docker_py_version="4.4.3", docker_api_version="1.40", detect_usage=lambda c: (c.module.params["placement"] or {}).get( "replicas_max_per_node" ) is not None, usage_msg="set placement.replicas_max_per_node", ), mounts_tmpfs=dict( docker_py_version="2.6.0", detect_usage=_detect_mount_tmpfs_usage, usage_msg="set mounts.tmpfs", ), rollback_config_order=dict( docker_api_version="1.29", detect_usage=lambda c: (c.module.params["rollback_config"] or {}).get( "order" ) is not None, usage_msg="set rollback_config.order", ), mode_replicated_job=dict( docker_py_version="6.0.0", docker_api_version="1.41", detect_usage=lambda c: c.module.params.get("mode") == "replicated-job", usage_msg="set 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.2", option_minimal_versions=option_minimal_versions, ) try: dsm = DockerServiceManager(client) msg, changed, rebuilt, changes, facts = dsm.run_safe() results = dict( msg=msg, changed=changed, rebuilt=rebuilt, changes=changes, swarm_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) except DockerException as e: client.fail( f"An unexpected Docker error occurred: {e}", exception=traceback.format_exc(), ) except RequestException as e: client.fail( f"An unexpected requests error occurred when Docker SDK for Python tried to talk to the docker daemon: {e}", exception=traceback.format_exc(), ) if __name__ == "__main__": main()