T-0.5 Kyverno L0 policies + CLI tests + admission scaffolding (R-TEST-01, R-NET-01, R-SEC-01/02, R-IDP-02, R-GITOPS-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-16 00:08:32 -05:00
1 parent bb08d0e048
commit bb2e9fc931
28 files changed
+1243 -2

No files matched your search

@@ -0,0 +1,58 @@
# ServiceDefinition L0 validation (R-GITOPS-02)
Validates the shape of each `catalog/` **ServiceDefinition** against the canonical
schema and invariants in the read-only spec `docs/13-service-catalog.md` (with
`auth` from `docs/15`, `egress` from `docs/03`). Runs in CI **without a cluster**.
## Why kyverno-json (not the resource CLI)
A ServiceDefinition is **arbitrary YAML** — its own `kind:` field means
`container | vm`, not a Kubernetes Kind — so it cannot be validated by
`kyverno apply`/`kyverno test` (which expect Kubernetes resources). Per
[ADR-0011] the Kyverno-ecosystem tool for arbitrary YAML/JSON is **kyverno-json**.
## Invariants enforced (`validate-servicedefinition.yaml`)
- `name` is a DNS-1123 label (→ `<name>.home.keithjackson.dev`).
- `kind` ∈ {`container`, `vm`}; container ⇒ `source.image`, vm ⇒ `source.vmTemplate`.
- `exposure` ∈ {`internal`, `public`}.
- `egress` ∈ {`internet`, `vpn`}.
- `auth.mode` ∈ {`oidc`, `forward-auth`, `native`, `none`}.
- **`public` exposure ⇒ `auth.mode` ≠ `none`** (no unauthenticated public UI, docs/15).
- each `dependencies[]` has `to`, `port` ∈ 1–65535, `protocol` ∈ {`tcp`, `udp`}.
- each `storage[].class` ∈ {`longhorn`, `bulk`}.
## Fixtures
`payloads/valid-*.yaml` must PASS; `payloads/invalid-*.yaml` must FAIL — one failure
reason each (bad egress, public+none auth, missing image, bad dependency).
## Run (CI entrypoint)
```bash
# Valid payloads: scan must exit 0
kyverno-json scan \
--policy validate-servicedefinition.yaml \
--payload payloads/valid-plex.yaml \
--payload payloads/valid-sonarr.yaml
# Invalid payloads: each scan must exit NON-zero (loop asserts failure)
for f in payloads/invalid-*.yaml; do
if kyverno-json scan --policy validate-servicedefinition.yaml --payload "$f"; then
echo "FAIL: expected $f to be rejected"; exit 1
fi
done
```
The `scripts/run-l0.sh` wrapper (see `../README.md`) runs both halves and is what CI
invokes.
## Caveat (escalated in the T-0.5 report)
kyverno-json is **pre-1.0**; its assertion syntax evolves. The **invariants above are
the normative content**. The CI agent must pin an exact kyverno-json version and
confirm the `check:` expressions parse under it; adjust syntax (never the invariants)
if the pinned release differs. The overlapping JSON Schema from WS-GITOPS (T-0.3) is
the complementary structural contract.
[ADR-0011]: ../../../../home-network-k3s/decisions/adr-0011-test-tooling-kyverno-chainsaw.md
@@ -0,0 +1,14 @@
# INVALID: a dependency uses protocol 'sctp' (not tcp|udp) and an out-of-range port
# (docs/13 dependencies shape). Rejected.
name: baddep
kind: container
source:
image: registry.example/app
exposure: internal
egress: internet
dependencies:
- to: plex
port: 70000
protocol: sctp
auth:
mode: forward-auth
@@ -0,0 +1,9 @@
# INVALID: egress class 'wan' is not in {internet, vpn} (docs/03, docs/13). Rejected.
name: badegress
kind: container
source:
image: registry.example/app
exposure: internal
egress: wan
auth:
mode: forward-auth
@@ -0,0 +1,9 @@
# INVALID: kind 'container' but no source.image (docs/13 requires an image ref for
# container kind). Rejected.
name: noimage
kind: container
source: {}
exposure: internal
egress: internet
auth:
mode: forward-auth
@@ -0,0 +1,10 @@
# INVALID: public exposure with auth.mode 'none' -> an unauthenticated public UI,
# which docs/15 forbids ("no management UI sits unauthenticated"). Rejected.
name: nakedui
kind: container
source:
image: registry.example/ui
exposure: public
egress: internet
auth:
mode: none
@@ -0,0 +1,22 @@
# VALID ServiceDefinition (R-GITOPS-02). Modelled on catalog service #1 (plex,
# docs/13): public, internet egress, native auth (Plex), longhorn + bulk storage.
name: plex
kind: container
source:
image: plexinc/pms-docker
exposure: public
egress: internet
storage:
- name: config
size: 10Gi
class: longhorn
- name: media
class: bulk
auth:
mode: native
update:
channel: stable
cadence: weekly
autoRollbackOnFailedHealth: true
observability:
metrics: true
@@ -0,0 +1,29 @@
# VALID ServiceDefinition (R-GITOPS-02). Modelled on catalog service #2 (sonarr,
# docs/13): internal, VPN egress + kill-switch, forward-auth, explicit dependencies.
name: sonarr
kind: container
source:
image: lscr.io/linuxserver/sonarr
exposure: internal
egress: vpn
storage:
- name: config
size: 5Gi
class: longhorn
- name: downloads
class: bulk
dependencies:
- to: qbittorrent
port: 8080
protocol: tcp
- to: jackett
port: 9117
protocol: tcp
auth:
mode: forward-auth
groups:
- media-users
update:
channel: stable
cadence: weekly
autoRollbackOnFailedHealth: true
@@ -0,0 +1,65 @@
# Serves: R-GITOPS-02 (the ServiceDefinition is the onboarding contract; it must be
# shape-valid before it can be rendered).
# Spec: docs/13-service-catalog.md "Fields (canonical schema)" + "Default posture";
# docs/15 (auth); docs/03 (egress).
#
# ServiceDefinitions are ARBITRARY YAML (their own `kind:` field is container|vm, NOT
# a Kubernetes Kind), so they are validated with kyverno-json (per ADR-0011:
# "kyverno-json for arbitrary YAML/JSON like ServiceDefinitions"), not the resource
# CLI. This policy encodes the docs/13 invariants only; the full JSON Schema +
# renderer contract is WS-GITOPS T-0.3 and is complementary, not a substitute.
#
# NOTE (see servicedefinition/README.md): kyverno-json is pre-1.0. The INVARIANTS
# below are the normative content; the CI agent must confirm the assertion syntax
# against the pinned kyverno-json release.
apiVersion: json.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
name: validate-servicedefinition
annotations:
homelab.keithjackson.dev/requirements: R-GITOPS-02
spec:
rules:
- name: servicedefinition-shape
assert:
all:
- message: "`name` must be a DNS-1123 label"
check:
(regex_match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', name)): true
- message: "`kind` must be 'container' or 'vm'"
check:
(kind == 'container' || kind == 'vm'): true
- message: "`exposure` must be 'internal' or 'public'"
check:
(exposure == 'internal' || exposure == 'public'): true
- message: "`egress` must be 'internet' or 'vpn'"
check:
(egress == 'internet' || egress == 'vpn'): true
- message: "container kind requires `source.image`"
check:
(kind != 'container' || source.image != null): true
- message: "vm kind requires `source.vmTemplate`"
check:
(kind != 'vm' || source.vmTemplate != null): true
- message: "`auth.mode` must be one of oidc|forward-auth|native|none"
check:
(auth.mode == 'oidc' || auth.mode == 'forward-auth' || auth.mode == 'native' || auth.mode == 'none'): true
- message: "public exposure must not use auth.mode 'none' -- no unauthenticated public UI (docs/15, R-IDP-02)"
check:
(exposure != 'public' || auth.mode != 'none'): true
- name: servicedefinition-dependencies
assert:
all:
- message: "each dependency needs `to`, `port` (1-65535) and `protocol` tcp|udp"
check:
~.dependencies:
(to != null): true
(port >= `1` && port <= `65535`): true
(protocol == 'tcp' || protocol == 'udp'): true
- name: servicedefinition-storage
assert:
all:
- message: "each storage entry's `class` must be 'longhorn' or 'bulk'"
check:
~.storage:
(class == 'longhorn' || class == 'bulk'): true