community.docker/plugins/modules/docker_swarm_service.py
2025-11-06 07:15:05 +01:00

3021 lines
114 KiB
Python

#!/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>[<unit>]). 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>[<unit>]). 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>[<unit>]). 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
import typing as t
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: str | dict[str, t.Any] | list[t.Any] | None, env_files: list[str] | None
) -> list[str] | None:
"""
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: dict[str, str] = {}
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 as exc:
raise ValueError(
"Invalid environment variable found in list, needs to be in format KEY=VALUE."
) from exc
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 []
return None
return sorted(env_list)
@t.overload
def get_docker_networks(
networks: list[str | dict[str, t.Any]], network_ids: dict[str, str]
) -> list[dict[str, t.Any]]: ...
@t.overload
def get_docker_networks(
networks: list[str | dict[str, t.Any]] | None, network_ids: dict[str, str]
) -> list[dict[str, t.Any]] | None: ...
def get_docker_networks(
networks: list[str | dict[str, t.Any]] | None, network_ids: dict[str, str]
) -> list[dict[str, t.Any]] | None:
"""
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:
parsed_network: dict[str, t.Any]
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}.") from None
parsed_networks.append(parsed_network)
return parsed_networks or []
def get_nanoseconds_from_raw_option(name: str, value: t.Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return convert_duration_to_nanosecond(value)
raise ValueError(
f"Invalid type for {name} {value} ({type(value)}). Only string or int allowed."
)
def get_value(key: str, values: dict[str, t.Any], default: t.Any = None) -> t.Any:
value = values.get(key)
return value if value is not None else default
def has_dict_changed(
new_dict: dict[str, t.Any] | None, old_dict: dict[str, t.Any] | None
) -> bool:
"""
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
if old_dict is None:
# in this case new_dict is empty, only the type checker didn't notice
return False
defined_options = {
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: list[t.Any] | None,
old_list: list[t.Any] | None,
sort_lists: bool = True,
sort_key: str | None = None,
) -> bool:
"""
Check two lists have differences. Sort lists by default.
"""
def sort_list(unsorted_list: list[t.Any]) -> list[t.Any]:
"""
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 ValueError("A sort key was not specified when sorting list")
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
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: list[dict[str, t.Any]] | None,
old_networks: list[dict[str, t.Any]] | None,
) -> bool:
"""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: LooseVersion, docker_py_version: LooseVersion
) -> None:
super().__init__()
self.image: str | None = ""
self.command: t.Any = None
self.args: list[str] | None = None
self.endpoint_mode: t.Literal["vip", "dnsrr"] | None = None
self.dns: list[str] | None = None
self.healthcheck: dict[str, t.Any] | None = None
self.healthcheck_disabled: bool | None = None
self.hostname: str | None = None
self.hosts: dict[str, t.Any] | None = None
self.tty: bool | None = None
self.dns_search: list[str] | None = None
self.dns_options: list[str] | None = None
self.env: t.Any = None
self.force_update: int | None = None
self.groups: list[str] | None = None
self.log_driver: str | None = None
self.log_driver_options: dict[str, t.Any] | None = None
self.labels: dict[str, t.Any] | None = None
self.container_labels: dict[str, t.Any] | None = None
self.sysctls: dict[str, t.Any] | None = None
self.limit_cpu: float | None = None
self.limit_memory: int | None = None
self.reserve_cpu: float | None = None
self.reserve_memory: int | None = None
self.mode: t.Literal["replicated", "global", "replicated-job"] = "replicated"
self.user: str | None = None
self.mounts: list[dict[str, t.Any]] | None = None
self.configs: list[dict[str, t.Any]] | None = None
self.secrets: list[dict[str, t.Any]] | None = None
self.constraints: list[str] | None = None
self.replicas_max_per_node: int | None = None
self.networks: list[t.Any] | None = None
self.stop_grace_period: int | None = None
self.stop_signal: str | None = None
self.publish: list[dict[str, t.Any]] | None = None
self.placement_preferences: list[dict[str, t.Any]] | None = None
self.replicas: int | None = -1
self.service_id = False
self.service_version = False
self.read_only: bool | None = None
self.restart_policy: t.Literal["none", "on-failure", "any"] | None = None
self.restart_policy_attempts: int | None = None
self.restart_policy_delay: str | None = None
self.restart_policy_window: str | None = None
self.rollback_config: dict[str, t.Any] | None = None
self.update_delay: str | None = None
self.update_parallelism: int | None = None
self.update_failure_action: (
t.Literal["continue", "pause", "rollback"] | None
) = None
self.update_monitor: str | None = None
self.update_max_failure_ratio: float | None = None
self.update_order: str | None = None
self.working_dir: str | None = None
self.init: bool | None = None
self.cap_add: list[str] | None = None
self.cap_drop: list[str] | None = None
self.docker_api_version = docker_api_version
self.docker_py_version = docker_py_version
def get_facts(self) -> dict[str, t.Any]:
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) -> bool:
# 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) -> bool:
# 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: dict[str, t.Any],
) -> dict[str, t.Any]:
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: dict[str, t.Any],
) -> dict[str, t.Any]:
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: dict[str, t.Any],
) -> dict[str, t.Any] | None:
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: dict[str, t.Any]) -> dict[str, t.Any]:
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: dict[str, t.Any]) -> dict[str, t.Any]:
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 ValueError(
f"Failed to convert limit_memory to bytes: {exc}"
) from exc
return {
"limit_cpu": cpus,
"limit_memory": memory,
}
@staticmethod
def get_reservations_from_ansible_params(
params: dict[str, t.Any],
) -> dict[str, t.Any]:
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 ValueError(
f"Failed to convert reserve_memory to bytes: {exc}"
) from exc
return {
"reserve_cpu": cpus,
"reserve_memory": memory,
}
@staticmethod
def get_placement_from_ansible_params(params: dict[str, t.Any]) -> dict[str, t.Any]:
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: dict[str, t.Any],
old_service: DockerService | None,
image_digest: str,
secret_ids: dict[str, str],
config_ids: dict[str, str],
network_ids: dict[str, str],
client: AnsibleDockerClient,
) -> DockerService:
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 ValueError(
"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}"
) from 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: DockerService) -> tuple[bool, DifferenceTracker, bool, bool]:
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 or "")
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: DockerService) -> bool:
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: list[dict[str, t.Any]] | None) -> bool:
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: dict[str, t.Any]) -> tuple[int, int, str]:
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 = {
k: v for k, v in old_publish_item.items() if k not in ignored_keys
}
filtered_publish_item = {
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: str) -> tuple[bool, str]:
assert self.image is not None
if "@" not in self.image:
old_image = old_image.split("@")[0]
return self.image != old_image, old_image
def build_container_spec(self) -> types.ContainerSpec:
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: dict[str, t.Any] = {}
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: dict[str, t.Any] = {}
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) -> types.Placement | None:
placement_args: dict[str, t.Any] = {}
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) -> types.UpdateConfig | None:
update_config_args: dict[str, t.Any] = {}
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) -> types.DriverConfig | None:
log_driver_args: dict[str, t.Any] = {}
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) -> types.RestartPolicy | None:
restart_policy_args: dict[str, t.Any] = {}
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) -> types.RollbackConfig | None:
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) -> types.Resources | None:
resources_args: dict[str, t.Any] = {}
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: types.ContainerSpec,
placement: types.Placement | None = None,
) -> types.TaskTemplate:
log_driver = self.build_log_driver()
restart_policy = self.build_restart_policy()
resources = self.build_resources()
task_template_args: dict[str, t.Any] = {}
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) -> types.ServiceMode:
if self.mode == "global":
self.replicas = None
return types.ServiceMode(self.mode, replicas=self.replicas)
def build_networks(self) -> list[dict[str, t.Any]] | None:
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) -> types.EndpointSpec | None:
endpoint_spec_args: dict[str, t.Any] = {}
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) -> dict[str, t.Any]:
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: dict[str, t.Any] = {
"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: AnsibleDockerClient):
self.client = client
self.retries = 2
self.diff_tracker: DifferenceTracker | None = None
def get_service(self, name: str) -> DockerService | None:
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 = {
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 = {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(
{
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:
ds.mode = to_text("replicated", encoding="utf-8") # type: ignore
ds.replicas = mode["Replicated"]["Replicas"]
elif "Global" in mode:
ds.mode = "global"
elif "ReplicatedJob" in mode:
ds.mode = to_text("replicated-job", encoding="utf-8") # type: ignore
ds.replicas = mode["ReplicatedJob"]["TotalCompletions"]
else:
raise ValueError(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 = {
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: str, old_service: DockerService, new_service: DockerService
) -> None:
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: str, service: DockerService) -> None:
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: str) -> None:
self.client.remove_service(name)
def get_image_digest(self, name: str, resolve: bool = False) -> str:
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) -> dict[str, str]:
return {network["Name"]: network["Id"] for network in self.client.networks()}
def get_missing_secret_ids(self) -> dict[str, str]:
"""
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 = {
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) -> dict[str, str]:
"""
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 = {
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) -> tuple[str, bool, bool, list[str], dict[str, t.Any]]:
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: # pylint: disable=broad-exception-caught
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: # pylint: disable=broad-exception-caught
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) -> tuple[str, bool, bool, list[str], dict[str, t.Any]]:
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: AnsibleDockerClient) -> bool:
return any(
publish_def.get("mode") for publish_def in client.module.params["publish"] or []
)
def _detect_healthcheck_start_period(client: AnsibleDockerClient) -> bool:
if client.module.params["healthcheck"]:
return client.module.params["healthcheck"]["start_period"] is not None
return False
def _detect_mount_tmpfs_usage(client: AnsibleDockerClient) -> bool:
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: AnsibleDockerClient) -> bool:
rollback_config_failure_action = (client.module.params["update_config"] or {}).get(
"failure_action"
)
return rollback_config_failure_action == "rollback"
def main() -> None:
argument_spec = {
"name": {"type": "str", "required": True},
"image": {"type": "str"},
"state": {
"type": "str",
"default": "present",
"choices": ["present", "absent"],
},
"mounts": {
"type": "list",
"elements": "dict",
"options": {
"source": {"type": "str"},
"target": {"type": "str", "required": True},
"type": {
"type": "str",
"default": "bind",
"choices": ["bind", "volume", "tmpfs", "npipe"],
},
"readonly": {"type": "bool"},
"labels": {"type": "dict"},
"propagation": {
"type": "str",
"choices": [
"shared",
"slave",
"private",
"rshared",
"rslave",
"rprivate",
],
},
"no_copy": {"type": "bool"},
"driver_config": {
"type": "dict",
"options": {"name": {"type": "str"}, "options": {"type": "dict"}},
},
"tmpfs_size": {"type": "str"},
"tmpfs_mode": {"type": "int"},
},
},
"configs": {
"type": "list",
"elements": "dict",
"options": {
"config_id": {"type": "str"},
"config_name": {"type": "str", "required": True},
"filename": {"type": "str"},
"uid": {"type": "str"},
"gid": {"type": "str"},
"mode": {"type": "int"},
},
},
"secrets": {
"type": "list",
"elements": "dict",
"no_log": False,
"options": {
"secret_id": {"type": "str", "no_log": False},
"secret_name": {"type": "str", "required": True, "no_log": False},
"filename": {"type": "str"},
"uid": {"type": "str"},
"gid": {"type": "str"},
"mode": {"type": "int"},
},
},
"networks": {"type": "list", "elements": "raw"},
"command": {"type": "raw"},
"args": {"type": "list", "elements": "str"},
"env": {"type": "raw"},
"env_files": {"type": "list", "elements": "path"},
"force_update": {"type": "bool", "default": False},
"groups": {"type": "list", "elements": "str"},
"logging": {
"type": "dict",
"options": {
"driver": {"type": "str"},
"options": {"type": "dict"},
},
},
"publish": {
"type": "list",
"elements": "dict",
"options": {
"published_port": {"type": "int", "required": False},
"target_port": {"type": "int", "required": True},
"protocol": {
"type": "str",
"default": "tcp",
"choices": ["tcp", "udp"],
},
"mode": {"type": "str", "choices": ["ingress", "host"]},
},
},
"placement": {
"type": "dict",
"options": {
"constraints": {"type": "list", "elements": "str"},
"preferences": {"type": "list", "elements": "dict"},
"replicas_max_per_node": {"type": "int"},
},
},
"tty": {"type": "bool"},
"dns": {"type": "list", "elements": "str"},
"dns_search": {"type": "list", "elements": "str"},
"dns_options": {"type": "list", "elements": "str"},
"healthcheck": {
"type": "dict",
"options": {
"test": {"type": "raw"},
"interval": {"type": "str"},
"timeout": {"type": "str"},
"start_period": {"type": "str"},
"retries": {"type": "int"},
},
},
"hostname": {"type": "str"},
"hosts": {"type": "dict"},
"labels": {"type": "dict"},
"container_labels": {"type": "dict"},
"sysctls": {"type": "dict"},
"mode": {
"type": "str",
"default": "replicated",
"choices": ["replicated", "global", "replicated-job"],
},
"replicas": {"type": "int", "default": -1},
"endpoint_mode": {"type": "str", "choices": ["vip", "dnsrr"]},
"stop_grace_period": {"type": "str"},
"stop_signal": {"type": "str"},
"limits": {
"type": "dict",
"options": {
"cpus": {"type": "float"},
"memory": {"type": "str"},
},
},
"read_only": {"type": "bool"},
"reservations": {
"type": "dict",
"options": {
"cpus": {"type": "float"},
"memory": {"type": "str"},
},
},
"resolve_image": {"type": "bool", "default": False},
"restart_config": {
"type": "dict",
"options": {
"condition": {"type": "str", "choices": ["none", "on-failure", "any"]},
"delay": {"type": "str"},
"max_attempts": {"type": "int"},
"window": {"type": "str"},
},
},
"rollback_config": {
"type": "dict",
"options": {
"parallelism": {"type": "int"},
"delay": {"type": "str"},
"failure_action": {"type": "str", "choices": ["continue", "pause"]},
"monitor": {"type": "str"},
"max_failure_ratio": {"type": "float"},
"order": {"type": "str"},
},
},
"update_config": {
"type": "dict",
"options": {
"parallelism": {"type": "int"},
"delay": {"type": "str"},
"failure_action": {
"type": "str",
"choices": ["continue", "pause", "rollback"],
},
"monitor": {"type": "str"},
"max_failure_ratio": {"type": "float"},
"order": {"type": "str"},
},
},
"user": {"type": "str"},
"working_dir": {"type": "str"},
"init": {"type": "bool"},
"cap_add": {"type": "list", "elements": "str"},
"cap_drop": {"type": "list", "elements": "str"},
}
option_minimal_versions = {
"dns": {"docker_py_version": "2.6.0"},
"dns_options": {"docker_py_version": "2.6.0"},
"dns_search": {"docker_py_version": "2.6.0"},
"endpoint_mode": {"docker_py_version": "3.0.0"},
"force_update": {"docker_py_version": "2.1.0"},
"healthcheck": {"docker_py_version": "2.6.0"},
"hostname": {"docker_py_version": "2.2.0"},
"hosts": {"docker_py_version": "2.6.0"},
"groups": {"docker_py_version": "2.6.0"},
"tty": {"docker_py_version": "2.4.0"},
"secrets": {"docker_py_version": "2.4.0"},
"configs": {"docker_py_version": "2.6.0", "docker_api_version": "1.30"},
"stop_signal": {"docker_py_version": "2.6.0", "docker_api_version": "1.28"},
"publish": {"docker_py_version": "3.0.0"},
"read_only": {"docker_py_version": "2.6.0", "docker_api_version": "1.28"},
"resolve_image": {"docker_api_version": "1.30", "docker_py_version": "3.2.0"},
"rollback_config": {"docker_py_version": "3.5.0", "docker_api_version": "1.28"},
"init": {"docker_py_version": "4.0.0", "docker_api_version": "1.37"},
"cap_add": {"docker_py_version": "5.0.3", "docker_api_version": "1.41"},
"cap_drop": {"docker_py_version": "5.0.3", "docker_api_version": "1.41"},
"sysctls": {"docker_py_version": "6.0.0", "docker_api_version": "1.40"},
# specials
"publish_mode": {
"docker_py_version": "3.0.0",
"detect_usage": _detect_publish_mode_usage,
"usage_msg": "set publish.mode",
},
"healthcheck_start_period": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"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": {
"docker_py_version": "2.6.0",
"detect_usage": _detect_mount_tmpfs_usage,
"usage_msg": "set mounts.tmpfs",
},
"rollback_config_order": {
"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": {
"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 = {
"msg": msg,
"changed": changed,
"rebuilt": rebuilt,
"changes": changes,
"swarm_service": facts,
}
if client.module._diff:
assert dsm.diff_tracker is not None
before, after = dsm.diff_tracker.get_before_after()
results["diff"] = {"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()