mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +00:00
155f1fec98
Closure commit for Phase 13 (ARCH-H1 OpenAPI ↔ handler gap + ARCH-M1 per-process rate-limit ceiling). Tightens the parity-script CI guard to a HARD zero-exact pin on the rest-deferred bucket: any future PR adding a new REST route MUST author its OpenAPI op or fail CI. The `category: rest-deferred` escape hatch is now closed for good. The sibling monotonic-decrease guard (openapi-rest-deferred- monotonic.sh) stays in tree as belt-and-suspenders — both must hold. The monotonic guard catches baseline-drift accidents (operator edits the baseline up without surfacing rationale); this guard catches the underlying rest-deferred bucket re-growing at all. Phase 13 commit chain (six prior commits, ordered):67f346cdSprint 13.1 — two-bucket exception categorization + monotonic guard (rest-deferred=28 baseline, wire-protocol=36, fail-on-drift)c8347d74Sprint 13.2 — ARCH-M1 Postgres sliding-window limiter (SELECT FOR UPDATE arbitration) + migration 000046 rate_limit_buckets + falsifiable multi-replica integration test (TestRateLimit_PostgresBackend_CapEnforced AcrossReplicas: 100 concurrent allows across 3 limiters cap=10 → exactly 10 succeed / 90 ErrRateLimited)a41fc2d7Sprint 13.3 — backend selector (CERTCTL_RATE_LIMIT_BACKEND={memory|postgres}) + scheduler janitor sweeping updated_at<NOW()-maxWindow + helm chart wiring + docs/operator/observability.md operator decision tree952682ebSprint 13.4 — OpenAPI authoring batch 1 (13 ops + 8 schemas: sessions cluster + OIDC CRUD + JWKS + test + refresh + group-mappings). rest-deferred 28 → 15.9135c449Sprint 13.5 — OpenAPI authoring batch 2 (8 ops + 5 schemas: breakglass admin + users + runtime -config). rest-deferred 15 → 7.29cb13e7Sprint 13.6 — OpenAPI authoring batch 3 final 7 ops + 2 schemas (audit/export + demo-residual + auth/logout + breakglass/login + 3 OIDC browser flows modeled as 302+Location). rest-deferred 7 → 0. ARCH-H1 substantive close. Sprint 13.7 deliverables (this commit): • scripts/ci-guards/openapi-handler-parity.sh: append inline hard zero-exact check after the bucket-counts report. Fails CI immediately on any rest-deferred entry, enumerating offenders with the suggested-fix narrative. • Header docstring updated to reflect post-Sprint-13.7 state: 220 router routes 186 OpenAPI operations 36 documented exceptions (36 wire-protocol + 0 rest-deferred) 0 unaccounted router routes Falsifiable closure proofs (re-run in CI on every PR): $ bash scripts/ci-guards/openapi-handler-parity.sh Router routes: 220 OpenAPI operations: 186 Documented exceptions: 36 wire-protocol: 36 rest-deferred: 0 openapi-handler-parity: clean. $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh openapi-rest-deferred-monotonic: clean — rest-deferred = 0, baseline = 0. $ cat api/openapi-handler-exceptions-baseline.txt 0 Negative test (synthetic rest-deferred entry, restored after): $ # append GET /scep with category: rest-deferred … $ bash scripts/ci-guards/openapi-handler-parity.sh ::error::rest-deferred bucket is non-empty (1 entries) — Phase 13 Sprint 13.7 closure pins this at zero. Offending entries: GET /scep exit 1 ← guard fails correctly $ gofmt -l . (no output — clean) Findings flipped to ✓ Shipped in cowork/certctl-architecture-diligence-audit.html: • ARCH-H1 — OpenAPI surface diverges from REST handlers (commit chain67f346cd+952682eb+9135c449+29cb13e7) • ARCH-M1 — Per-process rate limiter caps single instance only (commit chainc8347d74+a41fc2d7) Progress widget: 46 / 56 findings shipped (82%) + 2 scaffolded. The remaining 8 open findings are v3-scope strategic items (multi-tenancy, EAB/External Account Binding, cluster coordination primitives) — explicitly out of v2.2 scope per audit triage. OPERATOR ACTION REQUIRED (one toggle, no code change): Promote TestRateLimit_PostgresBackend_CapEnforcedAcrossReplicas in deploy/test/integration_test.go to a required status check in GitHub branch-protection settings for master. Code-side wiring (.github/workflows/ci.yml) is done; the missing piece is the GitHub Settings → Branches → Branch protection rules toggle. Without that toggle, the test runs on every PR but isn't gating. After flipping the toggle, ARCH-M1 closure is fully load-bearing at the CI gate — a regression in the Postgres sliding-window backend (e.g. a future refactor that breaks SELECT FOR UPDATE arbitration) cannot reach master.
207 lines
8.6 KiB
Bash
Executable File
207 lines
8.6 KiB
Bash
Executable File
#!/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.
|
||
#
|
||
# Phase 13 Sprint 13.1 (2026-05-14) — every entry in the exceptions
|
||
# YAML now carries a required `category: wire-protocol | rest-deferred`
|
||
# field. This script reports the two buckets alongside the total. The
|
||
# rest-deferred bucket is gated by a sibling guard
|
||
# (openapi-rest-deferred-monotonic.sh) against a checked-in baseline
|
||
# at api/openapi-handler-exceptions-baseline.txt.
|
||
#
|
||
# Current state (post-Sprint-13.7 / 2026-05-14):
|
||
# 220 r.Register / r.mux.Handle call sites in internal/api/router/router.go
|
||
# 186 operationIds in api/openapi.yaml
|
||
# 36 documented exceptions (36 wire-protocol + 0 rest-deferred)
|
||
# 0 unaccounted router routes — guard passes clean today.
|
||
#
|
||
# Sprints 13.4-13.6 drove rest-deferred to zero by authoring 28 OpenAPI
|
||
# ops + deleting the corresponding exception entries. Sprint 13.7
|
||
# (this comment-block update + the inline fail-on-rest-deferred check
|
||
# at the bottom of the python block) tightens this guard's
|
||
# rest-deferred floor from "monotonic-decrease vs baseline" (the
|
||
# sibling guard openapi-rest-deferred-monotonic.sh) to a HARD
|
||
# zero-exact pin. The `category: rest-deferred` escape hatch is now
|
||
# closed for good: any future PR adding a new REST route MUST author
|
||
# its OpenAPI op or fail CI.
|
||
#
|
||
# The sibling monotonic-decrease guard stays in tree as belt-and-
|
||
# suspenders — both must hold. The monotonic guard catches baseline-
|
||
# drift accidents (e.g. an operator manually edits the baseline up
|
||
# without surfacing the rationale); this guard catches the underlying
|
||
# rest-deferred bucket re-growing at all.
|
||
#
|
||
# Going forward: any new gap (in either direction) fails the build
|
||
# unless documented in the exceptions YAML with category=wire-protocol
|
||
# (carry an RFC anchor in `why:` for review-time scrutiny).
|
||
#
|
||
# Subcommand:
|
||
# bash scripts/ci-guards/openapi-handler-parity.sh
|
||
# Full parity check + bucket reporting.
|
||
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
|
||
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
|
||
# Print just the count for the named bucket (used by sibling guards
|
||
# + Sprint 13.7's zero-exact pin). Exit 0 always; informational.
|
||
|
||
set -e
|
||
|
||
BUCKET=""
|
||
case "${1:-}" in
|
||
--bucket=wire-protocol|--bucket=rest-deferred)
|
||
BUCKET="${1#--bucket=}"
|
||
;;
|
||
"")
|
||
;;
|
||
*)
|
||
echo "::error::unknown argument: $1"
|
||
echo "usage: $0 [--bucket=wire-protocol|--bucket=rest-deferred]"
|
||
exit 2
|
||
;;
|
||
esac
|
||
|
||
python3 - "$BUCKET" <<'PY'
|
||
import re, sys, yaml
|
||
|
||
bucket_arg = sys.argv[1] if len(sys.argv) > 1 else ""
|
||
|
||
# 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()
|
||
bucket_counts = {'wire-protocol': 0, 'rest-deferred': 0}
|
||
missing_category = []
|
||
unknown_category = []
|
||
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]))
|
||
cat = entry.get('category')
|
||
if cat is None:
|
||
missing_category.append(route_str)
|
||
elif cat in bucket_counts:
|
||
bucket_counts[cat] += 1
|
||
else:
|
||
unknown_category.append((route_str, cat))
|
||
|
||
# --bucket=X subcommand: print just the count, exit 0, no other output.
|
||
if bucket_arg in bucket_counts:
|
||
print(bucket_counts[bucket_arg])
|
||
sys.exit(0)
|
||
|
||
# Report counts
|
||
print(f"Router routes: {len(router_set)}")
|
||
print(f"OpenAPI operations: {len(oapi_set)}")
|
||
print(f"Documented exceptions: {len(exception_set)}")
|
||
print(f" wire-protocol: {bucket_counts['wire-protocol']}")
|
||
print(f" rest-deferred: {bucket_counts['rest-deferred']}")
|
||
print()
|
||
|
||
fail = False
|
||
|
||
# Phase 13 Sprint 13.1: every entry MUST have a category. Missing or
|
||
# unknown categories fail the build — keeps the bucket math honest.
|
||
if missing_category:
|
||
print(f"::error::api/openapi-handler-exceptions.yaml: {len(missing_category)} entries missing required `category:` field:")
|
||
for r in missing_category:
|
||
print(f" {r}")
|
||
print()
|
||
print("Add `category: wire-protocol` (with an RFC anchor in `why:`) or")
|
||
print("author the route's OpenAPI op (the rest-deferred bucket is now")
|
||
print("pinned at zero — see Phase 13 Sprint 13.7 closure).")
|
||
fail = True
|
||
|
||
if unknown_category:
|
||
print(f"::error::api/openapi-handler-exceptions.yaml: {len(unknown_category)} entries with unknown category value (must be wire-protocol or rest-deferred):")
|
||
for r, c in unknown_category:
|
||
print(f" {r} → category: {c}")
|
||
fail = True
|
||
|
||
# Phase 13 Sprint 13.7 — hard zero-exact pin on the rest-deferred
|
||
# bucket. ARCH-H1's substantive close requires that the bucket stay
|
||
# empty in perpetuity: any new REST route MUST land with an
|
||
# OpenAPI op. Categorizing a new exception as `category: rest-deferred`
|
||
# is no longer an escape hatch — it fails CI immediately, surfacing
|
||
# the route + suggesting the fix.
|
||
if bucket_counts['rest-deferred'] > 0:
|
||
print(f"::error::rest-deferred bucket is non-empty ({bucket_counts['rest-deferred']} entries) — Phase 13 Sprint 13.7 closure pins this at zero.")
|
||
print()
|
||
print("Every entry in api/openapi-handler-exceptions.yaml with")
|
||
print("`category: rest-deferred` represents a REST-shaped route whose")
|
||
print("OpenAPI op was deferred. Author the OpenAPI op in api/openapi.yaml")
|
||
print("with a request/response schema mirroring the Go handler's")
|
||
print("projection types, then delete the exception entry.")
|
||
print()
|
||
print("Offending entries:")
|
||
for entry in (exc_doc.get('documented_exceptions') or []):
|
||
if entry.get('category') == 'rest-deferred':
|
||
print(f" {entry['route']}")
|
||
fail = True
|
||
|
||
# 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(" AND a `category: wire-protocol | rest-deferred` field (only protocol-shaped")
|
||
print(" or operational routes — health probes, Prometheus scrape, SCEP/EST/ACME")
|
||
print(" wire-protocol endpoints, etc. — qualify as wire-protocol).")
|
||
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
|