diff --git a/changelogs/fragments/1192-docker_container-pull.yml b/changelogs/fragments/1192-docker_container-pull.yml deleted file mode 100644 index f152064a..00000000 --- a/changelogs/fragments/1192-docker_container-pull.yml +++ /dev/null @@ -1,3 +0,0 @@ -bugfixes: - - "docker_container - fix ``pull`` idempotency with Docker 29.0.0 (https://github.com/ansible-collections/community.docker/pull/1192)." - - "docker_container - fix idempotency for IPv6 addresses with Docker 29.0.0 (https://github.com/ansible-collections/community.docker/pull/1192)." diff --git a/changelogs/fragments/1192-docker_container.yml b/changelogs/fragments/1192-docker_container.yml new file mode 100644 index 00000000..88d0e786 --- /dev/null +++ b/changelogs/fragments/1192-docker_container.yml @@ -0,0 +1,7 @@ +bugfixes: + - "docker_container - fix ``pull`` idempotency with Docker 29.0.0 (https://github.com/ansible-collections/community.docker/pull/1192)." + - "docker_container - fix idempotency for IPv6 addresses with Docker 29.0.0 (https://github.com/ansible-collections/community.docker/pull/1192)." + - "docker_container - fix handling of exposed port ranges. So far, the module used an undocumented feature of Docker that was removed from Docker 29.0.0, + that allowed to pass the range to the deamon and let handle it. Now the module explodes ranges into a list of all contained ports, same as the + Docker CLI does. For backwards compatibility with Docker < 29.0.0, it also explodes ranges returned by the API for existing containers so that + comparison should only indicate a difference if the ranges actually change (https://github.com/ansible-collections/community.docker/pull/1192)." diff --git a/plugins/module_utils/_module_container/base.py b/plugins/module_utils/_module_container/base.py index e8dd1f53..88a5fb57 100644 --- a/plugins/module_utils/_module_container/base.py +++ b/plugins/module_utils/_module_container/base.py @@ -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 diff --git a/plugins/module_utils/_module_container/docker_api.py b/plugins/module_utils/_module_container/docker_api.py index fdb7cc60..fa66d23f 100644 --- a/plugins/module_utils/_module_container/docker_api.py +++ b/plugins/module_utils/_module_container/docker_api.py @@ -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: diff --git a/tests/integration/targets/docker_container/tasks/tests/ports.yml b/tests/integration/targets/docker_container/tasks/tests/ports.yml index 4f70fe89..95a59a78 100644 --- a/tests/integration/targets/docker_container/tasks/tests/ports.yml +++ b/tests/integration/targets/docker_container/tasks/tests/ports.yml @@ -106,6 +106,101 @@ force_kill: true register: published_ports_3 +- name: published_ports -- port range (same range, but listed explicitly) + community.docker.docker_container: + image: "{{ docker_test_image_alpine }}" + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + exposed_ports: + - "9001" + - "9010" + - "9011" + - "9012" + - "9013" + - "9014" + - "9015" + - "9016" + - "9017" + - "9018" + - "9019" + - "9020" + - "9021" + - "9022" + - "9023" + - "9024" + - "9025" + - "9026" + - "9027" + - "9028" + - "9029" + - "9030" + - "9031" + - "9032" + - "9033" + - "9034" + - "9035" + - "9036" + - "9037" + - "9038" + - "9039" + - "9040" + - "9041" + - "9042" + - "9043" + - "9044" + - "9045" + - "9046" + - "9047" + - "9048" + - "9049" + - "9050" + published_ports: + - "9001:9001" + - "9020:9020" + - "9021:9021" + - "9022:9022" + - "9023:9023" + - "9024:9024" + - "9025:9025" + - "9026:9026" + - "9027:9027" + - "9028:9028" + - "9029:9029" + - "9030:9030" + - "9031:9031" + - "9032:9032" + - "9033:9033" + - "9034:9034" + - "9035:9035" + - "9036:9036" + - "9037:9037" + - "9038:9038" + - "9039:9039" + - "9040:9040" + - "9041:9041" + - "9042:9042" + - "9043:9043" + - "9044:9044" + - "9045:9045" + - "9046:9046" + - "9047:9047" + - "9048:9048" + - "9049:9049" + - "9050:9050" + - "9051:9051" + - "9052:9052" + - "9053:9053" + - "9054:9054" + - "9055:9055" + - "9056:9056" + - "9057:9057" + - "9058:9058" + - "9059:9059" + - "9060:9060" + force_kill: true + register: published_ports_4 + - name: cleanup community.docker.docker_container: name: "{{ cname }}" @@ -118,6 +213,7 @@ - published_ports_1 is changed - published_ports_2 is not changed - published_ports_3 is changed + - published_ports_4 is not changed #################################################################### ## published_ports: one-element container port range ###############