Files
home-network-k3s-impl/DECISIONS-LOG.md
T

264 lines
21 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.
# Autonomous Session Decision Log (PM)
Program Manager working independently while the operator is AFK (started 2026-08-16). Every
best-effort decision and assumption is recorded here for later review. **Specs were NOT modified**
(PM cannot change `../home-network-k3s`; that requires the System Architect hat). Blocked items that
need the operator are called out.
## Guardrails I'm holding to
- No spec changes. No fabricated secrets (Cloudflare token, PIA creds, SMTP — all await hand-off).
- Don't break the running cluster: `platform/` stays **suspended** in Flux until each component is
filled in + validated; `platform/cilium` must carry `k8sServiceHost=10.73.0.11` before it ever
reconciles.
- Everything author-first, `make -C ci gate` green before merge, feature-branch→main.
## Decisions / assumptions (append-only)
| # | Decision / assumption | Why | Review flag |
|---|---|---|---|
| A1 | Complete Phase 1 T-1.4 via **Option A** (Flux bootstrapped, `platform`+`apps` suspended) | You picked Option A earlier as the safe path; "keep going" authorizes it | Confirm scope |
| A2 | Advance Phase 2 only for components needing **no operator secret**: Longhorn, monitoring, Kyverno-admission, Cilium-parity | cert-manager/externaldns/velero/VPN are blocked on token/PIA/OQ-BKP | — |
| A3 | Dev resource caps for monitoring (short retention, small PVCs, single replicas) from `cluster-vars` | 22 GiB RAM ceiling | Tune later |
| A4 | Host Gitea repo is **public** (NAT-only) so Flux needs no git creds | dev bootstrap simplicity | Fine for dev |
## Autonomous session results (2026-08-16)
- **Phase-2 authoring (author-only, merged):** `platform/longhorn` (3-replica SC + bulk indirection),
`platform/monitoring` (kube-prometheus-stack + Loki + Alloy, dev-trimmed ~1.3 GiB, +8 alert rules),
`platform/cilium` (parity fix: k8sServiceHost=10.73.0.11 + masquerade, safe to adopt in Phase 2),
`platform/kyverno` (verified). Gate green.
- **Flux INSTALLED** on the cluster (4 controllers Running). Host **Gitea working** (named-volume fix),
repo pushed. GitRepository created → **BLOCKED**: cluster pods can't reach `10.73.0.1:3000`
(firewalld `libvirt` zone drops VM→host:3000).
- **RESOLVED (operator added the ansible-vault become password):** ran `host-prereqs.yml` (firewall
opened via vault→become), VM→Gitea = 200, and **Flux GitRepository is READY=True**
(`main@sha1:a2f3bdc…`). **Phase-1 T-1.4 core is DONE** — Flux installed + reconciling clusters/dev
from the host Gitea. `platform`/`apps` Kustomizations intentionally NOT created (Option A) — the
running cluster is untouched.
- Firewall + Gitea admin/repo/push + host become password are all IaC now (host-prereqs.yml,
bootstrap-gitea.sh automation, vaulted group_vars/hypervisor) → destroy/rebuild is hands-off.
- **NOT deployed:** `platform`/`apps` Kustomizations were deliberately NOT created — the running
cluster (Cilium etc.) is untouched. Only Flux controllers + the GitRepository source exist.
## T-1.5 — Destroy/rebuild proof (destructive IaC-validity test) — 2026-08-16
**Goal (operator-scoped):** prove the IaC is *valid* — the dev cluster can be destroyed and rebuilt
from IaC + git with no data loss ("Approach A is acceptable"; "more about validity of the IaC than a
COOP/DR scenario"). Ran **after K2 conformance passed**, with the operator approving each destructive
step at the prompt.
**Outcome: PASS (with one operator-visible scare and two concrete IaC findings).** The cluster was
torn down and reconstituted from IaC + preserved Longhorn/OS disks + the Flux git source. **End state:
3/3 Ready (k3s-dev-1/2/3, control-plane,etcd,master), Cilium status OK, Flux GitRepository READY=True,
zero state lost.** Behavioral health re-verified post-rebuild: conformance L2 **R-NET-01 default-deny +
R-NET-02 deny-undeclared both PASS** (exit 0, JUnit+coverage regenerated). The destroy/rebuild path is
demonstrably valid.
### Finding F1 — `tofu destroy -target` on a `count`-indexed resource is unsafe (ROOT CAUSE)
`tofu destroy -target 'libvirt_domain.node[2]'` (+ that node's `os[2]`/`data[2]`/`seed[2]`) **destroyed
all three domains**, not just node3 — while the individually-targeted *volumes* correctly hit only
index 2. Root cause: the nodes are declared with **`count`** in `infra/tofu/main.tf`; `-target` against a
single `count` index is a documented Terraform/OpenTofu hazard (the docs explicitly warn `-target` is
"not for routine use"). count-index addresses are positional and brittle under targeted destroys.
- **Recommended fix (Phase-2 IaC hardening):** refactor `libvirt_domain.node` + its per-node volumes
from `count` to **`for_each`** keyed by node name (`k3s-dev-1/2/3`). `for_each` keys are stable and
individually addressable, so a single node can be `-replace`d/destroyed without touching siblings.
- **Interim rule:** **never** use `tofu destroy -target` on this module for single-node lifecycle. To
cycle one node, use `tofu apply -replace='libvirt_domain.node["k3s-dev-N"]'` (post-`for_each`), or
destroy the specific VM via `virsh` and let `tofu apply` recreate it.
- **Silver lining:** the over-destroy is exactly what *proved* IaC validity — disks survived, `tofu
apply` recreated the domains, nodes rejoined, Flux reconciled unchanged. The IaC round-trips.
### Finding F2 — single-node Ansible rejoin needs the primary in scope for the join-token
Recovering node3 with `ansible-playbook ... --limit k3s-dev-3` **failed**: the `k3s_server` join task
reads `hostvars[groups['k3s_primary'][0]].k3s_node_token`, but that fact is only *published* when the
primary play runs, so a limit that excludes the primary yields
`'HostVarsVars' has no attribute 'k3s_node_token'`. Worked with `--limit 'k3s-dev-1,k3s-dev-3'`.
- **Recommended fix (Phase-2 IaC hardening):** make the join self-contained — have the secondary task
fetch the token on demand via `delegate_to: "{{ groups['k3s_primary'][0] }}"` (read
`/var/lib/rancher/k3s/server/node-token` at join time) instead of depending on a fact the primary
play must have populated in the same run. Then a true single-node rejoin (`--limit k3s-dev-N`) works
hands-off, which is the T-1.5 durability bar.
### F1 / F2 — IMPLEMENTED (author-first, 2026-08-16)
- **F1 done:** `infra/tofu/main.tf` node resources refactored `count` → `for_each` (keyed by node
name); ordered-list/`primary` helper locals added; `outputs.tf` updated off the map; **`moved.tf`**
adds 12 `moved` blocks (os/data/seed/domain × 3). `tofu validate` OK and **`tofu plan` = `0 to add,
0 to change, 0 to destroy`** with all 12 resources reported "has moved to" — a pure state-address
rename, no VM churn. **State-persist pending:** a follow-up `tofu apply` (a proven 0-change apply
that only rewrites state addresses) is operator-permission-gated; until then the migration re-shows
harmlessly on each plan and lands automatically on the next real apply.
- **F2 done:** `roles/k3s_server/tasks/main.yml` — secondary now fetches the join token via
`delegate_to: primary` (with the published fact as a fast path), so `--limit k3s-dev-N` rejoin is
self-contained. `ansible-playbook --syntax-check` OK on all playbooks; role lint clean under the CI
gate (`make -C ci gate` PASS). No cluster change; the running cluster is untouched.
## Phase 2 un-suspend PREP — no-secret rollout (author-first, 2026-08-16)
Operator directive: "Phase 2 un-suspend prep … verify cilium parity + fill REPLACE-MEs for the
no-secret components … author-first. Does NOT un-suspend/deploy yet." **Nothing was deployed;** the
running cluster is untouched (still 0 Flux Kustomizations / 0 HelmReleases — only controllers + the
GitRepository).
### Findings from three parallel readiness audits (Longhorn / monitoring / Kyverno)
- **Cilium parity VERIFIED (safety-critical):** every `[parity]` field in `platform/cilium/helmrelease.yaml`
equals the live CNI — `k8sServiceHost=10.73.0.11` (rendered by the chart as the pod env
`KUBERNETES_SERVICE_HOST`, **not** a configmap key — the empty `k8s-service-host` cm value was a red
herring), kube-proxy-replacement, gateway-api, egress+bpf masquerade, hubble, l2-announcements,
cgroup, v1.16.5. **Flux adoption is a no-op reconcile.**
- **Topology:** "suspended" = Option A (**no root Kustomization was ever created**), and the single base
`platform` Kustomization renders **all 11 components at once** — so a naive un-suspend would drag in
the secret-blocked components (cert-manager/externaldns/velero/keycloak/oauth2-proxy/image-automation)
and never go healthy (`apps` dependsOn platform, wait:true).
- **Longhorn:** manifests clean; node prereqs (open-iscsi, /var/lib/longhorn mount, iscsid) are in
cloud-init IaC; the REPLACE-ME bulk-NFS SC is isolated/harmless. Ready.
- **Monitoring:** right-sized (~1.25 GiB) but blocked by (a) needing the `longhorn` SC first and (b) two
**by-name secrets** (`grafana-admin-credentials`, `alertmanager-smtp`) that don't exist — the missing
mounts (not the REPLACE-ME text) are what wedge Grafana/Alertmanager.
- **Kyverno:** zero policy violations today (policies scope to `app-namespace=true`, none exist), but the
fail-closed webhook + 3 HA replicas + Enforce are unsafe/oversized for a cold cluster.
### DECISION D-P2A — per-component Flux Kustomizations for the dev no-secret rollout (operator-approved)
Chose **Option A (per-component Kustomizations)**. Authored `clusters/dev/rollout/` = 4 Flux
Kustomizations (`kyverno` → `cilium` → `longhorn` → `monitoring`, monitoring `dependsOn: longhorn`),
and the dev overlay now `$patch: delete`s the monolithic base `platform`/`apps` and adds `./rollout`.
The no-secret tier carries **no SOPS content**, so the missing `sops-age` key does not block it
(decryption dropped from these Kustomizations). Secret-blocked components are simply **not created**
until their hand-offs land.
- **cilium** uses `prune: false` (adopt the out-of-band CNI; never own-delete it).
- **R-ENV-02 divergence — REVIEW FLAG (architect):** the dev overlay now *adds resources* and diverges
its reconciliation graph from prod (base doc says "dev adds NO resources … graph reused verbatim").
This is a **transitional bootstrap scaffold**, consistent with the already-inlined dev bootstrap
patches (SOPS/URL). **Convergence path is coded in:** delete `clusters/dev/rollout/` + the two
`$patch: delete`s to re-adopt the unified base graph once all hand-offs arrive. **prod is untouched**
(still builds the monolith; verified). Flag for architect ratification.
### Component hardening (env-gated via cluster-vars so prod keeps the strict posture)
- New base knobs (strict/prod defaults) + dev overrides: `KYVERNO_VALIDATION_ACTION` (Enforce→**Audit**),
`KYVERNO_WEBHOOK_FAILURE_POLICY` (Fail→**Ignore**), `KYVERNO_ADMISSION_REPLICAS` (3→**1**),
`MONITORING_STORAGE_CLASS` (longhorn), `GRAFANA_ADMIN_SECRET` (grafana-admin-credentials→**""** in dev
→ chart-default password, dev-only/NAT-isolated).
- Kyverno webhook also gains an explicit `namespaceSelector` excluding kube-system/flux-system/kyverno/
longhorn-system/monitoring (belt-and-suspenders vs a fail-closed brick).
- Monitoring Alertmanager: the `alertmanager-smtp` mount + smtp_*/email_configs are **deferred** (commented
with verbatim re-enable markers) so Alertmanager starts with NULL receivers — alerts fire/route/show in
UI+Grafana, just no email until the Phase-4 SMTP hand-off.
- Validated: `kubectl kustomize` clean for clusters/dev (exactly the 4 Kustomizations, monolith removed),
clusters/prod (monolith intact), and all 4 components; envsubst→YAML-parse of kyverno+monitoring valid;
`make -C ci gate` PASS.
### Still required BEFORE an actual deploy (not part of this author-first prep)
1. **Push current `main` to the host Gitea** — Flux source is pinned at `6c3f9ed` (pre-dates these merges).
2. **Runtime node check:** `systemctl is-active iscsid` + `mountpoint /var/lib/longhorn` on all 3 nodes.
3. **Cilium adoption mechanism:** confirm helm-controller can adopt the Ansible-installed release (was it
`helm`-installed with a release secret, or CLI/kubectl?) — verify before creating the cilium Kustomization.
4. Create the dev root Kustomization (the actual un-suspend) — deliberately NOT done here.
## CORRECTION — credential-forward, unified graph (operator directive, 2026-08-16)
**Operator pushback:** "Are we making implementation choices solely on the fact that you have to ask
for credentials? It is better to get the credentials upfront (encrypted file) and keep the rollout in
line with the existing architecture." **Correct — and it exposed a scope distortion.**
- **A2 SUPERSEDED / D-P2A REVERTED.** Choosing components by "needs no operator secret" inverted the
roadmap: `roadmap/phases.md` **Phase 2 = Network, Storage, TLS, DNS, Ingress** → the credential-gated
cert-manager/externaldns/gateway ARE the Phase-2 core, while **monitoring is Phase 4** (I'd pulled it
forward). The per-component dev divergence (`clusters/dev/rollout/`) existed only to dodge asking for
the Cloudflare token.
- **Reverted to the existing architecture:** deleted `clusters/dev/rollout/` + the `$patch: delete`s;
restored the unified base graph (dev = ../base + cluster-vars patches + SOPS decryption patches, one
`platform` Kustomization, R-ENV-02 intact — **removes the earlier architect-review flag**). Reverted
the monitoring Grafana/SMTP dodges (it's Phase 4). Kept the non-credential-driven improvements: tofu
for_each/rejoin (F1/F2), and Kyverno parametrization + webhook namespace-excludes (Kyverno IS Phase 2).
- **DECISION D-P2B — phase the unified `platform/kustomization.yaml` (operator-approved).** Components
are enabled in the single shared list **per phase** (the same pattern that deferred kyverno):
Phase-2 set now active = **kyverno, cilium, longhorn, cert-manager, externaldns, gateway**; monitoring/
image-automation (P4), velero (P4, OQ-BKP), kubevirt (P5), keycloak/oauth2-proxy (P6) are commented
with phase markers. dev+prod render the same list. Verified: `kubectl kustomize platform` renders only
the 6 Phase-2 namespaces; dev shows the unified platform+apps Kustomizations; prod builds; gate PASS.
- **DECISION D-SEC1 — provision ALL credentials upfront via SOPS; operator encrypts (both operator-chosen).**
Activated SOPS: `.sops.yaml` installed at repo root (recipient still REPLACE-ME — operator fills after
age-keygen). Authored `bootstrap/sops/secret-templates/*.yaml.example` (cloudflare-api-token ×2 ns,
grafana-admin, alertmanager-smtp, keycloak-admin, oauth2-proxy, pia — all placeholder-only, `.example`
so kustomize/SOPS ignore them) + `bootstrap/sops/CREDENTIALS.md` (end-to-end checklist). Pre-wired
**commented** `- cloudflare-api-token.sops.yaml` lines in cert-manager + externaldns kustomizations
(operator uncomments post-encrypt). Filled the non-secret ACME email ([email protected],
confirm/change). **Agents never touch real secret values** (per `.sops.yaml`).
### Operator TODO to reach Phase-2 deploy (see bootstrap/sops/CREDENTIALS.md)
1. age-keygen → fill root `.sops.yaml` recipient → install `sops-age` on cluster.
2. Provision `cloudflare-api-token` (Zone:DNS:Edit + Zone:Read on keithjackson.dev) in **both**
cert-manager + external-dns; uncomment the two kustomization lines.
3. Confirm ACME email. 4. Push `main` to host Gitea. 5. Create the dev root Kustomization (the un-suspend).
6. (Upfront, optional now) encrypt the Phase-3/4/6 secrets so later phases are credential-ready.
7. **Chart-key verification** (flagged in templates): keycloak + oauth2-proxy `existingSecret` key names,
Keycloak DB secret, and PIA/Gluetun protocol (≠ TCP) — verify at each component's phase.
## Phase-2 UN-SUSPEND executed + fixes (autonomous session, 2026-08-16 overnight)
Operator said "go" for the un-suspend, then went AFK: "continue to work without me. Any major decision
that requires a system architecture change VIA SPEC CHANGE will need to be escalated. Anything else is
allowed. Keep track of assumptions and report on return." None of the below is a spec change (the spec
is silent on Flux Kustomization granularity / L2 wiring), so all within PM remit. **Nothing touched the
read-only spec repo.**
- **Un-suspended** via the sanctioned `kubectl apply -k clusters/dev` (bootstrap.sh step 3): controllers
already installed, `sops-age` present. Pre-deploy checks all green first — Cilium adoption safe (an
existing `helm` release `cilium` v3 exists → Flux adopts by name, no-op upgrade), Longhorn node prereqs
green on all 3 nodes (`iscsid` active, `/var/lib/longhorn`→`/dev/vdb`), host Gitea pushed + Flux source Ready.
### Fix 1 — Longhorn StorageClass numeric coercion (commit ~c04aea8)
Reconcile stalled: `.parameters.numberOfReplicas: expected string, got 3`. kustomize strips the quotes
around a bare `"${VAR}"` placeholder, so Flux substitution yielded an unquoted numeric that SSA rejects
(StorageClass.parameters is map[string]string). **Hardcoded `numberOfReplicas: "3"`** (quoted numeric
literals ARE preserved by kustomize; topology LOCKED at 3). The replica knob still drives the global
default via `defaultSettings.defaultReplicaCount` in the helmrelease (a Helm int value, unaffected).
### Fix 2 — DECISION: controllers/configs split (commit ~458a867) — NOTABLE, please review
Reconcile then failed atomically: `no matches for kind "ClusterIssuer"`. The single `platform`
Kustomization applied HelmReleases (which register CRDs) AND CRs of those CRDs (ClusterIssuer,
Certificate, Kyverno ClusterPolicies) in one pass → Flux SSA fails the WHOLE apply → the CRD-registering
HelmReleases never run. Deadlock. **Adopted the flux2 controllers/configs pattern:** new
`platform-config` Flux Kustomization (`dependsOn: platform`, wait) carries the CRs; `platform` keeps only
controllers; `apps` now `dependsOn platform-config`. Applies to dev AND prod (shared base) — no R-ENV-02
divergence. `platform-config/` references the CR files IN PLACE via `../platform/...` (Flux builds with
LoadRestrictionsNone; Kyverno policies must stay under `platform/kyverno/` for the CI L0 `kyverno test`
gate) — **ASSUMPTION: Flux's default LoadRestrictionsNone permits the `../` refs** (builds verified
locally with `--load-restrictor LoadRestrictionsNone`; confirming at reconcile).
### Fix 3 — Authored the missing Cilium L2 resources (in Fix 2 commit)
`l2announcements.enabled: true` was set but the IP pool + announcement policy were only a TODO, so a
Gateway LoadBalancer would never get an IP. Authored `platform-config/cilium/`: **CiliumLoadBalancerIPPool**
(dev NAT range **10.73.0.240-.250**, matching the tofu reservation) + **CiliumL2AnnouncementPolicy**
(interface regex `^en`, node interface is `ens3`). **ASSUMPTIONS:** (a) VIP range hardcoded to the dev
NAT range — a prod overlay must parameterize it (via cluster-vars) when prod is built; (b) `^en` matches
the node LAN NIC (`ens3`); (c) CRD apiVersion `cilium.io/v2alpha1` (confirmed served on the cluster).
### Result
Un-suspend reconcile in progress at last checkpoint (controllers installing → platform-config → apps).
Outcome + any further fixes appended when the reconcile settles.
## KNOWN ISSUES to fix before Phase-2 un-suspend (logged, not yet fixed)
| # | Issue | Impact | Fix |
|---|---|---|---|
| K1 | ~~Substitution-var case mismatch (UPPERCASE refs vs lowercase cluster-vars keys)~~ | — | ✅ **FIXED**: uppercased cluster-vars keys + dev/prod overlay paths; all refs resolve; builds green |
| K2 | ~~Conformance L2 tests partial/unverified~~ | — | ✅ **DONE**: fixed 3 test bugs (see below), ran via run.sh against the live cluster → **R-NET-01 default-deny + R-NET-02 deny-undeclared PASS**, JUnit+coverage written, exit 0. chainsaw v0.2.12 installed (host `~/.local/bin`) |
| K3 | `bootstrap/gitea/bootstrap-gitea.sh` used a host bind-mount → rootless-podman perms broke app.ini | initial Gitea start failed | FIXED: switched to a named volume |
| K4 | **Kyverno/chainsaw findings during K2 (fixed in the tests):** (a) chainsaw does STRICT (length-equal) list matching — asserting one condition against a pod's full `conditions[]` fails → used scalar `phase: Running`; (b) a default-deny baseline blocks the **kubelet readiness probe** (node→pod), so probe-bearing pods never go Ready → dropped probes; (c) chainsaw report `path: ../_artifacts` resolved to repo-root → exit 1 → fixed to `_artifacts`; (d) swapped agnhost→busybox httpd (cached, cache-timing-proof) | — | FIXED in the test files/config |
## FINDING for WS-NET (Phase 2, T-2.2 — default-deny baseline design)
The platform's real default-deny baseline **must not break kubelet health/liveness/readiness probes**
(node→pod ingress). A naive `ingress: []` NetworkPolicy in Cilium drops the kubelet TCP probe, so any
pod with a probe never becomes Ready. The baseline must explicitly allow the health-check path (Cilium
host/health-check allowance or an equivalent ingress exception). Surfaced empirically by the R-NET-02
conformance test; **carry into T-2.2**.
## Toolchain note
`chainsaw v0.2.12` is installed on the host (`~/.local/bin`) for local conformance runs; for full
reproducibility it should also be baked into `infra/devcontainer` (pinned) — the in-cluster Chainsaw
Job (ADR-0011) already uses the pinned image. Deferred (non-blocking).
## Blocked on operator (hand-offs)
- **T-2.4p** Cloudflare API token → cert-manager DNS-01 + ExternalDNS (Phase 2 TLS/DNS).
- **T-3.2p** PIA username/password → VPN egress gateway (Phase 3).
- **OQ-BKP** off-cluster backup target → Velero (Phase 4).
- SMTP creds → Alertmanager email (Phase 4).