Files
certctl/scripts/ci-guards/openapi-handler-parity.sh
T
shankar0123 3c81531398 ci: OpenAPI parity reconciliation + codegen scaffolding (Phase 5 — ARCH-H1 / ARCH-M6)
Phase 5 reconciliation: the audit's headline framing 'ARCH-H1 = 62-route
OpenAPI gap' was a measurement scoping error. Every one of the 209
unique router routes is already accounted for — 154 in api/openapi.yaml,
55 in api/openapi-handler-exceptions.yaml. The existing
openapi-handler-parity.sh CI guard already enforces this and passes
clean today. The audit subtracted operation-count from route-count
without accounting for the documented exceptions YAML.

Where real work remains (and what this PR does about it)
=========================================================

Of the 64 documented exceptions, 35 are legitimate wire-protocol
carve-outs that MUST stay (SCEP RFC 8894 × 8 entries, ACME RFC 8555
default + per-profile × 27 entries — they're protocol contracts, not
REST resources). The remaining 29 are REST-shaped routes whose
OpenAPI ops were deferred during their original Bundle 2 /
audit-2026-05-10 / 2026-05-11 work:

  - auth/sessions (3)
  - auth/oidc admin (9)
  - auth/breakglass admin (4)
  - auth/users mgmt (3)
  - auth/runtime-config (1)
  - auth/demo-residual/cleanup (1)
  - audit/export (1)
  - auth/logout (1)
  - auth/breakglass/login (1)
  - auth/oidc {login,callback,bcl} (3)
  - oidc/providers/{id}/jwks-status (1)
  - + 2 other auth-flow routes

Burn-down plan in 3 sprints (documented in
api/openapi-handler-exceptions.yaml header):
  Sprint A: Cluster 1 — sessions + oidc admin (12 ops)
  Sprint B: Cluster 2 — breakglass + users + runtime-config (8 ops)
  Sprint C: Cluster 3 — audit/export + auth flows (9 ops)

This PR does NOT author the 29 OpenAPI ops; each needs request/
response schemas, not placeholders, and the design work is too
large for one PR. The reconciliation here is documentation + a CI
guard that will fail any future schema-drift, plus the scaffolding
needed for sub-phase 5b.

Sub-phase 5b: codegen scaffolding
==================================

Adds the orval scaffolding without running npm install (sandbox
disk-full; first 'npm install' + 'npm run generate' happens on the
operator's workstation):

  - web/orval.config.ts — codegen config emits react-query hooks
    from api/openapi.yaml into web/src/api/generated/
  - web/package.json — adds orval@^7.0.0 devDep + 'generate' npm script
  - web/CODEGEN.md — operator-facing migration doc:
    first-time setup, per-consumer migration pattern, burn-down plan,
    CI-guard rules
  - scripts/ci-guards/openapi-codegen-drift.sh — blocks the build
    when api/openapi.yaml changes but web/src/api/generated/ wasn't
    regenerated alongside. Currently no-op (the directory doesn't
    exist yet); activates from the first 'npm run generate' run.

The legacy web/src/api/client.ts stays in tree per the phase prompt's
'do not delete in same PR as codegen' rule. Consumers migrate one
page at a time as their OpenAPI ops land; client.ts deletion is a
SEPARATE follow-up PR after the last consumer migrates.

Updates to existing guard + exceptions YAML
============================================

  - scripts/ci-guards/openapi-handler-parity.sh header rewritten
    with the Phase 5 reconciliation numbers (220/158/64/0) and the
    wire-protocol vs REST-deferred classification.
  - api/openapi-handler-exceptions.yaml header rewritten with the
    35/29 split + the 3-sprint burn-down plan. Each exception entry
    is unchanged; the header now documents which entries are
    permanent (wire-protocol) vs temporary (REST-deferred).

Sandbox limitations + operator follow-up
=========================================

  - 'npm install' was NOT run from the sandbox (sessions volume
    99%-full, 142 MB free). The operator runs 'cd web && npm install'
    on their workstation; this lands orval@^7.0.0 in node_modules,
    then 'cd web && npm run generate' produces the initial
    web/src/api/generated/ tree.
  - First per-consumer migration (suggested: web/src/pages/AuthSettings
    or one of the operator-decision pages) lands in a follow-up PR
    after npm install completes.
  - The 29-op OpenAPI burn-down is a 2-sprint effort tracked under
    ARCH-H1 in cowork/certctl-architecture-diligence-audit.html.

All CI guards (openapi-handler-parity, openapi-codegen-drift, plus
every existing guard) verified clean by running each individually.

Closes:
  - cowork/certctl-architecture-diligence-audit.html#fix-ARCH-H1
    (reconciliation: gap is 0 with exceptions accounted for; burn-down
    plan documented for follow-up sprints)
  - cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M6
    (codegen scaffolding shipped; client.ts deletion follows in a
    subsequent PR after consumers migrate)
2026-05-13 20:24:20 +00:00

116 lines
4.6 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 5 reconciliation (2026-05-13):
# 220 r.Register call sites in internal/api/router/router.go
# 209 unique (METHOD /path) router routes after de-duplication
# 158 operationIds in api/openapi.yaml
# 64 documented exceptions in api/openapi-handler-exceptions.yaml
# 0 unaccounted router routes — every route is in OpenAPI OR
# in the exceptions YAML. Guard passes clean today.
#
# Of the 64 exceptions:
# 35 wire-protocol carve-outs (SCEP RFC 8894 = 8, ACME RFC 8555
# default + per-profile = 27). These MUST stay as exceptions —
# they're protocol contracts, not REST resources.
# 29 REST-shaped routes deferred from openapi.yaml authoring
# (auth sessions, OIDC providers admin, breakglass admin,
# users mgmt, runtime-config, demo-residual-cleanup, audit
# export). Burn-down target: author the 29 OpenAPI ops over
# the next ~2 sprints so the generated client (web/orval.config.ts)
# covers them. Tracked under ARCH-H1 in
# cowork/certctl-architecture-diligence-audit.html.
#
# 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