--- # Deploy kube-vip (control-plane HA VIP) via the k3s auto-deploy manifests dir. # k3s applies /server/manifests/*.yaml on start, so this is # reproducible and independent of Flux (see kube_vip_manifest_path in defaults/main.yml — # the dir follows k3s's --data-dir, which prod moves to /data/k3s). The same manifest is written on every # server so the DaemonSet survives the loss of any single node. # WHICH INTERFACE kube-vip ARPs THE VIP ON. Detected from this node's OWN k3s_node_ip # (inventory, per host) rather than a hardcoded subnet: the previous awk matched the # literal 192.168.1.5[0-2], i.e. DEV's node addresses, so on prod (192.168.2.10/.11/.12) # it returned nothing and the assert below aborted the play. k3s_node_ip is the same # address k3s advertises with --node-ip and is defined for dev and prod alike, so this # resolves eno1 on prod and the identical dev interface it always resolved before. # `ip addr show to /32` does the matching in iproute2 instead of a regex over dotted # quads (where `.` would match any character). - name: Detect the LAN interface holding this node's k3s_node_ip ansible.builtin.shell: >- set -o pipefail; ip -o -4 addr show to {{ k3s_node_ip }}/32 | awk '{print $2; exit}' args: executable: /bin/bash register: kube_vip_iface_detect changed_when: false # RUN THIS EVEN UNDER --check. It is `ip -o -4 addr show`: it reads and changes nothing. # Without check_mode: false the shell module is SKIPPED in check mode, kube_vip_iface_detect # has no stdout, and the assert two tasks down aborts the play with "Could not detect the # interface ... set kube_vip_interface explicitly" — a misleading failure that says nothing # about the node. That mattered less while this play was an out-of-band operator step; it # matters now that playbooks/site.yml imports it, because `--check` on the bring-up before a # cold prod build is exactly the rehearsal an operator should be able to run. check_mode: false - name: Resolve the kube-vip VIP interface (override wins, else detected) ansible.builtin.set_fact: kube_vip_vip_interface: >- {{ kube_vip_interface if (kube_vip_interface | length > 0) else (kube_vip_iface_detect.stdout | trim) }} - name: Fail early if no LAN interface could be resolved ansible.builtin.assert: that: - kube_vip_vip_interface is defined - kube_vip_vip_interface | length > 0 fail_msg: >- Could not detect the interface holding {{ k3s_node_ip }} for kube-vip on {{ inventory_hostname }} — set kube_vip_interface explicitly. - name: Ensure the k3s auto-deploy manifests directory exists ansible.builtin.file: path: "{{ kube_vip_manifest_path | dirname }}" state: directory owner: root group: root mode: "0700" - name: Template the kube-vip control-plane manifest (RBAC + DaemonSet) ansible.builtin.template: src: kube-vip.yaml.j2 dest: "{{ kube_vip_manifest_path }}" owner: root group: root mode: "0600" # PROVE THE VIP IS ACTUALLY UP BEFORE THIS PLAY RETURNS (added 2026-09-06). # # Templating the manifest is not the same as having a floating endpoint: k3s's deploy # controller still has to apply it, the image still has to pull, and leader election still # has to pick an owner. Nothing used to check, which was tolerable while the VIP was only an # operator convenience. It is not tolerable now — Cilium's k8sServiceHost IS this VIP # (k3s_apiserver_endpoint) and playbooks/site.yml runs cilium.yml straight after this play, # so an unannounced VIP would mean installing a CNI that cannot reach the API server. That # failure is the 2026-09-01 shape and it is very expensive to diagnose from the far side; # failing here instead names the actual cause. # # run_once: the VIP is ONE address for the whole environment, so one probe answers for the # environment. Probing from a server host (not the controller) is deliberate: the servers # are on the VIP's own LAN in both environments, whereas the dev controller reaches dev's # API only through a pfSense NAT — a controller-side probe would have been a property of # where the play was launched from. READ-ONLY: it opens a TCP connection and changes nothing. - name: Wait for the control-plane VIP to answer (the CNI's endpoint depends on it) ansible.builtin.wait_for: host: "{{ kube_vip_address }}" port: "{{ kube_vip_port }}" timeout: "{{ kube_vip_wait_timeout }}" run_once: true changed_when: false vars: ansible_become: false # DELIBERATELY NOT check_mode: false, unlike the interface probe above — so a `--check` # rehearsal SKIPS this and does not sit on a socket for kube_vip_wait_timeout. wait_for # declares no check-mode support, so the skip is the module's own behaviour and this comment # is here only so the skip does not read as an oversight. CONSEQUENCE, stated plainly: a # `--check` run does NOT verify the VIP is up. Only a real run gates the CNI on it.