Coverage for fake_kubernetes_client/resource_instance.py: 16%

173 statements  

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

1"""FakeResourceInstance implementation for fake Kubernetes client""" 

2 

3import copy 

4import time 

5import uuid 

6from datetime import datetime, timezone 

7from typing import TYPE_CHECKING, Any, Iterator, Union 

8 

9from fake_kubernetes_client.exceptions import ConflictError, MethodNotAllowedError, NotFoundError 

10from fake_kubernetes_client.resource_field import FakeResourceField 

11from fake_kubernetes_client.status_templates import add_realistic_status 

12 

13if TYPE_CHECKING: 

14 from fake_kubernetes_client.dynamic_client import FakeDynamicClient 

15 from fake_kubernetes_client.resource_storage import FakeResourceStorage 

16 

17try: 

18 from kubernetes.client.rest import ApiException as K8sApiException 

19except ImportError: 

20 K8sApiException = None 

21 

22 

23class FakeResourceInstance: 

24 """Fake implementation of kubernetes.dynamic.client.ResourceInstance""" 

25 

26 def __init__( 

27 self, resource_def: dict[str, Any], storage: "FakeResourceStorage", client: Union["FakeDynamicClient", None] 

28 ) -> None: 

29 self.resource_def = resource_def 

30 self.storage = storage 

31 self.client = client 

32 

33 def _normalize_namespace(self, namespace: Union[str, None]) -> Union[str, None]: 

34 """Normalize namespace parameter (empty string -> None)""" 

35 return None if namespace == "" else namespace 

36 

37 def _get_storage_api_version(self) -> str: 

38 """Get consistent API version for storage operations""" 

39 return self.resource_def.get("group_version", self.resource_def["api_version"]) 

40 

41 def _create_not_found_error(self, name: str) -> None: 

42 """Create proper NotFoundError with Kubernetes-style exception""" 

43 try: 

44 api_exception = K8sApiException(status=404, reason=f"{self.resource_def['kind']} '{name}' not found") 

45 raise NotFoundError(api_exception) 

46 except (NameError, TypeError): 

47 # K8sApiException not available (ImportError at module level) 

48 raise NotFoundError(f"{self.resource_def['kind']} '{name}' not found") 

49 

50 def _create_conflict_error(self, name: str) -> None: 

51 """Create proper ConflictError with Kubernetes-style exception""" 

52 try: 

53 # ConflictError expects an ApiException as argument 

54 api_exception = K8sApiException(status=409, reason="AlreadyExists") 

55 api_exception.body = f'{{"kind":"Status","apiVersion":"v1","metadata":{{}},"status":"Failure","message":"{self.resource_def["kind"]} \\"{name}\\" already exists","reason":"AlreadyExists","code":409}}' 

56 raise ConflictError(api_exception) 

57 except (NameError, TypeError): 

58 # K8sApiException not available (ImportError at module level) 

59 # Use our fake ConflictError which has status attribute 

60 raise ConflictError(f"{self.resource_def['kind']} '{name}' already exists") 

61 

62 def _generate_resource_version(self) -> str: 

63 """Generate unique resource version""" 

64 return str(int(time.time() * 1000)) 

65 

66 def _generate_timestamp(self) -> str: 

67 """Generate current UTC timestamp in ISO format""" 

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

69 

70 def create( 

71 self, body: Union[dict[str, Any], None] = None, namespace: Union[str, None] = None, **kwargs: Any 

72 ) -> FakeResourceField: 

73 """Create a resource""" 

74 if body is None: 

75 raise ValueError("body is required for create") 

76 

77 # Validate body structure 

78 if not isinstance(body, dict): 

79 raise ValueError("body must be a dictionary") 

80 

81 if "metadata" not in body: 

82 body["metadata"] = {} 

83 

84 # Set namespace if provided - REQUIRE namespaced field to be present 

85 if namespace and self.resource_def.get("namespaced"): 

86 body["metadata"]["namespace"] = namespace 

87 

88 # Generate metadata if not present 

89 if "name" not in body["metadata"]: 

90 raise ValueError("metadata.name is required") 

91 

92 name = body["metadata"]["name"] 

93 resource_namespace = ( 

94 body["metadata"].get("namespace", "default") if self.resource_def.get("namespaced") else None 

95 ) 

96 

97 # Use group_version for storage operations (consistent full API version) 

98 storage_api_version = self._get_storage_api_version() 

99 

100 # Check if resource already exists 

101 existing = self.storage.get_resource( 

102 kind=self.resource_def["kind"], api_version=storage_api_version, name=name, namespace=resource_namespace 

103 ) 

104 if existing: 

105 self._create_conflict_error(name) 

106 

107 # Add generated metadata 

108 body["metadata"].update({ 

109 "uid": str(uuid.uuid4()), 

110 "resourceVersion": self._generate_resource_version(), 

111 "creationTimestamp": self._generate_timestamp(), 

112 "generation": 1, 

113 "labels": body["metadata"].get("labels", {}), 

114 "annotations": body["metadata"].get("annotations", {}), 

115 }) 

116 

117 # Set API version and kind 

118 body["apiVersion"] = storage_api_version # Use the same full API version for body 

119 body["kind"] = self.resource_def["kind"] 

120 

121 # Add realistic status for resources that need it 

122 # Pass resource mappings from registry if available 

123 resource_mappings = None 

124 if self.client and hasattr(self.client, "registry"): 

125 resource_mappings = self.client.registry._get_resource_mappings() 

126 

127 add_realistic_status(body=body, resource_mappings=resource_mappings) 

128 

129 # Special case: ProjectRequest is ephemeral - only creates Project (matches real cluster behavior) 

130 if self.resource_def["kind"] == "ProjectRequest": 

131 # Don't store ProjectRequest - it's ephemeral 

132 project_body = self._create_corresponding_project(body) 

133 # Generate events for the Project creation, not ProjectRequest 

134 self._generate_resource_events(project_body, "Created", "created") 

135 # Return Project data, not ProjectRequest (ProjectRequest is ephemeral) 

136 return FakeResourceField(data=project_body) 

137 

138 # Store resource with initial metadata and status 

139 self.storage.store_resource( 

140 api_version=storage_api_version, # Use consistent API version for storage 

141 kind=self.resource_def["kind"], 

142 name=name, 

143 namespace=self._normalize_namespace(namespace), # Normalize namespace (empty string -> None) 

144 resource=body, 

145 ) 

146 

147 # Generate automatic events for resource creation 

148 self._generate_resource_events(body, "Created", "created") 

149 

150 return FakeResourceField(data=body) 

151 

152 def _create_corresponding_project(self, project_request_body: dict[str, Any]) -> dict[str, Any]: 

153 """Create a corresponding Project when ProjectRequest is created (simulates real OpenShift behavior)""" 

154 project_name = project_request_body["metadata"]["name"] 

155 

156 # Create Project with same name and similar metadata 

157 project_body = { 

158 "apiVersion": "project.openshift.io/v1", 

159 "kind": "Project", 

160 "metadata": { 

161 "name": project_name, 

162 "uid": str(uuid.uuid4()), 

163 "resourceVersion": self._generate_resource_version(), 

164 "creationTimestamp": self._generate_timestamp(), 

165 "generation": 1, 

166 "labels": project_request_body["metadata"].get("labels", {}), 

167 "annotations": project_request_body["metadata"].get("annotations", {}), 

168 }, 

169 "spec": {"finalizers": ["kubernetes"]}, 

170 "status": {"phase": "Active"}, 

171 } 

172 

173 # Store the Project (cluster-scoped resource) 

174 self.storage.store_resource( 

175 kind="Project", 

176 api_version="project.openshift.io/v1", 

177 name=project_name, 

178 namespace=None, # Projects are cluster-scoped 

179 resource=project_body, 

180 ) 

181 

182 # Return the project body for use by create() method 

183 return project_body 

184 

185 def _generate_resource_events(self, resource: dict[str, Any], reason: str, action: str) -> None: 

186 """Generate automatic events for resource operations - completely resource-agnostic""" 

187 if not resource or not resource.get("metadata"): 

188 return 

189 

190 resource_name = resource["metadata"].get("name") 

191 resource_namespace = resource["metadata"].get("namespace") 

192 resource_kind = resource.get("kind") 

193 resource_uid = resource["metadata"].get("uid") 

194 

195 if not resource_name or not resource_kind: 

196 return 

197 

198 # Generate event name (Kubernetes pattern) 

199 event_name = f"{resource_name}.{int(time.time() * 1000000)}" 

200 

201 # Create realistic event 

202 event = { 

203 "apiVersion": "v1", 

204 "kind": "Event", 

205 "metadata": { 

206 "name": event_name, 

207 "namespace": resource_namespace or "default", 

208 "uid": str(uuid.uuid4()), 

209 "resourceVersion": str(int(time.time() * 1000)), 

210 "creationTimestamp": datetime.now(timezone.utc).isoformat(), 

211 "generation": 1, 

212 }, 

213 "involvedObject": { 

214 "apiVersion": resource.get("apiVersion"), 

215 "kind": resource_kind, 

216 "name": resource_name, 

217 "namespace": resource_namespace, 

218 "uid": resource_uid, 

219 "resourceVersion": resource["metadata"].get("resourceVersion"), 

220 }, 

221 "reason": reason, 

222 "message": f"{resource_kind} {resource_name} has been {action}", 

223 "source": {"component": "fake-client", "host": "fake-cluster.example.com"}, 

224 "firstTimestamp": datetime.now(timezone.utc).isoformat(), 

225 "lastTimestamp": datetime.now(timezone.utc).isoformat(), 

226 "count": 1, 

227 "type": "Normal", 

228 } 

229 

230 # Store the event 

231 event_namespace = resource_namespace or "default" 

232 self.storage.store_resource( 

233 kind="Event", api_version="v1", name=event_name, namespace=event_namespace, resource=event 

234 ) 

235 

236 def get( 

237 self, 

238 name: Union[str, None] = None, 

239 namespace: Union[str, None] = None, 

240 label_selector: Union[str, None] = None, 

241 field_selector: Union[str, None] = None, 

242 **kwargs: Any, 

243 ) -> FakeResourceField: 

244 """Get resource(s)""" 

245 if name: 

246 # Get specific resource 

247 namespace = self._normalize_namespace(namespace) 

248 

249 # Use group_version for storage access (consistent full API version) 

250 storage_api_version = self._get_storage_api_version() 

251 resource = self.storage.get_resource( 

252 kind=self.resource_def["kind"], api_version=storage_api_version, name=name, namespace=namespace 

253 ) 

254 if not resource: 

255 self._create_not_found_error(name) 

256 return FakeResourceField(data=resource) 

257 

258 # List resources using consistent API version 

259 storage_api_version = self._get_storage_api_version() 

260 resources = self.storage.list_resources( 

261 kind=self.resource_def["kind"], 

262 api_version=storage_api_version, 

263 namespace=self._normalize_namespace(namespace), # Normalize namespace here too 

264 label_selector=label_selector, 

265 field_selector=field_selector, 

266 ) 

267 

268 # Create list response 

269 response = { 

270 "apiVersion": self.resource_def["api_version"], 

271 "kind": f"{self.resource_def['kind']}List", 

272 "metadata": { 

273 "resourceVersion": self._generate_resource_version(), 

274 }, 

275 "items": resources, 

276 } 

277 

278 return FakeResourceField(data=response) 

279 

280 def delete( 

281 self, 

282 name: Union[str, None] = None, 

283 namespace: Union[str, None] = None, 

284 body: Union[dict[str, Any], None] = None, 

285 **kwargs: Any, 

286 ) -> FakeResourceField: 

287 """Delete resource(s)""" 

288 if name: 

289 # Delete specific resource 

290 namespace = self._normalize_namespace(namespace) 

291 

292 storage_api_version = self._get_storage_api_version() 

293 deleted = self.storage.delete_resource( 

294 kind=self.resource_def["kind"], api_version=storage_api_version, name=name, namespace=namespace 

295 ) 

296 if not deleted: 

297 self._create_not_found_error(name) 

298 # Generate automatic events for resource deletion 

299 if deleted: 

300 self._generate_resource_events(deleted, "Deleted", "deleted") 

301 

302 return FakeResourceField(data=deleted if deleted else {}) 

303 

304 # Delete collection - not implemented for safety 

305 raise MethodNotAllowedError("Collection deletion not supported") 

306 

307 def patch( 

308 self, 

309 name: Union[str, None] = None, 

310 body: Union[dict[str, Any], None] = None, 

311 namespace: Union[str, None] = None, 

312 **kwargs: Any, 

313 ) -> FakeResourceField: 

314 """Patch a resource""" 

315 if not body: 

316 raise ValueError("body is required for patch") 

317 

318 # Extract name from body if not provided (like real Kubernetes client) 

319 if not name: 

320 name = body.get("metadata", {}).get("name") 

321 if not name: 

322 raise ValueError("name is required for patch") 

323 

324 namespace = self._normalize_namespace(namespace) 

325 

326 storage_api_version = self._get_storage_api_version() 

327 existing = self.storage.get_resource( 

328 kind=self.resource_def["kind"], api_version=storage_api_version, name=name, namespace=namespace 

329 ) 

330 if not existing: 

331 self._create_not_found_error(name) 

332 # This line is unreachable but satisfies type checker 

333 return FakeResourceField(data={}) 

334 

335 # Simple merge patch implementation 

336 patched = copy.deepcopy(existing) 

337 self._merge_patch(patched, body) 

338 

339 # Update metadata 

340 patched["metadata"]["resourceVersion"] = self._generate_resource_version() 

341 if "generation" in patched["metadata"]: 

342 patched["metadata"]["generation"] += 1 

343 

344 # Store updated resource 

345 self.storage.store_resource( 

346 kind=self.resource_def["kind"], 

347 api_version=storage_api_version, 

348 name=name, 

349 namespace=namespace, 

350 resource=patched, 

351 ) 

352 

353 # Generate automatic events for resource patch 

354 self._generate_resource_events(patched, "Updated", "updated") 

355 

356 return FakeResourceField(data=patched) 

357 

358 def replace( 

359 self, 

360 name: Union[str, None] = None, 

361 body: Union[dict[str, Any], None] = None, 

362 namespace: Union[str, None] = None, 

363 **kwargs: Any, 

364 ) -> FakeResourceField: 

365 """Replace a resource""" 

366 if not name: 

367 raise ValueError("name is required for replace") 

368 if not body: 

369 raise ValueError("body is required for replace") 

370 

371 namespace = self._normalize_namespace(namespace) 

372 

373 storage_api_version = self._get_storage_api_version() 

374 existing = self.storage.get_resource( 

375 kind=self.resource_def["kind"], api_version=storage_api_version, name=name, namespace=namespace 

376 ) 

377 if not existing: 

378 self._create_not_found_error(name) 

379 # This line is unreachable but satisfies type checker 

380 return FakeResourceField(data={}) 

381 

382 # Check for resourceVersion conflict - this is what Kubernetes does 

383 if "metadata" in body and "resourceVersion" in body["metadata"]: 

384 if body["metadata"]["resourceVersion"] != existing["metadata"]["resourceVersion"]: 

385 # Create conflict error with proper message 

386 try: 

387 api_exception = K8sApiException(status=409, reason="Conflict") 

388 api_exception.body = f'{{"kind":"Status","apiVersion":"v1","metadata":{{}},"status":"Failure","message":"Operation cannot be fulfilled on {self.resource_def["kind"].lower()}s.{self.resource_def.get("group", "")} \\"{name}\\": the object has been modified; please apply your changes to the latest version and try again","reason":"Conflict","code":409}}' 

389 raise ConflictError(api_exception) 

390 except (NameError, TypeError): 

391 # Use our fake ConflictError which has status attribute 

392 raise ConflictError( 

393 f"Operation cannot be fulfilled on {self.resource_def['kind']} '{name}': the object has been modified" 

394 ) 

395 

396 # Ensure metadata is preserved 

397 if "metadata" not in body: 

398 body["metadata"] = {} 

399 

400 body["metadata"].update({ 

401 "uid": existing["metadata"]["uid"], 

402 "resourceVersion": self._generate_resource_version(), 

403 "creationTimestamp": existing["metadata"]["creationTimestamp"], 

404 "generation": existing["metadata"].get("generation", 1) + 1, 

405 }) 

406 

407 # Set API version and kind 

408 body["apiVersion"] = self.resource_def["api_version"] 

409 body["kind"] = self.resource_def["kind"] 

410 

411 # Store replaced resource 

412 self.storage.store_resource( 

413 kind=self.resource_def["kind"], 

414 api_version=storage_api_version, 

415 name=name, 

416 namespace=namespace, 

417 resource=body, 

418 ) 

419 

420 # Generate automatic events for resource replacement 

421 self._generate_resource_events(body, "Updated", "replaced") 

422 

423 # Return the stored resource (which has the updated metadata) 

424 return FakeResourceField( 

425 data=self.storage.get_resource( 

426 kind=self.resource_def["kind"], api_version=storage_api_version, name=name, namespace=namespace 

427 ) 

428 ) 

429 

430 def update( 

431 self, 

432 name: Union[str, None] = None, 

433 body: Union[dict[str, Any], None] = None, 

434 namespace: Union[str, None] = None, 

435 **kwargs: Any, 

436 ) -> FakeResourceField: 

437 """Update a resource (alias for replace)""" 

438 return self.replace(name=name, body=body, namespace=namespace, **kwargs) 

439 

440 def watch( 

441 self, namespace: Union[str, None] = None, timeout: Union[int, None] = None, **kwargs: Any 

442 ) -> Iterator[dict[str, Any]]: 

443 """Watch for resource changes""" 

444 # Simple implementation - yields existing resources as ADDED events 

445 storage_api_version = self.resource_def.get("group_version", self.resource_def["api_version"]) 

446 

447 # Extract label and field selectors from kwargs 

448 label_selector = kwargs.get("label_selector") 

449 field_selector = kwargs.get("field_selector") 

450 

451 resources = self.storage.list_resources( 

452 kind=self.resource_def["kind"], 

453 api_version=storage_api_version, 

454 namespace=self._normalize_namespace(namespace), # Normalize namespace here too 

455 label_selector=label_selector, 

456 field_selector=field_selector, 

457 ) 

458 

459 for resource in resources: 

460 yield {"type": "ADDED", "object": FakeResourceField(data=resource), "raw_object": resource} 

461 

462 def _merge_patch(self, target: dict[str, Any], patch: dict[str, Any]) -> None: 

463 """Simple merge patch implementation""" 

464 if isinstance(patch, dict): 

465 for key, value in patch.items(): 

466 if key in target and isinstance(target[key], dict) and isinstance(value, dict): 

467 self._merge_patch(target[key], value) 

468 else: 

469 target[key] = value