Coverage for gco / services / api_routes / manifests.py: 100.00%
87 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Manifest submission, validation, and resource management endpoints."""
3from __future__ import annotations
5import logging
6from datetime import UTC, datetime
7from typing import Any
9from fastapi import APIRouter, HTTPException, Query
10from fastapi.responses import JSONResponse, Response
12from gco.models import ManifestSubmissionRequest
13from gco.services.api_shared import (
14 ManifestSubmissionAPIRequest,
15 _check_processor,
16 internal_server_error,
17)
19router = APIRouter(prefix="/api/v1/manifests", tags=["Manifests"])
20logger = logging.getLogger(__name__)
23@router.post("")
24async def submit_manifests(request: ManifestSubmissionAPIRequest) -> Response:
25 """Submit Kubernetes manifests for processing."""
26 from gco.services.manifest_api import manifest_metrics
28 processor = _check_processor()
30 try:
31 logger.info(f"Received manifest submission request with {len(request.manifests)} manifests")
33 try:
34 submission_request = ManifestSubmissionRequest(
35 manifests=request.manifests,
36 namespace=request.namespace,
37 dry_run=request.dry_run,
38 validate=request.validate_manifests,
39 )
40 except ValueError as e:
41 # Client-side validation failures (empty manifests, unparseable
42 # payloads, etc.) should surface as 400, not 500.
43 logger.info(f"Rejected manifest submission as invalid input: {e}")
44 raise HTTPException(status_code=400, detail=str(e)) from e
46 response = await processor.process_manifest_submission(submission_request)
48 if manifest_metrics and not request.dry_run:
49 try:
50 successful = sum(1 for r in response.resources if r.is_successful())
51 failed = sum(1 for r in response.resources if not r.is_successful())
52 validation_failures = sum(
53 1
54 for r in response.resources
55 if r.status == "failed" and "validation" in (r.message or "").lower()
56 )
57 manifest_metrics.publish_submission_metrics(
58 total_submissions=len(response.resources),
59 successful_submissions=successful,
60 failed_submissions=failed,
61 validation_failures=validation_failures,
62 )
63 except Exception as e:
64 logger.warning(f"Failed to publish manifest metrics: {e}")
66 api_response: dict[str, Any] = {
67 "success": response.success,
68 "cluster_id": response.cluster_id,
69 "region": response.region,
70 "timestamp": datetime.now(UTC).isoformat(),
71 "summary": response.get_summary(),
72 "resources": [
73 {
74 "api_version": r.api_version,
75 "kind": r.kind,
76 "name": r.name,
77 "namespace": r.namespace,
78 "status": r.status,
79 "message": r.message,
80 }
81 for r in response.resources
82 ],
83 }
85 if response.errors:
86 api_response["errors"] = response.errors
88 status_code = 200 if response.success else 400
89 return JSONResponse(status_code=status_code, content=api_response)
91 except HTTPException:
92 # Already a well-formed HTTP error — don't demote to 500.
93 raise
94 except Exception as e:
95 raise internal_server_error("processing manifest submission", e) from e
98@router.post("/validate")
99async def validate_manifests(request: ManifestSubmissionAPIRequest) -> Response:
100 """Validate manifests without applying them."""
101 processor = _check_processor()
103 try:
104 logger.info(f"Validating {len(request.manifests)} manifests")
106 validation_results = []
107 overall_valid = True
109 for i, manifest in enumerate(request.manifests):
110 is_valid, error_msg = processor.validate_manifest(manifest, request.namespace)
112 result: dict[str, Any] = {
113 "manifest_index": i,
114 "valid": is_valid,
115 "api_version": manifest.get("apiVersion", "unknown"),
116 "kind": manifest.get("kind", "unknown"),
117 "name": manifest.get("metadata", {}).get("name", f"manifest-{i + 1}"),
118 "namespace": manifest.get("metadata", {}).get(
119 "namespace", request.namespace or "gco-jobs"
120 ),
121 }
123 if not is_valid:
124 result["error"] = error_msg
125 overall_valid = False
127 validation_results.append(result)
129 response = {
130 "valid": overall_valid,
131 "cluster_id": processor.cluster_id,
132 "region": processor.region,
133 "timestamp": datetime.now(UTC).isoformat(),
134 "total_manifests": len(request.manifests),
135 "valid_manifests": sum(1 for r in validation_results if r["valid"]),
136 "invalid_manifests": sum(1 for r in validation_results if not r["valid"]),
137 "results": validation_results,
138 }
140 return JSONResponse(status_code=200, content=response)
142 except Exception as e:
143 raise internal_server_error("validating manifests", e) from e
146@router.get("/{namespace}/{name}")
147async def get_resource_status(
148 namespace: str,
149 name: str,
150 api_version: str = Query("apps/v1", description="Kubernetes API version"),
151 kind: str = Query("Deployment", description="Resource kind"),
152) -> Response:
153 """Get the status of a specific resource."""
154 processor = _check_processor()
156 try:
157 resource_info = await processor.get_resource_status(
158 api_version=api_version, kind=kind, name=name, namespace=namespace
159 )
161 if resource_info is None:
162 raise HTTPException(status_code=500, detail="Failed to retrieve resource information")
163 if resource_info.get("forbidden", False):
164 raise HTTPException(
165 status_code=403,
166 detail=resource_info.get("error", "Resource access denied"),
167 )
169 response = {
170 "cluster_id": processor.cluster_id,
171 "region": processor.region,
172 "timestamp": datetime.now(UTC).isoformat(),
173 "resource": resource_info,
174 }
176 status_code = 200 if resource_info.get("exists", False) else 404
177 return JSONResponse(status_code=status_code, content=response)
179 except HTTPException:
180 raise
181 except Exception as e:
182 raise internal_server_error("getting resource status", e) from e
185@router.delete("/{namespace}/{name}")
186async def delete_resource(
187 namespace: str,
188 name: str,
189 api_version: str = Query("apps/v1", description="Kubernetes API version"),
190 kind: str = Query("Deployment", description="Resource kind"),
191) -> Response:
192 """Delete a specific resource from the cluster."""
193 processor = _check_processor()
195 try:
196 resource_status = await processor.delete_resource(
197 api_version=api_version, kind=kind, name=name, namespace=namespace
198 )
200 if resource_status.status == "forbidden":
201 raise HTTPException(
202 status_code=403,
203 detail=resource_status.message or "Resource access denied",
204 )
206 response = {
207 "success": resource_status.is_successful(),
208 "cluster_id": processor.cluster_id,
209 "region": processor.region,
210 "timestamp": datetime.now(UTC).isoformat(),
211 "resource": {
212 "api_version": resource_status.api_version,
213 "kind": resource_status.kind,
214 "name": resource_status.name,
215 "namespace": resource_status.namespace,
216 "status": resource_status.status,
217 "message": resource_status.message,
218 },
219 }
221 status_code = 200 if resource_status.is_successful() else 400
222 return JSONResponse(status_code=status_code, content=response)
224 except HTTPException:
225 raise
226 except Exception as e:
227 raise internal_server_error("deleting resource", e) from e