From 8c1368977e7870e3def5b0f13ba289d65f6c082e Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Sun, 16 Aug 2026 00:42:43 -0500 Subject: [PATCH] Phase 0 clarification follow-ups + reconcile CI integration seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the spec's Phase-0 clarifications (spec now at pin a7fc19f) into the impl: - schema v1.1: enforce exposure:public => auth.mode!=none (positive+negative tested). - Kyverno: add longhorn=>size and longhorn=>NOT-reacquirable invariants (R-STATE-02/03). - Ratify the homelab.keithjackson.dev/* label-key convention (now in spec docs/03). - R-TEST-06 spec-immutability gate: ci/SPEC_PIN + ci/spec-immutability.sh + gate.sh stage + Makefile target + a Gitea job (checks spec repo clean & at pin). - touched-requirements.sh: conformance tags are the authoritative source. Reconcile 3 CI seams the CI worker flagged (gate was RED, now GREEN): - create schemas/validate.sh (SCHEMA_VALIDATE_CMD entrypoint) — L0 schema gate runs. - fix l1.sh/conformance.sh to run.sh's real interface (fixed _artifacts/ paths; no --junit/--coverage) and collect artifacts. - exec bits on new scripts. make -C ci gate => PASS (l0 skips locally w/o kyverno binary; green in CI). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/merge-gate.yaml | 19 ++++++++ ci/Makefile | 5 +- ci/SPEC_PIN | 6 +++ ci/conformance.sh | 30 ++++++------ ci/gate.sh | 9 ++++ ci/l1.sh | 23 ++++++---- ci/spec-immutability.sh | 46 +++++++++++++++++++ ci/touched-requirements.sh | 21 +++++---- platform/kyverno/labels.md | 12 ++--- .../validate-servicedefinition.yaml | 16 ++++++- schemas/servicedefinition.schema.json | 16 ++++++- schemas/validate.sh | 15 ++++++ 12 files changed, 177 insertions(+), 41 deletions(-) create mode 100644 ci/SPEC_PIN create mode 100755 ci/spec-immutability.sh create mode 100755 schemas/validate.sh diff --git a/.gitea/workflows/merge-gate.yaml b/.gitea/workflows/merge-gate.yaml index 42a8615..dc5e06b 100644 --- a/.gitea/workflows/merge-gate.yaml +++ b/.gitea/workflows/merge-gate.yaml @@ -44,8 +44,27 @@ env: CLUSTER_AVAILABLE: ${{ vars.CLUSTER_AVAILABLE }} jobs: + # R-TEST-06 hard gate: the specification is read-only to agents. Fail the whole run + # if the spec repo is modified or has drifted from ci/SPEC_PIN. Runs first; everything + # else depends on it. Requires the spec repo checked out beside the impl repo — wired + # for when Gitea hosts both. If the spec repo is unavailable the guard SKIPS (exit 3), + # tolerated here (surfaced, never a fake green); a real violation (exit 1) blocks. + spec-immutability: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 # the implementation repo + - name: Check out the read-only spec (sibling path) + uses: actions/checkout@v4 + with: + repository: ${{ vars.SPEC_REPO_SLUG }} # e.g. Home-Network/home-network-k3s (set in Gitea) + path: ../home-network-k3s + fetch-depth: 0 + - name: Verify spec immutability (R-TEST-06) + run: make -C ci spec-immutability || [ $? -eq 3 ] + lint: runs-on: ubuntu-latest + needs: [spec-immutability] steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } # full history so touched-requirement diff works diff --git a/ci/Makefile b/ci/Makefile index 8d50e8e..ca0030c 100644 --- a/ci/Makefile +++ b/ci/Makefile @@ -11,11 +11,14 @@ CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) .DEFAULT_GOAL := help -.PHONY: help lint schema l0 l1 conformance gate all touched versions tools +.PHONY: help spec-immutability lint schema l0 l1 conformance gate all touched versions tools help: ## Show this help @awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST) +spec-immutability: ## R-TEST-06: fail if the spec repo is modified or drifted from ci/SPEC_PIN + @"$(CI_DIR)spec-immutability.sh" + lint: ## yaml/lint (no cluster; live now) @"$(CI_DIR)lint.sh" diff --git a/ci/SPEC_PIN b/ci/SPEC_PIN new file mode 100644 index 0000000..79e0e13 --- /dev/null +++ b/ci/SPEC_PIN @@ -0,0 +1,6 @@ +# The spec commit (home-network-k3s main) this implementation is built against. +# R-TEST-06: agents MUST NOT modify the spec. The spec changes only via the human/ +# System Architect; each legitimate spec advance is adopted here by bumping this pin +# in a reviewed change. CI (ci/spec-immutability.sh) fails if the spec repo is dirty +# or its HEAD != this pin — catching any unauthorized/unadopted spec modification. +a7fc19fad57c70e5c7b142afa142ac16e8b13091 diff --git a/ci/conformance.sh b/ci/conformance.sh index 4d0fa3b..f75218f 100755 --- a/ci/conformance.sh +++ b/ci/conformance.sh @@ -38,20 +38,22 @@ else log "no touched requirements detected — running full L2/L3 subset as a safety net" fi -# Contract we ASSUME of conformance/run.sh (flag for PM to reconcile): -# --layer l2,l3 behavioral + fault-injection layers -# --trigger in-cluster-job create/await the in-cluster Chainsaw Job (Job holds -# cluster creds via its scoped SA; CI uses only a -# narrow trigger credential, referenced by SECRET NAME -# in the workflow: CONFORMANCE_TRIGGER_KUBECONFIG) -# --requirements R-A,R-B touched subset (R-TEST-03) -# --junit --coverage collected artifacts feed the gate -"$CONFORMANCE_CMD" \ - --layer l2,l3 \ - --trigger in-cluster-job \ - "${req_args[@]}" \ - --junit "${JUNIT_DIR}/l2l3.junit.xml" \ - --coverage "${COVERAGE_DIR}" +# RECONCILED (PM 2026-08-16): run.sh writes artifacts to conformance/_artifacts/ and does +# NOT accept --junit/--coverage. This stage is CLUSTER-GATED (only runs when +# CLUSTER_AVAILABLE=true, Phase 1+), so its trigger path is confirmed against a real +# cluster in Phase 1 — TODO(Phase 1): drive the in-cluster Chainsaw Job +# (conformance/incluster/chainsaw-job.yaml) under the narrow CONFORMANCE_TRIGGER_KUBECONFIG, +# and confirm multi-layer (l2 + l3) selection syntax against run.sh. Artifacts are then +# COLLECTED from the fixed location below. +"$CONFORMANCE_CMD" --layer l2,l3 "${req_args[@]}" + +CONF_ART="$(dirname "$CONFORMANCE_CMD")/_artifacts" +if [ -f "${CONF_ART}/report.junit.xml" ]; then + cp "${CONF_ART}/report.junit.xml" "${JUNIT_DIR}/l2l3.junit.xml" +fi +if [ -d "${CONF_ART}/coverage" ]; then + cp -r "${CONF_ART}/coverage/." "${COVERAGE_DIR}/" +fi ok "L2/L3 conformance passed for touched subset" summary "- conformance-l2-l3: **passed**" diff --git a/ci/gate.sh b/ci/gate.sh index 518379e..4a44ab4 100755 --- a/ci/gate.sh +++ b/ci/gate.sh @@ -37,6 +37,7 @@ run_stage() { summary "## Merge gate (R-TEST-03)" summary "" +run_stage spec-immutability "${CI_DIR}/spec-immutability.sh" run_stage lint "${CI_DIR}/lint.sh" run_stage l0 "${CI_DIR}/l0.sh" run_stage l1 "${CI_DIR}/l1.sh" @@ -46,6 +47,14 @@ echo >&2 log "==== gate decision ====" BLOCK=0 +# spec-immutability (R-TEST-06): a FAIL always blocks (the spec was modified/drifted). +# A skip means the spec repo wasn't available to verify — surfaced, not green. +case "${OUTCOME[spec-immutability]}" in + fail) err "spec-immutability: FAILED -> blocks merge (R-TEST-06)"; BLOCK=1 ;; + skip) warn "spec-immutability: skipped (spec repo unavailable) — not verified" ;; + pass) ok "spec-immutability: green" ;; +esac + # L0/L1 stages: any failure blocks. A SKIP here means a seam is unwired — in # Phase 0 that is tolerated (placeholders), but surfaced. for s in lint l0 l1; do diff --git a/ci/l1.sh b/ci/l1.sh index 8e72095..98707e3 100755 --- a/ci/l1.sh +++ b/ci/l1.sh @@ -28,16 +28,19 @@ if [ "${#reqs[@]}" -gt 0 ]; then req_args=(--requirements "$(IFS=,; echo "${reqs[*]}")") fi -# Contract we ASSUME of conformance/run.sh (flag for PM to reconcile): -# --layer l1 select the L1 rendering layer (no cluster) -# --requirements R-A,R-B filter to the touched subset -# --junit write JUnit XML -# --coverage write the requirement-coverage report -"$CONFORMANCE_CMD" \ - --layer l1 \ - "${req_args[@]}" \ - --junit "${JUNIT_DIR}/l1.junit.xml" \ - --coverage "${COVERAGE_DIR}" +# RECONCILED (PM 2026-08-16): conformance/run.sh's real interface is `--layer ` +# [--requirements R-A,R-B]; it ALWAYS writes artifacts to a FIXED location +# (conformance/_artifacts/) and does NOT accept --junit/--coverage. Invoke by layer + +# touched subset, then COLLECT its artifacts into the CI artifact dirs. +"$CONFORMANCE_CMD" --layer l1 "${req_args[@]}" + +CONF_ART="$(dirname "$CONFORMANCE_CMD")/_artifacts" +if [ -f "${CONF_ART}/report.junit.xml" ]; then + cp "${CONF_ART}/report.junit.xml" "${JUNIT_DIR}/l1.junit.xml" +fi +if [ -d "${CONF_ART}/coverage" ]; then + cp -r "${CONF_ART}/coverage/." "${COVERAGE_DIR}/" +fi ok "L1 rendering checks passed" summary "- l1-render: **passed**" diff --git a/ci/spec-immutability.sh b/ci/spec-immutability.sh new file mode 100755 index 0000000..5789654 --- /dev/null +++ b/ci/spec-immutability.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# ci/spec-immutability.sh — R-TEST-06 gate: the specification is read-only to agents. +# +# The spec lives in a SEPARATE repo (home-network-k3s). Agents implement against it and +# never modify it; only the human/System Architect changes the spec. This gate fails CI if +# the spec repo has uncommitted edits or its HEAD has drifted from the pinned commit in +# ci/SPEC_PIN — catching any unauthorized (or un-adopted) spec change. +# +# Adopting a legitimate spec advance = bump ci/SPEC_PIN to the new spec main SHA in a +# reviewed PR. That makes every spec movement explicit and gated, per docs/14 / R-TEST-06. +# +# Exit codes (per ci/gate.sh contract): 0 pass · 1 fail (blocks merge) · 3 skip (spec +# repo not available to verify — honest skip, never fake green). +set -uo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" +set +e + +PIN="$(grep -vE '^\s*#' "${CI_DIR}/SPEC_PIN" | tr -d '[:space:]')" +: "${SPEC_REPO:=${REPO_ROOT}/../home-network-k3s}" + +if [ -z "$PIN" ]; then err "ci/SPEC_PIN has no pinned SHA"; exit 1; fi +if [ ! -d "${SPEC_REPO}/.git" ]; then + skip "spec repo not present at ${SPEC_REPO} (set SPEC_REPO) — cannot verify immutability" + exit 3 +fi + +dirty="$(git -C "$SPEC_REPO" status --porcelain 2>/dev/null)" +head="$(git -C "$SPEC_REPO" rev-parse HEAD 2>/dev/null)" + +rc=0 +if [ -n "$dirty" ]; then + err "spec repo has UNCOMMITTED changes — the spec is read-only to agents (R-TEST-06):" + printf '%s\n' "$dirty" >&2 + rc=1 +fi +if [ "$head" != "$PIN" ]; then + err "spec HEAD (${head:0:12}) != pinned (${PIN:0:12})." + err " If this is a legitimate spec advance by the System Architect, adopt it by bumping ci/SPEC_PIN." + err " Otherwise the spec was modified without authorization — reject." + rc=1 +fi + +[ "$rc" -eq 0 ] && ok "spec immutable: clean and at pinned ${PIN:0:12} (R-TEST-06)" +exit "$rc" diff --git a/ci/touched-requirements.sh b/ci/touched-requirements.sh index 14320a3..2935195 100755 --- a/ci/touched-requirements.sh +++ b/ci/touched-requirements.sh @@ -8,15 +8,20 @@ # Output (stdout): newline-separated, sorted, unique R-* IDs. Nothing else goes # to stdout, so callers can `mapfile`/`$(...)` it directly. # -# Detection = union of two signals: -# 1. R-* tokens literally present in the diff of changed files (tests, policies, -# and ServiceDefinitions carry their R-IDs per docs/14 + ADR-0011). -# 2. A coarse path->requirement map for changes that don't self-declare IDs. +# SOURCE OF TRUTH (reconciled, PM 2026-08-16): the conformance requirement TAGS are +# authoritative. Each Chainsaw test declares the requirements it verifies via +# `conformance.home.k3s/` labels; the coverage report (conformance/_artifacts/ +# coverage/coverage.json) is generated from those tags. This script's job is only to +# select which tagged subset a change TOUCHES, then hand those IDs to +# `conformance.sh --requirements ...` which runs exactly the tests carrying those tags. # -# ASSUMPTION (flag for PM): the authoritative change->requirement mapping is -# really WS-TEST's coverage tooling (tests tag their own R-IDs). This script is a -# CI-side approximation good enough to *filter* the subset; at integration it -# should defer to / cross-check the coverage report emitted by conformance/. +# Detection = union of: +# 1. R-* tokens literally present in the diff of changed files — including the +# authoritative `conformance.home.k3s/R-*` tags on changed tests, and the R-IDs +# that policies/ServiceDefinitions self-declare (docs/14 + ADR-0011). +# 2. A coarse path->requirement fallback for changes that don't self-declare IDs. +# The coarse map (2) NEVER overrides an explicit tag; it only widens coverage for +# untagged changes so nothing silently escapes the gate. set -euo pipefail CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 diff --git a/platform/kyverno/labels.md b/platform/kyverno/labels.md index 1ee6b1a..5798c04 100644 --- a/platform/kyverno/labels.md +++ b/platform/kyverno/labels.md @@ -1,11 +1,11 @@ -# Platform label/annotation convention (PROPOSED contract) +# Platform label/annotation convention (RATIFIED) These label keys are consumed by the Kyverno policies in this directory. The **values** -are fixed by the spec; the **keys** are a convention this workstream (WS-TEST) proposes -and are a **shared contract** with WS-NET (network/egress rendering), WS-SEC (auth), and -WS-GITOPS (ServiceDefinition → manifest rendering). Per -[`agents/coordination-protocol.md` §2] a contract like this needs the owning roles' -sign-off before it is locked. **Escalated as an open question in the T-0.5 report.** +are fixed by the spec; the **keys** are the platform convention, now **ratified in the +spec** — `homelab.keithjackson.dev/{egress-class,auth-mode,app-namespace,baseline}` are +documented in `docs/03-networking-isolation-and-egress.md` ("Platform label-key +convention") as the contract that network policies, Kyverno rules, and renderers key off. +Shared contract with WS-NET (egress rendering), WS-SEC (auth), and WS-GITOPS (rendering). | Label key | Applied to | Values | Fixed by | Used by policy | |---|---|---|---|---| diff --git a/platform/kyverno/servicedefinition/validate-servicedefinition.yaml b/platform/kyverno/servicedefinition/validate-servicedefinition.yaml index 1fd7fa9..4ca13a4 100644 --- a/platform/kyverno/servicedefinition/validate-servicedefinition.yaml +++ b/platform/kyverno/servicedefinition/validate-servicedefinition.yaml @@ -17,7 +17,7 @@ kind: ValidatingPolicy metadata: name: validate-servicedefinition annotations: - homelab.keithjackson.dev/requirements: R-GITOPS-02 + homelab.keithjackson.dev/requirements: R-GITOPS-02,R-STATE-02,R-STATE-03 spec: rules: - name: servicedefinition-shape @@ -63,3 +63,17 @@ spec: check: ~.storage: (class == 'longhorn' || class == 'bulk'): true + # docs/13 v1.1: longhorn (replicated, irreplaceable) volumes MUST be sized (R-STATE-02). + # bulk may omit size (external mount, sized outside the cluster). + - message: "a `longhorn` volume requires an explicit `size` (docs/13 v1.1; R-STATE-02)" + check: + ~.storage: + (class != 'longhorn' || size != null): true + - name: servicedefinition-backup + # docs/13 v1.1: irreplaceable (longhorn-tier) state MUST NOT be `reacquirable` — it must + # reference an off-cluster backup schedule (R-STATE-03). Only bulk-tier data may be reacquirable. + assert: + all: + - message: "backup.policy 'reacquirable' is not allowed when any volume is on the longhorn (irreplaceable) tier; reference an off-cluster schedule (docs/13 v1.1; R-STATE-03)" + check: + (backup.policy == 'reacquirable' && storage != null && contains(storage[].class, 'longhorn')): false diff --git a/schemas/servicedefinition.schema.json b/schemas/servicedefinition.schema.json index 5751ca3..f78f643 100644 --- a/schemas/servicedefinition.schema.json +++ b/schemas/servicedefinition.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json", "title": "ServiceDefinition", - "description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.0.0.", + "description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.1.0 (adds shape invariant exposure:public => auth.mode!=none, docs/13 v1.1 / docs/15 / R-IDP-02).", "type": "object", "additionalProperties": false, "required": ["name", "source"], @@ -244,6 +244,20 @@ } } } + }, + { + "$comment": "public exposure must not be unauthenticated: an explicit auth.mode 'none' is disallowed on public services (docs/13 v1.1, docs/15, R-IDP-02). Absent auth defaults to forward-auth at render time, so it is permitted here.", + "if": { + "required": ["exposure"], + "properties": { "exposure": { "const": "public" } } + }, + "then": { + "properties": { + "auth": { + "properties": { "mode": { "not": { "const": "none" } } } + } + } + } } ] } diff --git a/schemas/validate.sh b/schemas/validate.sh new file mode 100755 index 0000000..aaaec52 --- /dev/null +++ b/schemas/validate.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# schemas/validate.sh — validate ServiceDefinition YAML against the JSON Schema. +# The CI L0/L1 gate's entrypoint (ci/lib.sh SCHEMA_VALIDATE_CMD). No cluster needed +# (R-TEST-02). Usage: +# validate.sh ... # exit 0 = all valid, non-zero = a file failed +# validate.sh --version # print the validator version +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCHEMA="${HERE}/servicedefinition.schema.json" + +if [ "${1:-}" = "--version" ]; then + exec check-jsonschema --version +fi +[ "$#" -gt 0 ] || { echo "usage: validate.sh ... | --version" >&2; exit 2; } +exec check-jsonschema --schemafile "$SCHEMA" "$@"