Files
home-network-k3s-impl/platform
jakman85andClaude Opus 5 4656b07178 fix(vpn-ingress): the VPN ingress node must announce no L2 VIPs, or clients route nothing
A node cannot reach a LoadBalancer VIP whose Cilium L2 announcement lease it
holds itself: the announcer does not answer its own ARP request, so on the
lease holder the neighbour entry settles at FAILED and packets to that VIP die
before leaving the node. Measured on prod 2026-10-08, symmetric across 3 nodes
x 5 VIPs -- each node reached every VIP EXCEPT the ones it alone announced.

platform/vpn-ingress is uniquely exposed: it runs WireGuard on hostNetwork and
MASQUERADEs remote clients to the node's own address, so a VPN client takes
the node's LAN path and inherits its ARP failures. The router's udp/51820
forward pointed at k3s-prod-1, which held the shared Gateway lease, so all 16
Gateway hostnames were dead over the VPN -- while PiHole DNS kept answering
(its VIP has two Services and k3s-prod-2 held the other lease). The tunnel
handshook, names resolved, every app hung, and nothing reported it.

  on k3s-prod-1:  ip neigh show 192.168.2.20 -> FAILED      (its own VIP)
  on prod-2/3:    ip neigh show 192.168.2.20 -> lladdr ...  (prod-1), 443 OK

Mark the node and exclude it from announcing:

* node_baseline labels the forwarded node (node_baseline_vpn_ingress_node,
  k3s-prod-1 on prod) homelab.keithjackson.dev/vpn-ingress=true, and removes
  the label from any node that no longer matches so moving it MOVES the label.
* l2-announcement-policy.yaml excludes that label, so the node ARP-resolves
  every VIP. Fixes all VIPs for VPN clients, not just the one that collided.
  Cost: announcement candidacy drops from 3 nodes to 2.
* VpnIngressNodeAnnouncesL2VIP joins kube_lease_owner against kube_node_labels
  (label added to the kube-state-metrics allowlist), with
  VpnIngressNodeLabelMissing as its watchdog -- NotIn on an absent key matches,
  so a missing label would silently re-arm the whole failure AND leave the
  critical alert inert. Four promtool cases, including that inertness.

A node LABEL rather than a cluster-vars knob because the alert must read it
too, and extract_rules.py rightly refuses a ${...} placeholder in an alert
expr -- the rule unit-tested would not be the rule deployed. One source of
truth, and it touches no sensitive path.

Runbook/docs: step 3 of vpn-remote-access.md said "pick any node" and
"failover is this one line". Both false; the first caused this.

UNAPPLIED and unproven end to end; the Ansible tasks have never run. See the
handoff entry's third section.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 23:42:25 -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.