Files
home-network-k3s-impl/platform
jakman85andClaude Opus 5 b7d6ba3d5f fix(kyverno): unblock CDI imports by SATISFYING the label requirement, not exempting it
Prod's UniFi VM has no disk. `DataVolume unifi/unifi-rootdisk` stuck in
ImportScheduled for hours, because Kyverno denied CDI's own generated pod:

  validate.kyverno.svc-fail denied Pod/unifi/importer-prime-<uid>
    require-auth-mode-label     (R-IDP-02)
    require-egress-class-label  (R-NET-05)

Both policies match any Pod in an `app-namespace: "true"` namespace with no
exclusion, and CDI generates `importer-*` itself — it cannot carry repo labels.
A COLD-START defect, not a prod quirk: dev's identical import Succeeded 9 days
ago, before the policies enforced, so dev has never exercised it and would hit it
on any rebuild. Fourth bug of this shape in two days.

MECHANISM: a MUTATE ClusterPolicy that ADDS
`egress-class: internet` + `auth-mode: none` (add-if-absent anchor) to Pods on
CREATE in app namespaces WHEN the requesting subject is ServiceAccount
`cdi/cdi-sa`. Both validate policies are untouched — no exclude, no exception,
`background: true` preserved on them.

Why mutate beats exempting: the requirement stays literally true ("every pod in an
app namespace declares both labels") and the values are HONEST — the importer does
pull over the default WAN path, and `internet` is the least-reach class (it does
not grant the VPN path); a transient worker pod with no UI genuinely is
`auth-mode: none`.

THE MATCH IS IDENTITY, NOT A NAME. `subjects: ServiceAccount cdi/cdi-sa` is the
AdmissionReview userInfo, taken from the real prod denial
(`system:serviceaccount:cdi:cdi-sa`, name importer-prime-…, op CREATE). A name
prefix is attacker-choosable; a requesting ServiceAccount is not — and the test
proves it, with a fixture `importer-prime-forged-by-an-app` that copies both the
name prefix AND CDI's labels and is still DENIED. Covers every CDI worker kind
(importer/uploader/cloner) without enumerating names that change per release.
Confirmed on prod that cdi-sa is the only cdi-namespace SA that can create pods.

REJECTED, and the first one is a trap worth recording:
  * PolicyException — the CRD EXISTS (v2/v2beta1, from the pre-applied bundle) but
    all controllers run `--enablePolicyException=false` (verified live on prod:
    admission, background and reports controllers all carry the flag). An exception
    would apply cleanly through Flux and be SILENTLY IGNORED. Enabling the gate is a
    cluster-wide capability change for no gain over the mutate.
  * `exclude` scoped to cdi-sa — sound identity, worse consequences: any userInfo
    match forces `background: false`, which would drop background scanning and
    policy-report coverage of R-NET-05/R-IDP-02 for ALL EXISTING PODS to fix one pod
    class. It also leaves the importer with no labels at all — the unlabelled
    fall-through that require-egress-class-label's own header calls a fail-open leak.
  * name-prefix / CDI-label matching — attacker-choosable, and proven walkable.

TWO-WAY PROOF, non-vacuous by construction — same resource, different requester,
opposite outcomes (verified by me, not just reported):
  userinfo cdi-sa   -> mutate pass, require-egress-class pass, require-auth-mode pass
  userinfo ordinary -> mutate SKIP,  require-egress-class FAIL, require-auth-mode FAIL
Kyverno L0 went 17 -> 29 tests passed, 0 failed. The mutate policy is deliberately
kept OUT of the existing tests/kyverno-test.yaml: that suite declares no userinfo,
and without userinfo the offline engine does not discriminate on subjects — it
mutates everything, which would have silently turned its negative rows into passes.

platform-config/kustomization.yaml is the live apply path for these ClusterPolicies;
without it the policy never reaches a cluster.

RESIDUAL TRADE-OFF, reversible by deleting one file: anything created by cdi-sa in
an app namespace now has these labels supplied rather than being rejected for not
declaring them — including a pod that is not a CDI worker. That identity already
holds `pods create/delete` cluster-wide via ClusterRole `cdi`, so it is not a new
capability; what changes is that for that one identity the declaration is assumed
rather than demanded. restrict-host-namespaces, the namespace default-deny baseline
and the CiliumNetworkPolicies all still apply.

FIRST resource-mutating policy on this cluster: kyverno-resource-mutating-webhook-cfg
holds 0 webhooks today; with autoUpdateWebhooks=true Kyverno registers
mutate.kyverno.svc-fail for Pods within seconds of this applying. Prod's
forceFailurePolicyIgnore=false makes it failurePolicy: Fail — same posture as the
validating webhook already in that path.

CDI retries importer creation on a loop, so the stuck import should proceed once
this lands. The detached prime-be3f9d1c-… 40 GiB scratch PVC is separate operator
cleanup; untouched, as is the DataVolume.

VERIFIED: full CI gate PASS. Nothing applied to any cluster.

Refs R-IDP-02, R-NET-05, R-PLAT-01.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 08:41:18 -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.