Coverage for gco / services / api_routes / templates.py: 100.00%

89 statements  

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

1"""Job template CRUD and job-from-template endpoints.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from datetime import UTC, datetime 

7from typing import TYPE_CHECKING 

8 

9from fastapi import APIRouter, HTTPException 

10from fastapi.responses import JSONResponse, Response 

11 

12from gco.models import ManifestSubmissionRequest 

13from gco.services.api_shared import ( 

14 JobFromTemplateRequest, 

15 JobTemplateRequest, 

16 _apply_template_parameters, 

17 _check_namespace, 

18 _check_processor, 

19 internal_server_error, 

20) 

21 

22if TYPE_CHECKING: 

23 from gco.services.template_store import TemplateStore 

24 

25router = APIRouter(tags=["Templates"]) 

26logger = logging.getLogger(__name__) 

27 

28 

29def _get_template_store() -> TemplateStore: 

30 from gco.services.manifest_api import template_store 

31 

32 if template_store is None: 

33 raise HTTPException(status_code=503, detail="Template store not initialized") 

34 return template_store 

35 

36 

37@router.get("/api/v1/templates") 

38async def list_templates() -> Response: 

39 """List all job templates.""" 

40 store = _get_template_store() 

41 try: 

42 templates = store.list_templates() 

43 return JSONResponse( 

44 status_code=200, 

45 content={ 

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

47 "count": len(templates), 

48 "templates": templates, 

49 }, 

50 ) 

51 except Exception as e: 

52 logger.error(f"Failed to list templates: {e}") 

53 raise HTTPException(status_code=500, detail=f"Failed to list templates: {e!s}") from e 

54 

55 

56@router.post("/api/v1/templates") 

57async def create_template(request: JobTemplateRequest) -> Response: 

58 """Create a new job template.""" 

59 store = _get_template_store() 

60 try: 

61 template = store.create_template( 

62 name=request.name, 

63 manifest=request.manifest, 

64 description=request.description, 

65 parameters=request.parameters, 

66 ) 

67 return JSONResponse( 

68 status_code=201, 

69 content={ 

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

71 "message": "Template created successfully", 

72 "template": template, 

73 }, 

74 ) 

75 except ValueError as e: 

76 raise HTTPException(status_code=409, detail=str(e)) from e 

77 except Exception as e: 

78 logger.error(f"Failed to create template: {e}") 

79 raise HTTPException(status_code=500, detail=f"Failed to create template: {e!s}") from e 

80 

81 

82@router.get("/api/v1/templates/{name}") 

83async def get_template(name: str) -> Response: 

84 """Get a specific job template.""" 

85 store = _get_template_store() 

86 try: 

87 template = store.get_template(name) 

88 if template is None: 

89 raise HTTPException(status_code=404, detail=f"Template '{name}' not found") 

90 return JSONResponse( 

91 status_code=200, 

92 content={"timestamp": datetime.now(UTC).isoformat(), "template": template}, 

93 ) 

94 except HTTPException: 

95 raise 

96 except Exception as e: 

97 logger.error(f"Failed to get template: {e}") 

98 raise HTTPException(status_code=500, detail=f"Failed to get template: {e!s}") from e 

99 

100 

101@router.delete("/api/v1/templates/{name}") 

102async def delete_template(name: str) -> Response: 

103 """Delete a job template.""" 

104 store = _get_template_store() 

105 try: 

106 deleted = store.delete_template(name) 

107 if not deleted: 

108 raise HTTPException(status_code=404, detail=f"Template '{name}' not found") 

109 return JSONResponse( 

110 status_code=200, 

111 content={ 

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

113 "message": f"Template '{name}' deleted successfully", 

114 }, 

115 ) 

116 except HTTPException: 

117 raise 

118 except Exception as e: 

119 logger.error(f"Failed to delete template: {e}") 

120 raise HTTPException(status_code=500, detail=f"Failed to delete template: {e!s}") from e 

121 

122 

123@router.post("/api/v1/jobs/from-template/{name}") 

124async def create_job_from_template(name: str, request: JobFromTemplateRequest) -> Response: 

125 """Create a job from a template with parameter substitution.""" 

126 processor = _check_processor() 

127 store = _get_template_store() 

128 

129 template = store.get_template(name) 

130 if template is None: 

131 raise HTTPException(status_code=404, detail=f"Template '{name}' not found") 

132 

133 _check_namespace(request.namespace, processor) 

134 

135 try: 

136 parameters = {**template.get("parameters", {}), **(request.parameters or {})} 

137 parameters["name"] = request.name 

138 

139 manifest = _apply_template_parameters(template["manifest"], parameters) 

140 

141 if "metadata" not in manifest: 

142 manifest["metadata"] = {} 

143 manifest["metadata"]["namespace"] = request.namespace 

144 manifest["metadata"]["name"] = request.name 

145 

146 if "labels" not in manifest["metadata"]: 

147 manifest["metadata"]["labels"] = {} 

148 manifest["metadata"]["labels"]["gco.io/template"] = name 

149 

150 submission_request = ManifestSubmissionRequest( 

151 manifests=[manifest], namespace=request.namespace, dry_run=False, validate=True 

152 ) 

153 

154 result = await processor.process_manifest_submission(submission_request) 

155 

156 response = { 

157 "cluster_id": processor.cluster_id, 

158 "region": processor.region, 

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

160 "template": name, 

161 "job_name": request.name, 

162 "namespace": request.namespace, 

163 "success": result.success, 

164 "parameters_applied": parameters, 

165 "errors": result.errors, 

166 } 

167 

168 status_code = 201 if result.success else 400 

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

170 

171 except HTTPException: 

172 raise 

173 except Exception as e: 

174 raise internal_server_error("creating job from template", e) from e