Files
home-network-k3s-impl/platform
jakman85andClaude Opus 5 a3f4ddb971 merge: feat/egress-gateway-staleness-alert — finished so it can actually pass the gate
Brings the two alerts for the silent egress-gateway failure: VPNEgressSteeringStale
(critical) and VPNEgressUplinkLabelMissing (warning), plus the kube-state-metrics
allowlist entry that makes them non-inert.

WHY THE BRANCH SAT IN THE BACKLOG: it could not pass the merge gate. Three
whole-repo checks failed on it, and each one was a real gap rather than a nuisance:

  * alert-runbooks-resolvable — both alerts link to /runbooks/vpn-egress-stale,
    which did not exist. An alert firing now would hand the operator a 404.
  * alert-rules-unit-tested — alerts.yaml is already a covered file, so new rules
    in it must be tested or a "tested" file quietly grows untested rules.
  * alert-metrics-have-scrape-targets — see the guard bug below.

WHAT THE ALERTS CATCH. egress-class=vpn pods leave through one gateway node's tun0;
Cilium resolves it to an ifindex and caches it. An OpenVPN reconnect destroys and
re-creates tun0 with a NEW ifindex, and the node label
homelab.keithjackson.dev/egress-uplink=<addr>-<ifindex> is what forces Cilium to
re-resolve. If the label does not follow the tunnel, the cache points at a destroyed
interface and every vpn-class pod is silently dropped — while tun0 stays up and
VPNTunnelDown says nothing. That ran ~80 minutes undetected on the dev gateway.

Verified this is live on prod, not inert: k3s-prod-3 carries egress-gateway=true and
egress-uplink=10.150.192.86-139, and vpn-egress-steering has been in place 14 days.
The label was exported on 0 series before this change, so the allowlist entry is
what switches the alerts on.

runbooks/vpn-egress-stale.md — written and wired into the configMapGenerator, with
the four required operator sections. It leads with proving egress from a pod the
policy selects, because the symptom is "outbound traffic silently disappears", and
it says plainly not to "fix" this by deleting the policy: that does not restore
egress, it sends vpn-class traffic out of the real ISP connection, which is the leak
the class exists to prevent.

platform/monitoring/tests/vpn-egress.test.yaml — four promtool fixtures. 1 and 2 are
the SAME tunnel reconnect with and without the label following it, which isolates the
mechanism the rule actually keys on: a label VALUE change is not a changing sample,
it retires one series and starts another. 3 is the inert case (gateway node, no uplink
label) where the critical rule CANNOT fire, which is why the warning exists.

Two fixes the branch needed that were defects in the checks themselves:

1. alert-metrics-have-scrape-targets asserted the KSM allowlist matched
   `nodes=[...egress-gateway]` with a literal closing bracket — i.e. that the list
   contained EXACTLY that one label. Adding a second label correctly (egress-uplink,
   which these alerts need) therefore FAILED the check while the property it guards
   was perfectly satisfied. Loosened to `[,\]]`: in the list, not the whole list. A
   guard that breaks when you correctly extend the thing it guards teaches people to
   edit the guard, which is how it stops guarding.

2. UNTESTED_ALERTS listed VPNTunnelDown, and the check flagged it as stale because
   "asserted on" is a bare word match over the test file — my header COMMENT
   mentioning VPNTunnelDown was enough to count as coverage. Rather than delete the
   register entry and claim coverage that did not exist, the fixtures now assert
   VPNTunnelDown QUIET in all four cases. That required making them faithful: they
   had no node_network_receive_bytes_total{device="tun0"} at all, so VPNTunnelDown
   would have been firing throughout unnoticed, because promtool only checks the
   alertnames you name. A healthy tun0 passes traffic, and that silence IS the claim
   these rules exist for.

NOTE FOR LATER, not fixed here: that word-match means any alert named in prose counts
as tested. Tightening it to `alertname:` positions could reveal other prose-only
"coverage", and the register is shrink-only, so the cascade wants doing deliberately
rather than at 01:00 unattended.

Conflicts resolved: kept HEAD's Velero custom-resource-state block and took only the
branch's one-line allowlist change (14 lines down to 1); inserted the branch's
2026-09-12 DECISIONS-LOG entry in date order rather than appending it out of sequence.

Gated: offline l0,l1 --include-scope whole-repo — 24 cases, 0 failures, 0 skips,
0 unwired. The new unit tests pass under the pinned promtool 3.14.0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:52:06 -05:00
..

platform/

Cluster-wide platform services, Flux-managed (R-PLAT-01, R-GITOPS-01/02/03). Reconciled by the platform Flux Kustomization defined in clusters/base/platform.yaml, before apps/.

Governing spec: docs/05-gitops-and-delivery.md, docs/01-architecture-overview.md.

Phase 0 scaffolding only. Every component here is a structurally-correct stub — a Flux HelmRelease and/or Kustomization with a values placeholder, a README citing the governing ADR/spec, and TODOs for the phase that fills it in. No cluster exists; nothing reconciles yet. Do not add working config, secrets, or provisioning under Phase 0 (spec AGENTS.md §1/§5/§6, roadmap/phases.md).

Reconciliation flow (how a change lands)

git (source of truth)
   │  operator PR → CI (lint · schema · Kyverno L0 · Chainsaw) → merge to tracked branch
   ▼
Flux GitRepository (clusters/base/flux-system.yaml)
   ▼
Flux Kustomization "platform"  → ./platform  (this dir)     ── substituteFrom cluster-vars ──┐
   │  prune=true (drift correction, R-GITOPS-03) · wait=true                                  │ R-ENV-02
   ▼                                                                                          │
Flux Kustomization "apps"      → ./apps  (dependsOn platform) ─ substituteFrom cluster-vars ──┘

Git is authoritative; the cluster conforms to git and drift is auto-corrected (R-GITOPS-01/03). Manual kubectl apply of platform state is prohibited (spec AGENTS.md §5).

The dev ↔ prod overlay model (R-ENV-02)

Platform manifests are authored once. Every value that legitimately differs between environments is a ${TOKEN} resolved from the cluster-vars ConfigMap via Flux postBuild.substituteFrom. The clusters/dev and clusters/prod overlays differ only by the values they patch into cluster-vars (issuer, replica counts, storage sizes, retention). See clusters/README.md. Base manifests are never forked per environment.

Components (one directory each)

Dir Component Governing ADR / spec Serves
cilium/ CNI + kube-proxy replacement + Gateway API + Egress Gateway + Hubble ADR-0002, docs/03 R-NET-*
longhorn/ Replicated block storage; 3-replica default StorageClass + bulk tier ADR-0003, docs/04 R-STATE-*
cert-manager/ TLS via Let's Encrypt DNS-01 (Cloudflare); staging + prod ClusterIssuers ADR-0006, docs/08 R-TLS-*
externaldns/ Automatic DNS records for public services (Cloudflare) docs/09 R-DNS-*
gateway/ Gateway API GatewayClass + shared HTTPS Gateway + wildcard cert ADR-0002/ADR-0006, docs/09 R-DNS-01, R-TLS-01
monitoring/ kube-prometheus-stack + Loki + Grafana Alloy + Alertmanager docs/10 R-OBS-*
velero/ Off-cluster backup/DR of volumes + resource state ADR-0003, docs/04 R-STATE-03
kubevirt/ KubeVirt + CDI (IaaS: application VMs on the same K8s surface) ADR-0005, docs/06 R-PLAT-03
image-automation/ Flux image-update-automation (hands-off image tag advance) ADR-0009, docs/11 R-UPD-02
keycloak/ Platform IdP (OIDC + SAML), config-as-code, PostgreSQL on Longhorn ADR-0014, docs/15 R-IDP-*
oauth2-proxy/ Forward-auth at the Gateway for non-OIDC apps → Keycloak ADR-0014, docs/15 R-IDP-*
kyverno/ L0 policy-as-code gate — OWNED BY THE POLICY ENGINEER (WS-TEST). ADR-0011, docs/14 R-TEST-*

⚠️ INTEGRATION TODO(PM): wire kyverno into the platform kustomization

platform/kyverno/ is authored by a different agent (the policy engineer, WS-TEST). This workstream did not create any files under platform/kyverno/ and must not.

platform/kustomization.yaml intentionally omits - kyverno. Once the policy engineer's files land, the PM must add - kyverno to that list (recommended first, so policies are present before other components reconcile). The line is present as a commented TODO in the kustomization.

Secrets

Every secret referenced below (Cloudflare API token, Velero backup-target creds, Keycloak admin/DB, oauth2-proxy cookie/client secrets) is referenced by name only and will be committed SOPS-encrypted (WS-SEC, ADR-0007). No plaintext secret appears anywhere in this tree.

Cross-workstream contracts surfaced by these stubs

  • cilium (WS-NET ↔ WS-INFRA): Cilium's initial CNI install is done by Ansible at cluster bootstrap (docs/02, Phase 1). The HelmRelease here manages Cilium config thereafter; its Helm values MUST match the Ansible bootstrap values (kubeProxyReplacement, k8sServiceHost/Port, API server host) or reconciliation will fight the bootstrap. Contract owner: WS-NET + WS-INFRA.
  • gateway + cert-manager + externaldns (WS-NET + WS-SEC): the shared Gateway listener, the wildcard *.home.keithjackson.dev cert, and the ExternalDNS source all reference each other; they form the "Issuer + Gateway TLS" interface in roadmap/workstreams.md.
  • image-automation (WS-UPDATE): this dir holds only the cluster-wide ImageUpdateAutomation + git write-back. Per-service ImageRepository/ImagePolicy are rendered from each ServiceDefinition's update block (WS-GITOPS/WS-UPDATE), living with apps/.
  • keycloak/oauth2-proxy (WS-SEC): the SSO/auth contract (auth block → Keycloak client / oauth2-proxy protection) is owned by WS-SEC; these are placeholders pending that contract.