INCIDENT FIX. Prod's external-dns DELETED EVERY ONE OF DEV'S PUBLIC DNS RECORDS. Both
clusters ran policy: sync against the SAME Cloudflare zone with the SAME implicit
txtOwnerId (`default`), so prod believed it owned dev's records, found no matching sources,
and removed them plus their TXT registry entries. Dev recovered ~30s after prod was scaled
to 0; verified restored against Cloudflare directly (my first check went through a pfSense
override and falsely showed healthy — local resolvers are not evidence about public DNS).
THE MISS WAS MINE, and it was a failure of imagination in how I briefed every agent. I put
"never touch dev" in brief after brief and enforced it — separate kubeconfigs, verified
--limit, sha-checked dev's kubeconfig before and after. Every one of those guards held. NONE
of them covered SHARED EXTERNAL RESOURCES. Prod reconciling its OWN correctly-authored
manifests reached across a third party and destroyed dev's state. Cluster-to-cluster
isolation was verified; the dependency that mattered was cluster-to-Cloudflare.
Operator's architecture: dev -> *.dev.home.keithjackson.dev, prod ->
*.prod.home.keithjackson.dev, apex CNAME <svc>.home -> <svc>.prod.home.
PHASE 1 ONLY (this commit): prod moves to its own subdomain. DEV IS NOT MIGRATED and its
effective hostname stays home.keithjackson.dev, so dev renders byte-identically and nothing
live moves. That alone ends the collision and unblocks prod. Phase 2 (dev -> dev.home. +
apex CNAMEs) is deferred to when the operator is present: it relocates Keycloak's issuer and
12 clients' redirect URIs, forces a rolling k3s restart and kubectl SSO re-issue for every
user, and moves gitea.home. — which is BOTH the GitOps source and the current push target.
BASE_DOMAIN WAS A DEAD KNOB — it existed in cluster-vars but the status report flagged it as
referenced by nothing, because hostnames were hardcoded throughout. Now real: 141 refs.
Searched the AUTHORITATIVE reconciled set by resolving each Flux spec.path transitively via
conformance/lib/status.py — the same resolver the L0 gate uses, so "what Flux reconciles"
has one definition. 185 occurrences across 47 files in 154 reconciled files. Tokenized:
Gateway listeners, the wildcard Certificate, coredns-custom, external-dns, Keycloak
instance/realm (26 redirect URIs), oauth2-proxy, Grafana OIDC, all HTTPRoutes, Homepage's 15
links, 30 runbook_urls, unifi cloud-init, render.py's ZONE constant (which drove
--oidc-issuer-url/--cookie-domain/--whitelist-domain on every forward-auth sidecar) and 11
catalog/ ServiceDefinitions re-rendered canonically. Left alone: 43 comment/prose
occurrences and non-reconciled paths.
TWO THAT CANNOT BE TOKENIZED, flagged not forced:
- headlamp's OIDC URLs live inside a SOPS-encrypted Secret; re-encrypting rewrites the MAC
over every value and the OFFLINE harness cannot verify it (kustomize does not decrypt),
so an error would break live-dev SSO with no pre-flight proof. Prod needs its own copy
anyway — client secrets are realm-scoped.
- kube-apiserver --oidc-issuer-url is a k3s SERVER FLAG, not a manifest, so Flux can never
reach it. Done on the inventory seam instead (k3s_base_domain -> k3s_oidc_issuer_url,
overridden in group_vars/k3s_prod). Verified per host: dev keeps the default, prod gets
prod.home.
EXTERNAL-DNS OWNER, the actual incident cause: txtOwnerId is now explicit per environment —
dev renders exactly `default` (the chart default it was already using, so its existing
records stay recognised) and prod gets `prod`. The BASE default is a NON-COLLIDING SENTINEL,
deliberately not `default`, so a future environment that forgets the knob owns nothing and
can delete nothing rather than silently inheriting dev's identity.
I OVERRODE ONE AGENT DECISION against our own prior finding: it narrowed prod's
domainFilters to prod.home.keithjackson.dev as defence in depth. FINDING F-DNS1
(DECISIONS-LOG 2026-08-17) already proved a subdomain filter DROPS THE PARENT ZONE —
ExternalDNS generates the record then skips it with "no hosted zone matching record DNS
Name". Prod would have published NOTHING. Set to the ZONE (keithjackson.dev) like dev.
Isolation comes from the subdomain split plus txtOwnerId, which is what the incident
actually needed and what a domain filter cannot provide.
DEV PROVEN UNCHANGED: 11 of 12 substituting paths byte-identical; the 12th differs by
exactly one line, `+ txtOwnerId: default` — the value dev was already using implicitly.
POSITIVE CONTROLS confirm the harness is not blind: perturbing BASE_DOMAIN moves 10 of 12
hashes (the 2 that don't provably contain no hostname), perturbing the owner id moves
exactly 1. Prod's rendered output has ZERO home.keithjackson.dev references outside prod.,
and its cert renders *.prod.home.keithjackson.dev — no overlap with dev's.
L0 substitution assertions 106 -> 254; BASE_DOMAIN is no longer flagged dead. Gate PASS.
PROD REMAINS SUSPENDED (platform + 10 downstream tiers) pending review; nothing applied.
Serves: R-ENV-02, R-DNS-01, R-DNS-02, R-DNS-03, R-TLS-01.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
174 lines
8.2 KiB
YAML
174 lines
8.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'
|
|
|
|
- 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
|