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:
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
|
||||
Reference in new issue
Block a user