diff --git a/plugins/modules/podman_network.py b/plugins/modules/podman_network.py index 26c6a1e..c8fc87a 100644 --- a/plugins/modules/podman_network.py +++ b/plugins/modules/podman_network.py @@ -483,7 +483,9 @@ class PodmanNetworkDiff: @staticmethod def _lease_range_to_str(lease_range): """Convert lease_range dict to normalized start-end string.""" - return f'{lease_range["start_ip"]}-{lease_range["end_ip"]}' + start = PodmanNetworkDiff._normalize_ip(lease_range["start_ip"]) + end = PodmanNetworkDiff._normalize_ip(lease_range["end_ip"]) + return f"{start}-{end}" @staticmethod def _ip_range_to_str(ip_range_cidr): @@ -497,6 +499,26 @@ class PodmanNetworkDiff: end = net.broadcast_address return f"{start}-{end}" + @staticmethod + def _normalize_ip(addr): + """Normalize an IP address string to its compressed canonical form.""" + if not HAS_IP_ADDRESS_MODULE: + return addr + try: + return str(ipaddress.ip_address(addr)) + except (ValueError, TypeError): + return addr + + @staticmethod + def _normalize_subnet(subnet): + """Normalize a subnet string to its compressed canonical form.""" + if not HAS_IP_ADDRESS_MODULE: + return subnet + try: + return str(ipaddress.ip_network(subnet, strict=False)) + except (ValueError, TypeError): + return subnet + def diffparam_disable_dns(self): # For v3 it's impossible to find out DNS settings. if LooseVersion(self.version) >= LooseVersion("4.0.0"): @@ -534,6 +556,10 @@ class PodmanNetworkDiff: after = before if self.params["gateway"] is not None: after = self.params["gateway"] + if before: + before = self._normalize_ip(before) + if after: + after = self._normalize_ip(after) return self._diff_update_and_compare("gateway", before, after) else: before_subs = self.info.get("subnets") @@ -542,12 +568,17 @@ class PodmanNetworkDiff: before = None if before_subs: if len(before_subs) > 1 and after: - return self._diff_update_and_compare( - "gateway", ",".join([i["gateway"] for i in before_subs]), after + before_gws = ",".join( + [self._normalize_ip(i.get("gateway")) for i in before_subs if i.get("gateway")] ) + return self._diff_update_and_compare("gateway", before_gws, self._normalize_ip(after)) before = [i.get("gateway") for i in before_subs][0] if not after: after = before + if before: + before = self._normalize_ip(before) + if after: + after = self._normalize_ip(after) return self._diff_update_and_compare("gateway", before, after) def diffparam_internal(self): @@ -564,9 +595,7 @@ class PodmanNetworkDiff: def diffparam_ip_range(self): if not HAS_IP_ADDRESS_MODULE: - self.module.warn( - "Python 'ipaddress' module is not available. " - "Skipping ip_range idempotency check.") + self.module.warn("Python 'ipaddress' module is not available. Skipping ip_range idempotency check.") return False before = "" after = self.params["ip_range"] or "" @@ -594,7 +623,9 @@ class PodmanNetworkDiff: if before_subs: before_parts = [] for i in before_subs: - parts = [i["subnet"], i.get("gateway") or ""] + subnet = self._normalize_subnet(i["subnet"]) + gateway = self._normalize_ip(i["gateway"]) if i.get("gateway") else "" + parts = [subnet, gateway] lr = i.get("lease_range") if lr: parts.append(self._lease_range_to_str(lr)) @@ -604,14 +635,17 @@ class PodmanNetworkDiff: before = "" after_parts = [] for i in after: - parts = [i["subnet"], i.get("gateway") or ""] + subnet = self._normalize_subnet(i["subnet"]) + gateway = self._normalize_ip(i["gateway"]) if i.get("gateway") else "" + parts = [subnet, gateway] if i.get("ip_range"): if HAS_IP_ADDRESS_MODULE: parts.append(self._ip_range_to_str(i["ip_range"])) else: self.module.warn( "Python 'ipaddress' module is not available. " - "Skipping ip_range idempotency check in net_config.") + "Skipping ip_range idempotency check in net_config." + ) after_parts.append(",".join(parts)) after = ":".join(sorted(after_parts)) return self._diff_update_and_compare("net_config", before, after) @@ -619,10 +653,25 @@ class PodmanNetworkDiff: def diffparam_route(self): routes = self.info.get("routes", []) if routes: - before = [",".join([r["destination"], r["gateway"], str(r.get("metric", ""))]).rstrip(",") for r in routes] + before = [ + ",".join( + [ + self._normalize_subnet(r["destination"]), + self._normalize_ip(r["gateway"]), + str(r.get("metric", "")), + ] + ).rstrip(",") + for r in routes + ] else: before = [] - after = self.params["route"] or [] + after = [] + for r in self.params["route"] or []: + parts = r.split(",") + if len(parts) >= 2: + parts[0] = self._normalize_subnet(parts[0]) + parts[1] = self._normalize_ip(parts[1]) + after.append(",".join(parts)) return self._diff_update_and_compare("route", sorted(before), sorted(after)) def diffparam_subnet(self): @@ -635,8 +684,10 @@ class PodmanNetworkDiff: after = before if self.params["subnet"] is not None: after = self.params["subnet"] - if HAS_IP_ADDRESS_MODULE: - after = ipaddress.ip_network(after).compressed + if before: + before = self._normalize_subnet(before) + if after: + after = self._normalize_subnet(after) return self._diff_update_and_compare("subnet", before, after) else: if self.params["ipv6"] is not None: @@ -649,8 +700,13 @@ class PodmanNetworkDiff: before = self.info.get("subnets") if before: if len(before) > 1 and after: - return self._diff_update_and_compare("subnet", ",".join([i["subnet"] for i in before]), after) + before_subs = ",".join([self._normalize_subnet(i["subnet"]) for i in before]) + return self._diff_update_and_compare("subnet", before_subs, self._normalize_subnet(after)) before = [i["subnet"] for i in before][0] + if before: + before = self._normalize_subnet(before) + if after: + after = self._normalize_subnet(after) return self._diff_update_and_compare("subnet", before, after) def diffparam_macvlan(self): @@ -859,7 +915,7 @@ class PodmanNetworkManager: } process_action = states_map[self.state] process_action() - self.module.fail_json(msg="Unexpected logic error happened, " "please contact maintainers ASAP!") + self.module.fail_json(msg="Unexpected logic error happened, please contact maintainers ASAP!") def make_present(self): """Run actions if desired state is 'started'.""" diff --git a/tests/integration/targets/podman_network/tasks/main.yml b/tests/integration/targets/podman_network/tasks/main.yml index 25f3231..e5ba0ee 100644 --- a/tests/integration/targets/podman_network/tasks/main.yml +++ b/tests/integration/targets/podman_network/tasks/main.yml @@ -832,6 +832,9 @@ - name: Test ip_range diff include_tasks: test_ip_range_diff.yml + - name: Test IPv6 normalization idempotency (issue 1041) + include_tasks: test_issue_1041.yml + always: - name: Cleanup diff --git a/tests/integration/targets/podman_network/tasks/test_issue_1041.yml b/tests/integration/targets/podman_network/tasks/test_issue_1041.yml new file mode 100644 index 0000000..e3275ed --- /dev/null +++ b/tests/integration/targets/podman_network/tasks/test_issue_1041.yml @@ -0,0 +1,175 @@ +- name: Test IPv6 address normalization idempotency (issue 1041) + block: + + - name: Make sure network doesn't exist + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + state: absent + + # Test 1: net_config with compressed IPv6 gateway (the reported bug) + - name: Create network with IPv6 gateway using compressed notation + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + net_config: + - subnet: "fcb8:b02:5b0e:bc43:aa32:8d2c::/96" + gateway: "fcb8:b02:5b0e:bc43:aa32:8d2c::1" + - subnet: "10.89.55.0/24" + gateway: "10.89.55.1" + register: ipv6_gw_create + + - name: Check network was created + assert: + that: + - ipv6_gw_create is changed + + - name: Re-run with same compressed IPv6 gateway - idempotency + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + net_config: + - subnet: "fcb8:b02:5b0e:bc43:aa32:8d2c::/96" + gateway: "fcb8:b02:5b0e:bc43:aa32:8d2c::1" + - subnet: "10.89.55.0/24" + gateway: "10.89.55.1" + register: ipv6_gw_same + diff: true + + - name: Check idempotency with compressed IPv6 gateway + assert: + that: + - ipv6_gw_same is not changed + + - name: Re-run with expanded IPv6 gateway - must also be idempotent + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + net_config: + - subnet: "fcb8:b02:5b0e:bc43:aa32:8d2c::/96" + gateway: "fcb8:b02:5b0e:bc43:aa32:8d2c:0:1" + - subnet: "10.89.55.0/24" + gateway: "10.89.55.1" + register: ipv6_gw_expanded + diff: true + + - name: Check idempotency with expanded IPv6 gateway + assert: + that: + - ipv6_gw_expanded is not changed + + - name: Re-run with fully expanded IPv6 gateway and subnet + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + net_config: + - subnet: "fcb8:0b02:5b0e:bc43:aa32:8d2c:0000:0000/96" + gateway: "fcb8:0b02:5b0e:bc43:aa32:8d2c:0000:0001" + - subnet: "10.89.55.0/24" + gateway: "10.89.55.1" + register: ipv6_gw_full + diff: true + + - name: Check idempotency with fully expanded IPv6 + assert: + that: + - ipv6_gw_full is not changed + + # Test 2: standalone subnet/gateway params with IPv6 + - name: Delete network + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + state: absent + + - name: Create IPv6 network with standalone subnet/gateway + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + subnet: "fd00:dead:beef::/48" + gateway: "fd00:dead:beef::1" + register: ipv6_standalone_create + + - name: Check network was created + assert: + that: + - ipv6_standalone_create is changed + + - name: Re-run with same compressed notation - idempotency + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + subnet: "fd00:dead:beef::/48" + gateway: "fd00:dead:beef::1" + register: ipv6_standalone_same + diff: true + + - name: Check standalone IPv6 idempotency + assert: + that: + - ipv6_standalone_same is not changed + + - name: Re-run with expanded notation - must be idempotent + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + subnet: "fd00:dead:beef:0000::/48" + gateway: "fd00:dead:beef:0000:0000:0000:0000:0001" + register: ipv6_standalone_expanded + diff: true + + - name: Check standalone IPv6 idempotency with expanded form + assert: + that: + - ipv6_standalone_expanded is not changed + + # Test 3: dual-stack net_config with mixed IPv4 and IPv6 + - name: Delete network + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + state: absent + + - name: Create dual-stack network + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + ipv6: true + net_config: + - subnet: "172.31.55.0/24" + gateway: "172.31.55.1" + - subnet: "fd99:abcd:ef01::/64" + gateway: "fd99:abcd:ef01::1" + register: dual_create + + - name: Check dual-stack was created + assert: + that: + - dual_create is changed + + - name: Re-run dual-stack with expanded IPv6 - idempotency + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + ipv6: true + net_config: + - subnet: "172.31.55.0/24" + gateway: "172.31.55.1" + - subnet: "fd99:abcd:ef01:0000::/64" + gateway: "fd99:abcd:ef01:0:0:0:0:1" + register: dual_expanded + diff: true + + - name: Check dual-stack idempotency with expanded IPv6 + assert: + that: + - dual_expanded is not changed + + always: + + - name: Cleanup network + containers.podman.podman_network: + executable: "{{ test_executable | default('podman') }}" + name: "{{ network_name }}" + state: absent + ignore_errors: true