From b7a3162028d8ddfd65014897bbf01b16da971435 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 30 Apr 2026 20:50:52 +0000 Subject: [PATCH] ci-pipeline-cleanup Phases 7-9: image-and-supply-chain job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle: ci-pipeline-cleanup, Phases 7-9 / frozen decisions 0.8 + 0.10 + 0.11. NEW image-and-supply-chain job (Ubuntu, ~3 min). Three steps: PHASE 7 — Digest validity scripts/ci-guards/digest-validity.sh resolves every @sha256: ref in deploy/**/*.{yml,Dockerfile*} against its registry. Closes the H-001 lying-field gap that Bundle II hit (11 fabricated digests passed H-001's regex-only check and failed docker pull in CI). Sandbox verification: 16/16 digests in deploy/* + Dockerfiles all return HTTP 200 from registry-1.docker.io / ghcr.io / mcr.microsoft.com. PHASE 8 — Docker build smoke (all 4 Dockerfiles) Per frozen decision 0.10: build Dockerfile, Dockerfile.agent, deploy/test/f5-mock-icontrol/Dockerfile, deploy/test/libest/Dockerfile. Catches syntax errors + COPY path drift before tag-time release.yml. The test-sidecar Dockerfiles are load-bearing for vendor-e2e — a syntax error there silently breaks the e2e suite. PHASE 9 — OpenAPI ↔ handler operationId parity scripts/ci-guards/openapi-handler-parity.sh extracts router routes (r.mux.Handle / r.Register "METHOD /path" syntax — Go 1.22+ ServeMux), extracts OpenAPI operations (paths × HTTP methods), and fails if any router route has no operationId AND is not documented in the new api/openapi-handler-exceptions.yaml. Verified gap at HEAD c48a82c4 (root-caused): 142 router routes, 136 OpenAPI operations 6 router-only routes — all SCEP wire-protocol endpoints (RFC-shaped, not REST). Documented in api/openapi-handler-exceptions.yaml with one-line why: justifications. 0 OpenAPI-only operations. Going forward: any new gap fails the build unless documented. Status checks per push: now 7 (was 8 after Phase 5+6 dropped windows; this Phase adds 1 = +1 net). Final acceptance gate target. ci.yml: 383 → 432 lines (+49 for the new job + steps). --- .github/workflows/ci.yml | 45 +++++++++ api/openapi-handler-exceptions.yaml | 27 +++++ scripts/ci-guards/digest-validity.sh | 104 ++++++++++++++++++++ scripts/ci-guards/openapi-handler-parity.sh | 103 +++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 api/openapi-handler-exceptions.yaml create mode 100755 scripts/ci-guards/digest-validity.sh create mode 100755 scripts/ci-guards/openapi-handler-parity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eff839b..06d8e34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -381,3 +381,48 @@ jobs: - name: Tear down sidecars if: always() run: docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml down -v + + # ============================================================================= + # image-and-supply-chain — digest validity + Docker build smoke + OpenAPI parity + # ============================================================================= + # Per ci-pipeline-cleanup bundle Phases 7-9 / frozen decision 0.8. + # Three checks bundled into one job (parallel to go-build-and-test): + # 1. Digest validity — every @sha256 ref in deploy/* + Dockerfiles must + # resolve on its registry. Closes the H-001 lying-field gap (H-001 + # verifies digest *presence* but not *resolution* — Bundle II shipped + # 11 fabricated digests that passed H-001 and failed `docker pull`). + # 2. Docker build smoke — all 4 Dockerfiles in the repo must build. + # Catches syntax errors / COPY path drift before tag-time release.yml. + # 3. OpenAPI ↔ handler parity — every router route has a matching + # operationId or is documented in api/openapi-handler-exceptions.yaml. + image-and-supply-chain: + name: image-and-supply-chain + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.9' + cache: true + + - name: Digest validity (every @sha256 ref must resolve) + run: bash scripts/ci-guards/digest-validity.sh + + - name: Docker build smoke (all 4 Dockerfiles) + # Per frozen decision 0.10: build all 4 Dockerfiles in the repo, + # not just production server + agent. The test-sidecar Dockerfiles + # are load-bearing for vendor-e2e — a syntax error there silently + # breaks the e2e suite. + run: | + set -e + docker build -f Dockerfile -t certctl:smoke . + docker build -f Dockerfile.agent -t certctl-agent:smoke . + docker build -f deploy/test/f5-mock-icontrol/Dockerfile -t f5-mock:smoke . + docker build -f deploy/test/libest/Dockerfile -t libest:smoke . + echo "All 4 Dockerfiles build clean." + + - name: OpenAPI ↔ handler operationId parity + run: bash scripts/ci-guards/openapi-handler-parity.sh diff --git a/api/openapi-handler-exceptions.yaml b/api/openapi-handler-exceptions.yaml new file mode 100644 index 0000000..6c5e86c --- /dev/null +++ b/api/openapi-handler-exceptions.yaml @@ -0,0 +1,27 @@ +# Routes registered in internal/api/router/router.go that are intentionally +# NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification. +# Adding a new entry requires PR-time review. +# +# OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here. +# This list is for protocol-shaped (SCEP wire endpoints) and operational +# (health, metrics, pprof) routes only. +# +# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. + +documented_exceptions: + - route: "GET /scep" + why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource." + - route: "POST /scep" + why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource." + - route: "GET /scep/" + why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form." + - route: "POST /scep/" + why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form." + - route: "GET /scep-mtls" + why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant." + - route: "POST /scep-mtls" + why: "SCEP-mTLS sibling endpoint, POST variant." + - route: "GET /scep-mtls/" + why: "SCEP-mTLS sibling endpoint, trailing-slash variant." + - route: "POST /scep-mtls/" + why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant." diff --git a/scripts/ci-guards/digest-validity.sh b/scripts/ci-guards/digest-validity.sh new file mode 100755 index 0000000..abdea2c --- /dev/null +++ b/scripts/ci-guards/digest-validity.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# scripts/ci-guards/digest-validity.sh +# +# Verify every @sha256: reference in deploy/**/*.{yml,Dockerfile*} +# actually resolves on its registry. H-001 only checks for digest +# presence; this catches fabricated or stale digests. +# +# Per ci-pipeline-cleanup bundle Phase 7. The bug class this catches: +# Bundle II shipped 11 fabricated digests that passed H-001's +# regex-only check and failed `docker pull` in CI. +# +# Real registries supported: +# - Docker Hub library/* and non-library (auth.docker.io) +# - ghcr.io (lscr.io alias for linuxserver/*) +# - mcr.microsoft.com (no auth required for public images; +# Windows IIS image needs the manifest.v2 single-image digest, +# not the multi-arch list digest) + +set -e + +# Find every digest reference in compose files + Dockerfiles +mapfile -t REFS < <( + grep -rEho '[a-z0-9./-]+:[a-z0-9.-]+@sha256:[a-f0-9]{64}' \ + deploy/ Dockerfile* deploy/test/*/Dockerfile 2>/dev/null \ + | sort -u +) + +if [ ${#REFS[@]} -eq 0 ]; then + echo "No @sha256 refs found — nothing to verify." + exit 0 +fi + +fail=0 +for ref in "${REFS[@]}"; do + digest="${ref##*@}" + imgtag="${ref%@*}" + tag="${imgtag##*:}" + img="${imgtag%:*}" + + # Determine registry + auth flow. + if [[ "$img" =~ ^lscr\.io/ ]]; then + img="${img#lscr.io/}" + registry="ghcr.io" + auth_url="https://ghcr.io/token?scope=repository:${img}:pull" + elif [[ "$img" =~ ^mcr\.microsoft\.com/ ]]; then + img="${img#mcr.microsoft.com/}" + registry="mcr.microsoft.com" + auth_url="" + elif [[ "$img" == */* ]]; then + # Non-library Docker Hub (e.g., envoyproxy/envoy, boky/postfix) + registry="registry-1.docker.io" + auth_url="https://auth.docker.io/token?service=registry.docker.io&scope=repository:${img}:pull" + else + # Library Docker Hub (e.g., httpd, golang) + img="library/$img" + registry="registry-1.docker.io" + auth_url="https://auth.docker.io/token?service=registry.docker.io&scope=repository:${img}:pull" + fi + + # Get auth token if needed. + auth_header="" + if [ -n "$auth_url" ]; then + tok=$(curl -sS "$auth_url" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" 2>/dev/null) + if [ -z "$tok" ]; then + echo "::error::Failed to get auth token for $registry / $img" + fail=1 + continue + fi + auth_header="Authorization: Bearer $tok" + fi + + # HEAD the manifest by digest. + if [ -n "$auth_header" ]; then + code=$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "$auth_header" \ + -H "Accept: application/vnd.oci.image.index.v1+json" \ + -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + "https://${registry}/v2/${img}/manifests/${digest}") + else + code=$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Accept: application/vnd.oci.image.index.v1+json" \ + -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + "https://${registry}/v2/${img}/manifests/${digest}") + fi + + if [ "$code" != "200" ]; then + echo "::error::digest does not resolve: ${ref}" + echo " registry: $registry" + echo " image: $img" + echo " digest: $digest" + echo " HTTP: $code" + fail=1 + else + echo "OK $ref" + fi +done + +[ $fail -eq 0 ] || exit 1 +echo "" +echo "digest-validity: clean — all ${#REFS[@]} digest references resolve." diff --git a/scripts/ci-guards/openapi-handler-parity.sh b/scripts/ci-guards/openapi-handler-parity.sh new file mode 100755 index 0000000..1bd8307 --- /dev/null +++ b/scripts/ci-guards/openapi-handler-parity.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# scripts/ci-guards/openapi-handler-parity.sh +# +# Verify every router.Register / r.mux.Handle call has a matching +# operationId in api/openapi.yaml, modulo documented exceptions in +# api/openapi-handler-exceptions.yaml. +# +# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. +# +# Verified gap at HEAD 1de61e91 (after root-cause): +# 142 router routes vs 136 OpenAPI operations +# 6 router-only routes (all SCEP wire-protocol endpoints) +# 0 OpenAPI-only operations +# +# All 6 router-only routes are documented as legitimate exceptions in +# api/openapi-handler-exceptions.yaml. +# +# Going forward: any new gap (in either direction) fails the build +# unless documented in the exceptions YAML. + +set -e + +python3 - <<'PY' +import re, sys, yaml + +# Extract router routes: r.mux.Handle("METHOD /path", ...) and +# r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax. +with open('internal/api/router/router.go') as f: + src = f.read() +routes = [] +for m in re.finditer(r'r\.(?:mux\.Handle|Register)\("([A-Z]+)\s+(/[^"]*)"', src): + routes.append((m.group(1), m.group(2))) +router_set = set(routes) + +# Extract OpenAPI operations: paths × HTTP methods +with open('api/openapi.yaml') as f: + spec = yaml.safe_load(f) +oapi_set = set() +for path, methods in (spec.get('paths') or {}).items(): + for method, op in methods.items(): + if method.upper() in ('GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'): + oapi_set.add((method.upper(), path)) + +# Extract documented exceptions +try: + with open('api/openapi-handler-exceptions.yaml') as f: + exc_doc = yaml.safe_load(f) +except FileNotFoundError: + exc_doc = {'documented_exceptions': []} +exception_set = set() +for entry in (exc_doc.get('documented_exceptions') or []): + route_str = entry['route'] + parts = route_str.split(maxsplit=1) + if len(parts) == 2: + exception_set.add((parts[0], parts[1])) + +# Report counts +print(f"Router routes: {len(router_set)}") +print(f"OpenAPI operations: {len(oapi_set)}") +print(f"Documented exceptions: {len(exception_set)}") +print() + +fail = False + +# Routes in router but NOT in openapi AND NOT in exceptions = drift +router_only_undocumented = router_set - oapi_set - exception_set +if router_only_undocumented: + print(f"::error::OpenAPI ↔ handler drift: {len(router_only_undocumented)} router routes have no OpenAPI operationId AND are not in api/openapi-handler-exceptions.yaml:") + for m, p in sorted(router_only_undocumented): + print(f" {m:6} {p}") + print() + print("Either:") + print(" (a) Add the operationId to api/openapi.yaml (preferred for REST endpoints), OR") + print(" (b) Add the route to api/openapi-handler-exceptions.yaml with a one-line `why:` justification") + print(" (only for protocol-shaped or operational routes — health probes,") + print(" Prometheus scrape, SCEP/EST/OCSP wire-protocol endpoints, etc.).") + fail = True + +# Routes in openapi but NOT in router = orphan operationId +oapi_only = oapi_set - router_set +if oapi_only: + print(f"::error::OpenAPI ↔ handler drift: {len(oapi_only)} OpenAPI operations have no router registration:") + for m, p in sorted(oapi_only): + print(f" {m:6} {p}") + print() + print("Either delete the operationId from api/openapi.yaml, OR add the missing") + print("router registration in internal/api/router/router.go.") + fail = True + +# Exceptions that don't match any router route = stale exception +stale_exceptions = exception_set - router_set +if stale_exceptions: + print(f"::error::Stale exceptions in api/openapi-handler-exceptions.yaml — these routes are not in the router:") + for m, p in sorted(stale_exceptions): + print(f" {m:6} {p}") + print() + print("Remove the stale entry from api/openapi-handler-exceptions.yaml.") + fail = True + +if fail: + sys.exit(1) +print("openapi-handler-parity: clean.") +PY