Coverage for fake_kubernetes_client/status_schema_parser.py: 10%

228 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-07-29 12:31 +0300

1"""Status schema parser for generating dynamic status from resource schemas""" 

2 

3import json 

4import os 

5import random 

6from datetime import datetime, timezone 

7from typing import Any, Union 

8 

9 

10class StatusSchemaParser: 

11 """Parser for generating status from resource schemas""" 

12 

13 def __init__(self, resource_mappings: dict[str, Any]) -> None: 

14 self.resource_mappings = resource_mappings 

15 self._definitions_cache: dict[str, Any] = {} 

16 self._definitions: dict[str, Any] = {} 

17 self._load_definitions() 

18 

19 def _load_definitions(self) -> None: 

20 """Load the definitions file once""" 

21 definitions_path = os.path.join( 

22 os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 

23 "class_generator", 

24 "schema", 

25 "_definitions.json", 

26 ) 

27 

28 if os.path.exists(definitions_path): 

29 with open(definitions_path, "r") as f: 

30 data = json.load(f) 

31 self._definitions = data.get("definitions", {}) 

32 

33 def get_status_schema_for_resource(self, kind: str, api_version: str) -> Union[dict[str, Any], None]: 

34 """Get status schema for a specific resource""" 

35 kind_lower = kind.lower() 

36 resource_schemas = self.resource_mappings.get(kind_lower, []) 

37 

38 if not resource_schemas: 

39 return None 

40 

41 # Find the matching schema for the API version 

42 for schema in resource_schemas: 

43 gvk_list = schema.get("x-kubernetes-group-version-kind", []) 

44 for gvk in gvk_list: 

45 if gvk.get("kind") == kind: 

46 # Found matching resource, check if it has status 

47 properties = schema.get("properties", {}) 

48 status_ref = properties.get("status", {}) 

49 

50 if "$ref" in status_ref: 

51 # Resolve the reference to get actual status schema 

52 return self._resolve_reference(status_ref["$ref"]) 

53 elif status_ref.get("type") == "object": 

54 # Direct status object 

55 return status_ref 

56 

57 return None 

58 

59 def _resolve_reference(self, ref: str) -> Union[dict[str, Any], None]: 

60 """Resolve a $ref to get the actual schema definition""" 

61 # Check cache first 

62 if ref in self._definitions_cache: 

63 return self._definitions_cache[ref] 

64 

65 # Parse the reference 

66 # References are like "#/definitions/io.k8s.api.core.v1.PersistentVolumeClaimStatus" 

67 if not ref.startswith("#/"): 

68 # External references not supported for now 

69 return None 

70 

71 # Remove the leading '#/' and split the path 

72 ref_path = ref[2:].split("/") 

73 

74 # Navigate the schema structure 

75 current = None 

76 if ref_path[0] == "definitions" and len(ref_path) > 1: 

77 definition_name = ref_path[1] 

78 if definition_name in self._definitions: 

79 current = self._definitions[definition_name] 

80 

81 # Navigate any additional path segments 

82 for segment in ref_path[2:]: 

83 if isinstance(current, dict) and segment in current: 

84 current = current[segment] 

85 else: 

86 current = None 

87 break 

88 

89 # Cache the result 

90 if current is not None: 

91 self._definitions_cache[ref] = current 

92 return current 

93 

94 # Fallback to the old method if definition not found 

95 if len(ref_path) > 1: 

96 definition_name = ref_path[-1] 

97 return self._get_status_schema_by_type(definition_name) 

98 

99 return None 

100 

101 def _get_status_schema_by_type(self, type_name: str) -> dict[str, Any]: 

102 """Get a status schema based on the type name""" 

103 # Extract the resource type from the definition name 

104 # e.g., "io.k8s.api.core.v1.PersistentVolumeClaimStatus" -> "PersistentVolumeClaim" 

105 if "Status" in type_name: 

106 resource_type = type_name.split(".")[-1].replace("Status", "") 

107 return self._get_default_status_schema_for_type(resource_type) 

108 

109 return {"type": "object", "properties": {}} 

110 

111 def _get_default_status_schema_for_type(self, resource_type: str) -> dict[str, Any]: 

112 """Get default status schema based on resource type patterns""" 

113 # Common patterns in Kubernetes status objects 

114 base_schema = {"type": "object", "properties": {}} 

115 

116 # Add common status fields based on resource type 

117 if resource_type == "PersistentVolumeClaim": 

118 base_schema["properties"] = { 

119 "phase": {"type": "string", "enum": ["Pending", "Bound", "Lost"]}, 

120 "accessModes": { 

121 "type": "array", 

122 "items": {"type": "string", "enum": ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany"]}, 

123 }, 

124 "capacity": {"type": "object", "additionalProperties": {"type": "string"}}, 

125 "conditions": { 

126 "type": "array", 

127 "items": { 

128 "type": "object", 

129 "properties": { 

130 "type": {"type": "string"}, 

131 "status": {"type": "string"}, 

132 "lastTransitionTime": {"type": "string"}, 

133 "reason": {"type": "string"}, 

134 "message": {"type": "string"}, 

135 }, 

136 }, 

137 }, 

138 } 

139 elif resource_type in ["StatefulSet", "DaemonSet", "ReplicaSet"]: 

140 base_schema["properties"] = { 

141 "replicas": {"type": "integer"}, 

142 "readyReplicas": {"type": "integer"}, 

143 "currentReplicas": {"type": "integer"}, 

144 "updatedReplicas": {"type": "integer"}, 

145 "observedGeneration": {"type": "integer"}, 

146 "conditions": { 

147 "type": "array", 

148 "items": { 

149 "type": "object", 

150 "properties": { 

151 "type": {"type": "string"}, 

152 "status": {"type": "string", "enum": ["True", "False", "Unknown"]}, 

153 "lastTransitionTime": {"type": "string"}, 

154 }, 

155 }, 

156 }, 

157 } 

158 elif resource_type == "PersistentVolume": 

159 base_schema["properties"] = { 

160 "phase": {"type": "string", "enum": ["Pending", "Available", "Bound", "Released", "Failed"]}, 

161 "message": {"type": "string"}, 

162 "reason": {"type": "string"}, 

163 } 

164 elif resource_type == "StorageClass": 

165 # StorageClass typically doesn't have status 

166 base_schema["properties"] = {} 

167 else: 

168 # Default status with conditions 

169 base_schema["properties"] = { 

170 "conditions": { 

171 "type": "array", 

172 "items": { 

173 "type": "object", 

174 "properties": { 

175 "type": {"type": "string"}, 

176 "status": {"type": "string", "enum": ["True", "False", "Unknown"]}, 

177 "lastTransitionTime": {"type": "string"}, 

178 "reason": {"type": "string"}, 

179 "message": {"type": "string"}, 

180 }, 

181 }, 

182 }, 

183 "observedGeneration": {"type": "integer"}, 

184 } 

185 

186 # Add phase for resources that commonly have it 

187 if resource_type in ["Job", "CronJob", "Backup", "Restore"]: 

188 properties = base_schema["properties"] 

189 if isinstance(properties, dict): 

190 properties["phase"] = { 

191 "type": "string", 

192 "enum": ["Pending", "Running", "Succeeded", "Failed", "Unknown"], 

193 } 

194 

195 return base_schema 

196 

197 def generate_status_from_schema(self, schema: dict[str, Any], resource_body: dict[str, Any]) -> dict[str, Any]: 

198 """Generate a realistic status based on the schema""" 

199 if not schema or schema.get("type") != "object": 

200 return {} 

201 

202 status = {} 

203 properties = schema.get("properties", {}) 

204 

205 # Check if resource is configured as not ready 

206 is_ready = self._is_resource_ready(resource_body) 

207 

208 for field_name, field_schema in properties.items(): 

209 # Resolve any $ref in the field schema 

210 if "$ref" in field_schema: 

211 resolved_schema = self._resolve_reference(field_schema["$ref"]) 

212 if resolved_schema: 

213 field_schema = resolved_schema 

214 

215 value = self._generate_value_for_field(field_name, field_schema, resource_body, is_ready) 

216 if value is not None: 

217 status[field_name] = value 

218 

219 return status 

220 

221 def _is_resource_ready(self, resource_body: dict[str, Any]) -> bool: 

222 """Check if resource should be generated as ready""" 

223 # Check annotations for test configuration 

224 metadata = resource_body.get("metadata", {}) 

225 annotations = metadata.get("annotations", {}) 

226 

227 # Allow configuration via annotation "fake-client.io/ready" 

228 if annotations.get("fake-client.io/ready", "").lower() == "false": 

229 return False 

230 

231 # Also check for a more specific ready status in spec 

232 if "readyStatus" in resource_body.get("spec", {}): 

233 return bool(resource_body["spec"]["readyStatus"]) 

234 

235 # Default to ready 

236 return True 

237 

238 def _generate_value_for_field( 

239 self, field_name: str, field_schema: dict[str, Any], resource_body: dict[str, Any], is_ready: bool = True 

240 ) -> Any: 

241 """Generate a value for a specific field based on its schema""" 

242 field_type = field_schema.get("type", "string") 

243 

244 if "enum" in field_schema: 

245 # For enums, pick an appropriate value 

246 return self._pick_enum_value(field_name, field_schema["enum"], is_ready) 

247 

248 if field_type == "string": 

249 return self._generate_string_value(field_name, is_ready) 

250 elif field_type == "integer": 

251 return self._generate_integer_value(field_name, resource_body, is_ready) 

252 elif field_type == "boolean": 

253 return self._generate_boolean_value(field_name, is_ready) 

254 elif field_type == "array": 

255 return self._generate_array_value(field_name, field_schema, resource_body, is_ready) 

256 elif field_type == "object": 

257 return self._generate_object_value(field_name, field_schema, resource_body, is_ready) 

258 

259 return None 

260 

261 def _pick_enum_value(self, field_name: str, enum_values: list[str], is_ready: bool = True) -> str: 

262 """Pick an appropriate enum value based on field name""" 

263 if not enum_values: 

264 return "" 

265 

266 # Smart selection based on field name and ready status 

267 if field_name == "phase": 

268 if is_ready: 

269 # Prefer positive states 

270 positive_states = ["Bound", "Running", "Active", "Ready", "Available", "Succeeded", "Complete"] 

271 for state in positive_states: 

272 if state in enum_values: 

273 return state 

274 else: 

275 # Prefer negative/pending states 

276 negative_states = ["Failed", "Error", "Terminating", "Pending", "Unknown"] 

277 for state in negative_states: 

278 if state in enum_values: 

279 return state 

280 # Fallback to first value 

281 return enum_values[0] 

282 

283 elif field_name == "status" or field_name.endswith("Status"): 

284 # For condition statuses 

285 if is_ready and "True" in enum_values: 

286 return "True" 

287 elif not is_ready and "False" in enum_values: 

288 return "False" 

289 elif "Unknown" in enum_values: 

290 return "Unknown" 

291 

292 elif field_name == "type" or field_name.endswith("Type"): 

293 # For condition types, prefer "Ready" or "Available" 

294 preferred = ["Ready", "Available", "Initialized", "Progressing"] 

295 for pref in preferred: 

296 if pref in enum_values: 

297 return pref 

298 

299 # Default to first value 

300 return enum_values[0] 

301 

302 def _generate_string_value(self, field_name: str, is_ready: bool = True) -> str: 

303 """Generate a string value based on field name""" 

304 if "time" in field_name.lower() or "timestamp" in field_name.lower(): 

305 return datetime.now(timezone.utc).isoformat() 

306 elif field_name == "message": 

307 return "Resource is ready" if is_ready else "Resource is not ready" 

308 elif field_name == "reason": 

309 return "ResourceReady" if is_ready else "ResourceNotReady" 

310 elif field_name.endswith("IP"): 

311 return f"10.0.0.{random.randint(1, 254)}" 

312 elif field_name == "hostname": 

313 return "node-1" 

314 elif field_name == "storagePolicyID": 

315 return "default-policy" 

316 else: 

317 return f"test-{field_name}" 

318 

319 def _generate_integer_value(self, field_name: str, resource_body: dict[str, Any], is_ready: bool = True) -> int: 

320 """Generate an integer value based on field name""" 

321 if "replicas" in field_name: 

322 # Match the spec replicas if available 

323 spec_replicas = resource_body.get("spec", {}).get("replicas", 1) 

324 if is_ready: 

325 if field_name in ["replicas", "readyReplicas", "availableReplicas", "updatedReplicas"]: 

326 return spec_replicas 

327 elif field_name == "unavailableReplicas": 

328 return 0 

329 else: 

330 if field_name == "replicas": 

331 return spec_replicas 

332 elif field_name in ["readyReplicas", "availableReplicas", "updatedReplicas"]: 

333 return 0 

334 elif field_name == "unavailableReplicas": 

335 return spec_replicas 

336 elif field_name == "observedGeneration": 

337 return resource_body.get("metadata", {}).get("generation", 1) 

338 elif field_name == "restartCount": 

339 return 0 

340 elif field_name.endswith("Count"): 

341 return 1 

342 else: 

343 return 1 

344 return 1 

345 

346 def _generate_boolean_value(self, field_name: str, is_ready: bool = True) -> bool: 

347 """Generate a boolean value based on field name""" 

348 # Generally prefer positive values unless not ready 

349 negative_indicators = ["disabled", "failed", "error", "unavailable", "notready"] 

350 field_lower = field_name.lower() 

351 

352 for indicator in negative_indicators: 

353 if indicator in field_lower: 

354 return False 

355 

356 return is_ready 

357 

358 def _generate_array_value( 

359 self, field_name: str, field_schema: dict[str, Any], resource_body: dict[str, Any], is_ready: bool = True 

360 ) -> list[Any]: 

361 """Generate an array value based on field schema""" 

362 items_schema = field_schema.get("items", {}) 

363 

364 # Resolve $ref in items if present 

365 if "$ref" in items_schema: 

366 resolved_items = self._resolve_reference(items_schema["$ref"]) 

367 if resolved_items: 

368 items_schema = resolved_items 

369 

370 if field_name == "conditions": 

371 # Generate standard conditions 

372 return self._generate_conditions(is_ready) 

373 elif field_name == "accessModes": 

374 # Use access modes from spec if available 

375 spec_modes = resource_body.get("spec", {}).get("accessModes", ["ReadWriteOnce"]) 

376 return spec_modes 

377 elif field_name == "addresses": 

378 return [{"type": "InternalIP", "address": "10.0.0.1"}] 

379 elif field_name == "ports": 

380 return resource_body.get("spec", {}).get("ports", [{"port": 80}]) 

381 else: 

382 # Generate a single item based on the items schema 

383 item = self._generate_value_for_field(f"{field_name}_item", items_schema, resource_body, is_ready) 

384 return [item] if item is not None else [] 

385 

386 def _generate_object_value( 

387 self, field_name: str, field_schema: dict[str, Any], resource_body: dict[str, Any], is_ready: bool = True 

388 ) -> dict[str, Any]: 

389 """Generate an object value based on field schema""" 

390 if field_name == "capacity": 

391 # For PVC capacity, match the requested storage 

392 requested = resource_body.get("spec", {}).get("resources", {}).get("requests", {}) 

393 return requested.copy() if requested else {"storage": "1Gi"} 

394 elif field_name == "allocatedResources": 

395 # Match capacity 

396 return self._generate_object_value("capacity", field_schema, resource_body, is_ready) 

397 elif "properties" in field_schema: 

398 # Generate based on properties 

399 obj = {} 

400 for prop_name, prop_schema in field_schema["properties"].items(): 

401 # Resolve any $ref in property schema 

402 if "$ref" in prop_schema: 

403 resolved = self._resolve_reference(prop_schema["$ref"]) 

404 if resolved: 

405 prop_schema = resolved 

406 

407 value = self._generate_value_for_field(prop_name, prop_schema, resource_body, is_ready) 

408 if value is not None: 

409 obj[prop_name] = value 

410 return obj 

411 elif "additionalProperties" in field_schema: 

412 # For maps like capacity, labels, etc. 

413 if field_name in ["capacity", "allocatedResources", "requests", "limits"]: 

414 return {"storage": "1Gi", "cpu": "100m", "memory": "128Mi"} 

415 else: 

416 return {"key1": "value1"} 

417 else: 

418 return {} 

419 

420 def _generate_conditions(self, is_ready: bool = True) -> list[dict[str, Any]]: 

421 """Generate standard Kubernetes conditions""" 

422 if is_ready: 

423 return [ 

424 { 

425 "type": "Ready", 

426 "status": "True", 

427 "lastTransitionTime": datetime.now(timezone.utc).isoformat(), 

428 "reason": "ResourceReady", 

429 "message": "Resource is ready", 

430 }, 

431 { 

432 "type": "Available", 

433 "status": "True", 

434 "lastTransitionTime": datetime.now(timezone.utc).isoformat(), 

435 "reason": "ResourceAvailable", 

436 "message": "Resource is available", 

437 }, 

438 ] 

439 else: 

440 return [ 

441 { 

442 "type": "Ready", 

443 "status": "False", 

444 "lastTransitionTime": datetime.now(timezone.utc).isoformat(), 

445 "reason": "ResourceNotReady", 

446 "message": "Resource is not ready", 

447 }, 

448 { 

449 "type": "Available", 

450 "status": "False", 

451 "lastTransitionTime": datetime.now(timezone.utc).isoformat(), 

452 "reason": "ResourceUnavailable", 

453 "message": "Resource is unavailable", 

454 }, 

455 ]