ci-pipeline-cleanup Phases 7-9: image-and-supply-chain job

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:<digest>
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).
This commit is contained in:
shankar0123
2026-04-30 20:50:52 +00:00
parent b9a63a2521
commit b7a3162028
4 changed files with 279 additions and 0 deletions
+45
View File
@@ -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
+27
View File
@@ -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."
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# scripts/ci-guards/digest-validity.sh
#
# Verify every @sha256:<digest> 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."
+103
View File
@@ -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