Files
home-network-k3s-impl/infra/PROD-STANDUP.md
T
jakman85andClaude Opus 5 765ad032db fix(gitops): CDI cold-start deadlock repaired IN THE GRAPH, not by a pre-apply
`platform-config` applied the CDI CR *and* cdi-storageprofiles.yaml in ONE pass.
StorageProfile's CRD does not exist until cdi-operator reconciles that CR, and
Flux server-side dry-runs every object before applying any — so the unknown kind
aborted the tier, the CR was never applied, the CRD was never created, and the
tier could NEVER converge. Observed on prod: cdi-operator Available 12h, no CDI
CR, no storageprofiles CRD, every downstream tier blocked.

The profiles now live in their own tier ./platform-config-cdi with
dependsOn: platform-config and wait: true. Because dependsOn is REVISION-gated
and platform-config runs wait: true, the CDI CR must be applied AND healthy —
exactly the event that creates the CRD — before the profiles are applied. Cold
clusters order themselves. `standup.sh crds` is retained as a fallback for
clusters already stuck, and because it is idempotent.

THE GENERALISABLE RULE (recorded in clusters/README.md): a CR whose CRD is
created by a CONTROLLER AT RUNTIME is second-order and needs its own pass.
cdi-operator.yaml registers exactly ONE CRD (cdis.cdi.kubevirt.io); storageprofiles,
datavolumes, datasources and dataimportcrons appear only after it reconciles the
CDI CR. An audit found StorageProfile is the only second-order kind in
platform-config — KubeVirt, CDI, KeycloakRealmImport, CNPG Cluster,
VolumeSnapshotClass, ClusterIssuer, PrometheusRule, ServiceMonitor and the Cilium
L2 CRs all have their CRD in git or in a chart.

Moving the CDI CR up into `platform` was rejected: that would put CDI's health on
the tier the CNI gates and everything waits on — the mistake the oauth2-proxy and
headlamp splits exist to undo.

apps-vms gains dependsOn: platform-config-cdi. LOAD-BEARING, not cosmetic:
apps/vms/unifi/20-datavolume.yaml provisions on storageClassName: longhorn-vm and
its comment claimed platform-config guaranteed the profiles — which this move
would have silently falsified.

THE PRUNE TRANSIENT, MEASURED RATHER THAN ASSUMED. platform-config has
prune: true, so it deletes both profiles once, CDI recreates them with empty spec,
and the new tier re-applies the overrides ~30-60s later. Compared our override
against CDI's unaided inference for the same Longhorn provisioner on dev:
  longhorn-vm (we override)     spec RWX/Block  -> status ReadWriteMany/Block
  longhorn-static (no override) spec unset      -> status ReadWriteOnce/Block
Both infer cloneStrategy: snapshot. So cloneStrategy is NOT at risk; accessModes
IS — unaided CDI gives RWO. A DataVolume provisioned on longhorn-vm inside the
window would be stamped RWO/Block and be PERMANENTLY non-live-migratable. Flux can
no longer cause it (apps-vms gates on the new tier); a human must not do it by hand.
Corroborating evidence that the recreate happens at all: dev has 5 StorageClasses
and 5 StorageProfiles, three with fluxOwner=NONE, spec unset and status populated
— CDI creating and inferring them with no Flux involvement.

MERGE PRECONDITION: `flux get kustomization platform-config` must be Ready on the
target first. If it is not, the profiles are pruned and sit at CDI defaults until
it recovers — and prod was in that state hours earlier. Fallback is the two-step
the Cilium move used (merge with prune: false, verify the ownership label moved,
restore prune: true).

substituteFrom: cluster-vars is declared even though the manifest has no ${TOKEN},
deliberately: conformance/tests/l0-static/flux-postbuild-substitution only audits
paths whose Kustomization declares it, so omitting it would move the file OUT of
the audited set — a blind spot for a future literal-token ship. Gate confirms it
stayed in (path=./platform-config-cdi files=2 defined=0 hazards=0). No SOPS
decryption stanza: the moved file is two plain CRs, so no overlay change was needed.

VERIFIED INDEPENDENTLY OF THE AGENT: platform-config renders 0 StorageProfile
while keeping the CDI and KubeVirt CRs; platform-config-cdi renders exactly the
two profiles; the graph is byte-identical across dev and prod with apps-vms
carrying both edges and no cycle. Full CI gate PASS.

KNOWN FOLLOW-UP: bootstrap/standup.sh TIER_ORDER does not yet include
platform-config-cdi, so `resume` will not wait on it. Another agent holds that
file; the PM will add it.

Refs R-GITOPS-01, R-ENV-02.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:53:45 -05:00

441 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Prod cluster standup — the complete command sequence
**Every command below is copy-pasteable and runs from the repo root**, through the pinned dev
container. Nothing here needs a command invented on the spot; that is the point. If a step is not in
this file it is not part of standup.
State as of **2026-09-05**: phases **A** and **B** are DONE on the current hardware. The cluster is
live (k3s v1.36.4 HA, 3 etcd members, kube-vip, Cilium 1.20.1, Longhorn) with `platform` reconciling
and **8/9 HelmReleases Ready, 76/76 pods Running**. Outstanding work is **phase C**, blocked on the
two items in [§Blockers](#blockers).
> **The one guard that matters.** Dev is LIVE and shares the `keithjackson.dev` Cloudflare zone with
> prod. On 2026-09-04 prod deleted every one of dev's public DNS records. `bootstrap/standup.sh`
> therefore **requires** `--expect-server` and compares it against the live API server before running
> anything. There is no override flag. Do not add one.
## Constants
| | |
|---|---|
| kube-API (kube-vip VIP) | `https://192.168.2.9:6443` |
| Gateway / ingress VIP | `192.168.2.20` |
| Nodes | `k3s-prod-1/2/3` — `192.168.2.10 / .11 / .12` |
| Break-glass kubeconfig | `infra/ansible/.cache/prod.kubeconfig` (cert-based; survives OIDC/DNS failure) |
| GitOps source (interim) | `http://192.168.2.132:3000/Home-Network/home-network-k3s-impl.git` branch `main` |
| `BASE_DOMAIN` | `prod.home.keithjackson.dev` — **not** the apex; see §Blockers |
Container paths: the repo is mounted at `/work`, so kubeconfig paths passed to the script are
**repo-relative** (`infra/ansible/.cache/prod.kubeconfig`).
---
## Phase A — bare metal to a joined cluster (Ansible)
Prerequisites that are **not** commands: firmware/BIOS (§1), disk layout (§2), OS install (§3),
network (§4), and SSH + the vaulted `ansible_become_password` in
`inventory/group_vars/k3s_prod/vault.yml` (§5). Ansible cannot escalate on prod without that vault
file. See `infra/PROD-PROVISIONING.md`.
**Install the backports kernel first.** All three prod nodes need ≥6.13 because HP's R21 firmware
publishes a corrupt EFI Memory Attributes Table and Linux 6.12's `efi_memattr_init()` does not
validate it, reserving everything above the table (fixed upstream in 6.13 by `8fbe4c49c0cc`, absent
from `linux-6.12.y`). Prod runs `7.1.8+deb13-amd64` from trixie-backports.
```bash
timeout 3600 infra/devcontainer/dev.sh run env ANSIBLE_CONFIG=/work/infra/ansible/ansible.cfg \
ansible-playbook /work/infra/ansible/playbooks/node-baseline.yml \
-i /work/infra/ansible/inventory/hosts.ini -e k3s_hosts=k3s_prod --tags kernel
```
Then the full chain — baseline, k3s HA, Cilium, verify, Flux hand-off. **All three `-e` flags are
required**: a play's `hosts:` is templated before any host exists, so `group_vars` cannot retarget it.
```bash
timeout 3600 infra/devcontainer/dev.sh run env ANSIBLE_CONFIG=/work/infra/ansible/ansible.cfg \
ansible-playbook /work/infra/ansible/playbooks/site.yml \
-i /work/infra/ansible/inventory/hosts.ini \
-e k3s_hosts=k3s_prod -e k3s_primary_hosts=k3s_prod_primary -e k3s_secondary_hosts=k3s_prod_secondary
```
kube-vip is a separate play (not imported by `site.yml`):
```bash
timeout 1800 infra/devcontainer/dev.sh run env ANSIBLE_CONFIG=/work/infra/ansible/ansible.cfg \
ansible-playbook /work/infra/ansible/playbooks/kube-vip.yml \
-i /work/infra/ansible/inventory/hosts.ini -e k3s_hosts=k3s_prod
```
Hardware health (read-only; `node_health` asserts the memattr fix took and that all RAM is addressed).
> **The flag is `node_health_hosts`, NOT `k3s_hosts`.** Diagnostic plays deliberately keep their own
> target var so they stay independently pointable while a bring-up run is scoped elsewhere
> (`node-health.yml` → `node_health_hosts`, `memattr-survey.yml` → `memattr_survey_hosts`,
> `vpn.egress.gateway.yml` → `vpn_egress_gateway_hosts`). The consequence is a **silent** one: passing
> `k3s_hosts=k3s_prod` here does not error, it falls through to the `k3s_servers` default and runs
> against **DEV's three nodes**. Verified — this exact mistake was made while writing this file and
> caught only by `--list-hosts`. **Always dry-run the target first:**
>
> ```bash
> timeout 200 infra/devcontainer/dev.sh run env ANSIBLE_CONFIG=/work/infra/ansible/ansible.cfg \
> ansible-playbook /work/infra/ansible/playbooks/node-health.yml \
> -i /work/infra/ansible/inventory/hosts.ini -e node_health_hosts=k3s_prod --list-hosts
> ```
```bash
timeout 900 infra/devcontainer/dev.sh run env ANSIBLE_CONFIG=/work/infra/ansible/ansible.cfg \
ansible-playbook /work/infra/ansible/playbooks/node-health.yml \
-i /work/infra/ansible/inventory/hosts.ini -e node_health_hosts=k3s_prod
```
## Phase B — Flux bootstrap
`flux-bootstrap.yml` (last play of `site.yml`) shells to `bootstrap/flux/bootstrap.sh`, the single
documented entrypoint. That script now runs the CRD pre-apply as step **[2/4]**, so a cold bootstrap
no longer strands `platform`. The **age private key Secret must exist first** — bootstrap verifies it
and refuses to continue rather than silently skipping decryption
(`bootstrap/sops/age-key-secret.md`).
## Phase C — bring the reconciliation graph up
One script, six steps, each independently runnable and idempotent. **Run them in this order.**
```bash
K=infra/ansible/.cache/prod.kubeconfig
S=https://192.168.2.9:6443
```
### C1 — preflight (read-only, changes nothing)
Asserts the guards that prevent a repeat of the DNS incident and reports every tier's state. **Fails
closed** if `EXTERNALDNS_TXT_OWNER_ID` is empty, still the base sentinel, or `default` (dev's owner).
```bash
timeout 300 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 preflight
```
### C2 — pre-apply chart CRDs, and break the CDI cold-start deadlock (idempotent)
```bash
timeout 600 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 crds
```
Two cold-start problems, same root cause: **Flux server-side dry-runs every object in a Kustomization
before applying any of them, so one unknown `kind` aborts the entire tier.**
1. **Kyverno** — its ~5.6 MiB CRD bundle exceeds kubectl's 256 KiB annotation limit, so the chart sets
`crds.install: false` and nothing else installs them. `bootstrap/flux/preapply-crds.sh` applies them
server-side, deriving the version from the HelmRelease Flux reconciles (no second pin).
2. **CDI — a true deadlock.** `platform-config` applies the CDI CR *and*
`platform/kubevirt/cdi-storageprofiles.yaml` in the **same pass**. But `StorageProfile`'s CRD does
not exist until `cdi-operator` has reconciled that CR. So the dry-run fails on the unknown kind →
the CR is never applied → the CRD is never created → `platform-config` can never converge, and
every tier below it stays blocked. **Invisible on an established cluster** (dev's CRDs have been
there for months) **and fatal on a cold one.** Observed on prod 2026-09-05: `cdi-operator`
Available for 12h with **no CDI CR** and no `storageprofiles` CRD.
The step breaks it by applying **only the CR** — the minimum — and leaving everything else to
Flux. It carries no `${}` tokens, so applying it here cannot diverge from what Flux would render.
The CRD appeared in 10s.
**REPAIRED IN THE GRAPH (2026-09-05).** `cdi-storageprofiles.yaml` now lives in its own tier,
`./platform-config-cdi`, with `dependsOn: platform-config` and `wait: true`. Because `dependsOn` is
**revision**-gated and `platform-config` runs `wait: true`, the CDI CR must be applied *and healthy*
— which is precisely the event that creates the `StorageProfile` CRD — before the profiles are
applied. A cold cluster now orders itself. The `crds` step is retained as a fallback for a cluster
already stuck in the broken state, and because it is idempotent.
**The generalisable rule, now recorded in `clusters/README.md`:** a CR whose CRD is created by a
*controller at runtime* is **second-order** and needs its own pass. `cdi-operator.yaml` registers
exactly one CRD (`cdis.cdi.kubevirt.io`); `storageprofiles`, `datavolumes`, `datasources` and
`dataimportcrons` appear only after cdi-operator reconciles the CDI CR. An audit of
`platform-config` found `StorageProfile` is the only second-order kind there.
`apps-vms` gained `dependsOn: platform-config-cdi` — load-bearing, not cosmetic:
`apps/vms/unifi/20-datavolume.yaml` provisions on `storageClassName: longhorn-vm`, and its comment
previously claimed `platform-config` guaranteed the profiles, which the move would have silently
falsified.
#### Merge precondition and the one-time transient
`platform-config` has `prune: true`, so on the first reconcile after merge it **deletes** both
StorageProfiles, CDI recreates them within seconds with an empty spec, and `platform-config-cdi`
re-applies the overrides ~30–60s later (kustomize-controller's dependency requeue).
**Measured on dev, so the exposure is known rather than assumed** — comparing our override against
what CDI infers unaided for the same Longhorn provisioner:
| profile | `spec` (ours) | CDI-derived `status` |
|---|---|---|
| `longhorn-vm` (we override) | RWX / Block, `snapshot` | **ReadWriteMany** / Block, `snapshot` |
| `longhorn-static` (no override) | *unset* | **ReadWriteOnce** / Block, `snapshot` |
So **`cloneStrategy` is not at risk** — CDI infers `snapshot` for Longhorn anyway. **`accessModes`
is:** unaided, CDI produces **RWO**. A DataVolume provisioned on `longhorn-vm` *inside the window*
would be stamped RWO/Block and be **permanently non-live-migratable**. Flux can no longer cause that
(`apps-vms` now gates on the new tier); **do not start a VM disk import, clone or DataVolume by hand
during the window.**
- [ ] **Before merging, confirm `flux get kustomization platform-config` is Ready on the target.** If
it is not, the profiles are pruned and sit at CDI defaults until it recovers — and prod was in
exactly that state hours earlier. The fallback is the two-step the Cilium move used: merge with
`platform-config.prune: false`, confirm the ownership label transferred, restore `prune: true` in
a second commit.
- [ ] After: both profiles carry `spec.cloneStrategy: snapshot`, `longhorn-vm` carries
`claimPropertySets` RWX/Block, and both are labelled
`kustomize.toolkit.fluxcd.io/name=platform-config-cdi`.
### C3 — unwedge stuck Helm releases
A release left in `uninstalling` / `pending-*` cannot be advanced by helm-controller: it reports
`StateError` forever, so `platform` (`wait: true`) never goes Ready and all 10 tiers below it stall.
**Dry run by default** — it prints what it would do and changes nothing:
```bash
timeout 600 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 unwedge
```
To act (this **deletes** the release's Kubernetes objects and lets Flux install cold). Pre-applied
CRDs live **outside** the Helm release and survive, which is exactly why C2 comes first:
```bash
timeout 900 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 \
--confirm-destructive unwedge
```
### C4 — external-dns safe upgrade
**The most dangerous step in the repo, and the ordering is the entire mitigation.** A Helm upgrade
resets `replicas` to the chart default (1) and applies the new args in ONE operation — there is no
window in which to check the result first. If `--txt-owner-id` were missing, the pod starts as owner
`default`, claims dev's TXT registry entries, and under `policy: sync` **deletes the records behind
them**.
So this step **proves the outcome before causing it**: it renders the chart with the *live*
HelmRelease values and asserts `--txt-owner-id=<owner>` appears, aborting if not. Rendering is
deterministic and touches nothing, which converts a race into a precondition. It then snapshots the
Cloudflare zone, upgrades, re-verifies the live args (scaling to 0 and failing if they are wrong),
and diffs the zone.
```bash
timeout 900 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 externaldns
```
Expected diff: **additions only, all under `prod.home.keithjackson.dev`, all TXT entries owner
`prod`.** Any change to a name outside that subtree, or to a TXT entry owned by another cluster, is a
regression — stop and investigate.
### C5 — resume the tiers, in dependency order
`platform → platform-config → {oauth2-proxy, headlamp, homepage, crafty, apps-wave-0, apps-vms} →
apps-wave-1 → apps-wave-2`. Waits for each to go Ready and **stops at the first that does not** — a
partial, understood bring-up beats a forced green. `velero` is skipped while `S3_BACKUP_ENDPOINT` is
a placeholder (see §Blockers). Long-running; expect it to background.
```bash
timeout 3600 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 resume
```
### C6 — verify
```bash
timeout 900 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 verify
```
### Everything at once
Runs C1→C6 with `unwedge` in **dry-run** mode (no `--confirm-destructive`), so it stops short of the
destructive step by design:
```bash
timeout 3600 infra/devcontainer/dev.sh run bootstrap/standup.sh \
--kubeconfig infra/ansible/.cache/prod.kubeconfig --expect-server https://192.168.2.9:6443 all
```
---
## VERIFIED WORKING — 2026-09-05, standup complete
| | |
|---|---|
| Kustomizations | **12/13 Ready** (`velero` deliberately suspended — see §Blockers) |
| Pods | **113 total, 0 not Running/Completed** |
| Wildcard cert | `wildcard-home` **Ready**, Let's Encrypt **prod**, `*.prod.home.keithjackson.dev` |
| Gateway VIP | `192.168.2.20` · crafty L4 `192.168.2.22` · unifi L4 `192.168.2.21` |
| Published DNS | **36 records** under `prod.home.keithjackson.dev`, every TXT registry entry `owner=prod` |
| Dev's records | **57, byte-identical to the pre-standup baseline — nothing added, changed or removed** |
End-to-end HTTPS **through the workstation's own resolver — no `--resolve` override, no `-k`** (after
installing the dnsmasq profile below). Every request landed on `192.168.2.20`:
```
prod.home.keithjackson.dev 200 headlamp. 200 keycloak. 302 (login) nextcloud. 302
gitea. 200 semaphore. 200 grafana. 302 (SSO) pihole. 302
```
The served certificate is prod's own, confirmed by SNI — **not** dev's wildcard, which is what the
resolver bug previously made it look like:
```
subject = CN=*.prod.home.keithjackson.dev
issuer = C=US, O=Let's Encrypt, CN=YR1 (LE production)
SAN = *.prod.home.keithjackson.dev, prod.home.keithjackson.dev
valid = 2026-09-05 .. 2026-12-04
```
Dev, checked in the same pass, still serves `CN=*.home.keithjackson.dev` — **unchanged**.
> Ignore `curl: (91) No OCSP response received` if you pass `--cert-status`: Let's Encrypt retired
> OCSP responders in 2025, so there is nothing to staple. It is not a certificate fault, and the plain
> requests above all validated normally.
### Workstation DNS — install the prod dnsmasq profile
**CORRECTION (operator, 2026-09-05): this is the workstation's own dnsmasq, NOT pfSense.** Earlier
notes in this repo called it a "pfSense override"; that was wrong and the topology below is why.
`/etc/NetworkManager/dnsmasq.d/10-home.keithjackson.dev.conf` (the dev profile) contains:
```
address=/.home.keithjackson.dev/192.168.2.50
```
dnsmasq's `address=` matches the named domain **and every subdomain**, so that one line also captures
the whole `prod.home.keithjackson.dev` tree and answers it with **dev's** address, while Cloudflare
correctly returns prod's `192.168.2.20`.
**Fix — one command, already authored in this repo:**
```bash
sudo infra/workstation/dnsmasq/install.sh
```
It installs `20-prod.home.keithjackson.dev.conf` and reloads the live resolver. dnsmasq resolves
`address=` by **longest match**, so the prod rules win without editing or reordering the dev profile.
Verified against a throwaway dnsmasq loaded with both profiles:
| query | answer |
|---|---|
| `prod.home.keithjackson.dev`, `gitea.prod…`, `keycloak.prod…` | `192.168.2.20` (Gateway) |
| `unifi.prod.home.keithjackson.dev` | `192.168.2.21` (L4) |
| `crafty-mc.prod.home.keithjackson.dev` | `192.168.2.22` (L4) |
| `gitea.home.keithjackson.dev`, `home.keithjackson.dev` | `192.168.2.50` — **dev, unchanged** |
> **Install to `/etc/NetworkManager/dnsmasq.d/`, never `/etc/dnsmasq.d/`.** The standalone
> `dnsmasq.service` is **inactive**; the live resolver is the one NetworkManager spawns
> (`dns=dnsmasq`) with `--conf-dir=/etc/NetworkManager/dnsmasq.d`. A file in the other directory
> changes nothing and looks like it should. `install.sh` pins the destination for exactly this reason.
**Until it is installed, verify prod only with an explicit resolve** — and note *why* this trap is
convincing: dev serves a `*.home.keithjackson.dev` wildcard cert, which matches
`prod.home.keithjackson.dev` (one label), so the prod **apex returns 200 over valid TLS** while
`keycloak.prod.home.keithjackson.dev` (two labels) fails with a certificate-name error. It reads like
a broken prod certificate. It is the wrong server answering.
```bash
curl -sS --resolve gitea.prod.home.keithjackson.dev:443:192.168.2.20 \
https://gitea.prod.home.keithjackson.dev/
```
## Blockers
### 1. Backups: endpoint DECIDED, but prod cannot reach it — **prod has no backup coverage**
**Operator ruling 2026-09-05: "the S3 backup location is the same as Dev."** Prod's
`S3_BACKUP_ENDPOINT` is now `http://192.168.1.9:9000`, and the `TBD-PROD-*` placeholder is retired.
**It is not reachable from prod.** Measured from a throwaway pod **inside the prod cluster** — not
from the workstation, which proves nothing about pod egress:
```
curl http://192.168.1.9:9000/minio/health/live → connection timed out after 15s
```
**This is expected topology, not a misconfiguration** (operator, 2026-09-05): the k3s prod cluster is
**not behind pfSense**. It sits on the SOHO router network `192.168.2.0/24`, which is pfSense's
**WAN** side; pfSense *contains* `192.168.1.0/24`, the dev LAN where MinIO lives. Prod reaching
`192.168.1.9` therefore means crossing **into** pfSense from its WAN — which requires a deliberate
pfSense rule or port-forward that does not exist. **Operator decision required**, and worth weighing:
a prod backup that lands inside the dev LAN is arguably not off-cluster DR at all.
Re-test with `standup.sh --s3-endpoint http://192.168.1.9:9000 s3check` — it fails closed and says so.
> **Do not confuse this with the SOHO router's inbound NAT.** That router forwards inbound **443** to
> the pfSense WAN address `192.168.2.102` to serve the **existing, non-k3s production environment**,
> which must stay live on 443 until the k3s cutover is deliberately made. Nothing in this runbook
> touches that NAT, and prod's Gateway on `192.168.2.20:443` does not contend with it.
Until that routing exists, `velero` stays suspended and **`apps-wave-0` still applies GREEN** — the
token *is* defined, so strict substitution succeeds — while every write to that endpoint fails at
**runtime**. A green `apps-wave-0` does not mean backups work. **Treat prod as having no backup
coverage.**
#### Sharing one MinIO is only safe because of two new knobs
Nothing about the backup *paths* was per-environment before 2026-09-05. Enabling prod against dev's
MinIO without these would have been the external-dns incident again, in a worse place:
| | before | after |
|---|---|---|
| Velero | `bucket: velero`, **no prefix**, both clusters run schedules named `daily` | `prefix: ${VELERO_BACKUP_PREFIX}` — dev `""`, prod `prod` |
| CNPG | `destinationPath: s3://cnpg`, serverName defaults to the **Cluster name** | `${CNPG_BACKUP_PATH}` — dev `s3://cnpg`, prod `s3://cnpg/prod` |
- **Velero** syncs backups it finds in its BSL into itself as `Backup` CRs and **garbage-collects them
when their TTL expires**. Prod would have adopted dev's backups and deleted them on schedule.
- **CNPG is the worse one.** Both environments have `keycloak-db`, `nextcloud-db` and `semaphore-db`,
so prod's Postgres would archive WAL into the *exact prefix* dev's does. Two servers interleaving
WAL into one Barman stream **corrupts the chain and destroys PITR** — and dev's restore is
**proven** (R-STATE-03).
**The base values are dev's current layout on purpose and must stay that way.** Dev's existing
backups live at the bucket root and at `s3://cnpg`; giving dev a prefix would **orphan the very
backups its proven restore depends on** — the same reasoning that keeps dev's
`EXTERNALDNS_TXT_OWNER_ID` at `default`. Prod has no history to preserve, so it takes the nested paths.
> **Delivery note.** Prod reconciles the bootstrap Gitea's `main` at `c8c1d29`. This change sits on a
> later branch that also carries the DNS inversion, which must **not** reach prod (it would move prod
> to the apex and collide with dev). Delivering it needs a `c8c1d29`-based branch carrying only these
> commits. No urgency: velero cannot be enabled until the routing above is fixed.
### 2. Prod is on `prod.home.keithjackson.dev`, not the apex
The operator's target layout is dev under `*.dev.home.keithjackson.dev` and prod on the **apex**.
Prod cannot take the apex until dev vacates it, because both clusters would request a
`letsencrypt-prod` wildcard for the *same name set* and race each other's `_acme-challenge` TXT
records, so neither reliably solves DNS-01.
**This does not block phase C.** Prod on its own subtree with `txtOwnerId: prod` collides with
nothing, and standing it up first is strictly better: it proves the whole stack before the flip, so
the cutover becomes a DNS/TLS change on a known-good cluster instead of a bring-up and a cutover at
once. The apex move is `infra/PROD-PROVISIONING.md` §10a, sequence **D** (dev vacates) then **P**.
> **THE APEX FLIP IS ALSO THE LEGACY-PROD CUTOVER — they are the same event.** The SOHO router NATs
> inbound **443** to the pfSense WAN address `192.168.2.102`, serving the **existing, non-k3s
> production environment**, which the operator requires to stay live until the switch is deliberately
> made. So §10a sequence **P** is not merely a DNS/TLS change on the k3s side: taking
> `home.keithjackson.dev` means public traffic must also stop resolving to / being forwarded to the
> legacy stack. Sequence **P** as written does **not** cover repointing that NAT, and the two must be
> planned together or the apex will resolve to k3s while inbound 443 still lands on the legacy box.
>
> Nothing in phase C touches it: prod's Gateway is `192.168.2.20:443`, a different address from the
> NAT target, and prod's wildcard cert is issued by **DNS-01**, which needs no inbound 443 at all.
---
## Not covered here
- **Dev's migration off the apex** — §10a sequence D. Touches Keycloak's issuer, ~12 OIDC client
redirect URIs, and a rolling k3s restart that invalidates every OIDC kubeconfig. Operator-present.
- **Prod's GitOps cutover** (P5) from the workstation bootstrap Gitea to prod's own in-cluster Gitea.
`clusters/prod/kustomization.yaml` owns that procedure.
- **Six L2 conformance tests hardcode the apex** and must be parameterized per environment before
§9's "suite passes green against the prod cluster" can be claimed.