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
« prev ^ index » next coverage.py v7.10.1, created at 2025-07-29 12:31 +0300
1"""FakeResourceInstance implementation for fake Kubernetes client"""
3import copy
4import time
5import uuid
6from datetime import datetime, timezone
7from typing import TYPE_CHECKING, Any, Iterator, Union
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
13if TYPE_CHECKING:
14 from fake_kubernetes_client.dynamic_client import FakeDynamicClient
15 from fake_kubernetes_client.resource_storage import FakeResourceStorage
17try:
18 from kubernetes.client.rest import ApiException as K8sApiException
19except ImportError:
20 K8sApiException = None
23class FakeResourceInstance:
24 """Fake implementation of kubernetes.dynamic.client.ResourceInstance"""
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
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
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"])
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")
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")
62 def _generate_resource_version(self) -> str:
63 """Generate unique resource version"""
64 return str(int(time.time() * 1000))
66 def _generate_timestamp(self) -> str:
67 """Generate current UTC timestamp in ISO format"""
68 return datetime.now(timezone.utc).isoformat()
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")
77 # Validate body structure
78 if not isinstance(body, dict):
79 raise ValueError("body must be a dictionary")
81 if "metadata" not in body:
82 body["metadata"] = {}
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
88 # Generate metadata if not present
89 if "name" not in body["metadata"]:
90 raise ValueError("metadata.name is required")
92 name = body["metadata"]["name"]
93 resource_namespace = (
94 body["metadata"].get("namespace", "default") if self.resource_def.get("namespaced") else None
95 )
97 # Use group_version for storage operations (consistent full API version)
98 storage_api_version = self._get_storage_api_version()
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)
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 })
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"]
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()
127 add_realistic_status(body=body, resource_mappings=resource_mappings)
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)
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 )
147 # Generate automatic events for resource creation
148 self._generate_resource_events(body, "Created", "created")
150 return FakeResourceField(data=body)
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"]
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 }
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 )
182 # Return the project body for use by create() method
183 return project_body
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
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")
195 if not resource_name or not resource_kind:
196 return
198 # Generate event name (Kubernetes pattern)
199 event_name = f"{resource_name}.{int(time.time() * 1000000)}"
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 }
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 )
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)
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)
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 )
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 }
278 return FakeResourceField(data=response)
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)
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")
302 return FakeResourceField(data=deleted if deleted else {})
304 # Delete collection - not implemented for safety
305 raise MethodNotAllowedError("Collection deletion not supported")
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")
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")
324 namespace = self._normalize_namespace(namespace)
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={})
335 # Simple merge patch implementation
336 patched = copy.deepcopy(existing)
337 self._merge_patch(patched, body)
339 # Update metadata
340 patched["metadata"]["resourceVersion"] = self._generate_resource_version()
341 if "generation" in patched["metadata"]:
342 patched["metadata"]["generation"] += 1
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 )
353 # Generate automatic events for resource patch
354 self._generate_resource_events(patched, "Updated", "updated")
356 return FakeResourceField(data=patched)
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")
371 namespace = self._normalize_namespace(namespace)
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={})
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 )
396 # Ensure metadata is preserved
397 if "metadata" not in body:
398 body["metadata"] = {}
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 })
407 # Set API version and kind
408 body["apiVersion"] = self.resource_def["api_version"]
409 body["kind"] = self.resource_def["kind"]
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 )
420 # Generate automatic events for resource replacement
421 self._generate_resource_events(body, "Updated", "replaced")
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 )
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)
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"])
447 # Extract label and field selectors from kwargs
448 label_selector = kwargs.get("label_selector")
449 field_selector = kwargs.get("field_selector")
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 )
459 for resource in resources:
460 yield {"type": "ADDED", "object": FakeResourceField(data=resource), "raw_object": resource}
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