Files
home-network-k3s-impl/platform
jakman85andClaude Opus 5 4664b41db3 fix(l4): externalTrafficPolicy Cluster on unifi + crafty L4 VIPs
Cilium's L2 announcer elects one node per Service to answer ARP for its
LoadBalancer VIP, and that election is NOT backend-aware. Combined with
externalTrafficPolicy: Local -- which lets a node deliver only to a backend on
itself -- an elected announcer holding no backend answers ARP for the address
and then has nowhere to send the packet, so the client gets ICMP port
unreachable. It never self-heals: the lease holder keeps renewing after the
backend moves away.

Measured on prod 2026-09-14: unifi's VM ran on k3s-prod-1 with both leases on
k3s-prod-2, and crafty's pod ran on k3s-prod-1 with the -tcp lease on
k3s-prod-2 while its identical -udp twin happened to draw k3s-prod-1 and
worked. Confirmed causally on the announcing node with `cilium-dbg service
list`: under Local the frontend 192.168.2.22:25565 is absent from that node's
service table entirely; under Cluster it appears as `1 => 10.0.0.11:25565
(active)`; flipped back, it vanishes again. Durability confirmed by cordoning
k3s-prod-1 and moving the crafty pod to k3s-prod-3, after which the announcing
node re-pointed to the new backend with no intervention.

Both files also carried a comment claiming Local "pins the VIP to the node
running the pod", which is the precise thing that is false here; both are
rewritten. apps/wave-0/pihole-dns.yaml is the reference implementation.

The client-IP cost is stated per service rather than waved away. For unifi it
is ZERO: the VM's KubeVirt `masquerade` binding already SNATs everything into
the guest, so Local was never preserving a client address the controller could
see. For crafty it is real and accepted -- player identity is a Mojang/Xbox
UUID so /ban and the whitelist are unaffected, but /ban-ip would hit every
player at once and must not be used; no IP ban or per-IP rate limit is
configured today.

No loadBalancerSourceRanges here, deliberately departing from pihole-dns.yaml:
these Services never had a source restriction (their CNPs admit `world`), so
Cluster removes no protection, and adding a LAN-only filter during an outage
fix could cut off off-LAN players or a remote site's device informing.

No CiliumNetworkPolicy change is needed, and that was verified rather than
assumed: Cilium's `cluster` entity subsumes `remote-node`, confirmed by
dumping the crafty endpoint's datapath policy map with the CNP unmodified
(`reserved:remote-node` was already an Allow on the game ports). Unlike
pihole, whose policy omits `cluster` and must name remote-node explicitly.

Adds an L0 whole-repo guard for the class. It fails on the pre-fix tree with
exactly these four Services named and passes after. whole-repo scope is
necessary: ci/touched-requirements.sh has no arm for apps/* or
platform/crafty/*, so the change most likely to reintroduce this would
attribute no requirement and be gated by nothing.

HONEST LIMIT -- this does NOT end the reported outage. All four ports also
have no listening backend: UniFi OS was never installed in the VM (its README
flags this as the open blocker) and /crafty/servers is empty, so no Minecraft
server exists. Probed directly from the node, bypassing the VIP entirely,
10.0.0.74:8443/:8080/:11443 and 10.0.0.11:25565 all refuse, while the two
controls answer. This commit fixes a real latent defect that would have
blackholed those VIPs once the applications exist; it cannot make an absent
application answer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 01:32:47 -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.