Coverage for gco / models / manifest_models.py: 100.00%

101 statements  

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

1""" 

2Manifest submission data models for GCO (Global Capacity Orchestrator on AWS). 

3 

4This module defines dataclasses for Kubernetes manifest processing: 

5- KubernetesManifest: Single Kubernetes resource definition 

6- ManifestSubmissionRequest: Request to submit one or more manifests 

7- ManifestSubmissionResponse: Response with processing results 

8- ResourceStatus: Status of a single resource operation 

9 

10These models are used by the manifest processor service to validate, 

11process, and track Kubernetes resource submissions. 

12""" 

13 

14from __future__ import annotations 

15 

16from dataclasses import dataclass 

17from typing import Any 

18 

19 

20@dataclass 

21class KubernetesManifest: 

22 """ 

23 Represents a single Kubernetes manifest. 

24 

25 Attributes: 

26 apiVersion: Kubernetes API version (e.g., 'apps/v1', 'v1') 

27 kind: Resource kind (e.g., 'Deployment', 'Service', 'ConfigMap') 

28 metadata: Resource metadata including name and namespace 

29 spec: Resource specification (for most resource types) 

30 data: Resource data (for ConfigMaps and Secrets) 

31 """ 

32 

33 apiVersion: str 

34 kind: str 

35 metadata: dict[str, Any] 

36 spec: dict[str, Any] | None = None 

37 data: dict[str, Any] | None = None 

38 

39 def __post_init__(self) -> None: 

40 """Validate Kubernetes manifest structure""" 

41 if not self.apiVersion: 

42 raise ValueError("apiVersion cannot be empty") 

43 

44 if not self.kind: 

45 raise ValueError("kind cannot be empty") 

46 

47 if not isinstance(self.metadata, dict): 

48 raise ValueError("metadata must be a dictionary") 

49 

50 if "name" not in self.metadata: 

51 raise ValueError("metadata must contain a 'name' field") 

52 

53 # Either spec or data should be present (or both for some resources) 

54 if self.spec is not None and not isinstance(self.spec, dict): 

55 raise ValueError("spec must be a dictionary") 

56 

57 if self.data is not None and not isinstance(self.data, dict): 

58 raise ValueError("data must be a dictionary") 

59 

60 # At least one of spec or data should be present for most resources 

61 if ( 

62 self.spec is None 

63 and self.data is None 

64 and self.kind not in ["Namespace", "ServiceAccount"] 

65 ): 

66 raise ValueError("Either spec or data must be provided for most resource types") 

67 

68 def get_name(self) -> str: 

69 """Get the resource name""" 

70 return str(self.metadata.get("name", "")) 

71 

72 def get_namespace(self) -> str: 

73 """Get the resource namespace""" 

74 return str(self.metadata.get("namespace", "gco-jobs")) 

75 

76 def to_dict(self) -> dict[str, Any]: 

77 """Convert to dictionary representation""" 

78 result = {"apiVersion": self.apiVersion, "kind": self.kind, "metadata": self.metadata} 

79 

80 if self.spec is not None: 

81 result["spec"] = self.spec 

82 

83 if self.data is not None: 

84 result["data"] = self.data 

85 

86 return result 

87 

88 @classmethod 

89 def from_dict(cls, data: dict[str, Any]) -> KubernetesManifest: 

90 """Create from dictionary representation""" 

91 return cls( 

92 apiVersion=data["apiVersion"], 

93 kind=data["kind"], 

94 metadata=data["metadata"], 

95 spec=data.get("spec"), 

96 data=data.get("data"), 

97 ) 

98 

99 

100@dataclass 

101class ManifestSubmissionRequest: 

102 """Request to submit Kubernetes manifests""" 

103 

104 manifests: list[dict[str, Any]] 

105 namespace: str | None = None 

106 dry_run: bool = False 

107 validate: bool = True 

108 

109 def __post_init__(self) -> None: 

110 """Validate submission request""" 

111 if not self.manifests: 

112 raise ValueError("At least one manifest must be provided") 

113 

114 # Validate each manifest can be parsed 

115 for i, manifest_data in enumerate(self.manifests): 

116 try: 

117 KubernetesManifest.from_dict(manifest_data) 

118 except Exception as e: 

119 raise ValueError(f"Invalid manifest at index {i}: {e}") from e 

120 

121 def get_kubernetes_manifests(self) -> list[KubernetesManifest]: 

122 """Convert to KubernetesManifest objects""" 

123 return [KubernetesManifest.from_dict(manifest) for manifest in self.manifests] 

124 

125 def get_resource_count(self) -> int: 

126 """Get total number of resources to be created""" 

127 return len(self.manifests) 

128 

129 

130@dataclass 

131class ResourceStatus: 

132 """Status of a deployed Kubernetes resource""" 

133 

134 api_version: str 

135 kind: str 

136 name: str 

137 namespace: str 

138 status: str # 'created', 'updated', 'unchanged', 'failed', 'deleted', 'forbidden' 

139 message: str | None = None 

140 uid: str | None = None # Kubernetes resource UID 

141 

142 def __post_init__(self) -> None: 

143 """Validate resource status""" 

144 valid_statuses = { 

145 "created", 

146 "updated", 

147 "unchanged", 

148 "failed", 

149 "deleted", 

150 "forbidden", 

151 } 

152 if self.status not in valid_statuses: 

153 raise ValueError(f"Status must be one of {valid_statuses}, got {self.status}") 

154 

155 if not self.name: 

156 raise ValueError("Resource name cannot be empty") 

157 

158 if not self.namespace: 

159 raise ValueError("Resource namespace cannot be empty") 

160 

161 def is_successful(self) -> bool: 

162 """Check if the resource operation was successful""" 

163 return self.status in {"created", "updated", "unchanged", "deleted"} 

164 

165 def get_resource_identifier(self) -> str: 

166 """Get unique identifier for this resource""" 

167 return f"{self.api_version}/{self.kind}/{self.namespace}/{self.name}" 

168 

169 

170@dataclass 

171class ManifestSubmissionResponse: 

172 """Response from manifest submission""" 

173 

174 success: bool 

175 cluster_id: str 

176 region: str 

177 resources: list[ResourceStatus] 

178 errors: list[str] | None = None 

179 

180 def __post_init__(self) -> None: 

181 """Validate submission response""" 

182 if not self.cluster_id: 

183 raise ValueError("Cluster ID cannot be empty") 

184 

185 if not self.region: 

186 raise ValueError("Region cannot be empty") 

187 

188 if not isinstance(self.resources, list): 

189 raise ValueError("Resources must be a list") 

190 

191 def get_successful_resources(self) -> list[ResourceStatus]: 

192 """Get list of successfully processed resources""" 

193 return [r for r in self.resources if r.is_successful()] 

194 

195 def get_failed_resources(self) -> list[ResourceStatus]: 

196 """Get list of failed resources""" 

197 return [r for r in self.resources if not r.is_successful()] 

198 

199 def get_summary(self) -> dict[str, int]: 

200 """Get summary of resource processing results""" 

201 summary = {"created": 0, "updated": 0, "unchanged": 0, "failed": 0} 

202 for resource in self.resources: 

203 summary[resource.status] = summary.get(resource.status, 0) + 1 

204 return summary