Coverage for gco / services / manifest_api.py: 100.00%

163 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Manifest API Service for GCO (Global Capacity Orchestrator on AWS). 

3 

4This FastAPI service provides REST endpoints for Kubernetes manifest 

5submission, validation, and management. Endpoint implementations live 

6in the ``api_routes`` sub-package; this module wires them together and 

7owns the application lifecycle, Pydantic request/response models, and 

8health probes. 

9 

10See ``api_routes/`` for the individual routers: 

11 - manifests.py — manifest submit / validate / resource CRUD 

12 - jobs.py — job list / get / logs / events / metrics / delete / retry 

13 - templates.py — job template CRUD + create-from-template 

14 - webhooks.py — webhook registration 

15 - queue.py — DynamoDB-backed global job queue 

16""" 

17 

18from __future__ import annotations 

19 

20import asyncio 

21import logging 

22import os 

23from collections.abc import AsyncIterator 

24from contextlib import asynccontextmanager, suppress 

25from datetime import UTC, datetime 

26from typing import Any 

27 

28from fastapi import FastAPI, HTTPException, Request 

29from fastapi.responses import JSONResponse 

30 

31from gco.services.auth_middleware import AuthenticationMiddleware 

32from gco.services.central_queue_worker import CentralQueueWorker 

33from gco.services.manifest_processor import ( 

34 ManifestProcessor, 

35 create_manifest_processor_from_env, 

36) 

37from gco.services.metrics_publisher import ManifestProcessorMetrics 

38from gco.services.request_size_middleware import ( 

39 DEFAULT_MAX_REQUEST_BODY_BYTES, 

40 RequestSizeLimitMiddleware, 

41) 

42from gco.services.structured_logging import configure_structured_logging 

43from gco.services.template_store import ( 

44 JobStore, 

45 TemplateStore, 

46 WebhookStore, 

47 get_job_store, 

48 get_template_store, 

49 get_webhook_store, 

50) 

51 

52# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

53# Generated at (UTC): 2026-09-11T22:15:15Z 

54# Generated from Git commit: 14ba13224fdaf5a82bbcee12270092e39a8201ca 

55# Flowchart(s) generated from this file: 

56# * ``lifespan`` -> ``diagrams/code_diagrams/gco/services/manifest_api.lifespan.html`` 

57# (PNG: ``diagrams/code_diagrams/gco/services/manifest_api.lifespan.png``) 

58# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

59# <pyflowchart-code-diagram> END 

60 

61 

62logging.basicConfig( 

63 level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 

64) 

65logger = logging.getLogger(__name__) 

66 

67 

68# --------------------------------------------------------------------------- 

69# Global state — populated by lifespan, read by routers via this module. 

70# --------------------------------------------------------------------------- 

71manifest_processor: ManifestProcessor | None = None 

72manifest_metrics: ManifestProcessorMetrics | None = None 

73template_store: TemplateStore | None = None 

74webhook_store: WebhookStore | None = None 

75job_store: JobStore | None = None 

76 

77 

78def _env_bool(name: str, default: bool = False) -> bool: 

79 """Parse an explicit deployment boolean without truthy-string surprises.""" 

80 raw = os.getenv(name) 

81 if raw is None: 

82 return default 

83 return raw.strip().lower() in {"1", "true", "yes", "on"} 

84 

85 

86def _env_number(name: str, default: float, minimum: float, maximum: float) -> float: 

87 """Read a finite bounded worker setting from the environment.""" 

88 try: 

89 value = float(os.getenv(name, str(default))) 

90 except ValueError: 

91 return default 

92 return value if minimum <= value <= maximum else default 

93 

94 

95# ============================================================================= 

96# Pydantic Models for API 

97# ============================================================================= 

98 

99 

100# ============================================================================= 

101# Application Lifecycle 

102# ============================================================================= 

103 

104 

105@asynccontextmanager 

106async def lifespan(app: FastAPI) -> AsyncIterator[None]: 

107 """Initialize API dependencies and the optional regional queue worker.""" 

108 global manifest_processor, manifest_metrics, template_store, webhook_store, job_store 

109 

110 queue_worker: CentralQueueWorker | None = None 

111 queue_worker_task: asyncio.Task[None] | None = None 

112 logger.info("Starting Manifest API Service") 

113 try: 

114 manifest_processor = create_manifest_processor_from_env() 

115 

116 configure_structured_logging( 

117 service_name="manifest-api", 

118 cluster_id=manifest_processor.cluster_id, 

119 region=manifest_processor.region, 

120 ) 

121 

122 manifest_metrics = ManifestProcessorMetrics( 

123 cluster_name=manifest_processor.cluster_id, 

124 region=manifest_processor.region, 

125 ) 

126 logger.info("Manifest processor initialized") 

127 

128 template_store = get_template_store() 

129 webhook_store = get_webhook_store() 

130 job_store = get_job_store() 

131 logger.info("DynamoDB stores initialized") 

132 

133 if _env_bool("CENTRAL_QUEUE_WORKER_ENABLED"): 

134 queue_worker = CentralQueueWorker( 

135 processor=manifest_processor, 

136 store=job_store, 

137 poll_interval_seconds=_env_number( 

138 "CENTRAL_QUEUE_POLL_INTERVAL_SECONDS", 10.0, 1.0, 300.0 

139 ), 

140 batch_size=int(_env_number("CENTRAL_QUEUE_BATCH_SIZE", 5.0, 1.0, 20.0)), 

141 reconcile_limit=int( 

142 _env_number("CENTRAL_QUEUE_RECONCILE_LIMIT", 100.0, 1.0, 500.0) 

143 ), 

144 lease_renewal_seconds=_env_number( 

145 "CENTRAL_QUEUE_LEASE_RENEWAL_SECONDS", 60.0, 1.0, 300.0 

146 ), 

147 ) 

148 queue_worker_task = asyncio.create_task( 

149 queue_worker.run(), 

150 name=f"central-queue-worker-{manifest_processor.region}", 

151 ) 

152 app.state.central_queue_worker = queue_worker 

153 app.state.central_queue_worker_task = queue_worker_task 

154 else: 

155 app.state.central_queue_worker = None 

156 app.state.central_queue_worker_task = None 

157 except Exception as e: 

158 logger.error(f"Failed to initialize manifest processor: {e}") 

159 raise 

160 

161 try: 

162 yield 

163 finally: 

164 if queue_worker is not None and queue_worker_task is not None: 

165 queue_worker.stop() 

166 try: 

167 await asyncio.wait_for(queue_worker_task, timeout=30) 

168 except TimeoutError: 

169 queue_worker_task.cancel() 

170 with suppress(asyncio.CancelledError): 

171 await queue_worker_task 

172 logger.info("Shutting down Manifest API Service") 

173 

174 

175# ============================================================================= 

176# Create FastAPI app and include routers 

177# ============================================================================= 

178 

179app = FastAPI( 

180 title="GCO Manifest Processor API", 

181 description="Kubernetes manifest submission and management service for GCO (Global Capacity Orchestrator on AWS)", 

182 version="2.0.0", 

183 lifespan=lifespan, 

184) 

185 

186app.add_middleware(AuthenticationMiddleware) 

187 

188# Request size limit middleware — added after auth middleware so it executes 

189# first in the request pipeline (Starlette processes middleware in LIFO order). 

190_max_body_bytes = int(os.getenv("MAX_REQUEST_BODY_BYTES", str(DEFAULT_MAX_REQUEST_BODY_BYTES))) 

191app.add_middleware(RequestSizeLimitMiddleware, max_body_bytes=_max_body_bytes) 

192 

193# Request correlation. Every request gets a server-generated id that is 

194# echoed as the X-Request-ID response header and embedded in generic 500 

195# details (see api_shared.internal_server_error), so an operator can tie a 

196# client-reported failure back to the exact logged exception. The id is 

197# never read from an inbound header — a client-controlled value adjacent to 

198# log lines would need CWE-117 sanitization and could muddy investigations. 

199from gco.services.request_context import ( # noqa: E402 

200 REQUEST_ID_HEADER, 

201 bind_request_id, 

202 current_request_id, 

203 unbind_request_id, 

204) 

205 

206 

207@app.middleware("http") 

208async def request_correlation_middleware(request: Request, call_next: Any) -> Any: 

209 """Bind a fresh correlation id for the request and echo it on the response.""" 

210 request_id, token = bind_request_id() 

211 try: 

212 response = await call_next(request) 

213 finally: 

214 unbind_request_id(token) 

215 response.headers[REQUEST_ID_HEADER] = request_id 

216 return response 

217 

218 

219# Expose Prometheus /metrics for the in-cluster observability scrape. The auth 

220# middleware exempts /metrics, so the cluster Prometheus reaches it over the 

221# existing service port without credentials. 

222from gco.services.service_metrics import mount_metrics # noqa: E402 

223 

224mount_metrics(app, "manifest-processor") 

225 

226# Include domain routers 

227from gco.services.api_routes.cost import router as cost_router # noqa: E402 

228from gco.services.api_routes.jobs import router as jobs_router # noqa: E402 

229from gco.services.api_routes.manifests import router as manifests_router # noqa: E402 

230from gco.services.api_routes.queue import router as queue_router # noqa: E402 

231from gco.services.api_routes.templates import router as templates_router # noqa: E402 

232from gco.services.api_routes.webhooks import router as webhooks_router # noqa: E402 

233 

234app.include_router(manifests_router) 

235app.include_router(jobs_router) 

236app.include_router(templates_router) 

237app.include_router(webhooks_router) 

238app.include_router(queue_router) 

239app.include_router(cost_router) 

240 

241 

242# ============================================================================= 

243# Root & Health Endpoints (kept here — they're thin and tightly coupled to state) 

244# ============================================================================= 

245 

246 

247@app.get("/", tags=["Info"]) 

248async def root() -> dict[str, Any]: 

249 """Root endpoint with basic service information and API overview.""" 

250 return { 

251 "service": "GCO Manifest Processor API", 

252 "version": "2.0.0", 

253 "status": "running", 

254 "cluster_id": (manifest_processor.cluster_id if manifest_processor else "unknown"), 

255 "region": (manifest_processor.region if manifest_processor else "unknown"), 

256 "endpoints": { 

257 "manifests": { 

258 "submit": "POST /api/v1/manifests", 

259 "validate": "POST /api/v1/manifests/validate", 

260 "get": "GET /api/v1/manifests/{namespace}/{name}", 

261 "delete": "DELETE /api/v1/manifests/{namespace}/{name}", 

262 }, 

263 "jobs": { 

264 "list": "GET /api/v1/jobs", 

265 "get": "GET /api/v1/jobs/{namespace}/{name}", 

266 "logs": "GET /api/v1/jobs/{namespace}/{name}/logs", 

267 "events": "GET /api/v1/jobs/{namespace}/{name}/events", 

268 "pods": "GET /api/v1/jobs/{namespace}/{name}/pods", 

269 "metrics": "GET /api/v1/jobs/{namespace}/{name}/metrics", 

270 "delete": "DELETE /api/v1/jobs/{namespace}/{name}", 

271 "bulk_delete": "DELETE /api/v1/jobs", 

272 "retry": "POST /api/v1/jobs/{namespace}/{name}/retry", 

273 }, 

274 "templates": { 

275 "list": "GET /api/v1/templates", 

276 "create": "POST /api/v1/templates", 

277 "get": "GET /api/v1/templates/{name}", 

278 "delete": "DELETE /api/v1/templates/{name}", 

279 "create_job": "POST /api/v1/jobs/from-template/{name}", 

280 }, 

281 "webhooks": { 

282 "list": "GET /api/v1/webhooks", 

283 "create": "POST /api/v1/webhooks", 

284 "delete": "DELETE /api/v1/webhooks/{id}", 

285 }, 

286 "cost": { 

287 "status": "GET /api/v1/cost/status", 

288 "reports": "GET /api/v1/cost/reports", 

289 "generate_report": "POST /api/v1/cost/reports", 

290 }, 

291 "health": "GET /api/v1/health", 

292 "status": "GET /api/v1/status", 

293 "policy": "GET /api/v1/policy", 

294 }, 

295 } 

296 

297 

298@app.get("/healthz", tags=["Health"]) 

299async def kubernetes_health_check() -> dict[str, str]: 

300 """Kubernetes-style liveness probe.""" 

301 return {"status": "ok"} 

302 

303 

304@app.get("/readyz", tags=["Health"]) 

305async def kubernetes_readiness_check() -> dict[str, str]: 

306 """Kubernetes readiness includes the enabled queue worker task.""" 

307 if manifest_processor is None: 

308 raise HTTPException(status_code=503, detail="Manifest processor not ready") 

309 worker_task = getattr(app.state, "central_queue_worker_task", None) 

310 if worker_task is not None and worker_task.done(): 

311 raise HTTPException(status_code=503, detail="Central queue worker stopped unexpectedly") 

312 return {"status": "ready"} 

313 

314 

315@app.get("/api/v1/health", tags=["Health"]) 

316async def health_check() -> JSONResponse: 

317 """Health check endpoint for load balancer health checks.""" 

318 try: 

319 if manifest_processor is None: 

320 return JSONResponse( 

321 status_code=503, 

322 content={ 

323 "status": "unhealthy", 

324 "timestamp": datetime.now(UTC).isoformat(), 

325 "message": "Manifest processor not initialized", 

326 }, 

327 ) 

328 

329 try: 

330 manifest_processor.core_v1.list_namespace(limit=1) 

331 api_healthy = True 

332 except Exception as e: 

333 logger.error(f"Kubernetes API health check failed: {e}") 

334 api_healthy = False 

335 

336 status_code = 200 if api_healthy else 503 

337 return JSONResponse( 

338 status_code=status_code, 

339 content={ 

340 "status": "healthy" if api_healthy else "unhealthy", 

341 "timestamp": datetime.now(UTC).isoformat(), 

342 "cluster_id": manifest_processor.cluster_id, 

343 "region": manifest_processor.region, 

344 "kubernetes_api": "connected" if api_healthy else "disconnected", 

345 }, 

346 ) 

347 

348 except Exception as e: 

349 logger.error(f"Health check failed: {e}") 

350 return JSONResponse( 

351 status_code=503, 

352 content={ 

353 "status": "unhealthy", 

354 "timestamp": datetime.now(UTC).isoformat(), 

355 "error": "manifest processor unavailable", 

356 }, 

357 ) 

358 

359 

360@app.get("/api/v1/status", tags=["Health"]) 

361async def get_service_status() -> dict[str, Any]: 

362 """Service status endpoint with detailed information.""" 

363 templates_count = 0 

364 webhooks_count = 0 

365 try: 

366 if template_store: 

367 templates_count = len(template_store.list_templates()) 

368 if webhook_store: 

369 webhooks_count = len(webhook_store.list_webhooks()) 

370 except Exception as e: 

371 logger.warning(f"Failed to get store counts: {e}") 

372 

373 status_info: dict[str, Any] = { 

374 "service": "GCO Manifest Processor API", 

375 "version": "2.0.0", 

376 "timestamp": datetime.now(UTC).isoformat(), 

377 "manifest_processor_initialized": manifest_processor is not None, 

378 "environment": { 

379 "cluster_name": os.getenv("CLUSTER_NAME", "unknown"), 

380 "region": os.getenv("REGION", "unknown"), 

381 "max_cpu_per_manifest": os.getenv("MAX_CPU_PER_MANIFEST", "10"), 

382 "max_memory_per_manifest": os.getenv("MAX_MEMORY_PER_MANIFEST", "32Gi"), 

383 "max_gpu_per_manifest": os.getenv("MAX_GPU_PER_MANIFEST", "4"), 

384 "allowed_namespaces": os.getenv("ALLOWED_NAMESPACES", "gco-jobs"), 

385 "validation_enabled": os.getenv("VALIDATION_ENABLED", "true"), 

386 }, 

387 "templates_count": templates_count, 

388 "webhooks_count": webhooks_count, 

389 "central_queue_worker": ( 

390 worker.health() 

391 if (worker := getattr(app.state, "central_queue_worker", None)) is not None 

392 else {"enabled": False, "running": False} 

393 ), 

394 } 

395 

396 if manifest_processor: 

397 status_info.update( 

398 { 

399 "cluster_id": manifest_processor.cluster_id, 

400 "region": manifest_processor.region, 

401 "resource_limits": { 

402 "max_cpu_millicores": manifest_processor.max_cpu_per_manifest, 

403 "max_memory_bytes": manifest_processor.max_memory_per_manifest, 

404 "max_gpu_count": manifest_processor.max_gpu_per_manifest, 

405 }, 

406 "allowed_namespaces": list(manifest_processor.allowed_namespaces), 

407 "validation_enabled": manifest_processor.validation_enabled, 

408 } 

409 ) 

410 

411 return status_info 

412 

413 

414@app.get("/api/v1/policy", tags=["Health"]) 

415async def get_job_validation_policy() -> dict[str, Any]: 

416 """The validation policy this region actually enforces, as deployed. 

417 

418 Answers "will this cluster admit the job I am about to pay to run?" 

419 before submission, so a policy conflict surfaces at plan time instead of 

420 after a region has been provisioned and billed. 

421 

422 Reads the live ``ManifestProcessor`` instance rather than any config file. 

423 A local ``cdk.json`` is the *input* to a deploy, not the state of one: 

424 the cluster may have been deployed from a different checkout, and CDK 

425 augments ``trusted_registries`` with the project's own ECR hostnames at 

426 synth time, so the effective allowlist is strictly larger than the 

427 configured one. 

428 

429 Three layers govern admission and all three are reported: 

430 

431 1. ``policy`` — the front-door checks the manifest processor and the SQS 

432 queue processor both apply (they read the same env vars, so neither 

433 submission path is a bypass). 

434 2. ``cluster_enforcement.limit_ranges`` — per-container ceilings. 

435 3. ``cluster_enforcement.resource_quotas`` — namespace aggregate ceilings. 

436 

437 A manifest must clear all three. Layers 2 and 3 are read live from the 

438 Kubernetes API and degrade to ``status="unavailable"`` rather than 

439 failing the whole response. 

440 """ 

441 if manifest_processor is None: 

442 raise HTTPException(status_code=503, detail="Manifest processor not ready") 

443 

444 return { 

445 "service": "GCO Manifest Processor API", 

446 "timestamp": datetime.now(UTC).isoformat(), 

447 "cluster_id": manifest_processor.cluster_id, 

448 "region": manifest_processor.region, 

449 # Names the origin of these values so a caller never mistakes this for 

450 # a config-file read. 

451 "source": "deployed-cluster-runtime", 

452 "policy": manifest_processor.effective_job_validation_policy(), 

453 "cluster_enforcement": manifest_processor.cluster_resource_governance(), 

454 } 

455 

456 

457# ============================================================================= 

458# Error Handlers 

459# ============================================================================= 

460 

461 

462@app.exception_handler(Exception) 

463async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: 

464 """Global exception handler for unhandled errors.""" 

465 request_id = current_request_id() 

466 logger.error( 

467 f"Unhandled exception in {request.method} {request.url} (request-id {request_id}): {exc}" 

468 ) 

469 return JSONResponse( 

470 status_code=500, 

471 content={ 

472 "error": "Internal server error", 

473 "detail": str(exc) if os.getenv("DEBUG") else "An unexpected error occurred", 

474 "request_id": request_id, 

475 "timestamp": datetime.now(UTC).isoformat(), 

476 }, 

477 ) 

478 

479 

480# ============================================================================= 

481# App Factory & Entrypoint 

482# ============================================================================= 

483 

484 

485def create_app() -> FastAPI: 

486 """Factory function to create the FastAPI app.""" 

487 return app 

488 

489 

490# The pod manifest gives the kubelet terminationGracePeriodSeconds > preStop + 

491# this budget, so Uvicorn can finish in-flight requests before SIGKILL. The 

492# same variable drives the TLS sidecar's drain (gco.services.tls_proxy). 

493DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 50 

494 

495 

496def _run_server() -> None: 

497 """Run Uvicorn with the same drain budget declared by the pod manifest.""" 

498 import uvicorn 

499 

500 host = os.getenv("HOST", "0.0.0.0") # nosec B104 — container listener 

501 port = int(os.getenv("PORT", "8080")) 

502 log_level = os.getenv("LOG_LEVEL", "info").lower() 

503 graceful_shutdown_seconds = int( 

504 os.getenv( 

505 "GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS", 

506 str(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS), 

507 ) 

508 ) 

509 

510 logger.info("Starting Manifest API on %s:%d", host, port) 

511 

512 uvicorn.run( 

513 "gco.services.manifest_api:app", 

514 host=host, 

515 port=port, 

516 log_level=log_level, 

517 reload=False, 

518 timeout_graceful_shutdown=graceful_shutdown_seconds, 

519 ) 

520 

521 

522if __name__ == "__main__": 

523 _run_server()