# Copyright (c) 2022 Felix Fontein # Copyright 2016 Red Hat | Ansible # 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 # Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time. # Do not use this from other collections or standalone plugins/modules! from __future__ import annotations import json import traceback import typing as t from ansible.module_utils.common.text.converters import to_text from ansible.module_utils.common.text.formatters import human_to_bytes from ansible_collections.community.docker.plugins.module_utils._api.errors import ( APIError, DockerException, NotFound, ) from ansible_collections.community.docker.plugins.module_utils._api.utils.utils import ( convert_port_bindings, normalize_links, ) from ansible_collections.community.docker.plugins.module_utils._common_api import ( AnsibleDockerClient, RequestException, ) from ansible_collections.community.docker.plugins.module_utils._module_container.base import ( _DEFAULT_IP_REPLACEMENT_STRING, OPTION_AUTO_REMOVE, OPTION_BLKIO_WEIGHT, OPTION_CAP_DROP, OPTION_CAPABILITIES, OPTION_CGROUP_NS_MODE, OPTION_CGROUP_PARENT, OPTION_COMMAND, OPTION_CPU_PERIOD, OPTION_CPU_QUOTA, OPTION_CPU_SHARES, OPTION_CPUS, OPTION_CPUSET_CPUS, OPTION_CPUSET_MEMS, OPTION_DETACH_INTERACTIVE, OPTION_DEVICE_CGROUP_RULES, OPTION_DEVICE_READ_BPS, OPTION_DEVICE_READ_IOPS, OPTION_DEVICE_REQUESTS, OPTION_DEVICE_WRITE_BPS, OPTION_DEVICE_WRITE_IOPS, OPTION_DEVICES, OPTION_DNS_OPTS, OPTION_DNS_SEARCH_DOMAINS, OPTION_DNS_SERVERS, OPTION_DOMAINNAME, OPTION_ENTRYPOINT, OPTION_ENVIRONMENT, OPTION_ETC_HOSTS, OPTION_GROUPS, OPTION_HEALTHCHECK, OPTION_HOSTNAME, OPTION_IMAGE, OPTION_INIT, OPTION_IPC_MODE, OPTION_KERNEL_MEMORY, OPTION_LABELS, OPTION_LINKS, OPTION_LOG_DRIVER_OPTIONS, OPTION_MAC_ADDRESS, OPTION_MEMORY, OPTION_MEMORY_RESERVATION, OPTION_MEMORY_SWAP, OPTION_MEMORY_SWAPPINESS, OPTION_MOUNTS_VOLUMES, OPTION_NETWORK, OPTION_OOM_KILLER, OPTION_OOM_SCORE_ADJ, OPTION_PID_MODE, OPTION_PIDS_LIMIT, OPTION_PLATFORM, OPTION_PORTS, OPTION_PRIVILEGED, OPTION_READ_ONLY, OPTION_RESTART_POLICY, OPTION_RUNTIME, OPTION_SECURITY_OPTS, OPTION_SHM_SIZE, OPTION_STOP_SIGNAL, OPTION_STOP_TIMEOUT, OPTION_STORAGE_OPTS, OPTION_SYSCTLS, OPTION_TMPFS, OPTION_TTY, OPTION_ULIMITS, OPTION_USER, OPTION_USERNS_MODE, OPTION_UTS, OPTION_VOLUME_DRIVER, OPTION_VOLUMES_FROM, OPTION_WORKING_DIR, OPTIONS, Engine, EngineDriver, _is_volume_permissions, ) from ansible_collections.community.docker.plugins.module_utils._platform import ( compose_platform_string, normalize_platform_string, ) from ansible_collections.community.docker.plugins.module_utils._util import ( normalize_healthcheck_test, omit_none_from_dict, ) from ansible_collections.community.docker.plugins.module_utils._version import ( LooseVersion, ) if t.TYPE_CHECKING: from collections.abc import Callable, Sequence from ansible.module_utils.basic import AnsibleModule from .base import Option, OptionGroup Sentry = object _SENTRY: Sentry = object() class DockerAPIEngineDriver(EngineDriver[AnsibleDockerClient]): name = "docker_api" def setup( self, argument_spec: dict[str, t.Any], mutually_exclusive: Sequence[Sequence[str]] | None = None, required_together: Sequence[Sequence[str]] | None = None, required_one_of: Sequence[Sequence[str]] | None = None, required_if: ( Sequence[ tuple[str, t.Any, Sequence[str]] | tuple[str, t.Any, Sequence[str], bool] ] | None ) = None, required_by: dict[str, Sequence[str]] | None = None, ) -> tuple[AnsibleModule, list[OptionGroup], AnsibleDockerClient]: argument_spec = dict(argument_spec or {}) mutually_exclusive = list(mutually_exclusive or []) required_together = list(required_together or []) required_one_of = list(required_one_of or []) required_if = list(required_if or []) required_by = dict(required_by or {}) active_options = [] option_minimal_versions = {} for options in OPTIONS: if not options.supports_engine(self.name): continue mutually_exclusive.extend(options.ansible_mutually_exclusive) required_together.extend(options.ansible_required_together) required_one_of.extend(options.ansible_required_one_of) required_if.extend(options.ansible_required_if) required_by.update(options.ansible_required_by) argument_spec.update(options.argument_spec) engine = options.get_engine(self.name) if engine.min_api_version is not None: for option in options.options: if not option.not_an_ansible_option: option_minimal_versions[option.name] = { "docker_api_version": engine.min_api_version } if engine.extra_option_minimal_versions: option_minimal_versions.update(engine.extra_option_minimal_versions) active_options.append(options) client = AnsibleDockerClient( argument_spec=argument_spec, mutually_exclusive=mutually_exclusive, required_together=required_together, required_one_of=required_one_of, required_if=required_if, required_by=required_by, option_minimal_versions=option_minimal_versions, supports_check_mode=True, ) return client.module, active_options, client def get_host_info(self, client: AnsibleDockerClient) -> dict[str, t.Any]: return client.info() def get_api_version(self, client: AnsibleDockerClient) -> LooseVersion: return client.docker_api_version def get_container_id(self, container: dict[str, t.Any]) -> str: return container["Id"] def get_image_from_container(self, container: dict[str, t.Any]) -> str: return container["Image"] def get_image_name_from_container(self, container: dict[str, t.Any]) -> str | None: return container["Config"].get("Image") def is_container_removing(self, container: dict[str, t.Any]) -> bool: if container.get("State"): return container["State"].get("Status") == "removing" return False def is_container_running(self, container: dict[str, t.Any]) -> bool: return bool( container.get("State") and container["State"].get("Running") and not container["State"].get("Ghost", False) ) def is_container_paused(self, container: dict[str, t.Any]) -> bool: if container.get("State"): return container["State"].get("Paused", False) return False def inspect_container_by_name( self, client: AnsibleDockerClient, container_name: str ) -> dict[str, t.Any] | None: return client.get_container(container_name) def inspect_container_by_id( self, client: AnsibleDockerClient, container_id: str ) -> dict[str, t.Any] | None: return client.get_container_by_id(container_id) def inspect_image_by_id( self, client: AnsibleDockerClient, image_id: str ) -> dict[str, t.Any] | None: return client.find_image_by_id(image_id, accept_missing_image=True) def inspect_image_by_name( self, client: AnsibleDockerClient, repository: str, tag: str ) -> dict[str, t.Any] | None: return client.find_image(repository, tag) def pull_image( self, client: AnsibleDockerClient, repository: str, tag: str, image_platform: str | None = None, ) -> tuple[dict[str, t.Any] | None, bool]: return client.pull_image(repository, tag, image_platform=image_platform) def pause_container(self, client: AnsibleDockerClient, container_id: str) -> None: client.post_call("/containers/{0}/pause", container_id) def unpause_container(self, client: AnsibleDockerClient, container_id: str) -> None: client.post_call("/containers/{0}/unpause", container_id) def disconnect_container_from_network( self, client: AnsibleDockerClient, container_id: str, network_id: str ) -> None: client.post_json( "/networks/{0}/disconnect", network_id, data={"Container": container_id} ) def _create_endpoint_config(self, parameters: dict[str, t.Any]) -> dict[str, t.Any]: parameters = parameters.copy() params = {} for para, dest_para in { "ipv4_address": "IPv4Address", "ipv6_address": "IPv6Address", "links": "Links", "aliases": "Aliases", "mac_address": "MacAddress", "driver_opts": "DriverOpts", }.items(): value = parameters.pop(para, None) if value: if para == "links": value = normalize_links(value) elif para == "driver_opts": # Ensure driver_opts values are strings for key, val in value.items(): if not isinstance(val, str): raise ValueError( f"driver_opts values must be strings, got {type(val).__name__} for key '{key}'" ) params[dest_para] = value for para, dest_para in { "gw_priority": "GwPriority", }.items(): value = parameters.pop(para, None) if value is not None: params[dest_para] = value if parameters: ups = ", ".join([f'"{p}"' for p in sorted(parameters)]) raise ValueError( f"Unknown parameter(s) for connect_container_to_network for Docker API driver: {ups}" ) ipam_config = {} for param in ("IPv4Address", "IPv6Address"): if param in params: ipam_config[param] = params.pop(param) if ipam_config: params["IPAMConfig"] = ipam_config return params def connect_container_to_network( self, client: AnsibleDockerClient, container_id: str, network_id: str, parameters: dict[str, t.Any] | None = None, ) -> None: parameters = (parameters or {}).copy() params = self._create_endpoint_config(parameters or {}) data = { "Container": container_id, "EndpointConfig": params, } client.post_json("/networks/{0}/connect", network_id, data=data) def create_container_supports_more_than_one_network( self, client: AnsibleDockerClient ) -> bool: return client.docker_api_version >= LooseVersion("1.44") def create_container( self, client: AnsibleDockerClient, container_name: str, create_parameters: dict[str, t.Any], networks: dict[str, dict[str, t.Any]] | None = None, ) -> str: params = {"name": container_name} if "platform" in create_parameters: params["platform"] = create_parameters.pop("platform") if networks is not None: create_parameters = create_parameters.copy() create_parameters["NetworkingConfig"] = { "EndpointsConfig": dict( (network, self._create_endpoint_config(network_params)) for network, network_params in networks.items() ) } new_container = client.post_json_to_json( "/containers/create", data=create_parameters, params=params ) client.report_warnings(new_container) return new_container["Id"] def start_container(self, client: AnsibleDockerClient, container_id: str) -> None: client.post_json("/containers/{0}/start", container_id) def wait_for_container( self, client: AnsibleDockerClient, container_id: str, timeout: int | float | None = None, ) -> int | None: return client.post_json_to_json( "/containers/{0}/wait", container_id, timeout=timeout )["StatusCode"] def get_container_output( self, client: AnsibleDockerClient, container_id: str ) -> tuple[bytes, t.Literal[True]] | tuple[str, t.Literal[False]]: config = client.get_json("/containers/{0}/json", container_id) logging_driver = config["HostConfig"]["LogConfig"]["Type"] if logging_driver in ("json-file", "journald", "local"): params = { "stderr": 1, "stdout": 1, "timestamps": 0, "follow": 0, "tail": "all", } res = client._get( client._url("/containers/{0}/logs", container_id), params=params ) output = client._get_result_tty(False, res, config["Config"]["Tty"]) return output, True return f"Result logged using `{logging_driver}` driver", False def update_container( self, client: AnsibleDockerClient, container_id: str, update_parameters: dict[str, t.Any], ) -> None: result = client.post_json_to_json( "/containers/{0}/update", container_id, data=update_parameters ) client.report_warnings(result) def restart_container( self, client: AnsibleDockerClient, container_id: str, timeout: int | float | None = None, ) -> None: client_timeout: int | float | None = client.timeout if client_timeout is not None: client_timeout += timeout or 10 client.post_call( "/containers/{0}/restart", container_id, params={"t": timeout}, timeout=client_timeout, ) def kill_container( self, client: AnsibleDockerClient, container_id: str, kill_signal: str | None = None, ) -> None: params = {} if kill_signal is not None: params["signal"] = kill_signal client.post_call("/containers/{0}/kill", container_id, params=params) def stop_container( self, client: AnsibleDockerClient, container_id: str, timeout: int | float | None = None, ) -> None: if timeout: params = {"t": timeout} else: params = {} timeout = 10 client_timeout = client.timeout if client_timeout is not None: client_timeout += timeout count = 0 while True: try: client.post_call( "/containers/{0}/stop", container_id, params=params, timeout=client_timeout, ) except APIError as exc: if ( "Unpause the container before stopping or killing" in exc.explanation ): # New docker daemon versions do not allow containers to be removed # if they are paused. Make sure we do not end up in an infinite loop. if count == 3: raise RuntimeError( f"{exc} [tried to unpause three times]" ) from exc count += 1 # Unpause try: self.unpause_container(client, container_id) except Exception as exc2: raise RuntimeError(f"{exc2} [while unpausing]") from exc2 # Now try again continue raise # We only loop when explicitly requested by 'continue' break def remove_container( self, client: AnsibleDockerClient, container_id: str, remove_volumes: bool = False, link: bool = False, force: bool = False, ) -> None: params = {"v": remove_volumes, "link": link, "force": force} count = 0 while True: try: client.delete_call("/containers/{0}", container_id, params=params) except NotFound: pass except APIError as exc: if ( "Unpause the container before stopping or killing" in exc.explanation ): # New docker daemon versions do not allow containers to be removed # if they are paused. Make sure we do not end up in an infinite loop. if count == 3: raise RuntimeError( f"{exc} [tried to unpause three times]" ) from exc count += 1 # Unpause try: self.unpause_container(client, container_id) except Exception as exc2: raise RuntimeError(f"{exc2} [while unpausing]") from exc2 # Now try again continue if ( "removal of container " in exc.explanation and " is already in progress" in exc.explanation ): pass else: raise # We only loop when explicitly requested by 'continue' break def run(self, runner: Callable[[], None], client: AnsibleDockerClient) -> None: try: runner() 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 trying to talk to the Docker daemon: {e}", exception=traceback.format_exc(), ) class DockerAPIEngine(Engine[AnsibleDockerClient]): def get_value( self, module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: return self._get_value( module, container, api_version, options, image, host_info ) def compare_value( self, option: Option, param_value: t.Any, container_value: t.Any ) -> bool: if self._compare_value is not None: return self._compare_value(option, param_value, container_value) return super().compare_value(option, param_value, container_value) def set_value( self, module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if self._set_value is not None: self._set_value(module, data, api_version, options, values) def get_expected_values( self, module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: if self._get_expected_values is None: return values return self._get_expected_values( module, client, api_version, options, image, values, host_info ) def ignore_mismatching_result( self, module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, option: Option, image: dict[str, t.Any] | None, container_value: t.Any, expected_value: t.Any, host_info: dict[str, t.Any] | None, ) -> bool: if self._ignore_mismatching_result is None: return False return self._ignore_mismatching_result( module, client, api_version, option, image, container_value, expected_value, host_info, ) def preprocess_value( self, module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> dict[str, t.Any]: if self._preprocess_value is None: return values return self._preprocess_value(module, client, api_version, options, values) def update_value( self, module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if self._update_value is not None: self._update_value(module, data, api_version, options, values) def can_set_value(self, api_version: LooseVersion) -> bool: if self._can_set_value is None: return self._set_value is not None return self._can_set_value(api_version) def can_update_value(self, api_version: LooseVersion) -> bool: if self._can_update_value is None: return self._update_value is not None return self._can_update_value(api_version) def needs_container_image(self, values: dict[str, t.Any]) -> bool: if self._needs_container_image is None: return False return self._needs_container_image(values) def needs_host_info(self, values: dict[str, t.Any]) -> bool: if self._needs_host_info is None: return False return self._needs_host_info(values) def __init__( self, get_value: Callable[ [ AnsibleModule, dict[str, t.Any], LooseVersion, list[Option], dict[str, t.Any] | None, dict[str, t.Any] | None, ], dict[str, t.Any], ], preprocess_value: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, list[Option], dict[str, t.Any], ], dict[str, t.Any], ] | None ) = None, get_expected_values: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, list[Option], dict[str, t.Any] | None, dict[str, t.Any], dict[str, t.Any] | None, ], dict[str, t.Any], ] | None ) = None, ignore_mismatching_result: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, Option, dict[str, t.Any] | None, t.Any, t.Any, dict[str, t.Any] | None, ], bool, ] | None ) = None, set_value: ( Callable[ [ AnsibleModule, dict[str, t.Any], LooseVersion, list[Option], dict[str, t.Any], ], None, ] | None ) = None, update_value: ( Callable[ [ AnsibleModule, dict[str, t.Any], LooseVersion, list[Option], dict[str, t.Any], ], None, ] | None ) = None, can_set_value: Callable[[LooseVersion], bool] | None = None, can_update_value: Callable[[LooseVersion], bool] | None = None, min_api_version: str | None = None, compare_value: Callable[[Option, t.Any, t.Any], bool] | None = None, needs_container_image: Callable[[dict[str, t.Any]], bool] | None = None, needs_host_info: Callable[[dict[str, t.Any]], bool] | None = None, extra_option_minimal_versions: dict[str, dict[str, t.Any]] | None = None, ): self.min_api_version = min_api_version self.min_api_version_obj = ( None if min_api_version is None else LooseVersion(min_api_version) ) self.extra_option_minimal_versions = extra_option_minimal_versions self._get_value = get_value self._compare_value = compare_value self._set_value = set_value self._get_expected_values = get_expected_values self._ignore_mismatching_result = ignore_mismatching_result self._preprocess_value = preprocess_value self._update_value = update_value self._can_set_value = can_set_value self._can_update_value = can_update_value self._needs_container_image = needs_container_image self._needs_host_info = needs_host_info @classmethod def config_value( cls, config_name: str, postprocess_for_get: ( Callable[[AnsibleModule, LooseVersion, t.Any, Sentry], t.Any | Sentry] | None ) = None, preprocess_for_set: ( Callable[[AnsibleModule, LooseVersion, t.Any], t.Any] | None ) = None, get_expected_value: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, dict[str, t.Any] | None, t.Any, Sentry, ], t.Any | Sentry, ] | None ) = None, ignore_mismatching_result: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, Option, dict[str, t.Any] | None, t.Any, t.Any, dict[str, t.Any] | None, ], bool, ] | None ) = None, min_api_version: str | None = None, preprocess_value: ( Callable[[AnsibleModule, AnsibleDockerClient, LooseVersion, t.Any], t.Any] | None ) = None, update_parameter: str | None = None, extra_option_minimal_versions: dict[str, dict[str, t.Any]] | None = None, ) -> DockerAPIEngine: def preprocess_value_( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> dict[str, t.Any]: if len(options) != 1: raise AssertionError( "config_value can only be used for a single option" ) if preprocess_value is not None and options[0].name in values: value = preprocess_value( module, client, api_version, values[options[0].name] ) if value is None: del values[options[0].name] else: values[options[0].name] = value return values def get_value( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: if len(options) != 1: raise AssertionError( "config_value can only be used for a single option" ) value = container["Config"].get(config_name, _SENTRY) if postprocess_for_get: value = postprocess_for_get(module, api_version, value, _SENTRY) if value is _SENTRY: return {} return {options[0].name: value} get_expected_values_: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, list[Option], dict[str, t.Any] | None, dict[str, t.Any], dict[str, t.Any] | None, ], dict[str, t.Any], ] | None ) = None if get_expected_value: def get_expected_values_( # noqa: F811 module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: if len(options) != 1: raise AssertionError( "host_config_value can only be used for a single option" ) value = values.get(options[0].name, _SENTRY) value = get_expected_value( module, client, api_version, image, value, _SENTRY ) if value is _SENTRY: return values return {options[0].name: value} def set_value( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if len(options) != 1: raise AssertionError( "config_value can only be used for a single option" ) if options[0].name not in values: return value = values[options[0].name] if preprocess_for_set: value = preprocess_for_set(module, api_version, value) data[config_name] = value update_value: ( Callable[ [ AnsibleModule, dict[str, t.Any], LooseVersion, list[Option], dict[str, t.Any], ], None, ] | None ) = None if update_parameter: def update_value( # noqa: F811 module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if len(options) != 1: raise AssertionError( "update_parameter can only be used for a single option" ) if options[0].name not in values: return value = values[options[0].name] if preprocess_for_set: value = preprocess_for_set(module, api_version, value) data[update_parameter] = value return cls( get_value=get_value, preprocess_value=preprocess_value_, get_expected_values=get_expected_values_, ignore_mismatching_result=ignore_mismatching_result, set_value=set_value, min_api_version=min_api_version, update_value=update_value, extra_option_minimal_versions=extra_option_minimal_versions, ) @classmethod def host_config_value( cls, host_config_name: str, postprocess_for_get: ( Callable[[AnsibleModule, LooseVersion, t.Any, Sentry], t.Any | Sentry] | None ) = None, preprocess_for_set: ( Callable[[AnsibleModule, LooseVersion, t.Any], t.Any] | None ) = None, get_expected_value: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, dict[str, t.Any] | None, t.Any, Sentry, ], t.Any | Sentry, ] | None ) = None, ignore_mismatching_result: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, Option, dict[str, t.Any] | None, t.Any, t.Any, dict[str, t.Any] | None, ], bool, ] | None ) = None, min_api_version: str | None = None, preprocess_value: ( Callable[[AnsibleModule, AnsibleDockerClient, LooseVersion, t.Any], t.Any] | None ) = None, update_parameter: str | None = None, extra_option_minimal_versions: dict[str, dict[str, t.Any]] | None = None, ) -> DockerAPIEngine: def preprocess_value_( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> dict[str, t.Any]: if len(options) != 1: raise AssertionError( "host_config_value can only be used for a single option" ) if preprocess_value is not None and options[0].name in values: value = preprocess_value( module, client, api_version, values[options[0].name] ) if value is None: del values[options[0].name] else: values[options[0].name] = value return values def get_value( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: if len(options) != 1: raise AssertionError( "host_config_value can only be used for a single option" ) value = container["HostConfig"].get(host_config_name, _SENTRY) if postprocess_for_get: value = postprocess_for_get(module, api_version, value, _SENTRY) if value is _SENTRY: return {} return {options[0].name: value} get_expected_values_: ( Callable[ [ AnsibleModule, AnsibleDockerClient, LooseVersion, list[Option], dict[str, t.Any] | None, dict[str, t.Any], dict[str, t.Any] | None, ], dict[str, t.Any], ] | None ) = None if get_expected_value: def get_expected_values_( # noqa: F811 module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: if len(options) != 1: raise AssertionError( "host_config_value can only be used for a single option" ) value = values.get(options[0].name, _SENTRY) value = get_expected_value( module, client, api_version, image, value, _SENTRY ) if value is _SENTRY: return values return {options[0].name: value} def set_value( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if len(options) != 1: raise AssertionError( "host_config_value can only be used for a single option" ) if options[0].name not in values: return if "HostConfig" not in data: data["HostConfig"] = {} value = values[options[0].name] if preprocess_for_set: value = preprocess_for_set(module, api_version, value) data["HostConfig"][host_config_name] = value update_value: ( Callable[ [ AnsibleModule, dict[str, t.Any], LooseVersion, list[Option], dict[str, t.Any], ], None, ] | None ) = None if update_parameter: def update_value( # noqa: F811 module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if len(options) != 1: raise AssertionError( "update_parameter can only be used for a single option" ) if options[0].name not in values: return value = values[options[0].name] if preprocess_for_set: value = preprocess_for_set(module, api_version, value) data[update_parameter] = value return cls( get_value=get_value, preprocess_value=preprocess_value_, get_expected_values=get_expected_values_, ignore_mismatching_result=ignore_mismatching_result, set_value=set_value, min_api_version=min_api_version, update_value=update_value, extra_option_minimal_versions=extra_option_minimal_versions, ) def _normalize_port(port: str) -> str: if "/" not in port: return port + "/tcp" return port def _get_default_host_ip(module: AnsibleModule, client: AnsibleDockerClient) -> str: if module.params["default_host_ip"] is not None: return module.params["default_host_ip"] ip = "0.0.0.0" for network_data in module.params["networks"] or []: if network_data.get("name"): network = client.get_network(network_data["name"]) if network is None: client.fail( f"Cannot inspect the network '{network_data['name']}' to determine the default IP", ) if network.get("Driver") == "bridge" and network.get("Options", {}).get( "com.docker.network.bridge.host_binding_ipv4" ): ip = network["Options"]["com.docker.network.bridge.host_binding_ipv4"] break return ip def _get_value_detach_interactive( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: attach_stdin = container["Config"].get("OpenStdin") attach_stderr = container["Config"].get("AttachStderr") attach_stdout = container["Config"].get("AttachStdout") return { "interactive": bool(attach_stdin), "detach": not (attach_stderr and attach_stdout), } def _set_value_detach_interactive( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: interactive = values.get("interactive") detach = values.get("detach") data["AttachStdout"] = False data["AttachStderr"] = False data["AttachStdin"] = False data["StdinOnce"] = False data["OpenStdin"] = interactive if not detach: data["AttachStdout"] = True data["AttachStderr"] = True if interactive: data["AttachStdin"] = True data["StdinOnce"] = True def _get_expected_env_value( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, image: dict[str, t.Any] | None, value: t.Any, sentry: Sentry, ) -> t.Any | Sentry: expected_env = {} if image and image["Config"].get("Env"): for env_var in image["Config"]["Env"]: parts = env_var.split("=", 1) expected_env[parts[0]] = parts[1] if value and value is not sentry: for env_var in value: parts = env_var.split("=", 1) expected_env[parts[0]] = parts[1] param_env = [] for key, env_value in expected_env.items(): param_env.append(f"{key}={env_value}") return param_env def _preprocess_cpus( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if value is not None: value = int(round(value * 1e9)) return value def _preprocess_devices( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if not value: return value expected_devices = [] for device in value: parts = device.split(":") if len(parts) == 1: expected_devices.append( { "CgroupPermissions": "rwm", "PathInContainer": parts[0], "PathOnHost": parts[0], } ) elif len(parts) == 2: parts = device.split(":") expected_devices.append( { "CgroupPermissions": "rwm", "PathInContainer": parts[1], "PathOnHost": parts[0], } ) else: expected_devices.append( { "CgroupPermissions": parts[2], "PathInContainer": parts[1], "PathOnHost": parts[0], } ) return expected_devices def _preprocess_rate_bps( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if not value: return value devices = [] for device in value: devices.append( { "Path": device["path"], "Rate": human_to_bytes(device["rate"]), } ) return devices def _preprocess_rate_iops( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if not value: return value devices = [] for device in value: devices.append( { "Path": device["path"], "Rate": device["rate"], } ) return devices def _preprocess_device_requests( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if not value: return value device_requests = [] for dr in value: device_requests.append( { "Driver": dr["driver"], "Count": dr["count"], "DeviceIDs": dr["device_ids"], "Capabilities": dr["capabilities"], "Options": dr["options"], } ) return device_requests def _preprocess_etc_hosts( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if value is None: return value results = [] for key, val in value.items(): results.append(f"{key}:{val}") return results def _preprocess_healthcheck( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if value is None: return value if not value or not ( value.get("test") or (value.get("test_cli_compatible") and value.get("test") is None) ): value = {"test": ["NONE"]} elif "test" in value: value["test"] = normalize_healthcheck_test(value["test"]) return omit_none_from_dict( { "Test": value.get("test"), "Interval": value.get("interval"), "Timeout": value.get("timeout"), "StartPeriod": value.get("start_period"), "StartInterval": value.get("start_interval"), "Retries": value.get("retries"), } ) def _postprocess_healthcheck_get_value( module: AnsibleModule, api_version: LooseVersion, value: t.Any, sentry: Sentry ) -> t.Any | Sentry: if value is None or value is sentry or value.get("Test") == ["NONE"]: return {"Test": ["NONE"]} return value def _preprocess_convert_to_bytes( module: AnsibleModule, values: dict[str, t.Any], name: str, unlimited_value: int | None = None, ) -> dict[str, t.Any]: if name not in values: return values try: value = values[name] if unlimited_value is not None and value in ("unlimited", str(unlimited_value)): value = unlimited_value else: value = human_to_bytes(value) values[name] = value return values except ValueError as exc: module.fail_json(msg=f"Failed to convert {name} to bytes: {exc}") def _get_image_labels(image: dict[str, t.Any] | None) -> dict[str, str]: if not image: return {} # Cannot use get('Labels', {}) because 'Labels' may be present and be None return image["Config"].get("Labels") or {} def _get_expected_labels_value( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, image: dict[str, t.Any] | None, value: dict[str, t.Any], sentry: Sentry, ) -> dict[str, t.Any] | Sentry: if value is sentry: return sentry expected_labels = {} if module.params["image_label_mismatch"] == "ignore": expected_labels.update(dict(_get_image_labels(image))) expected_labels.update(value) return expected_labels def _preprocess_links( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if value is None: return None result = [] for link in value: parsed_link = link.split(":", 1) if len(parsed_link) == 2: link, alias = parsed_link else: link, alias = parsed_link[0], parsed_link[0] result.append(f"/{link}:/{module.params['name']}/{alias}") return result def _ignore_mismatching_label_result( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, option: Option, image: dict[str, t.Any] | None, container_value: t.Any, expected_value: t.Any, host_info: dict[str, t.Any] | None, ) -> bool: if ( option.comparison == "strict" and module.params["image_label_mismatch"] == "fail" ): # If there are labels from the base image that should be removed and # base_image_mismatch is fail we want raise an error. image_labels = _get_image_labels(image) would_remove_labels = [] labels_param = module.params["labels"] or {} for label in image_labels: if label not in labels_param: # Format label for error message would_remove_labels.append(f'"{label}"') if would_remove_labels: labels = ", ".join(would_remove_labels) msg = ( "Some labels should be removed but are present in the base image. You can set image_label_mismatch to 'ignore' to ignore" f" this error. Labels: {labels}" ) client.fail(msg) return False def _needs_host_info_network(values: dict[str, t.Any]) -> bool: return values.get("network_mode") == "default" def _ignore_mismatching_network_result( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, option: Option, image: dict[str, t.Any] | None, container_value: t.Any, expected_value: t.Any, host_info: dict[str, t.Any] | None, ) -> bool: # 'networks' is handled out-of-band if option.name == "networks": return True # The 'default' network_mode value is translated by the Docker daemon to 'bridge' on Linux and 'nat' on Windows. # This happens since Docker 26.1.0 due to https://github.com/moby/moby/pull/47431; before, 'default' was returned. if option.name == "network_mode" and expected_value == "default": os_type = host_info.get("OSType") if host_info else None if (container_value, os_type) in (("bridge", "linux"), ("nat", "windows")): return True return False def _preprocess_network_values( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> dict[str, t.Any]: if "networks" in values: for network in values["networks"]: network["id"] = _get_network_id(module, client, network["name"]) if not network["id"]: client.fail( f"Parameter error: network named {network['name']} could not be found. Does it exist?" ) if "network_mode" in values: values["network_mode"] = _preprocess_container_names( module, client, api_version, values["network_mode"] ) return values def _get_network_id( module: AnsibleModule, client: AnsibleDockerClient, network_name: str ) -> str | None: try: params = {"filters": json.dumps({"name": [network_name]})} for network in client.get_json("/networks", params=params): if network["Name"] == network_name: return network["Id"] return None except Exception as exc: # pylint: disable=broad-exception-caught client.fail(f"Error getting network id for {network_name} - {exc}") def _get_values_network( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: value = container["HostConfig"].get("NetworkMode", _SENTRY) if value is _SENTRY: return {} return {"network_mode": value} def _set_values_network( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "network_mode" not in values: return if "HostConfig" not in data: data["HostConfig"] = {} value = values["network_mode"] data["HostConfig"]["NetworkMode"] = value def _get_values_mounts( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: volumes = container["Config"].get("Volumes") binds = container["HostConfig"].get("Binds") # According to https://github.com/moby/moby/, support for HostConfig.Mounts # has been included at least since v17.03.0-ce, which has API version 1.26. # The previous tag, v1.9.1, has API version 1.21 and does not have # HostConfig.Mounts. I have no idea what about API 1.25... mounts = container["HostConfig"].get("Mounts") if mounts is not None: mounts_list = [] empty_dict: dict[str, t.Any] = {} for mount in mounts: mounts_list.append( { "type": mount.get("Type"), "source": mount.get("Source"), "target": mount.get("Target"), "read_only": mount.get( "ReadOnly", False ), # golang's omitempty for bool returns None for False "consistency": mount.get("Consistency"), "propagation": mount.get("BindOptions", empty_dict).get( "Propagation" ), "no_copy": mount.get("VolumeOptions", empty_dict).get( "NoCopy", False ), "labels": mount.get("VolumeOptions", empty_dict).get( "Labels", empty_dict ), "volume_driver": mount.get("VolumeOptions", empty_dict) .get("DriverConfig", empty_dict) .get("Name"), "volume_options": mount.get("VolumeOptions", empty_dict) .get("DriverConfig", empty_dict) .get("Options", empty_dict), "tmpfs_size": mount.get("TmpfsOptions", empty_dict).get( "SizeBytes" ), "tmpfs_mode": mount.get("TmpfsOptions", empty_dict).get("Mode"), "non_recursive": mount.get("BindOptions", empty_dict).get( "NonRecursive" ), "create_mountpoint": mount.get("BindOptions", empty_dict).get( "CreateMountpoint" ), "read_only_non_recursive": mount.get("BindOptions", empty_dict).get( "ReadOnlyNonRecursive" ), "read_only_force_recursive": mount.get( "BindOptions", empty_dict ).get("ReadOnlyForceRecursive"), "subpath": mount.get("VolumeOptions", empty_dict).get("Subpath") or mount.get("ImageOptions", empty_dict).get("Subpath"), "tmpfs_options": mount.get("TmpfsOptions", empty_dict).get( "Options" ), } ) mounts = mounts_list result = {} if volumes is not None: result["volumes"] = volumes if binds is not None: result["volume_binds"] = binds if mounts is not None: result["mounts"] = mounts return result def _get_bind_from_dict(volume_dict: dict[str, t.Any] | None) -> list[str]: results = [] if volume_dict: for host_path, config in volume_dict.items(): if isinstance(config, dict) and config.get("bind"): container_path = config.get("bind") mode = config.get("mode", "rw") results.append(f"{host_path}:{container_path}:{mode}") return results def _get_image_binds(volumes: dict[str, t.Any] | list[dict[str, t.Any]]) -> list[str]: """ Convert array of binds to array of strings with format host_path:container_path:mode :param volumes: array of bind dicts :return: array of strings """ results = [] if isinstance(volumes, dict): results += _get_bind_from_dict(volumes) elif isinstance(volumes, list): for vol in volumes: results += _get_bind_from_dict(vol) return results def _get_expected_values_mounts( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: expected_values = {} # binds if "mounts" in values: expected_values["mounts"] = values["mounts"] # volumes expected_vols = {} if image and image["Config"].get("Volumes"): expected_vols.update(image["Config"].get("Volumes")) if "volumes" in values: for vol in values["volumes"]: # We only expect anonymous volumes to show up in the list if ":" in vol: parts = vol.split(":") if len(parts) == 3: continue if len(parts) == 2 and not _is_volume_permissions(parts[1]): continue expected_vols[vol] = {} if expected_vols: expected_values["volumes"] = expected_vols # binds image_vols = [] if image: image_vols = _get_image_binds(image["Config"].get("Volumes")) param_vols = [] if "volume_binds" in values: param_vols = values["volume_binds"] expected_values["volume_binds"] = list(set(image_vols + param_vols)) return expected_values def _set_values_mounts( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "mounts" in values: if "HostConfig" not in data: data["HostConfig"] = {} mounts: list[dict[str, t.Any]] = [] for mount in values["mounts"]: mount_type = mount.get("type") mount_res = { "Target": mount.get("target"), "Source": mount.get("source"), "Type": mount_type, "ReadOnly": mount.get("read_only"), } if "consistency" in mount: mount_res["Consistency"] = mount["consistency"] if mount_type == "bind": bind_opts: dict[str, t.Any] = {} if "propagation" in mount: bind_opts["Propagation"] = mount["propagation"] if "non_recursive" in mount: bind_opts["NonRecursive"] = mount["non_recursive"] if "create_mountpoint" in mount: bind_opts["CreateMountpoint"] = mount["create_mountpoint"] if "read_only_non_recursive" in mount: bind_opts["ReadOnlyNonRecursive"] = mount["read_only_non_recursive"] if "read_only_force_recursive" in mount: bind_opts["ReadOnlyForceRecursive"] = mount[ "read_only_force_recursive" ] if bind_opts: mount_res["BindOptions"] = bind_opts if mount_type == "volume": volume_opts: dict[str, t.Any] = {} if mount.get("no_copy"): volume_opts["NoCopy"] = True if mount.get("labels"): volume_opts["Labels"] = mount.get("labels") if mount.get("volume_driver"): driver_config: dict[str, t.Any] = { "Name": mount.get("volume_driver"), } if mount.get("volume_options"): driver_config["Options"] = mount.get("volume_options") volume_opts["DriverConfig"] = driver_config if "subpath" in mount: volume_opts["Subpath"] = mount["subpath"] if volume_opts: mount_res["VolumeOptions"] = volume_opts if mount_type == "tmpfs": tmpfs_opts: dict[str, t.Any] = {} if mount.get("tmpfs_mode"): tmpfs_opts["Mode"] = mount.get("tmpfs_mode") if mount.get("tmpfs_size"): tmpfs_opts["SizeBytes"] = mount.get("tmpfs_size") if "tmpfs_options" in mount: tmpfs_opts["Options"] = mount["tmpfs_options"] if tmpfs_opts: mount_res["TmpfsOptions"] = tmpfs_opts if mount_type == "image": image_opts: dict[str, t.Any] = {} if "subpath" in mount: image_opts["Subpath"] = mount["subpath"] if image_opts: mount_res["ImageOptions"] = image_opts mounts.append(mount_res) data["HostConfig"]["Mounts"] = mounts if "volumes" in values: volumes: dict[str, t.Any] = {} for volume in values["volumes"]: # Only pass anonymous volumes to create container if ":" in volume: parts = volume.split(":") if len(parts) == 3: continue if len(parts) == 2 and not _is_volume_permissions(parts[1]): continue volumes[volume] = {} data["Volumes"] = volumes if "volume_binds" in values: if "HostConfig" not in data: data["HostConfig"] = {} data["HostConfig"]["Binds"] = values["volume_binds"] def _get_values_log( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: log_config = container["HostConfig"].get("LogConfig") or {} return { "log_driver": log_config.get("Type"), "log_options": log_config.get("Config"), } def _set_values_log( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "log_driver" not in values: return log_config = { "Type": values["log_driver"], "Config": values.get("log_options") or {}, } if "HostConfig" not in data: data["HostConfig"] = {} data["HostConfig"]["LogConfig"] = log_config def _get_values_platform( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: if image and (image.get("Os") or image.get("Architecture") or image.get("Variant")): return { "platform": compose_platform_string( os=image.get("Os"), arch=image.get("Architecture"), variant=image.get("Variant"), daemon_os=host_info.get("OSType") if host_info else None, daemon_arch=host_info.get("Architecture") if host_info else None, ) } return { "platform": container.get("Platform"), } def _get_expected_values_platform( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: expected_values = {} if "platform" in values: try: expected_values["platform"] = normalize_platform_string( values["platform"], daemon_os=host_info.get("OSType") if host_info else None, daemon_arch=host_info.get("Architecture") if host_info else None, ) except ValueError as exc: module.fail_json(msg=f"Error while parsing platform parameer: {exc}") return expected_values def _set_values_platform( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "platform" in values: data["platform"] = values["platform"] def _needs_container_image_platform(values: dict[str, t.Any]) -> bool: return "platform" in values def _needs_host_info_platform(values: dict[str, t.Any]) -> bool: return "platform" in values def _get_values_restart( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: restart_policy = container["HostConfig"].get("RestartPolicy") or {} return { "restart_policy": restart_policy.get("Name"), "restart_retries": restart_policy.get("MaximumRetryCount"), } def _set_values_restart( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "restart_policy" not in values: return restart_policy = { "Name": values["restart_policy"], "MaximumRetryCount": values.get("restart_retries"), } if "HostConfig" not in data: data["HostConfig"] = {} data["HostConfig"]["RestartPolicy"] = restart_policy def _update_value_restart( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "restart_policy" not in values: return data["RestartPolicy"] = { "Name": values["restart_policy"], "MaximumRetryCount": values.get("restart_retries"), } def _get_values_ports( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: host_config = container["HostConfig"] config = container["Config"] # "ExposedPorts": null returns None type & causes AttributeError - PR #5517 expected_exposed: list[str] = [] if config.get("ExposedPorts") is not None: for port_and_protocol in config.get("ExposedPorts", {}): port, protocol = _normalize_port(port_and_protocol).rsplit("/") try: start, end = port.split("-", 1) start_port = int(start) end_port = int(end) for port_no in range(start_port, end_port + 1): expected_exposed.append(f"{port_no}/{protocol}") continue except ValueError: # Either it is not a range, or a broken one - in both cases, simply add the original form expected_exposed.append(f"{port}/{protocol}") return { "published_ports": host_config.get("PortBindings"), "exposed_ports": expected_exposed, "publish_all_ports": host_config.get("PublishAllPorts"), } def _get_expected_values_ports( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: expected_values: dict[str, t.Any] = {} if "published_ports" in values: expected_bound_ports = {} for container_port, config in values["published_ports"].items(): if isinstance(container_port, int): container_port = f"{container_port}/tcp" if len(config) == 1: if isinstance(config[0], int): expected_bound_ports[container_port] = [ {"HostIp": "0.0.0.0", "HostPort": config[0]} ] else: expected_bound_ports[container_port] = [ {"HostIp": config[0], "HostPort": ""} ] elif isinstance(config[0], tuple): expected_bound_ports[container_port] = [] for host_ip, host_port in config: expected_bound_ports[container_port].append( { "HostIp": host_ip, "HostPort": to_text( host_port, errors="surrogate_or_strict" ), } ) else: expected_bound_ports[container_port] = [ { "HostIp": config[0], "HostPort": to_text(config[1], errors="surrogate_or_strict"), } ] expected_values["published_ports"] = expected_bound_ports image_ports: set[str] = set() if image: image_exposed_ports = image["Config"].get("ExposedPorts") or {} image_ports = {_normalize_port(p) for p in image_exposed_ports} param_ports: set[str] = set() if "ports" in values: param_ports = {f"{p[0]}/{p[1]}" for p in values["ports"]} result = sorted(image_ports | param_ports) expected_values["exposed_ports"] = result if "publish_all_ports" in values: expected_values["publish_all_ports"] = values["publish_all_ports"] return expected_values def _set_values_ports( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "ports" in values: exposed_ports: dict[str, dict[str, t.Any]] = {} for port_definition in values["ports"]: port = port_definition proto = "tcp" if isinstance(port_definition, tuple): if len(port_definition) == 2: proto = port_definition[1] port = port_definition[0] exposed_ports[f"{port}/{proto}"] = {} data["ExposedPorts"] = exposed_ports if "published_ports" in values: if "HostConfig" not in data: data["HostConfig"] = {} data["HostConfig"]["PortBindings"] = convert_port_bindings( values["published_ports"] ) if "publish_all_ports" in values and values["publish_all_ports"]: if "HostConfig" not in data: data["HostConfig"] = {} data["HostConfig"]["PublishAllPorts"] = values["publish_all_ports"] def _preprocess_value_ports( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> dict[str, t.Any]: if "published_ports" not in values: return values found = False for port_specs in values["published_ports"].values(): if not isinstance(port_specs, list): port_specs = [port_specs] for port_spec in port_specs: if port_spec[0] == _DEFAULT_IP_REPLACEMENT_STRING: found = True break if not found: return values default_ip = _get_default_host_ip(module, client) for port, port_specs in values["published_ports"].items(): if isinstance(port_specs, list): for index, port_spec in enumerate(port_specs): if port_spec[0] == _DEFAULT_IP_REPLACEMENT_STRING: port_specs[index] = tuple([default_ip] + list(port_spec[1:])) else: if port_specs[0] == _DEFAULT_IP_REPLACEMENT_STRING: values["published_ports"][port] = tuple( [default_ip] + list(port_specs[1:]) ) return values def _preprocess_container_names( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, value: t.Any, ) -> t.Any: if value is None or not value.startswith("container:"): return value container_name = value[len("container:") :] # Try to inspect container to see whether this is an ID or a # name (and in the latter case, retrieve its ID) container = client.get_container(container_name) if container is None: # If we cannot find the container, issue a warning and continue with # what the user specified. module.warn(f'Cannot find a container with name or ID "{container_name}"') return value return f"container:{container['Id']}" def _get_value_command( module: AnsibleModule, container: dict[str, t.Any], api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: value = container["Config"].get("Cmd", _SENTRY) if value is _SENTRY: return {} return {"command": value} def _set_value_command( module: AnsibleModule, data: dict[str, t.Any], api_version: LooseVersion, options: list[Option], values: dict[str, t.Any], ) -> None: if "command" not in values: return value = values["command"] data["Cmd"] = value def _get_expected_values_command( module: AnsibleModule, client: AnsibleDockerClient, api_version: LooseVersion, options: list[Option], image: dict[str, t.Any] | None, values: dict[str, t.Any], host_info: dict[str, t.Any] | None, ) -> dict[str, t.Any]: expected_values = {} if "command" in values: command = values["command"] if command == [] and image and image["Config"].get("Cmd"): command = image["Config"].get("Cmd") expected_values["command"] = command return expected_values def _needs_container_image_command(values: dict[str, t.Any]) -> bool: return values.get("command") == [] OPTION_AUTO_REMOVE.add_engine( "docker_api", DockerAPIEngine.host_config_value("AutoRemove") ) OPTION_BLKIO_WEIGHT.add_engine( "docker_api", DockerAPIEngine.host_config_value("BlkioWeight", update_parameter="BlkioWeight"), ) OPTION_CAPABILITIES.add_engine( "docker_api", DockerAPIEngine.host_config_value("CapAdd") ) OPTION_CAP_DROP.add_engine("docker_api", DockerAPIEngine.host_config_value("CapDrop")) OPTION_CGROUP_NS_MODE.add_engine( "docker_api", DockerAPIEngine.host_config_value("CgroupnsMode", min_api_version="1.41"), ) OPTION_CGROUP_PARENT.add_engine( "docker_api", DockerAPIEngine.host_config_value("CgroupParent") ) OPTION_COMMAND.add_engine( "docker_api", DockerAPIEngine( get_value=_get_value_command, set_value=_set_value_command, get_expected_values=_get_expected_values_command, needs_container_image=_needs_container_image_command, ), ) OPTION_CPU_PERIOD.add_engine( "docker_api", DockerAPIEngine.host_config_value("CpuPeriod", update_parameter="CpuPeriod"), ) OPTION_CPU_QUOTA.add_engine( "docker_api", DockerAPIEngine.host_config_value("CpuQuota", update_parameter="CpuQuota"), ) OPTION_CPUSET_CPUS.add_engine( "docker_api", DockerAPIEngine.host_config_value("CpusetCpus", update_parameter="CpusetCpus"), ) OPTION_CPUSET_MEMS.add_engine( "docker_api", DockerAPIEngine.host_config_value("CpusetMems", update_parameter="CpusetMems"), ) OPTION_CPU_SHARES.add_engine( "docker_api", DockerAPIEngine.host_config_value("CpuShares", update_parameter="CpuShares"), ) OPTION_ENTRYPOINT.add_engine("docker_api", DockerAPIEngine.config_value("Entrypoint")) OPTION_CPUS.add_engine( "docker_api", DockerAPIEngine.host_config_value("NanoCpus", preprocess_value=_preprocess_cpus), ) OPTION_DETACH_INTERACTIVE.add_engine( "docker_api", DockerAPIEngine( get_value=_get_value_detach_interactive, set_value=_set_value_detach_interactive ), ) OPTION_DEVICES.add_engine( "docker_api", DockerAPIEngine.host_config_value("Devices", preprocess_value=_preprocess_devices), ) OPTION_DEVICE_READ_BPS.add_engine( "docker_api", DockerAPIEngine.host_config_value( "BlkioDeviceReadBps", preprocess_value=_preprocess_rate_bps ), ) OPTION_DEVICE_WRITE_BPS.add_engine( "docker_api", DockerAPIEngine.host_config_value( "BlkioDeviceWriteBps", preprocess_value=_preprocess_rate_bps ), ) OPTION_DEVICE_READ_IOPS.add_engine( "docker_api", DockerAPIEngine.host_config_value( "BlkioDeviceReadIOps", preprocess_value=_preprocess_rate_iops ), ) OPTION_DEVICE_WRITE_IOPS.add_engine( "docker_api", DockerAPIEngine.host_config_value( "BlkioDeviceWriteIOps", preprocess_value=_preprocess_rate_iops ), ) OPTION_DEVICE_REQUESTS.add_engine( "docker_api", DockerAPIEngine.host_config_value( "DeviceRequests", min_api_version="1.40", preprocess_value=_preprocess_device_requests, ), ) OPTION_DEVICE_CGROUP_RULES.add_engine( "docker_api", DockerAPIEngine.host_config_value("DeviceCgroupRules", min_api_version="1.28"), ) OPTION_DNS_SERVERS.add_engine("docker_api", DockerAPIEngine.host_config_value("Dns")) OPTION_DNS_OPTS.add_engine( "docker_api", DockerAPIEngine.host_config_value("DnsOptions") ) OPTION_DNS_SEARCH_DOMAINS.add_engine( "docker_api", DockerAPIEngine.host_config_value("DnsSearch") ) OPTION_DOMAINNAME.add_engine("docker_api", DockerAPIEngine.config_value("Domainname")) OPTION_ENVIRONMENT.add_engine( "docker_api", DockerAPIEngine.config_value("Env", get_expected_value=_get_expected_env_value), ) OPTION_ETC_HOSTS.add_engine( "docker_api", DockerAPIEngine.host_config_value( "ExtraHosts", preprocess_value=_preprocess_etc_hosts ), ) OPTION_GROUPS.add_engine("docker_api", DockerAPIEngine.host_config_value("GroupAdd")) OPTION_HEALTHCHECK.add_engine( "docker_api", DockerAPIEngine.config_value( "Healthcheck", preprocess_value=_preprocess_healthcheck, postprocess_for_get=_postprocess_healthcheck_get_value, extra_option_minimal_versions={ "healthcheck.start_interval": { "docker_api_version": "1.44", "detect_usage": lambda c: c.module.params["healthcheck"] and c.module.params["healthcheck"]["start_interval"] is not None, }, }, ), ) OPTION_HOSTNAME.add_engine("docker_api", DockerAPIEngine.config_value("Hostname")) OPTION_IMAGE.add_engine( "docker_api", DockerAPIEngine.config_value( "Image", ignore_mismatching_result=lambda module, client, api_version, option, image, container_value, expected_value, host_info: True, ), ) OPTION_INIT.add_engine("docker_api", DockerAPIEngine.host_config_value("Init")) OPTION_IPC_MODE.add_engine( "docker_api", DockerAPIEngine.host_config_value( "IpcMode", preprocess_value=_preprocess_container_names ), ) OPTION_KERNEL_MEMORY.add_engine( "docker_api", DockerAPIEngine.host_config_value("KernelMemory", update_parameter="KernelMemory"), ) OPTION_LABELS.add_engine( "docker_api", DockerAPIEngine.config_value( "Labels", get_expected_value=_get_expected_labels_value, ignore_mismatching_result=_ignore_mismatching_label_result, ), ) OPTION_LINKS.add_engine( "docker_api", DockerAPIEngine.host_config_value("Links", preprocess_value=_preprocess_links), ) OPTION_LOG_DRIVER_OPTIONS.add_engine( "docker_api", DockerAPIEngine( get_value=_get_values_log, set_value=_set_values_log, ), ) OPTION_MAC_ADDRESS.add_engine("docker_api", DockerAPIEngine.config_value("MacAddress")) OPTION_MEMORY.add_engine( "docker_api", DockerAPIEngine.host_config_value("Memory", update_parameter="Memory") ) OPTION_MEMORY_RESERVATION.add_engine( "docker_api", DockerAPIEngine.host_config_value( "MemoryReservation", update_parameter="MemoryReservation" ), ) OPTION_MEMORY_SWAP.add_engine( "docker_api", DockerAPIEngine.host_config_value("MemorySwap", update_parameter="MemorySwap"), ) OPTION_MEMORY_SWAPPINESS.add_engine( "docker_api", DockerAPIEngine.host_config_value("MemorySwappiness") ) OPTION_STOP_TIMEOUT.add_engine( "docker_api", DockerAPIEngine.config_value("StopTimeout") ) OPTION_NETWORK.add_engine( "docker_api", DockerAPIEngine( preprocess_value=_preprocess_network_values, get_value=_get_values_network, set_value=_set_values_network, ignore_mismatching_result=_ignore_mismatching_network_result, needs_host_info=_needs_host_info_network, extra_option_minimal_versions={ "networks.mac_address": { "docker_api_version": "1.44", "detect_usage": lambda c: any( net_info.get("mac_address") is not None for net_info in (c.module.params["networks"] or []) ), }, "networks.driver_opts": { "docker_api_version": "1.32", "detect_usage": lambda c: any( net_info.get("driver_opts") is not None for net_info in (c.module.params["networks"] or []) ), }, "networks.gw_priority": { "docker_api_version": "1.48", "detect_usage": lambda c: any( net_info.get("gw_priority") is not None for net_info in (c.module.params["networks"] or []) ), }, }, ), ) OPTION_OOM_KILLER.add_engine( "docker_api", DockerAPIEngine.host_config_value("OomKillDisable") ) OPTION_OOM_SCORE_ADJ.add_engine( "docker_api", DockerAPIEngine.host_config_value("OomScoreAdj") ) OPTION_PID_MODE.add_engine( "docker_api", DockerAPIEngine.host_config_value( "PidMode", preprocess_value=_preprocess_container_names ), ) OPTION_PIDS_LIMIT.add_engine( "docker_api", DockerAPIEngine.host_config_value("PidsLimit") ) OPTION_PLATFORM.add_engine( "docker_api", DockerAPIEngine( get_value=_get_values_platform, set_value=_set_values_platform, get_expected_values=_get_expected_values_platform, needs_container_image=_needs_container_image_platform, needs_host_info=_needs_host_info_platform, min_api_version="1.41", ), ) OPTION_PRIVILEGED.add_engine( "docker_api", DockerAPIEngine.host_config_value("Privileged") ) OPTION_READ_ONLY.add_engine( "docker_api", DockerAPIEngine.host_config_value("ReadonlyRootfs") ) OPTION_RESTART_POLICY.add_engine( "docker_api", DockerAPIEngine( get_value=_get_values_restart, set_value=_set_values_restart, update_value=_update_value_restart, ), ) OPTION_RUNTIME.add_engine("docker_api", DockerAPIEngine.host_config_value("Runtime")) OPTION_SECURITY_OPTS.add_engine( "docker_api", DockerAPIEngine.host_config_value("SecurityOpt") ) OPTION_SHM_SIZE.add_engine("docker_api", DockerAPIEngine.host_config_value("ShmSize")) OPTION_STOP_SIGNAL.add_engine("docker_api", DockerAPIEngine.config_value("StopSignal")) OPTION_STORAGE_OPTS.add_engine( "docker_api", DockerAPIEngine.host_config_value("StorageOpt") ) OPTION_SYSCTLS.add_engine("docker_api", DockerAPIEngine.host_config_value("Sysctls")) OPTION_TMPFS.add_engine("docker_api", DockerAPIEngine.host_config_value("Tmpfs")) OPTION_TTY.add_engine("docker_api", DockerAPIEngine.config_value("Tty")) OPTION_ULIMITS.add_engine("docker_api", DockerAPIEngine.host_config_value("Ulimits")) OPTION_USER.add_engine("docker_api", DockerAPIEngine.config_value("User")) OPTION_USERNS_MODE.add_engine( "docker_api", DockerAPIEngine.host_config_value("UsernsMode") ) OPTION_UTS.add_engine("docker_api", DockerAPIEngine.host_config_value("UTSMode")) OPTION_VOLUME_DRIVER.add_engine( "docker_api", DockerAPIEngine.host_config_value("VolumeDriver") ) OPTION_VOLUMES_FROM.add_engine( "docker_api", DockerAPIEngine.host_config_value("VolumesFrom") ) OPTION_WORKING_DIR.add_engine("docker_api", DockerAPIEngine.config_value("WorkingDir")) OPTION_MOUNTS_VOLUMES.add_engine( "docker_api", DockerAPIEngine( get_value=_get_values_mounts, get_expected_values=_get_expected_values_mounts, set_value=_set_values_mounts, extra_option_minimal_versions={ "mounts.non_recursive": { "docker_api_version": "1.40", "detect_usage": lambda c: any( mount.get("non_recursive") is not None for mount in (c.module.params["mounts"] or []) ), }, "mounts.create_mountpoint": { "docker_api_version": "1.42", "detect_usage": lambda c: any( mount.get("create_mountpoint") is not None for mount in (c.module.params["mounts"] or []) ), }, "mounts.type=cluster": { "docker_api_version": "1.42", "detect_usage": lambda c: any( mount.get("type") == "cluster" for mount in (c.module.params["mounts"] or []) ), }, "mounts.read_only_non_recursive": { "docker_api_version": "1.44", "detect_usage": lambda c: any( mount.get("read_only_non_recursive") is not None for mount in (c.module.params["mounts"] or []) ), }, "mounts.read_only_force_recursive": { "docker_api_version": "1.44", "detect_usage": lambda c: any( mount.get("read_only_force_recursive") is not None for mount in (c.module.params["mounts"] or []) ), }, "mounts.subpath": { "docker_api_version": "1.45", "detect_usage": lambda c: any( mount.get("subpath") is not None for mount in (c.module.params["mounts"] or []) ), }, "mounts.tmpfs_options": { "docker_api_version": "1.46", "detect_usage": lambda c: any( mount.get("tmpfs_options") is not None for mount in (c.module.params["mounts"] or []) ), }, "mounts.type=image": { "docker_api_version": "1.47", "detect_usage": lambda c: any( mount.get("type") == "image" for mount in (c.module.params["mounts"] or []) ), }, }, ), ) OPTION_PORTS.add_engine( "docker_api", DockerAPIEngine( get_value=_get_values_ports, get_expected_values=_get_expected_values_ports, set_value=_set_values_ports, preprocess_value=_preprocess_value_ports, ), )