Coverage for gco / services / api_routes / manifests.py: 97%

85 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-30 21:47 +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 ManifestSubmissionAPIRequest, _check_processor 

14 

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

16logger = logging.getLogger(__name__) 

17 

18 

19@router.post("") 

20async def submit_manifests(request: ManifestSubmissionAPIRequest) -> Response: 

21 """Submit Kubernetes manifests for processing.""" 

22 from gco.services.manifest_api import manifest_metrics 

23 

24 processor = _check_processor() 

25 

26 try: 

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

28 

29 try: 

30 submission_request = ManifestSubmissionRequest( 

31 manifests=request.manifests, 

32 namespace=request.namespace, 

33 dry_run=request.dry_run, 

34 validate=request.validate_manifests, 

35 ) 

36 except ValueError as e: 

37 # Client-side validation failures (empty manifests, unparseable 

38 # payloads, etc.) should surface as 400, not 500. 

39 logger.info(f"Rejected manifest submission as invalid input: {e}") 

40 raise HTTPException(status_code=400, detail=str(e)) from e 

41 

42 response = await processor.process_manifest_submission(submission_request) 

43 

44 if manifest_metrics and not request.dry_run: 44 ↛ 62line 44 didn't jump to line 62 because the condition on line 44 was always true

45 try: 

46 successful = sum(1 for r in response.resources if r.is_successful()) 

47 failed = sum(1 for r in response.resources if not r.is_successful()) 

48 validation_failures = sum( 

49 1 

50 for r in response.resources 

51 if r.status == "failed" and "validation" in (r.message or "").lower() 

52 ) 

53 manifest_metrics.publish_submission_metrics( 

54 total_submissions=len(response.resources), 

55 successful_submissions=successful, 

56 failed_submissions=failed, 

57 validation_failures=validation_failures, 

58 ) 

59 except Exception as e: 

60 logger.warning(f"Failed to publish manifest metrics: {e}") 

61 

62 api_response: dict[str, Any] = { 

63 "success": response.success, 

64 "cluster_id": response.cluster_id, 

65 "region": response.region, 

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

67 "summary": response.get_summary(), 

68 "resources": [ 

69 { 

70 "api_version": r.api_version, 

71 "kind": r.kind, 

72 "name": r.name, 

73 "namespace": r.namespace, 

74 "status": r.status, 

75 "message": r.message, 

76 } 

77 for r in response.resources 

78 ], 

79 } 

80 

81 if response.errors: 

82 api_response["errors"] = response.errors 

83 

84 status_code = 200 if response.success else 400 

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

86 

87 except HTTPException: 

88 # Already a well-formed HTTP error — don't demote to 500. 

89 raise 

90 except Exception as e: 

91 logger.error(f"Error processing manifest submission: {e}") 

92 raise HTTPException(status_code=500, detail=f"Internal server error: {e!s}") from e 

93 

94 

95@router.post("/validate") 

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

97 """Validate manifests without applying them.""" 

98 processor = _check_processor() 

99 

100 try: 

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

102 

103 validation_results = [] 

104 overall_valid = True 

105 

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

107 is_valid, error_msg = processor.validate_manifest(manifest) 

108 

109 result: dict[str, Any] = { 

110 "manifest_index": i, 

111 "valid": is_valid, 

112 "api_version": manifest.get("apiVersion", "unknown"), 

113 "kind": manifest.get("kind", "unknown"), 

114 "name": manifest.get("metadata", {}).get("name", f"manifest-{i + 1}"), 

115 "namespace": manifest.get("metadata", {}).get( 

116 "namespace", request.namespace or "default" 

117 ), 

118 } 

119 

120 if not is_valid: 

121 result["error"] = error_msg 

122 overall_valid = False 

123 

124 validation_results.append(result) 

125 

126 response = { 

127 "valid": overall_valid, 

128 "cluster_id": processor.cluster_id, 

129 "region": processor.region, 

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

131 "total_manifests": len(request.manifests), 

132 "valid_manifests": sum(1 for r in validation_results if r["valid"]), 

133 "invalid_manifests": sum(1 for r in validation_results if not r["valid"]), 

134 "results": validation_results, 

135 } 

136 

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

138 

139 except Exception as e: 

140 logger.error(f"Error validating manifests: {e}") 

141 raise HTTPException(status_code=500, detail="Internal server error") from e 

142 

143 

144@router.get("/{namespace}/{name}") 

145async def get_resource_status( 

146 namespace: str, 

147 name: str, 

148 api_version: str = Query("apps/v1", description="Kubernetes API version"), 

149 kind: str = Query("Deployment", description="Resource kind"), 

150) -> Response: 

151 """Get the status of a specific resource.""" 

152 processor = _check_processor() 

153 

154 try: 

155 resource_info = await processor.get_resource_status( 

156 api_version=api_version, kind=kind, name=name, namespace=namespace 

157 ) 

158 

159 if resource_info is None: 

160 raise HTTPException(status_code=500, detail="Failed to retrieve resource information") 

161 

162 response = { 

163 "cluster_id": processor.cluster_id, 

164 "region": processor.region, 

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

166 "resource": resource_info, 

167 } 

168 

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

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

171 

172 except HTTPException: 

173 raise 

174 except Exception as e: 

175 logger.error(f"Error getting resource status: {e}") 

176 raise HTTPException(status_code=500, detail=f"Internal server error: {e!s}") from e 

177 

178 

179@router.delete("/{namespace}/{name}") 

180async def delete_resource( 

181 namespace: str, 

182 name: str, 

183 api_version: str = Query("apps/v1", description="Kubernetes API version"), 

184 kind: str = Query("Deployment", description="Resource kind"), 

185) -> Response: 

186 """Delete a specific resource from the cluster.""" 

187 processor = _check_processor() 

188 

189 try: 

190 resource_status = await processor.delete_resource( 

191 api_version=api_version, kind=kind, name=name, namespace=namespace 

192 ) 

193 

194 response = { 

195 "success": resource_status.is_successful(), 

196 "cluster_id": processor.cluster_id, 

197 "region": processor.region, 

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

199 "resource": { 

200 "api_version": resource_status.api_version, 

201 "kind": resource_status.kind, 

202 "name": resource_status.name, 

203 "namespace": resource_status.namespace, 

204 "status": resource_status.status, 

205 "message": resource_status.message, 

206 }, 

207 } 

208 

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

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

211 

212 except Exception as e: 

213 logger.error(f"Error deleting resource: {e}") 

214 raise HTTPException(status_code=500, detail=f"Internal server error: {e!s}") from e