T-0.2 CI merge-gate pipeline + portable local runners (R-TEST-02/03, R-GITOPS-01)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
de56c55dcc
commit
0d86630271
15 files changed
+1022
-2
No files matched your search
+46
@@ -0,0 +1,46 @@
|
||||
# ci/Makefile — portable, single-command local equivalent of the Gitea Actions
|
||||
# merge gate (R-TEST-02: the gates are runnable TODAY, without Gitea).
|
||||
#
|
||||
# The Gitea Actions workflow (.gitea/workflows/merge-gate.yaml) calls these exact
|
||||
# scripts, so `make -C ci gate` locally == the CI merge gate. Run from anywhere:
|
||||
# make -C ci gate
|
||||
#
|
||||
# Every stage is a thin wrapper over a ci/*.sh script; see ci/README.md.
|
||||
SHELL := /usr/bin/env bash
|
||||
CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help 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)
|
||||
|
||||
lint: ## yaml/lint (no cluster; live now)
|
||||
@"$(CI_DIR)lint.sh"
|
||||
|
||||
schema l0: ## ServiceDefinition schema validation + Kyverno L0 policy gate (no cluster; live now)
|
||||
@"$(CI_DIR)l0.sh"
|
||||
|
||||
l1: ## L1 rendering checks (no cluster; live now)
|
||||
@"$(CI_DIR)l1.sh"
|
||||
|
||||
conformance: ## L2/L3 in-cluster Chainsaw Job (SKIPS w/o CLUSTER_AVAILABLE=true)
|
||||
@"$(CI_DIR)conformance.sh"
|
||||
|
||||
gate: ## Full merge gate: all stages + R-TEST-03 decision (the single command)
|
||||
@"$(CI_DIR)gate.sh"
|
||||
|
||||
all: gate ## Alias for `gate`
|
||||
|
||||
touched: ## Print the touched-requirement subset for the current branch vs main
|
||||
@"$(CI_DIR)touched-requirements.sh"
|
||||
|
||||
versions: ## Print pinned tool versions (R-TEST-02)
|
||||
@cat "$(CI_DIR)versions.env"
|
||||
|
||||
tools: ## Report which required tools are installed locally
|
||||
@source "$(CI_DIR)versions.env"; \
|
||||
for t in yamllint kyverno chainsaw kubectl yq git; do \
|
||||
if command -v $$t >/dev/null 2>&1; then printf " \033[32mok\033[0m %s\n" "$$t"; \
|
||||
else printf " \033[33mmiss\033[0m %s\n" "$$t"; fi; done
|
||||
@@ -0,0 +1,84 @@
|
||||
# `ci/` — portable merge gate (runnable today, without Gitea)
|
||||
|
||||
**Task T-0.2 · R-TEST-02 (reproducible, single-command) · Owner WS-GITOPS (CI).**
|
||||
|
||||
The same merge gate the Gitea Actions workflow runs
|
||||
([`.gitea/workflows/`](../.gitea/workflows/README.md)), as plain scripts + a Makefile so the gates are
|
||||
runnable **now**, before any Gitea host or cluster exists. CI and a laptop call the *identical*
|
||||
scripts, so `make -C ci gate` == the CI merge gate.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
make -C ci gate # the single command: run every stage + the R-TEST-03 decision
|
||||
make -C ci help # list targets
|
||||
make -C ci tools # which pinned tools are installed locally
|
||||
make -C ci touched # print the touched-requirement subset for this branch vs main
|
||||
```
|
||||
|
||||
Individual stages:
|
||||
|
||||
```bash
|
||||
make -C ci lint # yaml/lint (no cluster; live now)
|
||||
make -C ci l0 # ServiceDefinition schema + Kyverno (no cluster; live now)
|
||||
make -C ci l1 # L1 rendering checks (no cluster; live now)
|
||||
make -C ci conformance # L2/L3 in-cluster Chainsaw Job (SKIPS unless CLUSTER_AVAILABLE=true)
|
||||
```
|
||||
|
||||
## Exit-code contract
|
||||
|
||||
Every stage script uses: **`0` = pass · `1` = fail (blocks merge) · `3` = SKIPPED** (cluster-gated
|
||||
with no cluster, or an unwired seam / missing tool). A `3` is **never** counted as green — that is how
|
||||
the gate stays honest in Phase 0 (no fake green).
|
||||
|
||||
## Reproducibility (R-TEST-02)
|
||||
|
||||
- **Version-pinned:** all tool versions live in [`versions.env`](versions.env) — the single source of
|
||||
truth shared by the local scripts and the CI installer. Bumping a version is a normal
|
||||
feature-branch → main change.
|
||||
- **Single command, environment-agnostic:** `make -C ci gate` on any machine with the pinned tools.
|
||||
- **Deterministic:** no cluster state is assumed; L2/L3 SKIP unless a cluster is explicitly declared.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
ci/
|
||||
Makefile targets: lint · l0 · l1 · conformance · gate · touched · tools · versions
|
||||
versions.env PINNED tool versions (R-TEST-02) — single source of truth
|
||||
lib.sh shared helpers: logging, tool checks, cluster detection, artifact paths
|
||||
yamllint.yaml lint config for the yaml/lint stage
|
||||
lint.sh Stage: yaml/lint
|
||||
l0.sh Stage: ServiceDefinition schema validation + Kyverno L0 policy gate
|
||||
l1.sh Stage: L1 rendering checks (Chainsaw L1 via the conformance runner)
|
||||
conformance.sh Stage: L2/L3 in-cluster Chainsaw Job (trigger + collect)
|
||||
touched-requirements.sh compute the R-* subset a change touches (drives the R-TEST-03 filter)
|
||||
gate.sh run all stages + the merge decision (the single command)
|
||||
README.md this file
|
||||
```
|
||||
|
||||
## Integration seams — entrypoints owned by OTHER agents
|
||||
|
||||
This gate is an **orchestrator**. It does not implement schema validation, Kyverno policies, or the
|
||||
conformance runner — those are other workstreams' deliverables. Each seam is **env-overridable** so
|
||||
the owning agent / PM can reconcile the real entrypoint **without editing these scripts**. Defaults
|
||||
below are this workstream's **assumptions** — flagged for the PM to verify at integration.
|
||||
|
||||
| Seam | Owner / task | Env override | Assumed default | Assumed interface |
|
||||
|---|---|---|---|---|
|
||||
| ServiceDefinition schema validator | WS-GITOPS · **T-0.3** (`schemas/`) | `SCHEMA_VALIDATE_CMD` | `schemas/validate.sh` | `validate.sh catalog/*.yaml` → exit 0/1; `--version` supported |
|
||||
| Kyverno L0 policies + tests | WS-TEST · **T-0.5** (`platform/kyverno/`) | `KYVERNO_POLICY_DIR` | `platform/kyverno` | contains `kyverno-test.yaml` files discoverable by `kyverno test <dir>` |
|
||||
| Conformance runner (Chainsaw) | WS-TEST · **T-0.4** (`conformance/`) | `CONFORMANCE_CMD` | `conformance/run.sh` | `run.sh --layer <l1\|l2,l3> [--requirements R-A,R-B] [--trigger in-cluster-job] --junit <file> --coverage <dir>` |
|
||||
| Catalog (ServiceDefinitions) | WS-APPS | `CATALOG_DIR` | `catalog` | `*.yaml` ServiceDefinition files |
|
||||
|
||||
Artifact locations (git-ignored): JUnit → `results/junit/*.xml`; coverage → `reports/coverage/**`.
|
||||
Override with `JUNIT_DIR` / `COVERAGE_DIR` if the runner writes elsewhere.
|
||||
|
||||
**A seam that is missing or not executable reports SKIPPED (exit 3), not a false pass** — so during
|
||||
parallel Phase 0 development the gate is green-honest, and each seam goes live automatically the moment
|
||||
its owner delivers a runnable entrypoint.
|
||||
|
||||
## Cluster gating
|
||||
|
||||
`CLUSTER_AVAILABLE=true` (env locally, `vars.CLUSTER_AVAILABLE` in Gitea) makes the L2/L3 stage run and
|
||||
be **required**; anything else makes it **SKIP** (Phase 0). `REQUIRE_CLUSTER_TESTS=true` (Phase 1+)
|
||||
turns any unverified cluster-needing touched requirement into a hard block.
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/conformance.sh — Stage 5: L2 behavioral + L3 fault-injection.
|
||||
#
|
||||
# Per docs/14 + ADR-0011, L2/L3 run as an IN-CLUSTER Chainsaw Job on the dev/test
|
||||
# cluster; CI only *triggers* the Job and *collects* its JUnit + coverage. CI holds
|
||||
# no broad kubeconfig — the Job's own scoped ServiceAccount holds cluster access.
|
||||
#
|
||||
# PHASE 0: no cluster exists. This stage therefore SKIPS (exit 3) — clearly
|
||||
# reported, never faked green (HARD RULE 4). It goes live in Phase 1+ once a
|
||||
# cluster is declared via CLUSTER_AVAILABLE=true.
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
|
||||
log "Stage: L2/L3 conformance (in-cluster Chainsaw Job)"
|
||||
mkartifacts
|
||||
|
||||
if ! cluster_available; then
|
||||
skip "no cluster configured (CLUSTER_AVAILABLE!=true) — L2/L3 NOT RUN. Phase 0: expected."
|
||||
skip "these requirements remain UNVERIFIED until a dev/test cluster exists (Phase 1)."
|
||||
summary "- conformance-l2-l3: **SKIPPED — cluster required** (Phase 0; not a pass)"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! entrypoint_ready "$CONFORMANCE_CMD"; then
|
||||
die "CLUSTER_AVAILABLE=true but conformance runner '${CONFORMANCE_CMD}' is missing (T-0.4)"
|
||||
fi
|
||||
|
||||
# Touched-requirement subset (R-TEST-03) — the merge gate runs only the tagged
|
||||
# subset the change touches, not the whole suite.
|
||||
mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true)
|
||||
req_args=()
|
||||
if [ "${#reqs[@]}" -gt 0 ]; then
|
||||
log "touched requirements: ${reqs[*]}"
|
||||
req_args=(--requirements "$(IFS=,; echo "${reqs[*]}")")
|
||||
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 <file> --coverage <dir> 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}"
|
||||
|
||||
ok "L2/L3 conformance passed for touched subset"
|
||||
summary "- conformance-l2-l3: **passed**"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/gate.sh — the merge-gate decision. Runs every stage in order and applies the
|
||||
# R-TEST-03 rule: a merge is allowed only when the touched-requirement subset is
|
||||
# GREEN. Honestly distinguishes three outcomes per stage:
|
||||
# pass (0) green
|
||||
# fail (1) BLOCKS the merge
|
||||
# skip (3) not run (cluster-gated stage w/ no cluster, or unwired seam) —
|
||||
# NOT counted as green (HARD RULE 4: don't fake green)
|
||||
#
|
||||
# Phase 0 policy (no cluster): L0/L1 stages (lint, schema, kyverno, L1 render)
|
||||
# MUST be green. L2/L3 SKIP. If the touched subset needs L2/L3 and no cluster is
|
||||
# available, the gate reports those requirements UNVERIFIED and — unless
|
||||
# REQUIRE_CLUSTER_TESTS=true — does not hard-block Phase-0 bootstrap work, while
|
||||
# making the gap loud. Set REQUIRE_CLUSTER_TESTS=true (Phase 1+) to make any
|
||||
# skipped, cluster-needing touched requirement a hard block.
|
||||
set -uo pipefail # NOTE: no -e; we want to run all stages and aggregate.
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
set +e # lib.sh enables -e; the gate must run ALL stages and aggregate, not abort.
|
||||
|
||||
: "${REQUIRE_CLUSTER_TESTS:=false}"
|
||||
|
||||
declare -A OUTCOME
|
||||
run_stage() {
|
||||
local name="$1"; shift
|
||||
log "==== stage: ${name} ===="
|
||||
"$@"
|
||||
local rc=$?
|
||||
case "$rc" in
|
||||
0) OUTCOME[$name]="pass"; ok "stage ${name}: pass" ;;
|
||||
3) OUTCOME[$name]="skip"; skip "stage ${name}: SKIPPED (not run)" ;;
|
||||
*) OUTCOME[$name]="fail"; err "stage ${name}: FAIL" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
summary "## Merge gate (R-TEST-03)"
|
||||
summary ""
|
||||
|
||||
run_stage lint "${CI_DIR}/lint.sh"
|
||||
run_stage l0 "${CI_DIR}/l0.sh"
|
||||
run_stage l1 "${CI_DIR}/l1.sh"
|
||||
run_stage conformance "${CI_DIR}/conformance.sh"
|
||||
|
||||
echo >&2
|
||||
log "==== gate decision ===="
|
||||
BLOCK=0
|
||||
|
||||
# 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
|
||||
case "${OUTCOME[$s]}" in
|
||||
fail) err "${s}: FAILED -> blocks merge"; BLOCK=1 ;;
|
||||
skip) warn "${s}: skipped (unwired seam / missing tool) — not green" ;;
|
||||
pass) ok "${s}: green" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# L2/L3 conformance: cluster-gated.
|
||||
case "${OUTCOME[conformance]}" in
|
||||
fail)
|
||||
err "conformance (L2/L3): FAILED -> blocks merge"; BLOCK=1 ;;
|
||||
pass)
|
||||
ok "conformance (L2/L3): green" ;;
|
||||
skip)
|
||||
if cluster_available; then
|
||||
err "conformance: skipped despite CLUSTER_AVAILABLE=true -> blocks merge"; BLOCK=1
|
||||
else
|
||||
mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true)
|
||||
if [ "${#reqs[@]}" -gt 0 ]; then
|
||||
warn "conformance L2/L3 UNVERIFIED for touched requirements: ${reqs[*]}"
|
||||
warn " (no cluster — Phase 0). These are NOT certified green."
|
||||
summary "- **UNVERIFIED (no cluster):** ${reqs[*]}"
|
||||
if [ "$REQUIRE_CLUSTER_TESTS" = "true" ]; then
|
||||
err "REQUIRE_CLUSTER_TESTS=true and cluster-needing requirements are unverified -> blocks merge"
|
||||
BLOCK=1
|
||||
fi
|
||||
else
|
||||
skip "conformance L2/L3 skipped; no cluster-needing requirements touched"
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
echo >&2
|
||||
if [ "$BLOCK" -eq 0 ]; then
|
||||
ok "MERGE GATE: PASS (touched-requirement subset green for the layers that can run)"
|
||||
summary ""
|
||||
summary "**Result: PASS** — L0/L1 green. L2/L3 status per cluster availability above."
|
||||
exit 0
|
||||
else
|
||||
err "MERGE GATE: BLOCKED"
|
||||
summary ""
|
||||
summary "**Result: BLOCKED** — see failures above."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/l0.sh — Stage 2+3: ServiceDefinition schema validation + Kyverno L0 policy gate.
|
||||
# Both run in CI WITHOUT a cluster (docs/14 §"Where each layer runs") — live now.
|
||||
#
|
||||
# Stage 2 schema : catalog/*.yaml validated against the ServiceDefinition
|
||||
# schema (owner: T-0.3, schemas/).
|
||||
# Stage 3 L0 : `kyverno test` over platform/kyverno/ — default-deny baseline
|
||||
# present, no plaintext secrets, required labels/egress-class,
|
||||
# ServiceDefinition schema conformance (owner: T-0.5, ADR-0011).
|
||||
#
|
||||
# Integration seams are env-overridable (see ci/lib.sh + README). When an owning
|
||||
# subtree is still a placeholder, this reports SKIPPED (exit 3) for that sub-stage
|
||||
# rather than a false pass — the gate then knows the seam is unwired, not green.
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
|
||||
rc=0
|
||||
|
||||
# ---- Stage 2: ServiceDefinition schema validation ---------------------------
|
||||
log "Stage: ServiceDefinition schema validation (catalog/ against schemas/)"
|
||||
if ! entrypoint_ready "$SCHEMA_VALIDATE_CMD"; then
|
||||
skip "schema validator not present at '${SCHEMA_VALIDATE_CMD}' (T-0.3 / schemas/ not delivered yet) — UNWIRED, not green"
|
||||
summary "- schema: **SKIPPED** (validator entrypoint \`${SCHEMA_VALIDATE_CMD}\` missing — T-0.3)"
|
||||
else
|
||||
if [ -d "$CATALOG_DIR" ] && compgen -G "${CATALOG_DIR}/*.y*ml" >/dev/null 2>&1; then
|
||||
if "$SCHEMA_VALIDATE_CMD" "${CATALOG_DIR}"/*.y*ml; then
|
||||
ok "schema validation passed"
|
||||
summary "- schema: **passed**"
|
||||
else
|
||||
err "schema validation failed"; summary "- schema: **FAILED**"; rc=1
|
||||
fi
|
||||
else
|
||||
log "no ServiceDefinitions in ${CATALOG_DIR} yet — validating validator is callable only"
|
||||
"$SCHEMA_VALIDATE_CMD" --version >/dev/null 2>&1 || true
|
||||
ok "schema validator present; no catalog entries to validate"
|
||||
summary "- schema: **passed** (no catalog entries yet)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- Stage 3: Kyverno L0 policy gate ----------------------------------------
|
||||
log "Stage: Kyverno L0 policy gate (kyverno test ${KYVERNO_POLICY_DIR})"
|
||||
# `kyverno test <dir>` discovers kyverno-test.yaml files recursively.
|
||||
if ! have kyverno; then
|
||||
skip "kyverno CLI not installed (pin v${KYVERNO_VERSION}) — cannot run L0 gate"
|
||||
summary "- kyverno-l0: **SKIPPED** (kyverno CLI not installed)"
|
||||
# Missing tool is an environment gap, not a code pass -> exit 3 unless already failing.
|
||||
[ "$rc" -eq 0 ] && rc=3
|
||||
elif [ ! -d "$KYVERNO_POLICY_DIR" ] || ! compgen -G "${KYVERNO_POLICY_DIR}/**/kyverno-test.yaml" >/dev/null 2>&1 \
|
||||
&& ! find "$KYVERNO_POLICY_DIR" -name 'kyverno-test.yaml' 2>/dev/null | grep -q .; then
|
||||
skip "no kyverno-test.yaml under '${KYVERNO_POLICY_DIR}' (T-0.5 / platform/kyverno not delivered yet) — UNWIRED, not green"
|
||||
summary "- kyverno-l0: **SKIPPED** (no policy tests yet — T-0.5)"
|
||||
else
|
||||
if kyverno test "$KYVERNO_POLICY_DIR"; then
|
||||
ok "Kyverno L0 policy tests passed"
|
||||
summary "- kyverno-l0: **passed**"
|
||||
else
|
||||
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$rc" in
|
||||
0) ok "L0 stage complete" ;;
|
||||
3) skip "L0 stage incomplete (unwired seam / missing tool) — not a pass" ;;
|
||||
*) err "L0 stage failed" ;;
|
||||
esac
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/l1.sh — Stage 4: L1 rendering checks. Runs in CI WITHOUT a cluster
|
||||
# (docs/14: "L1 rendering assertions run in CI without a cluster").
|
||||
#
|
||||
# L1 asserts ServiceDefinition -> rendered resources match golden expectations
|
||||
# (e.g. `egress: vpn` renders the kill-switched egress path). The actual render
|
||||
# + assert is Chainsaw's L1 layer, owned by the conformance harness (T-0.4).
|
||||
# CI just invokes the runner filtered to the L1 layer and the touched subset.
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
|
||||
log "Stage: L1 rendering checks (no cluster)"
|
||||
mkartifacts
|
||||
|
||||
if ! entrypoint_ready "$CONFORMANCE_CMD"; then
|
||||
skip "conformance runner not present at '${CONFORMANCE_CMD}' (T-0.4 / conformance/ not delivered yet) — UNWIRED, not green"
|
||||
summary "- l1-render: **SKIPPED** (conformance runner missing — T-0.4)"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Touched-requirement subset (R-TEST-03). Empty subset => run all L1 as a sanity net.
|
||||
mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true)
|
||||
req_args=()
|
||||
if [ "${#reqs[@]}" -gt 0 ]; then
|
||||
log "touched requirements: ${reqs[*]}"
|
||||
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 <file> write JUnit XML
|
||||
# --coverage <dir> write the requirement-coverage report
|
||||
"$CONFORMANCE_CMD" \
|
||||
--layer l1 \
|
||||
"${req_args[@]}" \
|
||||
--junit "${JUNIT_DIR}/l1.junit.xml" \
|
||||
--coverage "${COVERAGE_DIR}"
|
||||
|
||||
ok "L1 rendering checks passed"
|
||||
summary "- l1-render: **passed**"
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/lib.sh — shared helpers for the merge-gate scripts.
|
||||
# Sourced by every ci/*.sh; never executed directly.
|
||||
#
|
||||
# Conventions:
|
||||
# - All scripts: `set -euo pipefail`.
|
||||
# - Log to stderr; machine-readable output (e.g. requirement lists) to stdout.
|
||||
# - Exit codes: 0 = pass, 1 = fail (gate blocks), 3 = SKIPPED/not-run
|
||||
# (cluster-gated stage with no cluster — NOT a pass, NOT a fail).
|
||||
set -euo pipefail
|
||||
|
||||
# --- Paths -------------------------------------------------------------------
|
||||
# CI_DIR = this ci/ directory; REPO_ROOT = its parent (impl repo root).
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/versions.env"
|
||||
|
||||
# --- Integration points (other agents' entrypoints) --------------------------
|
||||
# These are the seams this gate depends on. Each has an env override so the PM /
|
||||
# owning agent can reconcile the real entrypoint at integration WITHOUT editing
|
||||
# these scripts. Defaults are this workstream's *assumptions* — see README.
|
||||
: "${CATALOG_DIR:=${REPO_ROOT}/catalog}" # ServiceDefinition sources
|
||||
: "${SCHEMAS_DIR:=${REPO_ROOT}/schemas}" # owned by T-0.3 (schema + renderer contract)
|
||||
: "${KYVERNO_POLICY_DIR:=${REPO_ROOT}/platform/kyverno}" # owned by T-0.5 (L0 Kyverno policies + tests)
|
||||
: "${CONFORMANCE_DIR:=${REPO_ROOT}/conformance}" # owned by T-0.4 (Chainsaw harness + coverage)
|
||||
# Commands (overridable). Defaults documented in ci/README.md.
|
||||
: "${SCHEMA_VALIDATE_CMD:=${SCHEMAS_DIR}/validate.sh}" # ASSUMED: validates catalog/*.yaml against the ServiceDefinition schema
|
||||
: "${CONFORMANCE_CMD:=${CONFORMANCE_DIR}/run.sh}" # ASSUMED: single-command conformance runner (R-TEST-02)
|
||||
|
||||
# --- Artifact locations (git-ignored: see .gitignore) ------------------------
|
||||
: "${RESULTS_DIR:=${REPO_ROOT}/results}" # JUnit XML lands here
|
||||
: "${REPORTS_DIR:=${REPO_ROOT}/reports}" # requirement-coverage report lands here
|
||||
: "${JUNIT_DIR:=${RESULTS_DIR}/junit}"
|
||||
: "${COVERAGE_DIR:=${REPORTS_DIR}/coverage}"
|
||||
|
||||
# --- Logging -----------------------------------------------------------------
|
||||
_is_tty() { [ -t 2 ]; }
|
||||
if _is_tty; then C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'
|
||||
else C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_DIM=""; C_RST=""; fi
|
||||
|
||||
log() { printf '%s[gate]%s %s\n' "$C_BLU" "$C_RST" "$*" >&2; }
|
||||
ok() { printf '%s[ ok ]%s %s\n' "$C_GRN" "$C_RST" "$*" >&2; }
|
||||
warn() { printf '%s[warn]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; }
|
||||
err() { printf '%s[fail]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; }
|
||||
skip() { printf '%s[skip]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; }
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
# Emit a GitHub/Gitea-Actions step-summary line if running in Actions.
|
||||
summary() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] && printf '%s\n' "$*" >> "$GITHUB_STEP_SUMMARY" || true; }
|
||||
|
||||
# --- Tooling -----------------------------------------------------------------
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# require_tool <bin> <hint> — fail with an actionable message if missing.
|
||||
require_tool() {
|
||||
have "$1" && return 0
|
||||
die "required tool '$1' not found. $2"
|
||||
}
|
||||
|
||||
# entrypoint_ready <path-or-cmd> — returns 0 only if it exists AND is runnable.
|
||||
# Used to distinguish 'other agent hasn't delivered yet / non-executable stub'
|
||||
# (-> SKIP) from a wired entrypoint that genuinely fails (-> FAIL). A file that
|
||||
# exists but isn't executable counts as NOT ready (unwired), not a failure.
|
||||
entrypoint_ready() {
|
||||
local ep="$1"
|
||||
have "$ep" && return 0 # a bare command on PATH
|
||||
[ -f "$ep" ] && [ -x "$ep" ] && return 0 # an executable file
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- Cluster detection (drives SKIP vs run for L2/L3) -------------------------
|
||||
# A cluster is 'available' only if explicitly declared. We NEVER auto-assume a
|
||||
# cluster in Phase 0 — silence means SKIP, not pass (HARD RULE 4).
|
||||
# CLUSTER_AVAILABLE=true -> L2/L3 run and are REQUIRED green
|
||||
# CLUSTER_AVAILABLE=false/unset -> L2/L3 SKIP (reported, never faked green)
|
||||
cluster_available() {
|
||||
case "${CLUSTER_AVAILABLE:-false}" in
|
||||
true|1|yes) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mkartifacts() { mkdir -p "$JUNIT_DIR" "$COVERAGE_DIR"; }
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/lint.sh — Stage 1: yaml/lint. Runs in CI without a cluster (live now).
|
||||
# Lints all YAML in the repo against ci/yamllint.yaml.
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
|
||||
log "Stage: yaml/lint"
|
||||
require_tool yamllint "Install: pip install 'yamllint==${YAMLLINT_VERSION}'"
|
||||
|
||||
# Lint tracked + untracked (not yet committed) YAML; skip .git and artifact dirs.
|
||||
cd "$REPO_ROOT"
|
||||
mapfile -t files < <(
|
||||
{ git ls-files '*.yaml' '*.yml' 2>/dev/null
|
||||
git ls-files --others --exclude-standard '*.yaml' '*.yml' 2>/dev/null
|
||||
} | sort -u
|
||||
)
|
||||
# Fallback for a non-git checkout.
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
mapfile -t files < <(find . -path ./.git -prune -o \( -name '*.yaml' -o -name '*.yml' \) -print)
|
||||
fi
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
ok "no YAML files to lint"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${files[@]}"
|
||||
ok "yaml/lint passed (${#files[@]} files)"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/touched-requirements.sh — compute the requirement subset a change "touches".
|
||||
#
|
||||
# R-TEST-03 gates a merge on the conformance subset tagged with the requirement
|
||||
# IDs the change touches. This script derives that subset from the diff so the
|
||||
# conformance runner can be filtered to it (conformance.sh --requirements ...).
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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/.
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
|
||||
# Base ref to diff against. In Gitea/GitHub Actions PR runs this is the target
|
||||
# branch (main). Locally, default to main; override with BASE_REF.
|
||||
: "${BASE_REF:=}"
|
||||
resolve_base() {
|
||||
if [ -n "$BASE_REF" ]; then echo "$BASE_REF"; return; fi
|
||||
if [ -n "${GITHUB_BASE_REF:-}" ]; then echo "origin/${GITHUB_BASE_REF}"; return; fi
|
||||
if git -C "$REPO_ROOT" rev-parse --verify -q origin/main >/dev/null; then echo "origin/main"; return; fi
|
||||
if git -C "$REPO_ROOT" rev-parse --verify -q main >/dev/null; then echo "main"; return; fi
|
||||
echo "HEAD~1" # last resort
|
||||
}
|
||||
|
||||
changed_files() {
|
||||
local base; base="$(resolve_base)"
|
||||
# Three-dot: changes on this branch since it diverged from base.
|
||||
if git -C "$REPO_ROOT" diff --name-only "${base}...HEAD" 2>/dev/null; then return; fi
|
||||
git -C "$REPO_ROOT" diff --name-only "${base}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Coarse path -> requirement map. Deliberately conservative; extend as subtrees
|
||||
# fill in. Left side is a path prefix (relative to repo root).
|
||||
path_requirements() {
|
||||
case "$1" in
|
||||
catalog/*) echo "R-GITOPS-02" ;;
|
||||
schemas/*) echo "R-GITOPS-02" ;;
|
||||
platform/kyverno/*) echo "R-TEST-01 R-NET-01 R-SEC-01" ;;
|
||||
platform/cilium/*) echo "R-NET-01 R-NET-02 R-NET-05" ;;
|
||||
platform/longhorn/*) echo "R-STATE-01 R-STATE-02" ;;
|
||||
platform/cert-manager/*) echo "R-TLS-01 R-TLS-02" ;;
|
||||
platform/externaldns/*) echo "R-DNS-02" ;;
|
||||
platform/velero/*) echo "R-STATE-03" ;;
|
||||
platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;;
|
||||
platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;;
|
||||
conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;;
|
||||
clusters/*) echo "R-GITOPS-01 R-ENV-02" ;;
|
||||
.gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;;
|
||||
*) : ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
local files reqs=""
|
||||
files="$(changed_files)"
|
||||
[ -n "$files" ] || { log "no changed files vs base; empty touched-requirement set"; exit 0; }
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
reqs+=" $(path_requirements "$f")"
|
||||
# Scan file contents for self-declared R-IDs (only if it still exists).
|
||||
if [ -f "${REPO_ROOT}/${f}" ]; then
|
||||
reqs+=" $(grep -hoE 'R-[A-Z]+-[0-9]+' "${REPO_ROOT}/${f}" 2>/dev/null | tr '\n' ' ' || true)"
|
||||
fi
|
||||
done <<< "$files"
|
||||
|
||||
# Normalize: split, unique, sort. Emit to stdout.
|
||||
printf '%s\n' $reqs | sed '/^$/d' | sort -u
|
||||
}
|
||||
main "$@"
|
||||
@@ -0,0 +1,22 @@
|
||||
# ci/versions.env — pinned tool versions (R-TEST-02: reproducible, version-pinned)
|
||||
#
|
||||
# Single source of truth for every tool the gate invokes. The Gitea Actions
|
||||
# workflow and the local scripts both source this file so CI and a laptop run
|
||||
# the exact same versions. Bump deliberately; a version change is a code change
|
||||
# that must go through the normal feature-branch -> main gate.
|
||||
#
|
||||
# Owner: WS-GITOSS (CI). The Kyverno/Chainsaw versions MUST stay in lockstep with
|
||||
# whatever platform/kyverno and conformance/ pin — see integration notes in README.
|
||||
|
||||
# L0 policy gate (Kyverno CLI) + ServiceDefinition validation (kyverno-json)
|
||||
KYVERNO_VERSION="1.13.4"
|
||||
KYVERNO_JSON_VERSION="0.0.5"
|
||||
|
||||
# L1/L2/L3 declarative runner (Chainsaw) — invoked via conformance/ entrypoint,
|
||||
# pinned here so a local run matches CI even before the in-cluster Job exists.
|
||||
CHAINSAW_VERSION="0.2.12"
|
||||
|
||||
# Lint / manifest tooling
|
||||
YAMLLINT_VERSION="1.35.1"
|
||||
KUBECTL_VERSION="1.31.4"
|
||||
YQ_VERSION="4.44.6"
|
||||
@@ -0,0 +1,33 @@
|
||||
# ci/yamllint.yaml — lint config for the yaml/lint gate stage.
|
||||
# Kept under ci/ (not repo root) because WS-GITOPS(CI) owns only .gitea/ and ci/.
|
||||
# Pass with: yamllint -c ci/yamllint.yaml <paths>
|
||||
extends: default
|
||||
|
||||
yaml-files:
|
||||
- "*.yaml"
|
||||
- "*.yml"
|
||||
|
||||
rules:
|
||||
# Kubernetes / Kustomize / Helm manifests routinely exceed 80 cols; allow room
|
||||
# but keep a sane ceiling so lines stay reviewable.
|
||||
line-length:
|
||||
max: 160
|
||||
level: warning
|
||||
# '---' at the top of multi-doc manifests is idiomatic; don't force it, don't ban it.
|
||||
document-start: disable
|
||||
# Helm/Kustomize templates and CRDs use 'on:', 'yes'/'no' keys legitimately.
|
||||
truthy:
|
||||
allowed-values: ["true", "false"]
|
||||
check-keys: false
|
||||
# Gitea/GitHub Actions expressions `${{ ... }}` and inline flow maps
|
||||
# (`with: { fetch-depth: 0 }`) legitimately carry a space inside braces.
|
||||
braces:
|
||||
max-spaces-inside: 1
|
||||
min-spaces-inside: 0
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
# Long inline comments in manifests are common.
|
||||
comments-indentation: disable
|
||||
indentation:
|
||||
spaces: 2
|
||||
indent-sequences: consistent
|
||||
Reference in new issue
Block a user