docker_image(_pull), docker_container: fix compatibility with Docker 29.0.0 (#1192)

* Add debug flag to failing task.

* Add more debug output.

* Fix pull idempotency.

* Revert "Add more debug output."

This reverts commit 64020149bf.

* Fix casing.

* Remove unreliable test.

* Add 'debug: true' to all tasks.

* Reformat.

* Fix idempotency problem for IPv6 addresses.

* Fix expose ranges handling.

* Update changelog fragment to also mention other affected modules.
This commit is contained in:
Felix Fontein
2025-11-15 17:13:46 +01:00
committed by GitHub
parent 68993fe353
commit 90c4b4c543
10 changed files with 197 additions and 54 deletions
+14 -3
View File
@@ -519,6 +519,17 @@ class AnsibleDockerClientBase(Client):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error inspecting image ID {image_id} - {exc}")
@staticmethod
def _compare_images(
img1: dict[str, t.Any] | None, img2: dict[str, t.Any] | None
) -> bool:
if img1 is None or img2 is None:
return img1 == img2
filter_keys = {"Metadata"}
img1_filtered = {k: v for k, v in img1.items() if k not in filter_keys}
img2_filtered = {k: v for k, v in img2.items() if k not in filter_keys}
return img1_filtered == img2_filtered
def pull_image(
self, name: str, tag: str = "latest", image_platform: str | None = None
) -> tuple[dict[str, t.Any] | None, bool]:
@@ -526,7 +537,7 @@ class AnsibleDockerClientBase(Client):
Pull an image
"""
self.log(f"Pulling image {name}:{tag}")
old_tag = self.find_image(name, tag)
old_image = self.find_image(name, tag)
try:
repository, image_tag = parse_repository_tag(name)
registry, dummy_repo_name = auth.resolve_repository_name(repository)
@@ -563,9 +574,9 @@ class AnsibleDockerClientBase(Client):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error pulling image {name}:{tag} - {exc}")
new_tag = self.find_image(name, tag)
new_image = self.find_image(name, tag)
return new_tag, old_tag == new_tag
return new_image, self._compare_images(old_image, new_image)
class AnsibleDockerClient(AnsibleDockerClientBase):
+25 -20
View File
@@ -1016,7 +1016,7 @@ def _preprocess_ports(
else:
port_binds = len(container_ports) * [(ipaddr,)]
else:
return module.fail_json(
module.fail_json(
msg=f'Invalid port description "{port}" - expected 1 to 3 colon-separated parts, but got {p_len}. '
"Maybe you forgot to use square brackets ([...]) around an IPv6 address?"
)
@@ -1037,38 +1037,43 @@ def _preprocess_ports(
binds[idx] = bind
values["published_ports"] = binds
exposed = []
exposed: set[tuple[int, str]] = set()
if "exposed_ports" in values:
for port in values["exposed_ports"]:
port = to_text(port, errors="surrogate_or_strict").strip()
protocol = "tcp"
matcher = re.search(r"(/.+$)", port)
if matcher:
protocol = matcher.group(1).replace("/", "")
port = re.sub(r"/.+$", "", port)
exposed.append((port, protocol))
parts = port.split("/", maxsplit=1)
if len(parts) == 2:
port, protocol = parts
parts = port.split("-", maxsplit=1)
if len(parts) < 2:
try:
exposed.add((int(port), protocol))
except ValueError as e:
module.fail_json(msg=f"Cannot parse port {port!r}: {e}")
else:
try:
start_port = int(parts[0])
end_port = int(parts[1])
if start_port > end_port:
raise ValueError(
"start port must be smaller or equal to end port."
)
except ValueError as e:
module.fail_json(msg=f"Cannot parse port range {port!r}: {e}")
for port in range(start_port, end_port + 1):
exposed.add((port, protocol))
if "published_ports" in values:
# Any published port should also be exposed
for publish_port in values["published_ports"]:
match = False
if isinstance(publish_port, str) and "/" in publish_port:
port, protocol = publish_port.split("/")
port = int(port)
else:
protocol = "tcp"
port = int(publish_port)
for exposed_port in exposed:
if exposed_port[1] != protocol:
continue
if isinstance(exposed_port[0], str) and "-" in exposed_port[0]:
start_port, end_port = exposed_port[0].split("-")
if int(start_port) <= port <= int(end_port):
match = True
elif exposed_port[0] == port:
match = True
if not match:
exposed.append((port, protocol))
values["ports"] = exposed
exposed.add((port, protocol))
values["ports"] = sorted(exposed)
return values
@@ -1970,10 +1970,20 @@ def _get_values_ports(
config = container["Config"]
# "ExposedPorts": null returns None type & causes AttributeError - PR #5517
expected_exposed: list[str] = []
if config.get("ExposedPorts") is not None:
expected_exposed = [_normalize_port(p) for p in config.get("ExposedPorts", {})]
else:
expected_exposed = []
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"),
@@ -2027,17 +2037,14 @@ def _get_expected_values_ports(
]
expected_values["published_ports"] = expected_bound_ports
image_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 = []
image_ports = {_normalize_port(p) for p in image_exposed_ports}
param_ports: set[str] = set()
if "ports" in values:
param_ports = [
to_text(p[0], errors="surrogate_or_strict") + "/" + p[1]
for p in values["ports"]
]
result = list(set(image_ports + param_ports))
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:
@@ -25,6 +25,7 @@ from ansible_collections.community.docker.plugins.module_utils._util import (
DockerBaseClass,
compare_generic,
is_image_name_id,
normalize_ip_address,
sanitize_result,
)
@@ -925,13 +926,13 @@ class ContainerManager(DockerBaseClass, t.Generic[Client]):
else:
diff = False
network_info_ipam = network_info.get("IPAMConfig") or {}
if network.get("ipv4_address") and network[
"ipv4_address"
] != network_info_ipam.get("IPv4Address"):
if network.get("ipv4_address") and normalize_ip_address(
network["ipv4_address"]
) != normalize_ip_address(network_info_ipam.get("IPv4Address")):
diff = True
if network.get("ipv6_address") and network[
"ipv6_address"
] != network_info_ipam.get("IPv6Address"):
if network.get("ipv6_address") and normalize_ip_address(
network["ipv6_address"]
) != normalize_ip_address(network_info_ipam.get("IPv6Address")):
diff = True
if network.get("aliases") and not compare_generic(
network["aliases"],
+23
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import ipaddress
import json
import re
import typing as t
@@ -505,3 +506,25 @@ def omit_none_from_dict(d: dict[str, t.Any]) -> dict[str, t.Any]:
Return a copy of the dictionary with all keys with value None omitted.
"""
return {k: v for (k, v) in d.items() if v is not None}
@t.overload
def normalize_ip_address(ip_address: str) -> str: ...
@t.overload
def normalize_ip_address(ip_address: str | None) -> str | None: ...
def normalize_ip_address(ip_address: str | None) -> str | None:
"""
Given an IP address as a string, normalize it so that it can be
used to compare IP addresses as strings.
"""
if ip_address is None:
return None
try:
return ipaddress.ip_address(ip_address).compressed
except ValueError:
# Fallback for invalid addresses: simply return the input
return ip_address