From f5483cdabed3d0db87016a62b552fc3405d8ab1f Mon Sep 17 00:00:00 2001 From: Techno Tim Date: Mon, 3 Aug 2026 12:01:50 -0500 Subject: [PATCH] fix(flannel): default the interface to each host's default IPv4 interface (#689) * fix(flannel): default the interface to each host's default IPv4 interface - Replace the hardcoded flannel_iface: eth0 with a per-host default derived from ansible_facts.default_ipv4.interface - KVM/cloud hosts that are not named eth0 (e.g. enp1s0, ens3) now resolve the interface automatically instead of failing the k3s_node_ip lookup - Update the commented calico_iface / cilium_iface examples to match - Co-authored-by: Fritz Dunkel <677609+FinalDoom@users.noreply.github.com> - Fixes #621 * test(flannel): add regression test for per-host interface default - Add a focused test that renders the sample inventory's flannel_iface expression against fake ansible facts - Assert a non-eth0 host (enp1s0, ens3) resolves its own interface and that the expression defaults from ansible_facts.default_ipv4.interface - Wire it as a local pre-commit hook (default-interface-test) * test(molecule): assert nodes register a non-loopback InternalIP - Add a flannel-scenario verify assertion that every node reports an InternalIP derived from its configured flannel_iface - Guards against k3s binding to 127.0.0.1 instead of the cluster interface - Complements the pre-commit default-interface test with an end-to-end check --- .github/scripts/test-default-interface.py | 90 +++++++++++++++++++ .pre-commit-config.yaml | 8 ++ inventory/sample/group_vars/all.yml | 8 +- .../tasks/test/verify-components.yml | 24 +++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/test-default-interface.py diff --git a/.github/scripts/test-default-interface.py b/.github/scripts/test-default-interface.py new file mode 100644 index 00000000..47ee8a46 --- /dev/null +++ b/.github/scripts/test-default-interface.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Assert the sample inventory resolves the flannel interface per host. + +flannel_iface defaults to the host's default IPv4 interface rather than a +hardcoded eth0. This test extracts the flannel_iface expression from the +sample inventory and proves that a host whose primary interface is not named +eth0 (e.g. enp1s0, ens3) resolves the interface from ansible facts. +""" + +from __future__ import print_function + +import os +import re +import subprocess + +from jinja2 import Environment, StrictUndefined + + +def repo_root(): + return subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True + ).strip() + + +def fail(message): + raise SystemExit("default-interface test failed: " + message) + + +class FakeAnsibleFacts(object): + """Stand-in for the per-host ``ansible_facts`` dict.""" + + def __init__(self, default_iface, iface_ip): + self._default = {"interface": default_iface, "address": iface_ip} + self._ifaces = { + default_iface: {"ipv4": {"address": iface_ip}}, + } + + @property + def default_ipv4(self): + return self._default + + def __getitem__(self, key): + return self._ifaces[key] + + +def read_all_yml(root): + path = os.path.join(root, "inventory", "sample", "group_vars", "all.yml") + with open(path, "r") as handle: + return handle.read() + + +def extract_value(content, key): + # Match a quoted value assigned to the key, e.g. flannel_iface: "...". + match = re.search(r"^%s:\s*\"(.+)\"\s*$" % re.escape(key), content, re.M) + if not match: + fail("could not find %s in the sample inventory" % key) + return match.group(1) + + +def resolve(env, expression, facts): + template = env.from_string(expression) + return template.render(ansible_facts=facts) + + +def main(): + root = repo_root() + content = read_all_yml(root) + env = Environment(undefined=StrictUndefined) + + flannel_expr = extract_value(content, "flannel_iface") + if "default_ipv4.interface" not in flannel_expr: + fail("flannel_iface no longer defaults from ansible facts") + + # A host whose primary interface is enp1s0 (the core #621 scenario). + facts = FakeAnsibleFacts("enp1s0", "192.168.30.11") + resolved = resolve(env, flannel_expr, facts) + if resolved != "enp1s0": + fail("flannel_iface resolved to %r, expected enp1s0" % resolved) + + # A different host with a different interface must resolve independently. + facts2 = FakeAnsibleFacts("ens3", "192.168.30.12") + resolved2 = resolve(env, flannel_expr, facts2) + if resolved2 != "ens3": + fail("flannel_iface resolved to %r, expected ens3" % resolved2) + + print("default-interface regression test passed") + + +if __name__ == "__main__": + main() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c289028..9057fd8d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -84,3 +84,11 @@ repos: language: system pass_filenames: false files: ^roles/k3s_server/tasks/metallb\.yml$|^\.github/scripts/test-metallb-remote-read\.sh$ + - id: default-interface-test + name: default interface test + entry: python3 .github/scripts/test-default-interface.py + language: python + additional_dependencies: + - Jinja2>=3.1 + pass_filenames: false + files: ^inventory/sample/group_vars/all\.yml$|^\.github/scripts/test-default-interface\.py$ diff --git a/inventory/sample/group_vars/all.yml b/inventory/sample/group_vars/all.yml index f1559257..4e461b6b 100644 --- a/inventory/sample/group_vars/all.yml +++ b/inventory/sample/group_vars/all.yml @@ -8,16 +8,18 @@ systemd_dir: /etc/systemd/system system_timezone: Your/Timezone # interface which will be used for flannel -flannel_iface: eth0 +# Defaults to each host's default IPv4 interface (e.g. eth0, enp1s0, ens3) +# so KVM/cloud hosts without eth0 work out of the box. Override per-host if needed. +flannel_iface: "{{ ansible_facts.default_ipv4.interface }}" # uncomment calico_iface to use tigera operator/calico cni instead of flannel https://docs.tigera.io/calico/latest/about -# calico_iface: "eth0" +# calico_iface: "{{ ansible_facts.default_ipv4.interface }}" calico_ebpf: false # use eBPF dataplane instead of iptables calico_tag: v3.32.1 # calico version tag # uncomment cilium_iface to use cilium cni instead of flannel or calico # ensure v4.19.57, v5.1.16, v5.2.0 or more recent kernel -# cilium_iface: "eth0" +# cilium_iface: "{{ ansible_facts.default_ipv4.interface }}" cilium_mode: native # native when nodes are on the same subnet or use BGP, otherwise set tunnel cilium_tag: v1.20.0 # cilium version tag cilium_cli_tag: v0.19.7 # cilium cli version tag diff --git a/molecule/resources/verify_from_outside/tasks/test/verify-components.yml b/molecule/resources/verify_from_outside/tasks/test/verify-components.yml index a72285fa..d6eb36f9 100644 --- a/molecule/resources/verify_from_outside/tasks/test/verify-components.yml +++ b/molecule/resources/verify_from_outside/tasks/test/verify-components.yml @@ -38,6 +38,30 @@ loop_control: label: "{{ item.metadata.name }} ready" + - name: Assert every node registered a node IP from its interface + # Each k3s node is launched with --node-ip derived from flannel_iface. + # Confirm every node carries a real InternalIP (not a loopback), which + # proves k3s bound to the cluster interface rather than defaulting to 127.0.0.1. + ansible.builtin.assert: + that: >- + (node_internal_ips | length) >= 1 and + (node_internal_ips | reject('eq', '127.0.0.1') | list | length) == node_internal_ips | length + success_msg: "{{ item.metadata.name }} is bound to {{ node_internal_ips | join(', ') }}" + fail_msg: >- + {{ item.metadata.name }} has no non-loopback InternalIP + (got: {{ node_internal_ips | join(', ') }}) + vars: + node_internal_ips: >- + {{ + (item.status.addresses | default([])) + | selectattr('type', 'equalto', 'InternalIP') + | map(attribute='address') + | list + }} + loop: "{{ verify_nodes.resources }}" + loop_control: + label: "{{ item.metadata.name }} InternalIP" + - name: Get any Calico namespaces with Flannel enabled kubernetes.core.k8s_info: kind: Namespace