import time from functools import partial from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union from ansible.module_utils.parsing.convert_bool import boolean from ansible_collections.kubernetes.core.plugins.module_utils.k8s.exceptions import ( CoreException, ) try: from kubernetes.dynamic.exceptions import NotFoundError from kubernetes.dynamic.resource import Resource, ResourceField, ResourceInstance except ImportError: # These are defined only for the sake of Ansible's checked import requirement Resource = Any # type: ignore ResourceInstance = Any # type: ignore pass try: from urllib3.exceptions import HTTPError except ImportError: # Handled during module setup pass def deployment_ready(deployment: ResourceInstance) -> bool: # FIXME: frustratingly bool(deployment.status) is True even if status is empty # Furthermore deployment.status.availableReplicas == deployment.status.replicas == None if status is empty # deployment.status.replicas is None is perfectly ok if desired replicas == 0 # Scaling up means that we also need to check that we're not in a # situation where status.replicas == status.availableReplicas # but spec.replicas != status.replicas return bool( deployment.status and deployment.spec.replicas == (deployment.status.replicas or 0) and deployment.status.availableReplicas == deployment.status.replicas and deployment.status.observedGeneration == deployment.metadata.generation and not deployment.status.unavailableReplicas ) def pod_ready(pod: ResourceInstance) -> bool: return bool( pod.status and pod.status.containerStatuses is not None and all(container.ready for container in pod.status.containerStatuses) ) def daemonset_ready(daemonset: ResourceInstance) -> bool: return bool( daemonset.status and daemonset.status.desiredNumberScheduled is not None and daemonset.status.updatedNumberScheduled == daemonset.status.desiredNumberScheduled and daemonset.status.numberReady == daemonset.status.desiredNumberScheduled and daemonset.status.observedGeneration == daemonset.metadata.generation and not daemonset.status.unavailableReplicas ) def statefulset_ready(statefulset: ResourceInstance) -> bool: if statefulset.spec.updateStrategy.type == "OnDelete": return bool( statefulset.status and statefulset.status.observedGeneration == (statefulset.metadata.generation or 0) and statefulset.status.replicas == statefulset.spec.replicas ) # These may be None updated_replicas = statefulset.status.updatedReplicas or 0 ready_replicas = statefulset.status.readyReplicas or 0 return bool( statefulset.status and statefulset.spec.updateStrategy.type == "RollingUpdate" and statefulset.status.observedGeneration == (statefulset.metadata.generation or 0) and statefulset.status.updateRevision == statefulset.status.currentRevision and updated_replicas == statefulset.spec.replicas and ready_replicas == statefulset.spec.replicas and statefulset.status.replicas == statefulset.spec.replicas ) def custom_condition(condition: Dict, resource: ResourceInstance) -> bool: if not resource.status or not resource.status.conditions: return False matches = [x for x in resource.status.conditions if x.type == condition["type"]] if not matches: return False # There should never be more than one condition of a specific type match: ResourceField = matches[0] if match.status == "Unknown": if match.status == condition["status"]: if "reason" not in condition: return True if condition["reason"]: return match.reason == condition["reason"] return False status = True if match.status == "True" else False if status == boolean(condition["status"], strict=False): if condition.get("reason"): return match.reason == condition["reason"] return True return False def resource_absent(resource: ResourceInstance) -> bool: return not exists(resource) def exists(resource: Optional[ResourceInstance]) -> bool: """Simple predicate to check for existence of a resource. While a List type resource technically always exists, this will only return true if the List contains items.""" return bool(resource) and not empty_list(resource) RESOURCE_PREDICATES = { "DaemonSet": daemonset_ready, "Deployment": deployment_ready, "Pod": pod_ready, "StatefulSet": statefulset_ready, } def empty_list(resource: ResourceInstance) -> bool: return resource["kind"].endswith("List") and not resource.get("items") def clock(total: int, interval: int) -> Iterator[int]: start = time.monotonic() yield 0 while (time.monotonic() - start) < total: time.sleep(interval) yield int(time.monotonic() - start) class Waiter: def __init__( self, client, resource: Resource, predicate: Callable[[ResourceInstance], bool] ): self.client = client self.resource = resource self.predicate = predicate def wait( self, timeout: int, sleep: int, name: Optional[str] = None, namespace: Optional[str] = None, label_selectors: Optional[List[str]] = None, field_selectors: Optional[List[str]] = None, ) -> Tuple[bool, Dict, int]: params = {} if name: params["name"] = name if namespace: params["namespace"] = namespace if label_selectors: params["label_selector"] = ",".join(label_selectors) if field_selectors: params["field_selector"] = ",".join(field_selectors) instance = {} response = None elapsed = 0 for i in clock(timeout, sleep): exception = None elapsed = i try: response = self.client.get(self.resource, **params) except NotFoundError: response = None # Retry connection errors as it may be intermittent network issues except HTTPError as e: exception = e if self.predicate(response): break if exception: msg = ( "Exception '{0}' raised while trying to get resource using {1}".format( exception, params ) ) raise CoreException(msg) from exception if response: instance = response.to_dict() return self.predicate(response), instance, elapsed class DummyWaiter: """A no-op waiter that simply returns the item being waited on. No API call will be made with this waiter; the function returns immediately. This waiter is useful for waiting on resource instances in check mode, for example. """ def wait( self, definition: Dict, timeout: int, sleep: int, label_selectors: Optional[List[str]] = None, ) -> Tuple[bool, Optional[Dict], int]: return True, definition, 0 # The better solution would be typing.Protocol, but this is only in 3.8+ SupportsWait = Union[Waiter, DummyWaiter] def get_waiter( client, resource: Resource, state: str = "present", condition: Optional[Dict] = None, check_mode: Optional[bool] = False, ) -> SupportsWait: """Create a Waiter object based on the specified resource. This is a convenience method for creating a waiter from a resource. Based on the arguments and the kind of resource, an appropriate waiter will be returned. A waiter can also be created directly, of course. """ if check_mode: return DummyWaiter() if state == "present": if condition: predicate: Callable[[ResourceInstance], bool] = partial( custom_condition, condition ) else: predicate = RESOURCE_PREDICATES.get(resource.kind, exists) else: predicate = resource_absent return Waiter(client, resource, predicate)