Commit Graph
55 Commits
Author SHA1 Message Date
jakman85andClaude Opus 5 e6bcecc78c docs: DECISIONS-LOG — overnight prod standup handoff
Prod is up: 12/13 tiers Ready, LE wildcard issued, 113 pods, dev's 57 DNS
records byte-identical throughout. Records both cold-start deadlocks (Kyverno
CRDs, CDI StorageProfile) as one root cause, the external-dns footgun and the
prove-before-acting pattern that disarmed it, three bugs in my own verification,
the pfSense wildcard now shadowing prod, and what remains for the operator.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 10:06:42 -05:00
jakman85 645994e3a3 docs: record P0/P1/P2 landing + the postStart crashloop incident
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Two bugs stacked and the outer one hid the inner. Fixing the dead OIDC guard made the
block execute for the first time in these apps' deployed lifetime, which immediately
exposed a latent defect: the hook's last statement was `[ cond ] && action`, so on the
SUCCESS path it returned 1 and became the hook's exit status — and a non-zero postStart
exit makes the kubelet kill the container.

Records the pattern for the next PM: fixing dead code is not a safe no-op. It is the first
execution of never-tested code and deserves the caution of new code, not of a
one-character escape.

Also records what is verified live (Cilium maxUnavailable applied with pod ages UNCHANGED,
proving nothing rolled), and that strict substitution is committed but NOT ACTIVE — it
needs bootstrap.sh step 1 because the controllers sit outside Flux's inventory.
2026-09-01 16:59:41 -05:00
jakman85 a344b046c9 docs: research findings — measured Flux substitution semantics + ranked remediation plan
Records three parallel research passes and, importantly, TWO CORRECTIONS.

Correction 1 (mine): StrictPostBuildSubstitutions alone is INSUFFICIENT — it fixes nothing
about ordering. The actual fix is one line: platform.dependsOn: [flux-system]. Verified
against kustomization_controller.go::checkDependencies at our exact version — dependsOn is
REVISION-gated, not merely Ready-gated, so a dependent sharing a GitRepository cannot
reconcile revision N until the dependency has APPLIED revision N.

Correction 2 (a research agent's): the claim that Helm considered the bad release healthy,
so remediation never fired, is WRONG — measured on the live cluster, the Helm release
secrets show v9 = failed. The machinery did fire. maxUnavailable: 2 of 3 IS confirmed, so
the real harm was blast radius, not missed detection.

Adds a MEASURED substitution-semantics table, including the two anti-patterns nobody had
written down: ${VAR:?msg} renders the MESSAGE as the value, and ${VAR:=default} silently
ships the dev value to prod. Both are exempt from strict mode by design — which is exactly
why strict would never have caught the gitea/nextcloud dead-guard bug.

Also corrects repo doctrine: escaping is TOTAL ($${ARR[0]} is safe); only the unescaped
indexed form is fatal. And qualifies an earlier overstatement in this log: kustomize strips
structural YAML comments before envsubst, so the hazard is block-scalar bodies, not all
comments.

Notes Flux 2.4.0 is out of support and that strict became default-on in kustomize-controller
v1.9.0 — so the audit is a prerequisite for the upgrade regardless.

Ranked plan P0-P5 with what is done, what is ready, and what needs an operator call.
2026-09-01 01:33:16 -05:00
jakman85 b8602c939c docs: INCIDENT — Flux post-build substitution fails OPEN, briefly broke Cilium
Merging PR #4 put cilium agents into Init:Error on 2/3 dev nodes for ~6 minutes. Self-healed;
cluster fully green. Recorded because the mechanism is systemic and far more dangerous on a
cold prod bootstrap.

Root cause: cluster-vars (which DEFINES a knob) is owned by the flux-system Kustomization,
while platform/ (which REFERENCES it) is owned by the platform Kustomization, with no
dependency ordering between defining a variable and using it. Flux post-build substitution is
NON-STRICT by default, so during the window where platform rendered the new manifest against
the old cluster-vars, ${K3S_API_HOST} became the EMPTY STRING. Cilium upgraded with
k8sServiceHost: "" and fell back to ClusterIP 10.43.0.1 — which only the Cilium agent can
translate, and that agent was the thing restarting. Its config init container then could not
reach the API server at all.

Proven by the HelmRelease config digest going e83bf695 -> eebd6c88 -> e83bf695: the config
changed and then changed back to BYTE-IDENTICAL. The pre-merge byte-identity verification was
correct — the steady state is identical; the damage was a transient intermediate render.

optional: false does NOT protect against this: it requires the ConfigMap to exist, not that
every referenced key is defined.

Fix recommended, NOT applied unilaterally: enable the StrictPostBuildSubstitutions feature gate
on kustomize-controller so an undefined variable FAILS the Kustomization instead of silently
emptying it — fail-closed, per this project's own doctrine. Requires first proving every ${VAR}
in every substituted path is defined or correctly escaped, or it would fail legitimate embedded
shell expansions. Interim discipline: add a knob in one commit, reference it in a later one.

Serves: R-ENV-02, R-GITOPS-01, R-PLAT-01.
2026-09-01 01:00:16 -05:00
jakman85andClaude Opus 5 ff0533106b feat(prod): operator decisions — node DNS, service VIPs, become vault, collections path
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
1. PROD NODE DNS = 8.8.8.8 + 1.1.1.1 (operator, 2026-09-01). Two INDEPENDENT public
   upstreams, so the single-upstream SPOF that broke containerd pulls and CoreDNS
   forwarding on dev cannot recur. Prod deliberately differs from dev, which is
   PiHole-first because dev needs the local jackson.lan zone.

   SCOPE CORRECTED IN THE COMMENTS. Three resolvers answer *.home.keithjackson.dev and
   only one is configured here:
     pods  -> CoreDNS -> platform/coredns/coredns-custom.yaml, whose `template` plugin
              answers the WHOLE zone with ${GATEWAY_VIP}. Self-contained in-cluster;
              PiHole is NOT in this path. Prod is already correct (.20).
     LAN   -> PiHole. Not a cluster concern.
     nodes -> THIS FILE.
   The one thing public node DNS breaks: the k3s API SERVER is a node process carrying
   oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab and reaches the
   issuer through the NODE resolver, so prod would get the PUBLIC record instead of the
   prod Gateway VIP — breaking kubectl OIDC SSO. Dev avoids this only because its node
   resolver IS PiHole. Not blocking (prod has no cluster or Keycloak yet); options are
   recorded in PROD-PROVISIONING.md §4 for the operator to pick.

2. SERVICE VIPs DECIDED: UNIFI_L4_VIP 192.168.2.21, CRAFTY_L4_VIP 192.168.2.22 — the
   first two addresses after GATEWAY_VIP (.20), mirroring dev's .53/.54/.55 ordering
   within the .20-.30 pool. S3_BACKUP_ENDPOINT stays TBD-PROD-* : no prod backup target
   exists, and dev's versitygw is across the pfSense boundary on the dev host, so it is
   not DR for prod.

3. PROD BECOME VAULT committed, ansible-vault encrypted, matching the hypervisor and
   proxmox_hosts pattern. Verified working: `ansible k3s_prod -m command -a 'id -un'
   --become` returns root. The L0 no-plaintext-secrets check asserts every ansible
   vault.yml starts with $ANSIBLE_VAULT, so this stays honest by gate.

4. TOOLCHAIN GAP FIXED: the devcontainer image does NOT ship the Galaxy collections, so
   EVERY ansible play died at parse time on community.general.modprobe. ansible.cfg now
   sets collections_path = collections, and the collections are installed there from the
   existing collections/requirements.yml (gitignored — pinned by requirements, fetched at
   setup, vendored bytes are not source).

5. DRIVE HEALTH MEASURED and recorded in §2 with the raw attribute table so the numbers
   are auditable. Verdict: usable, but not for production etcd/Longhorn state — 31% of
   rated life consumed, 15 grown bad blocks, 187 erase failures, and 32 unexpected power
   losses on a consumer drive with NO power-loss protection, in a single failure domain.
   etcd is the worst possible tenant: it fsyncs constantly and unclean power loss without
   PLP is exactly how a member corrupts.

6. NON-ECC RAM: RISK ACCEPTED by the operator, recorded as a CLOSED decision, not an open
   item — do not re-litigate. Rationale: 3-node k3s homelab, not enterprise; corruption on
   one node is outweighed by the other two and causes node ejection. That holds for the
   dominant mode (etcd raft CRC + quorum; Longhorn 3 replicas + hard anti-affinity +
   rebuild). Recorded residual: corruption introduced UPSTREAM of replication replicates
   faithfully to all three replicas, so quorum sees no disagreement — low probability, and
   the only mitigation is different hardware. Follow-up noted: Longhorn's
   snapshotDataIntegrity is left at the chart default rather than set explicitly, and it
   is the mechanism that operationalises the operator's own argument.

Serves: R-DNS-01, R-DNS-03, R-ENV-02, R-ENV-03, R-SEC-01. Task: T-7.1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 00:40:29 -05:00
jakman85andClaude Opus 5 ce1966acb0 docs: PM handoff — Phase-7 prep session complete (PR #4, 13 commits, CI green)
Records what landed, the numbers (coverage 37->40/51, safety-critical scaffolds 1->0,
PROVEN still 0 by design), the corrections made mid-session so they are not re-litigated
(prod NOPASSWD rejected; the '$i envsubst bug' disproven by running the real tool; the
stranded DNS branch dropped rather than landed), three near-misses worth remembering, the
six items blocked on the operator, and what is knowingly left undone.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 00:15:28 -05:00
jakman85andClaude Opus 5 677f9ea5f0 docs: CI is the enforcement point; local gate produced a FALSE GREEN
Operator directive: conformance and gates are enforced at the Gitea runner / CI-CD
level — use it. The authority on mergeability is the Actions run, not a local
`make -C ci gate`; local runs are a fast pre-check only.

Proven immediately on this branch. Same commit (a1fb31a):
  local  -> "no changed files vs base; empty touched-requirement set" -> MERGE GATE: PASS
  CI     -> 17 touched requirements -> REQUIRE: FAILED, uncovered
            ['R-SEC-01','R-SEC-02','R-TEST-06'] -> MERGE GATE: BLOCKED

The local run resolved an empty diff base, checked nothing, and reported green. A local
false green is this project's most dangerous failure mode — it hid real coverage debt.
Fix in progress so local agrees with CI or announces loudly that it cannot determine a
base; an honest noisy skip beats a quiet false pass (ci/README.md exit-code contract).

The gate was RIGHT: we authored a vaulted-secret flow (R-SEC-01) and status tooling
reasoning about spec immutability (R-TEST-06) while those requirements had NO covering
test. Tests are being authored; the rule is NOT being narrowed.

Also records how to read CI from a workstation — the API paths are non-obvious on this
Gitea version (actions/runs/<run>/jobs 404s; use actions/tasks + actions/jobs/<id>/logs,
noting the task-id -> job-id offset).

OPERATOR ATTESTATIONS: DR is proven (Velero -> Proxmox server, restore demonstrated) and
dev-cluster resilience is proven (slow only because Longhorn is on HDD; etcd is on its
dedicated SSD). Both recorded as fact. Kept distinct from automated proof: the status
report's R-STATE-03 finding is TEST debt, not capability debt — the system does the
thing, the suite does not yet assert it. The attestation does not close the requirement
and the gate must not be softened to make it appear closed.

Serves: R-TEST-03, R-TEST-05, R-TEST-06, R-STATE-03.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-31 23:05:57 -05:00
jakman85andClaude Opus 5 e3c44368c8 docs(prod): correct sudo posture to vaulted become; record status-surface ruling
Two operator corrections:

1. PROD DOES NOT GET NOPASSWD SUDO. My initial checklist recommended mirroring dev's
   passwordless sudo onto prod — rejected, correctly. Dev's NOPASSWD is a convenience
   that must not propagate to production. Prod instead keeps a real sudo boundary and
   drives Ansible `become` with an ansible-vault-encrypted `ansible_become_password` in
   inventory/group_vars/k3s_prod/vault.yml, mirroring the EXISTING hypervisor and
   proxmox_hosts groups. The decrypting passphrase stays outside the repo at
   ~/.config/home-k3s/ansible-vault-pass (ansible.cfg :: vault_password_file), so plays
   still run unattended with no plaintext secret in git.

2. THE TASK BOARD IS NOT THE STATUS SURFACE — THE REPOSITORY IS. Operator ruling closes
   the escalation raised in the previous commit. Requirement/spec status is to be DERIVED
   from repo state (what is authored, reconciled, and covered by a green requirement-tagged
   test), not from a hand-maintained board. The spec board is left as a historical seed;
   no agent edits it and no SPEC_PIN churn is spent on it. ci/spec-immutability.sh keeps
   its full strictness — no path exception was added, since weakening an R-TEST-06 gate
   for a bookkeeping file would be a defect, not a fix. A repo-derived spec status report
   becomes the artifact of record alongside the coverage report and this log.

Serves: R-ENV-03 (prod provisioning posture), R-SEC-01 (no plaintext secrets), R-TEST-06.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-31 22:29:39 -05:00
jakman85andClaude Opus 5 574088dfeb docs(prod): open Phase 7 (prep only) + T-7.1 bare-metal provisioning checklist
The operator added the first production node (k3s-prod-1, 192.168.2.10 — HP EliteDesk
800 G5 Mini, i7-9700T/31GiB, Debian 13, VT-x + /dev/kvm) and opened roadmap Phase 7 for
PREP WORK ONLY: no k3s install until the R-ENV-02 blockers close and the gate is green.

Records the Phase-7 entry-gate position honestly: the operator has declared hardware
ready, but "Phases 1-6 proven in dev/test" is NOT met (37/51 conformance; R-ENV-01/02/03
themselves uncovered). Proceeding is an explicit, accepted risk — not an oversight.

Adds infra/PROD-PROVISIONING.md (T-7.1, design-only): firmware/BIOS, disk layout, OS,
network, access, Ansible reuse, repo blockers, build sequence, Phase-7 exit criteria.

Key findings recorded in DECISIONS-LOG:
- R-ENV-02 is violated in the base — dev LAN addresses hardcoded into base manifests
  (cilium k8sServiceHost, the Cilium LB pool, crafty/UniFi LB IPs, 3 S3 backup
  endpoints). Prod is on 192.168.2.x, so prod would build WRONG today.
- clusters/prod never sets GATEWAY_VIP -> would inherit the base default 10.73.0.240,
  an address on the RETIRED libvirt network.
- No NOPASSWD sudo on the prod node -> all Ansible fails at its first become.
- node_baseline device defaults are virtio (/dev/vdb,vdc) and absent on bare metal.
- ESCALATION: agents/task-board.md is structurally unmaintainable — AGENTS.md designates
  it the one agent-editable surface, but ci/spec-immutability.sh fails the gate on ANY
  spec-repo dirt with no path exception. Explains the all-todo board after six phases.
  NOT worked around: exempting it would weaken an R-TEST-06 gate. Operator decision.

Operator decisions: Phase 7 open (prep only); wait for all 3 nodes before cluster-init;
check drive health before choosing the single-SSD disk layout.

Serves: R-ENV-01, R-ENV-02, R-ENV-03, R-PLAT-01/03. Task: T-7.1.
Gate: MERGE GATE: PASS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-31 22:17:51 -05:00
jakman85andClaude Opus 4.8 c182888133 docs: PM handoff — 2026-08-31 current-state, gotchas + next steps; PHASE1-STATUS 8 vCPU
Appends the definitive current-state/handoff entry to DECISIONS-LOG (SSO fixed via CVE-2025-30162
egress allow, 5 forward-auth UIs gated, 8 vCPU, Gitea Actions CI live, external-snapshotter vendored;
operational gotchas; prioritized Next Steps → Longhorn-SSD, L2/L3-CI, forward-auth long-term,
Phase-6 onboarding). Updates the reconnect guide (6→8 vCPU) and points new agents at the handoff.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 21:23:04 -05:00
jakman85andClaude Opus 4.8 cbc2301b30 docs: reconcile docs/comments with post-upgrade cluster state
Text-only cleanup after the upgrade campaign. Updates stale version
strings, prose, and comments to the verified current facts; no
Kubernetes behavior or version pins changed except the kubectl
toolchain bump (below).

Verified facts applied: k3s v1.36.4+k3s1, Debian 13 trixie,
Cilium 1.20.1 / Gateway API v1.6.1, Longhorn 1.12.1, node RAM 24 GiB,
dedicated virtio2 SSD etcd disk per node, Velero chart 12.x / v1.18,
Grafana 13.2.0.

- Version strings across infra/ and platform/ READMEs + comments
- kubectl toolchain 1.31.4 -> 1.36.4 (devcontainer/ci/conformance
  versions.env; 1.31 vs a 1.36 cluster exceeded the +-1 skew)
- Retired-single-apps-Kustomization refs -> apps-wave-0/1/2 + apps-vms
  with per-wave SOPS decryption (flux/clusters docs + bootstrap.sh)
- COMPLETED/historical banners on the three executed plan docs
- Resolved TODO(WS-SEC) markers -> Renovate-maintained ranges
- Appended dated DECISIONS-LOG entry (2026-08-30) superseding the
  2026-08-26 LIVE STATE line

Left as revalidation items (not blind-edited): external-snapshotter
v8.2.0 vs 1.36, Cilium ext_authz/forward-auth relevance under 1.20.1,
conformance ImageVolume feature-gate assertions under 1.36.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 15:13:12 -05:00
jakman85andClaude Opus 4.8 13b1f1d9a4 docs(decisions): record 2026-08-27 session (R2 state, seerr, app-SSO, oauth2-proxy tier, R-STATE-02)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 18:24:14 -05:00
jakman85andClaude Opus 5 9e8b694ba9 docs(conformance): record that the matcher debt is half paid
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
The content-scan half of the 'stop keying on doc-header tokens' follow-up is done
(ci/touched-requirements.sh). The path-map half remains: clusters/* and ci/* are
still keyed wholesale to R-ENV-02 + R-GITOPS-01, which is why those two stay in
DOCUMENTED_GAPS. Notes the accepted consequence for header-declared manifests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 00:08:16 -05:00
jakman85andClaude Opus 5 f7d3884ad2 fix(ci): scope the requirement matcher to the diff and to non-doc files
`ci/touched-requirements.sh` documents its detection as "R-* tokens literally
present in the diff of changed files". It did not do that — it grepped the ENTIRE
CONTENT of every changed file. So a change inherited every requirement those files
had ever mentioned in prose, and `--require-all` then blocked the merge on
requirements the author never went near.

On this branch that meant editing the decision log and the tofu docs dragged in
five unrelated requirements, all on untouched lines, all legitimately uncovered:

  [coverage] REQUIRE: FAILED — required requirement(s) uncovered: [5 IDs]

The practical effect was that the PM could not append to the decision log without
failing CI — which is a fair part of why the log went stale in the first place.
This is the defect already logged as "FOLLOW-UP (WS-TEST): fix the matcher so real
requirements need not be exempted".

Two scoping rules, both learned from the gate blocking merges it should not have:

  1. Scan the ADDED/REMOVED lines of the diff, as the header always claimed.
     Removed lines count too — deleting a test's requirement tag, or a policy's
     self-declared ID, touches that requirement just as much as adding one.

  2. Skip `*.md`. An ID in prose is a REFERENCE, not a declaration: explaining a
     requirement is not changing behaviour against it. Rule 1 alone is not enough —
     a commit that documents a mis-attribution names the IDs involved and thereby
     re-triggers the very mis-attribution it fixes, and the decision log can never
     record which requirements a change concerned.

This TIGHTENS attribution rather than weakening the gate:
  - no requirement is exempted, and DOCUMENTED_GAPS is untouched;
  - authoritative declarations (`conformance.home.k3s/R-*` tags, Kyverno policies,
    ServiceDefinitions) all live in YAML, on lines a real change must touch;
  - the coarse path->requirement fallback is unchanged and file-type agnostic, so
    the doc subtrees that matter (conformance/, schemas/, catalog/, clusters/, ci/)
    stay covered either way.

Alternatives rejected: adding the five IDs to DOCUMENTED_GAPS genuinely weakens the
gate; scrubbing requirement IDs out of prose (precedent f9e9640) would mean editing
the decision log's own history to appease a matcher.

Gate semantics change — flagged in DECISIONS-LOG.md for review.

Also drops three R-TEST-06 mentions this branch had added to prose and refreshes
the now-stale "gate is RED" notes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 00:06:28 -05:00
jakman85andClaude Opus 5 12c40d8c9c docs: reconcile the documentation with the deployed reality
The docs had drifted badly behind ~85 commits of delivery. The root README still
said "Phase 0 — Foundations. No cluster exists yet"; the infra docs still
described a libvirt/KVM cluster that has been retired for two days; and
DECISIONS-LOG.md — the PM's authoritative record — stopped at 2026-08-25 AM.
Anyone following these docs would have been working from a false map.

Verified against the live cluster (3/3 nodes, 7/7 Flux Kustomizations Ready,
10/10 HelmReleases Ready) rather than inferred from commit messages.

README.md
  - Phase 0 -> a per-phase status table: 0-6 delivered and deployed, 7 not open.
  - Layout now shows the wave split, platform-config, apps/vms, infra, bootstrap.
  - Real conformance number (36/51, 70.6%) and how to regenerate it.
  - Devcontainer workflow; note that the gate was red and why.

infra/PHASE1-STATUS.md — rewritten. Was a `virsh`/libvirt resume guide for a
  cluster that no longer exists. Now: the Proxmox substrate, the pfSense NAT
  paths, both kubectl access paths (Keycloak OIDC normal / cert break-glass),
  the toolchain, off-repo state that must not be lost, and the fact that Flux's
  git source is an app inside the cluster (`git push origin main` IS the deploy).

infra/PHASE1-SCOPE.md — historical banner. The exit criteria and sizing
  rationale are still worth keeping; the libvirt execution steps are not.

infra/README.md — Proxmox, plus a map of the subtree and where the cluster
  actually lives (it is not on this workstation).

infra/devcontainer/README.md — the "run tofu and ansible on the HOST" limitation
  is OBSOLETE: it was a dmacvicar/libvirt polkit/PID problem, and bpg/proxmox
  talks to an HTTP API. Everything runs in the container now. Documents the full
  CI-gate toolchain and what each bind mount is load-bearing for (the age keyring
  and the read-only spec repo especially — without the latter, spec-immutability
  silently skips instead of verifying).

infra/tofu/README.md — was "Authoring only — nothing here has been applied". It
  is applied and live. Disk sizes corrected 40/100 -> 64/128 GiB (confirmed
  against the running nodes), pfSense NAT marked done, and a warning that
  terraform.tfstate is absent on a fresh workstation — an apply with empty state
  would try to recreate the three live nodes.

infra/tofu/variables.tf — section comment disagreed with its own defaults.

infra/cluster.sh — retired-with-instructions header. It silently drives libvirt
  domains that do not exist; behaviour unchanged, but it no longer lies.

apps/vms/unifi/README.md — was "NOT WIRED IN", 60Gi, and listed a file that has
  since been dropped. Now: live status, 40Gi, resolved open questions marked
  resolved, and the two constraints the next person will otherwise rediscover —
  KubeVirt's 2048B inline userData cap, and that apps-vms must never carry
  postBuild.substituteFrom (envsubst corrupts the cloud-init).

DECISIONS-LOG.md — backfilled the gap as one structured entry: the Proxmox
  migration, NAT + kube-vip, the twice-proven oauth2-proxy cold-start deadlock,
  the Flux source cutover, CI going live in-cluster, ten app onboardings and the
  renderer bugs they exposed, the wave split (verified: DBs adopted, not
  recreated — original ages intact, prune restored), KubeVirt storage, the UniFi
  VM, and kubectl SSO. Plus verified live state and the carried-forward open list.

Two corrections to the record while verifying:
  - kubectl OIDC is LIVE (`kubectl auth whoami` -> oidc:<user>,
    [oidc:platform-admins]), so the rolling restart 3472f1d deferred has
    happened.
  - UniFi OS Server is NOT installed in the guest. The VM is healthy (Ubuntu
    24.04.4, guest agent connected, live-migratable) but :11443 answers nothing,
    and the CNP explicitly permits cluster -> :11443, so it is genuinely not
    listening rather than a policy artifact.

Docs and comments only; no manifest semantics change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 00:00:05 -05:00
jakman85 42fc8c7029 docs(pm): the Cilium VIP reachability bug was TRANSIENT - cleared by reboot, no CNI change needed 2026-08-25 08:22:21 -05:00
jakman85 f977bcbee5 docs(pm): root-caused Cilium pod->Gateway-VIP reachability bug (non-announcer nodes) + gateway-ingress/pihole 2026-08-25 01:48:46 -05:00
jakman85 1cb8282f62 docs(pm): both deliverables certified — Keycloak SSO + KubeVirt P5 all green on KVM 2026-08-24 23:23:03 -05:00
jakman85 ff59d2bc6d docs(pm): Session 7 log — Phase 5/6+4 deployed, Keycloak SSO functional (4-gap fix chain) 2026-08-24 22:50:22 -05:00
jakman85andClaude Opus 4.8 1868a47701 docs(pm): log Phase 5 (KubeVirt) + Phase 6 (Keycloak) integration + deploy blocker
Both components authored by worktree-isolated agents, reviewed, integrated to main,
offline-green (MERGE GATE: PASS, dev+prod overlays render). Deploy blocked on the
push-to-gitea permission + 3 operator SOPS hand-offs. Full runbook + verify-at-deploy
escalations recorded.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 21:17:58 -05:00
jakman85andClaude Opus 4.8 254d50a20c docs: pia-* cluster secrets retired + host vault committed (Phase 3 cleanup complete)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 15:04:31 -05:00
jakman85andClaude Opus 4.8 2d85c03e55 docs: gluetun retirement complete (docs merged, banner fixed, worktrees pruned)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 14:54:37 -05:00
jakman85andClaude Opus 4.8 05b04b8bc7 docs: record gluetun retirement done + VPN monitoring retargeted to node tun0
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 14:47:43 -05:00
jakman85andClaude Opus 4.8 17fc0b7f36 docs: conformance suite green live (both gates); gluetun retirement prepared, pending approval
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 14:22:52 -05:00
jakman85andClaude Opus 4.8 09569a08e6 docs: kill-switch robustness fix done+validated; conformance tests partial (rate-limited)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 13:13:45 -05:00
jakman85andClaude Opus 4.8 e14048e7fe docs: record Phase 3 egress steering validated end-to-end on dev (both gates pass)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 08:23:57 -05:00
jakman85andClaude Opus 4.8 83ea050e82 docs: record ADR-0015 impl (merged, not deployed) + escalation ruling + operator prereq
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 07:32:58 -05:00
jakman85andClaude Opus 4.8 03768ff6b0 docs: Phase 3 VPN resolved (working + stable, PIA Stockholm egress)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-24 00:02:47 -05:00
jakman85andClaude Opus 4.8 91aa7acab8 wip(vpn): gluetun custom-provider mode with operator's PIA .ovpn
PIA-provider server selection is flaky here (random dead IPs); the operator's .ovpn
uses sweden.privacy.network (load-balanced, reliable). Switch to custom mode
(VPN_SERVICE_PROVIDER=custom, OPENVPN_CUSTOM_CONFIG=/config/pia.ovpn), mount the
pia-custom-ovpn Secret, keep creds + kill-switch + tun-mtu 1300. Drops PIA-provider
server list/updater and native PF (Phase-6 concern). Pending the operator-provisioned
pia-custom-ovpn Secret (not merged until it exists).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-23 00:40:08 -05:00
jakman85andClaude Opus 4.8 457edb7a38 fix(vpn): enable gluetun server-list updater (bundled list was stale) + re-enable
CORRECTION: the connection failures were gluetun dialing RETIRED PIA IPs (46.246.x
= dead → timeouts, which I mis-read as a network block). A fresh PIA .ovpn connects
from the same host, and PIA's current SE Stockholm servers (158.173.x) are reachable.
Cilium was independently cleared (default-allow, host-fw off, Hubble FORWARDED).

Fix: UPDATER_PERIOD=24h so gluetun fetches PIA's current servers; keep
OPENVPN_MTU/MSSFIX=1350 for the real Cilium-VXLAN 1450 pod MTU (data channel);
PIA default UDP port. Re-enable vpn-egress. Full correction in DECISIONS-LOG.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 23:08:18 -05:00
jakman85andClaude Opus 4.8 95a55952cc fix(vpn): disable vpn-egress — network blackholes PIA IPs (not ports/MTU/cluster)
Decisive diagnosis: OpenVPN's outbound init packets are 14 bytes with zero
replies (so MTU was never the blocker), pod UDP egress is healthy (NTP replies),
but TCP 501/443 to PIA IPs time out while Google/Cloudflare are OPEN — across many
PIA servers. This 192.168.2.x WiFi segment blackholes PIA's IP ranges (and
intercepts DNS). Not code-fixable. Reverted the port/MTU red-herring env to PIA
defaults; disabled vpn-egress pending an operator network fix (move to the
unfiltered 192.168.1.x LAN, or unblock PIA on the router). Full trace in
DECISIONS-LOG.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 18:15:11 -05:00
jakman85andClaude Opus 4.8 f17c46b767 fix(vpn): shrink OpenVPN tun-mtu/mssfix to 1350 (Cilium VXLAN pod MTU 1450)
Actual root cause (operator-directed layered MTU check): Cilium runs VXLAN
(routing-mode tunnel) so pods get MTU 1450 (1500-50 encap) while node, hypervisor,
and the Google Fiber WAN are all 1500. OpenVPN's ~1500-byte handshake packets
exceed 1450 and are dropped (PMTUD broken). The ports were a red herring.

Fix: OPENVPN_MTU=1350 + OPENVPN_MSSFIX=1350 so encrypted packets (~1402) fit under
1450. Keeps OpenVPN + VXLAN unchanged. Re-enable vpn-egress. Full layered trace
(pod/node/host) in DECISIONS-LOG.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 18:03:46 -05:00
jakman85andClaude Opus 4.8 0aab1702e0 chore(vpn): disable vpn-egress, escalate OpenVPN→WireGuard decision
OpenVPN can't connect on this network (each port fails differently, so it's
OpenVPN-specific not a UDP block — NTP/123 works): 1197 = home OpenVPN-server
conflict; 53 = DNS interception; 8080 = TLS handshake fails across servers (MTU
on WiFi+triple-NAT). Recommend pivoting to WireGuard via thrnz/docker-wireguard-pia
(UDP 1337, MTU-robust, ADR-0013 fallback, operator's original preference) — a
spec-level (ADR-0013) change, so escalated. Disabled meanwhile. Full trace in
DECISIONS-LOG.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 16:47:18 -05:00
jakman85andClaude Opus 4.8 4cde690008 fix(vpn): PIA OpenVPN port 53 -> 8080 (53 is DNS-intercepted on this network)
Port 53 also got no PIA replies: this network transparently intercepts outbound
:53, so dig works but OpenVPN-over-53 is swallowed. An NTP/123 probe from the host
returned fine, proving arbitrary non-53 UDP works bidirectionally (so it's
port-specific: 1197 conflict + 53 DNS-intercept, not a UDP block). Move to 8080
(community-verified for PIA, non-DNS, non-1197).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 16:39:30 -05:00
jakman85andClaude Opus 4.8 ec5ceb328f fix(vpn): PIA OpenVPN on UDP/53 (avoid home WAN:1197 conflict); re-enable vpn-egress
Operator identified the block: the home network NATs its own OpenVPN server on
WAN:1197, colliding with the cluster's outbound PIA-on-1197 flow (port-specific,
not a general UDP block — UDP/53 worked in tcpdump). PIA accepts OpenVPN on
53/1194/1197/1198/8080/9201; chose 53 (empirically proven to traverse this net).

- cluster-vars: add PIA_OPENVPN_PORT="53"
- gluetun: OPENVPN_ENDPOINT_PORT=${PIA_OPENVPN_PORT}
- re-enable vpn-egress; DECISIONS-LOG resolution noted

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 16:28:37 -05:00
jakman85andClaude Opus 4.8 9d761872ea fix(vpn): disable vpn-egress — tunnel blocked by home network (env), config kept
Phase-3 VPN config is complete + correct (OpenVPN-UDP, SE Stockholm), but the
OpenVPN UDP/1197 handshake to PIA never gets a reply. Traced with tcpdump: packets
leave pod→node→host→WiFi (SNAT 192.168.2.103) fine, but 0 replies return to the
host, while host UDP/53 works. The 192.168.2.x home router/ISP drops OpenVPN UDP —
upstream of everything in this repo. Reverted to disabled to avoid a crashloop
firing false KubePodCrashLooping alerts; one-line re-enable once the network is
fixed. Full trace in DECISIONS-LOG.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 15:05:20 -05:00
jakman85andClaude Opus 4.8 015b423353 feat(vpn): enable vpn-egress on OpenVPN-UDP; align docs to ADR-0013 amendment
Gluetun cannot do PIA over WireGuard (upstream unsupported; live "provider name
is not valid for Wireguard"). Operator approved OpenVPN-UDP and ADR-0013 was
amended in the spec. This aligns the impl:

- platform/kustomization.yaml: re-enable `- vpn-egress` (Phase 3, OpenVPN-UDP)
- gluetun-gateway.yaml: clean stale WireGuard comments (header, image note,
  VPN_TYPE/creds comments); config already VPN_TYPE=openvpn
- README.md: PIA-over-OpenVPN, status=enabled, steering iface wg0 -> tun0
- DECISIONS-LOG.md: disk/drive incident (k3s -> NVMe; failing Seagate) + Phase-3
  OpenVPN resolution

Stale WireGuard comments inside the operator's encrypted pia-credentials.sops.yaml
header left untouched (editing breaks the SOPS MAC; cosmetic).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-22 14:49:16 -05:00
jakman85 2223e8403d docs: Phase-4 monitoring deployed + Phase-3 VPN authored + infra recovery + F-MON1 2026-08-21 22:40:17 -05:00
jakman85 e8b5cae34d docs: R-NET fail-closed (L3) green; conformance status 2026-08-17 17:43:27 -05:00
jakman85andClaude Opus 4.8 ce55332028 docs: R-STATE-01 green + chainsaw =~ harness fix (F-TEST1)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 17:02:39 -05:00
jakman85andClaude Opus 4.8 429708f0a7 docs: F-OPS1 resolved (self-managing root)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 16:51:30 -05:00
jakman85andClaude Opus 4.8 892fdc6a15 docs: log kyverno bitnami/kubectl cleanup-image fix (F-KY1)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 08:43:08 -05:00
jakman85andClaude Opus 4.8 f78ce4ebe7 docs: Phase-2 conformance exit achieved (R-NET/R-TLS/R-DNS green) + findings
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 08:33:24 -05:00
jakman85andClaude Opus 4.8 8732c8f4fd docs: autonomous session 2 (R-NET conformance green, TLS verified, SC + report fixes)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 07:41:32 -05:00
jakman85andClaude Opus 4.8 0811e2533b docs: Phase-2 platform fully deployed + green (fixes 4-6, follow-ups)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 01:53:13 -05:00
jakman85andClaude Opus 4.8 4260487af5 docs: log Phase-2 un-suspend + fixes (longhorn coercion, controllers/configs split, cilium L2)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-17 01:08:35 -05:00
jakman85andClaude Opus 4.8 a108d5ab98 correction: credential-forward + unified graph; activate SOPS (author-first)
Operator directive: don't diverge architecture to avoid asking for credentials;
provision them upfront via SOPS and keep the existing (unified) graph.

Roadmap reconciliation: Phase 2 = Network/Storage/TLS/DNS/Ingress, so
cert-manager/externaldns/gateway (the credential-gated pieces) ARE Phase 2;
monitoring is Phase 4. Earlier A2/rollout scope was inverted by credential-avoidance.

Reverts (credential-driven): remove clusters/dev/rollout/ + the $patch:delete's;
restore unified base graph (dev = ../base + cluster-vars + SOPS decryption,
R-ENV-02 intact); restore monitoring files (Phase 4). Kept non-credential
improvements: tofu for_each/rejoin, Kyverno parametrization + webhook excludes.

platform/kustomization.yaml now phased (single shared list): Phase-2 set =
kyverno, cilium, longhorn, cert-manager, externaldns, gateway; later components
commented with phase markers.

SOPS activated: .sops.yaml at repo root; bootstrap/sops/secret-templates/*.yaml.example
for all secrets (cloudflare x2, grafana, alertmanager-smtp, keycloak, oauth2-proxy, pia);
bootstrap/sops/CREDENTIALS.md checklist; pre-wired commented cloudflare-api-token lines
in cert-manager + externaldns; filled non-secret ACME email. Agents never touch real
secret values (operator encrypts).

Validated: kubectl kustomize dev(unified)/prod/platform(Phase-2 only), templates inert,
make -C ci gate PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 22:09:20 -05:00
jakman85andClaude Opus 4.8 d9a80e5350 phase2-prep: per-component no-secret rollout + component hardening (author-first)
Prep only — nothing deployed; running cluster untouched.

Reconciliation graph (operator-approved Option A): author clusters/dev/rollout/
= 4 per-component Flux Kustomizations (kyverno->cilium->longhorn->monitoring,
monitoring dependsOn longhorn). Dev overlay $patch:delete's the monolithic base
platform/apps and adds ./rollout, so only the no-secret tier reconciles; the
secret-blocked components are simply not created until hand-offs arrive. The
no-secret tier carries no SOPS content, so it needs no sops-age key. prod is
untouched (still builds the monolith). Convergence path coded in (delete rollout/
+ the two deletes) — R-ENV-02 divergence flagged for architect review.

Component hardening via cluster-vars (prod keeps strict posture):
- KYVERNO_VALIDATION_ACTION Enforce->Audit(dev), _WEBHOOK_FAILURE_POLICY
  Fail->Ignore(dev), _ADMISSION_REPLICAS 3->1(dev); webhook namespaceSelector
  excludes system+platform namespaces.
- MONITORING_STORAGE_CLASS var; GRAFANA_ADMIN_SECRET var (dev ''=chart default);
  Alertmanager SMTP/secret deferred to Phase-4 with verbatim re-enable markers.

Cilium parity verified against the live CNI (no-op adopt). Validated:
kubectl kustomize dev/prod/all-components, envsubst->YAML parse, make -C ci gate PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 21:34:57 -05:00
jakman85andClaude Opus 4.8 95e3eafac6 iac: harden node lifecycle — tofu for_each + self-contained ansible rejoin
Implements T-1.5 findings F1/F2 (author-first, no cluster change).

F1 (tofu): refactor libvirt node resources count -> for_each keyed by
node name so single-node -replace/destroy cannot cascade to siblings.
Add ordered-list/primary helper locals; update outputs.tf off the map;
add moved.tf (12 moved blocks). tofu plan proves a pure state-address
migration: 0 to add, 0 to change, 0 to destroy (all 12 'has moved to').
State-persist via a 0-change apply is operator-permission-gated.

F2 (ansible): k3s_server secondary fetches the join token via
delegate_to the primary (published fact as fast path), so a single-node
rejoin with --limit k3s-dev-N is self-contained and no longer depends on
a fact an earlier play had to set. Syntax-check + CI gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 19:42:12 -05:00
jakman85andClaude Opus 4.8 1eeb754bee docs(t-1.5): log destroy/rebuild proof outcome + IaC findings
T-1.5 destructive IaC-validity test PASSED: dev cluster torn down and
reconstituted from IaC + preserved disks + Flux git source with zero
state loss (3/3 Ready, Cilium OK, Flux READY=True). Post-rebuild
conformance L2 R-NET-01 + R-NET-02 both PASS.

Two IaC-hardening findings for Phase 2:
- F1: tofu destroy -target on a count-indexed resource over-destroyed
  all 3 domains; refactor libvirt_domain.node count -> for_each keyed by
  node name; never use destroy -target for single-node lifecycle.
- F2: single-node ansible rejoin needs the primary in --limit to publish
  the k3s join-token fact; make the join self-contained via delegate_to
  the primary so --limit k3s-dev-N is hands-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 19:35:01 -05:00