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

1"""Manifest submission, validation, and resource management endpoints.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from datetime import UTC, datetime 

7from typing import Any 

8 

9from fastapi import APIRouter, HTTPException, Query 

10from fastapi.responses import JSONResponse, Response 

11 

12from gco.models import ManifestSubmissionRequest 

13from gco.services.api_shared import ( 

14 ManifestSubmissionAPIRequest, 

15 _check_processor, 

16 internal_server_error, 

17) 

18 

19router = APIRouter(prefix="/api/v1/manifests", tags=["Manifests"]) 

20logger = logging.getLogger(__name__) 

21 

22 

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 

27 

28 processor = _check_processor() 

29 

30 try: 

31 logger.info(f"Received manifest submission request with {len(request.manifests)} manifests") 

32 

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 

45 

46 response = await processor.process_manifest_submission(submission_request) 

47 

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}") 

65 

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 } 

84 

85 if response.errors: 

86 api_response["errors"] = response.errors 

87 

88 status_code = 200 if response.success else 400 

89 return JSONResponse(status_code=status_code, content=api_response) 

90 

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 

96 

97 

98@router.post("/validate") 

99async def validate_manifests(request: ManifestSubmissionAPIRequest) -> Response: 

100 """Validate manifests without applying them.""" 

101 processor = _check_processor() 

102 

103 try: 

104 logger.info(f"Validating {len(request.manifests)} manifests") 

105 

106 validation_results = [] 

107 overall_valid = True 

108 

109 for i, manifest in enumerate(request.manifests): 

110 is_valid, error_msg = processor.validate_manifest(manifest, request.namespace) 

111 

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 } 

122 

123 if not is_valid: 

124 result["error"] = error_msg 

125 overall_valid = False 

126 

127 validation_results.append(result) 

128 

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 } 

139 

140 return JSONResponse(status_code=200, content=response) 

141 

142 except Exception as e: 

143 raise internal_server_error("validating manifests", e) from e 

144 

145 

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() 

155 

156 try: 

157 resource_info = await processor.get_resource_status( 

158 api_version=api_version, kind=kind, name=name, namespace=namespace 

159 ) 

160 

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 ) 

168 

169 response = { 

170 "cluster_id": processor.cluster_id, 

171 "region": processor.region, 

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

173 "resource": resource_info, 

174 } 

175 

176 status_code = 200 if resource_info.get("exists", False) else 404 

177 return JSONResponse(status_code=status_code, content=response) 

178 

179 except HTTPException: 

180 raise 

181 except Exception as e: 

182 raise internal_server_error("getting resource status", e) from e 

183 

184 

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() 

194 

195 try: 

196 resource_status = await processor.delete_resource( 

197 api_version=api_version, kind=kind, name=name, namespace=namespace 

198 ) 

199 

200 if resource_status.status == "forbidden": 

201 raise HTTPException( 

202 status_code=403, 

203 detail=resource_status.message or "Resource access denied", 

204 ) 

205 

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 } 

220 

221 status_code = 200 if resource_status.is_successful() else 400 

222 return JSONResponse(status_code=status_code, content=response) 

223 

224 except HTTPException: 

225 raise 

226 except Exception as e: 

227 raise internal_server_error("deleting resource", e) from e