*** DO NOT MERGE TO main WHILE THE OPERATOR IS AWAY. MERGING IS THE MIGRATION. ***
Operator simplified the architecture, inverting c8c1d29:
dev -> *.dev.home.keithjackson.dev (was the apex)
prod -> *.home.keithjackson.dev (the apex; no prod. subdomain, no CNAME layer)
WHY MERGING IS DANGEROUS, and this is the finding that matters most here:
clusters/dev/sync.yaml self-reconciles ./clusters/dev from `main` every 10 minutes. Merging
these files EXECUTES the cutover on the live dev cluster, unattended. And because the same
commit moves dev's GitRepository url to gitea.dev.home.keithjackson.dev, dev STRANDS — it
stops reconciling and cannot be fixed by pushing, because the broken thing IS the path Flux
reads pushes through. Banners added at the top of clusters/dev/kustomization.yaml and beside
the url itself. That url cannot be tokenized: the root Kustomization declares no
substituteFrom, so `gitea.${BASE_DOMAIN}` would ship the literal token.
Verified dev is currently safe: this work is NOT on origin/main, dev is reconciling
main@4809199, and its live BASE_DOMAIN is still home.keithjackson.dev. Deliberately NOT
pushed to the bootstrap Gitea either — prod reconciles from there, and prod on `prod.home.`
cannot collide with dev on the apex, so that is the safer interim for prod.
VALUES
BASE_DOMAIN TXT_OWNER_ID DOMAIN_FILTER
base dev.home.keithjackson.dev sentinel keithjackson.dev
dev dev.home.keithjackson.dev dev keithjackson.dev
prod home.keithjackson.dev prod keithjackson.dev
Ansible seam inverted too: role default k3s_base_domain = dev's subdomain, group_vars/k3s_prod
overrides to the apex.
dev's txtOwnerId moves `default` -> `dev`. The old value was FORCED (its live apex records
were already registered under `default`, so renaming would have orphaned them). On a new
suffix dev publishes a brand-new record set with no prior registry, so the id is chosen on
merits — and `default` has none: it is what you get by FORGETTING the flag, which is exactly
how both clusters came to share it and delete each other's records. Operationally it also
means dev's old apex records stay owned by a now-unclaimed id, so nothing deletes them
implicitly and vacating the apex becomes an explicit sequenceable step rather than a race
between two reconcile loops. PROD MUST NEVER BE GIVEN `default`: prod inherits the apex
NAMES, so `default` is the one value that would let it adopt and policy:sync-delete dev's
still-live apex records — the same incident with the roles swapped.
DOMAIN_FILTER stays the ZONE for both, with F-DNS1 written into all three files as a
do-not-retry: a subdomain filter drops the parent zone and publishes NOTHING.
VERIFICATION — the property changed, and so did the method. "Dev renders byte-identically" no
longer applies because dev is deliberately migrating. Instead: apply the INVERSE substitution
to the new render and demand byte-identity with the baseline. 145 changed line pairs, every
one classified: 143 hostname-move, 2 declared owner-id, ZERO unexplained, ZERO bare-apex
remnants. cilium and velero show 0 changes and provably contain no hostname, so they are not
blind spots. Prod: 0 occurrences of prod.home., 0 of dev.home., 143 apex names. Certs: dev
*.dev.home.keithjackson.dev, prod *.home.keithjackson.dev.
THREE positive controls, all fire — including one that proves the two checks are not
redundant: injecting a single un-moved hostname SURVIVES the inverse-substitution check and is
caught only by the independent bare-apex scan. That is why both exist.
Ansible probe (connection: local, zero SSH): dev nodes -> keycloak.dev.home..., prod nodes ->
keycloak.home.... Used roles: + --tags probe rather than include_vars, because include_vars
outranks group_vars and would have FALSELY shown prod inheriting dev's value.
Gates all PASS: l0, l1, render-golden (10 goldens), lint, substitution (254 assertions),
render.py --selftest, spec-immutability. Catalog ServiceDefinitions edited, never rendered apps/.
RUNBOOK rewritten in §10 + §10a with the ordering that avoids stranding: D0 snapshot escape
routes -> D1 break the automatic trigger -> D2 publish and PROVE gitea.dev.home + the new
wildcard cert WHILE dev still serves the apex -> D3 merge -> D4 release reconciliation
deliberately -> D5 Keycloak issuer + ~12 clients (headlamp's is SOPS, by hand) -> D6
one-server-at-a-time k3s restart + SSO re-issue (service restart only, NO reboots) -> D7
retire old apex records -> D8 verify the apex is empty AGAINST CLOUDFLARE, not pfSense —
the gate on everything after. Then P1 unsuspend prod.
ACME: prod's wildcard is EXACTLY the name set dev holds today, so prod's orders share dev's
5-per-week duplicate-certificate bucket. Order after D8, delete dev's old Certificate rather
than let it renew, rehearse on staging.
The 6 L2 chainsaw tests hardcoding the apex are enumerated but deliberately NOT edited — while
dev is live on the apex they are correct; they need per-environment parameterisation.
Serves: R-ENV-02, R-DNS-01/02/03, R-TLS-01.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
110 lines
6.9 KiB
YAML
110 lines
6.9 KiB
YAML
---
|
|
# k3s_server role defaults (ADR-0001).
|
|
|
|
# Best-effort version pin (kept near the ci/versions kubectl 1.36.x line). Bump
|
|
# deliberately — a version change is a code change. Empty string => latest stable.
|
|
k3s_version: "v1.36.4+k3s1"
|
|
|
|
k3s_install_url: "https://get.k3s.io"
|
|
k3s_install_script: "/usr/local/bin/k3s-install.sh"
|
|
|
|
# Components disabled at install so Cilium + Gateway API own networking (ADR-0001/0002).
|
|
# flannel + kube-proxy + network-policy are disabled via dedicated flags below;
|
|
# traefik + servicelb via --disable.
|
|
# local-storage: k3s ships local-path as a DEFAULT StorageClass, which collides with Longhorn's
|
|
# default (two default SCs = ambiguous PVC binding). Longhorn (ADR-0003) is the cluster's storage,
|
|
# so disable k3s local-path entirely → `longhorn` is the sole default. (Effective on fresh installs;
|
|
# the k3s install task is idempotent, so this does not restart an existing cluster — see DECISIONS-LOG.)
|
|
k3s_disable: "traefik,servicelb,local-storage"
|
|
|
|
# --- k3s --data-dir (R-ENV-02) ------------------------------------------------------
|
|
# EVERYTHING k3s persists lives under this one directory: the embedded etcd database and
|
|
# WAL (server/db), the node token, the TLS CA and every issued cert, AND the containerd
|
|
# image store (agent/containerd) — which is by far the largest tenant.
|
|
#
|
|
# THE DEFAULT IS k3s'S OWN DEFAULT, restated explicitly rather than left implicit, so the
|
|
# LIVE dev cluster's install command is byte-for-byte what it already was: dev keeps
|
|
# /var/lib/rancher/k3s, where it also has a DEDICATED etcd device mounted at
|
|
# server/db (docs/plans/ssd-etcd-isolation.md). Changing this value on dev would move a
|
|
# running cluster's entire state directory out from under it and orphan that etcd mount.
|
|
#
|
|
# PROD OVERRIDES IT, in inventory/group_vars/k3s_prod/vars.yml — the same seam
|
|
# kube_vip_address / k3s_api_host / node_baseline_backports_kernel use, so a FOURTH prod
|
|
# node inherits it from group membership rather than from anyone remembering. It is set
|
|
# there as the CLUSTER-WIDE `k3s_data_dir` because TWO roles must agree on it: this role
|
|
# turns it into --data-dir, and roles/node_baseline creates the directory on the claimed
|
|
# data partition and derives etcd's location from it (node_baseline_k3s_data_dir defers to
|
|
# this same variable). One line moves both.
|
|
#
|
|
# WHY PROD MOVES IT (infra/PROD-PROVISIONING.md §2, revised 2026-09-04). Prod's /var is a
|
|
# separate partition of 11.8 GiB. Measured on the fully-deployed dev cluster, containerd
|
|
# alone is 21-25 GB per node and /var totals 58-70 GB — so prod would fill /var during its
|
|
# FIRST image pulls, and a full /var means etcd cannot write its WAL. That is cluster death,
|
|
# not degradation. Prod points --data-dir at /data/k3s on the claimed ~424 GB partition.
|
|
#
|
|
# NOT ALSO WRITTEN TO /etc/rancher/k3s/config.yaml, deliberately. --data-dir is consumed at
|
|
# INSTALL time (it is baked into the systemd unit's ExecStart); adding a `data-dir:` line to
|
|
# config.yaml would make that file `changed` on the LIVE dev cluster and fire the rolling
|
|
# Restart handler for a value that is not changing. The install flags are the correct and
|
|
# sufficient place, and prod has no k3s installed yet.
|
|
k3s_data_dir: "/var/lib/rancher/k3s"
|
|
|
|
# Set per-play: 'primary' (runs --cluster-init) or 'secondary' (joins the primary).
|
|
k3s_server_mode: "secondary"
|
|
|
|
# Subject Alternative Names baked into the kube-apiserver serving cert. Must include
|
|
# the kube-vip control-plane VIP (kube_vip_address) so TLS via the VIP validates,
|
|
# every server's real node IP (so the cert is valid whichever node answers behind
|
|
# the VIP), and 127.0.0.1 (local kubeconfig / health probes). k3s regenerates the
|
|
# serving cert on restart when this set changes. Deduplicated (the primary IP is
|
|
# also k3s_api_host).
|
|
#
|
|
# k3s_servers_group — NOT a literal group name — is what makes this role environment-
|
|
# agnostic (R-ENV-02): group_vars/all.yml sets it to `k3s_servers` (dev) and
|
|
# group_vars/k3s_prod/vars.yml to `k3s_prod`, so each environment SANs its own servers.
|
|
k3s_tls_sans: >-
|
|
{{ ([k3s_api_host, kube_vip_address, '127.0.0.1']
|
|
+ (groups[k3s_servers_group] | map('extract', hostvars, 'k3s_node_ip') | list))
|
|
| unique }}
|
|
|
|
# --- Keycloak OIDC issuer for kubectl SSO (R-IDP-03, R-ENV-02) -----------------------
|
|
# THE ANSIBLE-SIDE HALF OF ${BASE_DOMAIN}, and it cannot be the Flux-side half.
|
|
#
|
|
# The kube-apiserver's `oidc-issuer-url` is a k3s SERVER FLAG written into
|
|
# /etc/rancher/k3s/config.yaml by this role — it is NOT a Kubernetes manifest, so Flux never
|
|
# sees it and `postBuild.substituteFrom: cluster-vars` cannot reach it. The platform hostname
|
|
# suffix therefore exists as ONE FACT IN TWO PLACES, and they must agree:
|
|
#
|
|
# Flux side clusters/base/cluster-vars.yaml BASE_DOMAIN (per-overlay)
|
|
# Ansible side THIS variable k3s_base_domain (per-inventory-group)
|
|
#
|
|
# [parity] k3s_base_domain MUST equal the BASE_DOMAIN the SAME cluster's Flux overlay sets.
|
|
# If they disagree, the apiserver validates ID tokens against a DIFFERENT Keycloak than the
|
|
# one the cluster actually runs: every `kubectl` SSO login fails with an issuer mismatch,
|
|
# while the cert-based admin kubeconfig keeps working — so it looks like "SSO is broken",
|
|
# not like a config skew. The break-glass cert kubeconfig is why this is recoverable.
|
|
#
|
|
# THE DEFAULT IS THE DEV VALUE — the same convention as k3s_data_dir above and cluster-vars'
|
|
# GATEWAY_VIP / LONGHORN_DATA_PATH: the role default is dev's value and prod overrides it in
|
|
# inventory/group_vars/k3s_prod/vars.yml (the same inventory seam k3s_data_dir / k3s_api_host /
|
|
# kube_vip_address use, so a FOURTH prod server inherits it from group membership rather than
|
|
# from anyone remembering).
|
|
#
|
|
# *** WHICH DEV VALUE — UPDATED 2026-09-04, AND IT IS AHEAD OF THE LIVE DEV CLUSTER. ***
|
|
# The operator inverted the suffix split: PROD is now the apex `home.keithjackson.dev` and DEV
|
|
# carries the label `dev.home.keithjackson.dev`. This default therefore moved from the apex to
|
|
# dev's subdomain, mirroring BASE_DOMAIN in clusters/dev/kustomization.yaml.
|
|
#
|
|
# CONSEQUENCE — this default no longer matches what the live dev nodes are running, so unlike
|
|
# every previous edit to this file A DEV RE-RUN IS NOT A NO-OP. Rendering config.yaml with the
|
|
# new issuer CHANGES the file, FIRES the Restart handler, and rolls k3s on all three dev
|
|
# servers, which also invalidates every user's OIDC kubeconfig (SSO must be re-issued). That is
|
|
# a deliberate step of the dev DNS migration, NOT a side effect to discover during unrelated
|
|
# work: DO NOT run the k3s_server role against dev until step D6 of
|
|
# infra/PROD-PROVISIONING.md §10, and never while the operator is away. The break-glass
|
|
# cert-based admin kubeconfig keeps working throughout, which is what makes the step safe.
|
|
k3s_base_domain: "dev.home.keithjackson.dev"
|
|
|
|
# Derived, so the realm path is stated once. Override k3s_base_domain, not this.
|
|
k3s_oidc_issuer_url: "https://keycloak.{{ k3s_base_domain }}/realms/homelab"
|