Files
home-network-k3s-impl/infra/ansible/roles/k3s_server/tasks/main.yml
T
jakman85andClaude Opus 5 6f61c9622d
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
feat(ha): the CNI dials the control-plane VIP — kube-vip becomes a CNI prerequisite
OPERATOR DECISION 2026-09-06: "use the VIP as intended — a virtual address to reach
the cluster and provide fault tolerant HA. Without a change it will not work in HA."

THE DEFECT. Cilium's kube-proxy replacement dialled a SINGLE NODE. Re-confirmed live
before changing anything: all three prod cilium-agent pods carried
KUBERNETES_SERVICE_HOST=192.168.2.10 — k3s-prod-1's own address — while the kube-vip
VIP 192.168.2.9 was Running on all three nodes with a held lease. Lose prod-1 and the
CNI's control-plane path is dead on an "HA" cluster. clusters/prod's own comment said
the value must be the VIP and NOT .10, then called it "Not yet decided" — I treated
that stale comment as an open question when the evidence already settled it.

SPLIT, NOT REPOINT, because k3s_api_host had two consumers with opposite needs:
  k3s_api_host            unchanged — the FOUNDER's IP: the cold-bootstrap join
                          target and a tls-san. infra/tofu/outputs.tf GENERATES this
                          name, so renaming would ripple into tofu and the runbooks.
  k3s_apiserver_endpoint  NEW = {{ kube_vip_address }} — the HA address; what Cilium
                          consumes. Derived, not restated, so an environment overrides
                          ONE line and the VIP, the SAN and the CNI endpoint move
                          together. Prod needed no new override.

THE SECOND HAZARD, WHICH MY BRIEF DID NOT NAME and the agent found: pointing Cilium at
the VIP breaks a COLD bootstrap for the same reason the join would — site.yml ran
node-baseline -> k3s -> cilium, so Cilium would install against an address nothing
announced yet. That is the 2026-09-01 deadlock shape. Fixed by SEQUENCE, not value:
site.yml now imports kube-vip.yml BETWEEN k3s and cilium. It cannot go earlier
(kube-vip is a k3s auto-deploy manifest, so it needs a cluster) and it works there
because the DaemonSet is hostNetwork with KUBERNETES_SERVICE_HOST=127.0.0.1 — needing
neither CNI nor kube-proxy. roles/kube_vip gained a read-only `wait_for` on the VIP so
a cold run stops THERE with a named cause instead of surfacing as unreachable agents.

Also fixed a pre-existing bug that only mattered once this play joined site.yml: the
role's read-only interface probe ran without `check_mode: false`, so under --check it
was skipped and the assert fired with a misleading "set kube_vip_interface explicitly".

VERIFIED BY ME, not just reported:
  renders    dev K3S_API_HOST=192.168.1.49   prod=192.168.2.9  (each environment's VIP)
  cilium     template consumes k3s_apiserver_endpoint, not k3s_api_host
  cold join  still `--server https://{{ k3s_api_host }}:6443` — the founder
  order      site.yml: node-baseline -> k3s -> kube-vip -> cilium -> verify -> flux
  gate       full CI gate PASS
Agent-side, additionally: in-memory render of cilium-values.yaml.j2 per host showing
k8sServiceHost=the VIP with the join still on the founder; k3s_tls_sans renders
['.10','.9','127.0.0.1','.11','.12'] so NO cert reissue and NO k3s restart is involved
— .9 already answers under full CA verification; and --check --diff of kube-vip.yml
against prod reported 6 ok / 0 changed, i.e. the role edits change nothing about the
kube-vip prod already runs.

ha-control-plane-as-code 92 -> 112 assertions, turning two escalations into machine
checks: the Cilium template must use the endpoint var; the endpoint must derive from
kube_vip_address and k3s_api_host must not; per environment K3S_API_HOST must equal
that environment's VIP and be none of its node IPs (the [parity] contract, now
machine-checked rather than a comment); site.yml must order k3s -> kube-vip -> cilium.
Every new assertion mutation-tested and bites. One honest negative recorded: reverting
only the BASE default is not caught, because both overlays patch the key — the check
reads effective per-environment values, so the base default is unguarded.

APPLY ORDER, and this is the operator's step — nothing was applied:
  PROD FIRST, and it is the low-risk direction (prod is the broken one). Push and let
  Flux reconcile platform/cilium: the value change rolls the cilium-agent DaemonSet
  ONE NODE AT A TIME (maxUnavailable: 1 — the knob that bounded the 2026-09-01
  incident), it will not proceed past an unready pod, existing datapath survives an
  agent restart, and .9 is already live before the change lands.
  DEV SEPARATELY, operator present. Same mechanics but live dev agents dial
  192.168.1.50, and dev is mid-DNS-migration on dev-live. Do not fold it into that.
  ANSIBLE: nothing to run for either live cluster; the Ansible half matters only for a
  cold build. Remove the now-redundant standalone kube-vip.yml step from any runbook.

NEW NAMED GAP, unwritten: the L2 test cannot assert the running agents' live
KUBERNETES_SERVICE_HOST, which is the drift the L0 check cannot see. Recorded in that
test's gap list.

Refs R-PLAT-01, R-NET-01, R-ENV-02.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 20:24:51 -05:00

186 lines
9.2 KiB
YAML

---
# Install k3s as an HA server (embedded etcd) with Cilium-required components
# disabled (ADR-0001, docs/02 stage 3 "Cluster-join"). The primary runs
# --cluster-init; secondaries join it with the shared node-token.
- name: Download the k3s install script
ansible.builtin.get_url:
url: "{{ k3s_install_url }}"
dest: "{{ k3s_install_script }}"
mode: "0755"
# --node-ip uses k3s_node_ip (the real 192.168.1.x), NOT ansible_host — the latter is
# now the pfSense NAT SSH target (192.168.2.102:225x) and must not be advertised to k3s.
- name: Compose the common k3s server flags
ansible.builtin.set_fact:
k3s_common_args: >-
server
--flannel-backend=none
--disable-kube-proxy
--disable-network-policy
--disable={{ k3s_disable }}
--write-kubeconfig-mode=644
{% for san in k3s_tls_sans %}--tls-san={{ san }} {% endfor %}
--node-ip={{ k3s_node_ip }}
--data-dir={{ k3s_data_dir }}
# On an ALREADY-installed cluster the install task above is idempotent (creates:
# k3s.yaml) and won't re-apply new --tls-san flags. Persist the SAN list in
# /etc/rancher/k3s/config.yaml, which a running k3s reads on the next restart —
# it then regenerates the serving cert when the SAN set changes, and regenerates
# its containerd config.toml (so nonroot-devices takes effect). Apply the restart
# ROLLING, one node at a time (kube-vip keeps the API reachable; etcd quorum holds
# at 2/3). See playbooks note: run with `serial: 1`.
- name: Ensure /etc/rancher/k3s exists (a freshly re-provisioned node has no k3s dir until install)
ansible.builtin.file:
path: /etc/rancher/k3s
state: directory
owner: root
group: root
mode: "0755"
- name: Write /etc/rancher/k3s/config.yaml (tls-san + nonroot-devices + oidc)
ansible.builtin.copy:
dest: /etc/rancher/k3s/config.yaml
owner: root
group: root
mode: "0600"
content: |
# Managed by Ansible (roles/k3s_server). Read by k3s in addition to the
# systemd unit flags; tls-san entries are merged into the serving cert.
tls-san:
{% for san in k3s_tls_sans %}
- "{{ san }}"
{% endfor %}
# Let non-root pods own the raw block devices bind-mounted into them —
# sets containerd's `device_ownership_from_security_context = true`
# (k3s >= v1.31.3+k3s1 exposes this as the --nonroot-devices flag).
#
# REQUIRED for KubeVirt/CDI on Block-mode PVCs (ADR-0005): CDI's importer,
# uploader and virt-launcher pods run non-root (UID 107, runAsNonRoot,
# cap-drop ALL) and open the volume's raw block device (/dev/cdi-block-volume,
# then the guest disk). Kubernetes only applies fsGroup ownership to
# *filesystem* volumes — it never chowns a raw block device — and Longhorn's
# CSIDriver (fsGroupPolicy: ReadWriteOnceWithFSType) skips fsGroup on RWX/Block
# volumes anyway, so the device stays root:root and the pod fails with
# `blockdev: cannot open /dev/cdi-block-volume: Permission denied`. This flag
# makes containerd set the in-container device node's owner from the pod's
# securityContext, which is the fix the CDI project documents for this case
# (doc/block_cri_ownership_config.md). Node-scoped and security-neutral: the
# pod stays non-root, only the device node it already mounts follows its UID/GID.
# Preserves the longhorn-vm RWX + Block + migratable disk contract unchanged.
nonroot-devices: true
# OIDC (Keycloak SSO for kubectl). The API server trusts ID tokens from the homelab realm;
# the `kubernetes` PUBLIC client (PKCE) + kubelogin mint them, and RBAC binds the `groups`
# claim (platform/keycloak/k8s-oidc-rbac.yaml: oidc:platform-admins->cluster-admin,
# oidc:viewers->view). Nodes resolve keycloak.<k3s_base_domain> to the Cilium gateway VIP
# and trust its public LE-prod cert, so NO oidc-ca-file is needed. The `oidc:`
# prefixes namespace federated identities away from cert / service-account subjects.
# ADDITIVE + break-glass-safe: the cert-based admin kubeconfig keeps full access with or
# without Keycloak, so a bad realm/OIDC state can never lock you out of the cluster.
#
# NOT a Flux ${BASE_DOMAIN} token: this is a k3s SERVER FLAG, not a manifest, so
# postBuild.substituteFrom never touches it. k3s_oidc_issuer_url derives from
# k3s_base_domain (defaults/main.yml), whose [parity] contract with the cluster's
# BASE_DOMAIN is documented there. Dev's default renders the value it already runs, so
# config.yaml is unchanged and the Restart handler does not fire.
kube-apiserver-arg:
- "oidc-issuer-url={{ k3s_oidc_issuer_url }}"
- "oidc-client-id=kubernetes"
- "oidc-username-claim=email"
- "oidc-username-prefix=oidc:"
- "oidc-groups-claim=groups"
- "oidc-groups-prefix=oidc:"
notify: Restart k3s
# --- primary: found the cluster --------------------------------------------
- name: Install k3s primary (cluster-init, embedded etcd)
ansible.builtin.command:
cmd: "{{ k3s_install_script }} {{ k3s_common_args }} --cluster-init"
creates: /etc/rancher/k3s/k3s.yaml
environment:
INSTALL_K3S_VERSION: "{{ k3s_version }}"
when: k3s_server_mode == 'primary'
# PATH FOLLOWS --data-dir. k3s writes the node-token to <data-dir>/server/token, NOT to
# the literal /var/lib/rancher/k3s. On dev k3s_data_dir IS /var/lib/rancher/k3s (the role
# default, restated explicitly above) so this is byte-for-byte the old path; on prod
# --data-dir is /data/k3s (group_vars/k3s_prod) and the hardcoded path did not exist at all
# — this task would have timed out after 180s and the two secondaries could never join.
- name: Wait for the k3s node-token to exist (primary)
ansible.builtin.wait_for:
path: "{{ k3s_data_dir }}/server/token"
timeout: 180
when: k3s_server_mode == 'primary'
- name: Read the k3s node-token (primary)
ansible.builtin.slurp:
src: "{{ k3s_data_dir }}/server/token"
register: k3s_token_raw
when: k3s_server_mode == 'primary'
- name: Publish the node-token as a fact for secondaries (primary)
ansible.builtin.set_fact:
k3s_node_token: "{{ k3s_token_raw.content | b64decode | trim }}"
when: k3s_server_mode == 'primary'
# --- secondaries: obtain the join token, then join the existing cluster -----
# Fetch the token straight from the primary via delegate_to, falling back to it
# only when the primary play didn't publish the fact this run. This makes a
# single-node rejoin (`--limit k3s-dev-N`, primary NOT in the batch) self-
# contained — delegate_to still reaches the primary even when it isn't a play
# target, so the join no longer depends on a fact an earlier play had to set.
# T-1.5 finding F2 (see DECISIONS-LOG.md).
#
# "The primary" is resolved through k3s_primary_group (group_vars: `k3s_primary` for
# dev, `k3s_prod_primary` for prod), never a literal group name — so a prod secondary
# delegates to the PROD primary using this same role, unchanged (R-ENV-02).
# k3s_data_dir is a CLUSTER-WIDE value (group_vars for prod, role default for dev), so the
# secondary's own copy of it is also the primary's data dir — correct for the delegated read.
- name: Fetch the k3s node-token directly from the primary (secondary)
ansible.builtin.slurp:
src: "{{ k3s_data_dir }}/server/token"
delegate_to: "{{ groups[k3s_primary_group][0] }}"
register: k3s_primary_token_raw
when:
- k3s_server_mode == 'secondary'
- hostvars[groups[k3s_primary_group][0]].k3s_node_token is not defined
- name: Resolve the join token (secondary)
ansible.builtin.set_fact:
k3s_join_token: >-
{{ hostvars[groups[k3s_primary_group][0]].k3s_node_token
| default(k3s_primary_token_raw.content | b64decode | trim, true) }}
when: k3s_server_mode == 'secondary'
# THE JOIN TARGET IS k3s_api_host — THE FOUNDER — AND MUST NOT BECOME THE VIP.
# group_vars/all.yml splits the two API addresses (k3s_api_host = the founder's own node IP,
# k3s_apiserver_endpoint = the kube-vip HA VIP) and this is the canonical founder-only
# consumer. On a COLD build the VIP does not exist at this point in the sequence: kube-vip
# is a k3s auto-deploy manifest, so it cannot be applied until a cluster exists, and
# playbooks/site.yml only imports kube-vip.yml AFTER k3s.yml. A `--server` pointed at the
# VIP would therefore dial an address nothing announces and the cold build would be
# unjoinable — with no diagnostic beyond a connection timeout.
# On a WARM single-node rejoin (`--limit k3s-dev-N`) the founder is up and answering too, so
# the founder works in both cases and the VIP works in only one. Cilium's k8sServiceHost is
# the opposite trade (steady state, must survive losing the founder) and uses the endpoint
# variable instead.
- name: Install k3s secondary (join as server)
ansible.builtin.command:
cmd: >-
{{ k3s_install_script }} {{ k3s_common_args }}
--server https://{{ k3s_api_host }}:{{ k3s_api_port }}
creates: /etc/rancher/k3s/k3s.yaml
environment:
INSTALL_K3S_VERSION: "{{ k3s_version }}"
K3S_TOKEN: "{{ k3s_join_token }}"
when: k3s_server_mode == 'secondary'
- name: Wait for the k3s API to be serving locally
ansible.builtin.wait_for:
host: "127.0.0.1"
port: "{{ k3s_api_port }}"
timeout: 180