diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py new file mode 100644 index 0000000000..7bbd79a201 --- /dev/null +++ b/ocp_resources/daemon_set.py @@ -0,0 +1,411 @@ +# Generated using https://github.com/RedHatQE/openshift-python-wrapper/blob/main/class_generator/README.md + +from datetime import datetime, timezone +from typing import Any + +import kubernetes +from timeout_sampler import TimeoutSampler + +from ocp_resources.exceptions import MissingRequiredArgumentError +from ocp_resources.resource import NamespacedResource +from ocp_resources.utils.constants import PROTOCOL_ERROR_EXCEPTION_DICT, TIMEOUT_4MINUTES + + +class DaemonSet(NamespacedResource): + """ + DaemonSet represents the configuration of a daemon set. + """ + + api_group: str = NamespacedResource.ApiGroup.APPS + + def __init__( + self, + min_ready_seconds: int | None = None, + revision_history_limit: int | None = None, + selector: dict[str, Any] | None = None, + template: dict[str, Any] | None = None, + update_strategy: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + r""" + Args: + min_ready_seconds (int): The minimum number of seconds for which a newly created DaemonSet pod + should be ready without any of its container crashing, for it to + be considered available. Defaults to 0 (pod will be considered + available as soon as it is ready). + + revision_history_limit (int): The number of old history to retain to allow rollback. This is a + pointer to distinguish between explicit zero and not specified. + Defaults to 10. + + selector (dict[str, Any]): matchExpressions key operator values matchLabels. + + template (dict[str, Any]): metadata annotations creationTimestamp deletionGracePeriodSeconds + deletionTimestamp finalizers generateName generation labels + managedFields apiVersion fieldsType fieldsV1 manager operation + subresource time name namespace ownerReferences apiVersion + blockOwnerDeletion controller kind name uid resourceVersion + selfLink uid spec activeDeadlineSeconds affinity nodeAffinity + preferredDuringSchedulingIgnoredDuringExecution preference + matchExpressions key operator enum: DoesNotExist, Exists, Gt, In, + .... values matchFields key operator enum: DoesNotExist, Exists, + Gt, In, .... values weight + requiredDuringSchedulingIgnoredDuringExecution nodeSelectorTerms + matchExpressions key operator enum: DoesNotExist, Exists, Gt, In, + .... values matchFields key operator enum: DoesNotExist, Exists, + Gt, In, .... values podAffinity + preferredDuringSchedulingIgnoredDuringExecution podAffinityTerm + labelSelector matchExpressions key operator values matchLabels + matchLabelKeys mismatchLabelKeys namespaceSelector + matchExpressions key operator values matchLabels namespaces + topologyKey weight requiredDuringSchedulingIgnoredDuringExecution + labelSelector matchExpressions key operator values matchLabels + matchLabelKeys mismatchLabelKeys namespaceSelector + matchExpressions key operator values matchLabels namespaces + topologyKey podAntiAffinity + preferredDuringSchedulingIgnoredDuringExecution podAffinityTerm + labelSelector matchExpressions key operator values matchLabels + matchLabelKeys mismatchLabelKeys namespaceSelector + matchExpressions key operator values matchLabels namespaces + topologyKey weight requiredDuringSchedulingIgnoredDuringExecution + labelSelector matchExpressions key operator values matchLabels + matchLabelKeys mismatchLabelKeys namespaceSelector + matchExpressions key operator values matchLabels namespaces + topologyKey automountServiceAccountToken containers args command + env name value valueFrom configMapKeyRef key name fieldRef + apiVersion fieldPath fileKeyRef key optional path volumeName + resourceFieldRef containerName divisor resource secretKeyRef key + name envFrom configMapRef name prefix secretRef name image + imagePullPolicy enum: Always, IfNotPresent, Never lifecycle + postStart exec command httpGet host httpHeaders name value path + port scheme enum: HTTP, HTTPS sleep seconds tcpSocket host port + preStop exec command httpGet host httpHeaders name value path port + scheme enum: HTTP, HTTPS sleep seconds tcpSocket host port + stopSignal enum: SIGABRT, SIGALRM, SIGBUS, SIGCHLD, .... + livenessProbe exec command failureThreshold grpc port service + httpGet host httpHeaders name value path port scheme enum: HTTP, + HTTPS initialDelaySeconds periodSeconds successThreshold tcpSocket + host port terminationGracePeriodSeconds timeoutSeconds name ports + containerPort hostIP hostPort name protocol enum: SCTP, TCP, UDP + readinessProbe exec command failureThreshold grpc port service + httpGet host httpHeaders name value path port scheme enum: HTTP, + HTTPS initialDelaySeconds periodSeconds successThreshold tcpSocket + host port terminationGracePeriodSeconds timeoutSeconds + resizePolicy resourceName restartPolicy resources claims name + request limits requests restartPolicy restartPolicyRules action + exitCodes operator values securityContext allowPrivilegeEscalation + appArmorProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined capabilities add drop privileged + procMount enum: Default, Unmasked readOnlyRootFilesystem + runAsGroup runAsNonRoot runAsUser seLinuxOptions level role type + user seccompProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined windowsOptions gmsaCredentialSpec + gmsaCredentialSpecName hostProcess runAsUserName startupProbe exec + command failureThreshold grpc port service httpGet host + httpHeaders name value path port scheme enum: HTTP, HTTPS + initialDelaySeconds periodSeconds successThreshold tcpSocket host + port terminationGracePeriodSeconds timeoutSeconds stdin stdinOnce + terminationMessagePath terminationMessagePolicy enum: + FallbackToLogsOnError, File tty volumeDevices devicePath name + volumeMounts mountPath mountPropagation enum: Bidirectional, + HostToContainer, None name readOnly recursiveReadOnly subPath + subPathExpr workingDir dnsConfig nameservers options name value + searches dnsPolicy enum: ClusterFirst, ClusterFirstWithHostNet, + Default, None enableServiceLinks ephemeralContainers args command + env name value valueFrom configMapKeyRef key name fieldRef + apiVersion fieldPath fileKeyRef key optional path volumeName + resourceFieldRef containerName divisor resource secretKeyRef key + name envFrom configMapRef name prefix secretRef name image + imagePullPolicy enum: Always, IfNotPresent, Never lifecycle + postStart exec command httpGet host httpHeaders name value path + port scheme enum: HTTP, HTTPS sleep seconds tcpSocket host port + preStop exec command httpGet host httpHeaders name value path port + scheme enum: HTTP, HTTPS sleep seconds tcpSocket host port + stopSignal enum: SIGABRT, SIGALRM, SIGBUS, SIGCHLD, .... + livenessProbe exec command failureThreshold grpc port service + httpGet host httpHeaders name value path port scheme enum: HTTP, + HTTPS initialDelaySeconds periodSeconds successThreshold tcpSocket + host port terminationGracePeriodSeconds timeoutSeconds name ports + containerPort hostIP hostPort name protocol enum: SCTP, TCP, UDP + readinessProbe exec command failureThreshold grpc port service + httpGet host httpHeaders name value path port scheme enum: HTTP, + HTTPS initialDelaySeconds periodSeconds successThreshold tcpSocket + host port terminationGracePeriodSeconds timeoutSeconds + resizePolicy resourceName restartPolicy resources claims name + request limits requests restartPolicy restartPolicyRules action + exitCodes operator values securityContext allowPrivilegeEscalation + appArmorProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined capabilities add drop privileged + procMount enum: Default, Unmasked readOnlyRootFilesystem + runAsGroup runAsNonRoot runAsUser seLinuxOptions level role type + user seccompProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined windowsOptions gmsaCredentialSpec + gmsaCredentialSpecName hostProcess runAsUserName startupProbe exec + command failureThreshold grpc port service httpGet host + httpHeaders name value path port scheme enum: HTTP, HTTPS + initialDelaySeconds periodSeconds successThreshold tcpSocket host + port terminationGracePeriodSeconds timeoutSeconds stdin stdinOnce + targetContainerName terminationMessagePath + terminationMessagePolicy enum: FallbackToLogsOnError, File tty + volumeDevices devicePath name volumeMounts mountPath + mountPropagation enum: Bidirectional, HostToContainer, None name + readOnly recursiveReadOnly subPath subPathExpr workingDir + hostAliases hostnames ip hostIPC hostNetwork hostPID hostUsers + hostname hostnameOverride imagePullSecrets name initContainers + args command env name value valueFrom configMapKeyRef key name + fieldRef apiVersion fieldPath fileKeyRef key optional path + volumeName resourceFieldRef containerName divisor resource + secretKeyRef key name envFrom configMapRef name prefix secretRef + name image imagePullPolicy enum: Always, IfNotPresent, Never + lifecycle postStart exec command httpGet host httpHeaders name + value path port scheme enum: HTTP, HTTPS sleep seconds tcpSocket + host port preStop exec command httpGet host httpHeaders name value + path port scheme enum: HTTP, HTTPS sleep seconds tcpSocket host + port stopSignal enum: SIGABRT, SIGALRM, SIGBUS, SIGCHLD, .... + livenessProbe exec command failureThreshold grpc port service + httpGet host httpHeaders name value path port scheme enum: HTTP, + HTTPS initialDelaySeconds periodSeconds successThreshold tcpSocket + host port terminationGracePeriodSeconds timeoutSeconds name ports + containerPort hostIP hostPort name protocol enum: SCTP, TCP, UDP + readinessProbe exec command failureThreshold grpc port service + httpGet host httpHeaders name value path port scheme enum: HTTP, + HTTPS initialDelaySeconds periodSeconds successThreshold tcpSocket + host port terminationGracePeriodSeconds timeoutSeconds + resizePolicy resourceName restartPolicy resources claims name + request limits requests restartPolicy restartPolicyRules action + exitCodes operator values securityContext allowPrivilegeEscalation + appArmorProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined capabilities add drop privileged + procMount enum: Default, Unmasked readOnlyRootFilesystem + runAsGroup runAsNonRoot runAsUser seLinuxOptions level role type + user seccompProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined windowsOptions gmsaCredentialSpec + gmsaCredentialSpecName hostProcess runAsUserName startupProbe exec + command failureThreshold grpc port service httpGet host + httpHeaders name value path port scheme enum: HTTP, HTTPS + initialDelaySeconds periodSeconds successThreshold tcpSocket host + port terminationGracePeriodSeconds timeoutSeconds stdin stdinOnce + terminationMessagePath terminationMessagePolicy enum: + FallbackToLogsOnError, File tty volumeDevices devicePath name + volumeMounts mountPath mountPropagation enum: Bidirectional, + HostToContainer, None name readOnly recursiveReadOnly subPath + subPathExpr workingDir nodeName nodeSelector os name overhead + preemptionPolicy enum: Never, PreemptLowerPriority priority + priorityClassName readinessGates conditionType resourceClaims name + resourceClaimName resourceClaimTemplateName resources claims name + request limits requests restartPolicy enum: Always, Never, + OnFailure runtimeClassName schedulerName schedulingGates name + securityContext appArmorProfile localhostProfile type enum: + Localhost, RuntimeDefault, Unconfined fsGroup fsGroupChangePolicy + enum: Always, OnRootMismatch runAsGroup runAsNonRoot runAsUser + seLinuxChangePolicy seLinuxOptions level role type user + seccompProfile localhostProfile type enum: Localhost, + RuntimeDefault, Unconfined supplementalGroups + supplementalGroupsPolicy enum: Merge, Strict sysctls name value + windowsOptions gmsaCredentialSpec gmsaCredentialSpecName + hostProcess runAsUserName serviceAccount serviceAccountName + setHostnameAsFQDN shareProcessNamespace subdomain + terminationGracePeriodSeconds tolerations effect enum: NoExecute, + NoSchedule, PreferNoSchedule key operator enum: Equal, Exists, Gt, + Lt tolerationSeconds value topologySpreadConstraints labelSelector + matchExpressions key operator values matchLabels matchLabelKeys + maxSkew minDomains nodeAffinityPolicy enum: Honor, Ignore + nodeTaintsPolicy enum: Honor, Ignore topologyKey whenUnsatisfiable + enum: DoNotSchedule, ScheduleAnyway volumes awsElasticBlockStore + fsType partition readOnly volumeID azureDisk cachingMode enum: + None, ReadOnly, ReadWrite diskName diskURI fsType kind enum: + Dedicated, Managed, Shared readOnly azureFile readOnly secretName + shareName cephfs monitors path readOnly secretFile secretRef name + user cinder fsType readOnly secretRef name volumeID configMap + defaultMode items key mode path name csi driver fsType + nodePublishSecretRef name readOnly volumeAttributes downwardAPI + defaultMode items fieldRef apiVersion fieldPath mode path + resourceFieldRef containerName divisor resource emptyDir medium + sizeLimit ephemeral volumeClaimTemplate metadata annotations + creationTimestamp deletionGracePeriodSeconds deletionTimestamp + finalizers generateName generation labels managedFields apiVersion + fieldsType fieldsV1 manager operation subresource time name + namespace ownerReferences apiVersion blockOwnerDeletion controller + kind name uid resourceVersion selfLink uid spec accessModes + dataSource apiGroup kind name dataSourceRef apiGroup kind name + namespace resources limits requests selector matchExpressions key + operator values matchLabels storageClassName + volumeAttributesClassName volumeMode enum: Block, Filesystem + volumeName fc fsType lun readOnly targetWWNs wwids flexVolume + driver fsType options readOnly secretRef name flocker datasetName + datasetUUID gcePersistentDisk fsType partition pdName readOnly + gitRepo directory repository revision glusterfs endpoints path + readOnly hostPath path type enum: "", BlockDevice, CharDevice, + Directory, .... image pullPolicy enum: Always, IfNotPresent, Never + reference iscsi chapAuthDiscovery chapAuthSession fsType + initiatorName iqn iscsiInterface lun portals readOnly secretRef + name targetPortal name nfs path readOnly server + persistentVolumeClaim claimName readOnly photonPersistentDisk + fsType pdID portworxVolume fsType readOnly volumeID projected + defaultMode sources clusterTrustBundle labelSelector + matchExpressions key operator values matchLabels name path + signerName configMap items key mode path name downwardAPI items + fieldRef apiVersion fieldPath mode path resourceFieldRef + containerName divisor resource podCertificate certificateChainPath + credentialBundlePath keyPath keyType maxExpirationSeconds + signerName userAnnotations secret items key mode path name + serviceAccountToken audience expirationSeconds path quobyte group + readOnly registry tenant user volume rbd fsType image keyring + monitors pool readOnly secretRef name user scaleIO fsType gateway + protectionDomain readOnly secretRef name sslEnabled storageMode + storagePool system volumeName secret defaultMode items key mode + path optional secretName storageos fsType readOnly secretRef name + volumeName volumeNamespace vsphereVolume fsType storagePolicyID + storagePolicyName volumePath workloadRef name podGroup + podGroupReplicaKey. + + update_strategy (dict[str, Any]): rollingUpdate maxSurge maxUnavailable type enum: OnDelete, + RollingUpdate. + + """ + super().__init__(**kwargs) + + self.min_ready_seconds = min_ready_seconds + self.revision_history_limit = revision_history_limit + self.selector = selector + self.template = template + self.update_strategy = update_strategy + + def to_dict(self) -> None: + + super().to_dict() + + if not self.kind_dict and not self.yaml_file: + if self.selector is None: + raise MissingRequiredArgumentError(argument="self.selector") + + if self.template is None: + raise MissingRequiredArgumentError(argument="self.template") + + self.res["spec"] = {} + _spec = self.res["spec"] + + _spec["selector"] = self.selector + _spec["template"] = self.template + + if self.min_ready_seconds is not None: + _spec["minReadySeconds"] = self.min_ready_seconds + + if self.revision_history_limit is not None: + _spec["revisionHistoryLimit"] = self.revision_history_limit + + if self.update_strategy is not None: + _spec["updateStrategy"] = self.update_strategy + + # End of generated code + + def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: + """ + Wait until all Pods are deployed and ready. + + Args: + timeout (int): Time to wait for the Daemonset. + + Raises: + TimeoutExpiredError: If not all the pods are deployed. + """ + self.logger.info(f"Wait for {self.kind} {self.name} to deploy all desired pods") + samples = TimeoutSampler( + wait_timeout=timeout, + sleep=1, + exceptions_dict=PROTOCOL_ERROR_EXCEPTION_DICT, + func=lambda: self.instance, + ) + for sample in samples: + if sample: + status = sample.status + if (desired_number_scheduled := status.desiredNumberScheduled) is not None: + if desired_number_scheduled > 0 and desired_number_scheduled == status.numberReady: + return + + def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, _body: Any = None) -> bool: + """ + Delete Daemonset. + + Uses Foreground propagation to ensure all Pods are deleted before the DaemonSet + is removed. Without it, delete(wait=True) could return while Pods are still running. + + Args: + wait (bool): True to wait for Daemonset to be deleted. + timeout (int): Time to wait for resource deletion + _body (dict): Content to send for delete() + + Returns: + bool: True if delete succeeded, False otherwise. + """ + return super().delete( + wait=wait, + timeout=timeout, + body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), + ) + + def rollout(self, wait_for_rollout: bool = False, timeout: int = TIMEOUT_4MINUTES) -> None: + """ + Restart the DaemonSet by patching the pod template with a restartedAt annotation. + + Args: + wait_for_rollout (bool): If True, wait for the rollout to complete after restarting. + timeout (int): Time to wait for the rollout to complete. + """ + self.logger.info(f"Restarting {self.kind} {self.name}") + self.update( + resource_dict={ + "metadata": {"name": self.name}, + "spec": { + "template": { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/restartedAt": datetime.now(tz=timezone.utc).isoformat() + } + } + } + }, + } + ) + if wait_for_rollout: + self.wait_for_rollout(timeout=timeout) + + def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: + """ + Wait until the DaemonSet rollout is complete. + + Checks that the controller has observed the latest generation, all pods have been + updated, and all updated pods are available. + + Args: + timeout (int): Time to wait for the rollout to complete. + + Raises: + TimeoutExpiredError: If the rollout does not complete within the timeout. + """ + self.logger.info(f"Wait for {self.kind} {self.name} rollout to complete") + samples = TimeoutSampler( + wait_timeout=timeout, + sleep=1, + exceptions_dict=PROTOCOL_ERROR_EXCEPTION_DICT, + func=lambda: self.instance, + ) + for sample in samples: + if sample: + status = sample.status + if not status: + continue + + if (desired_number_scheduled := status.desiredNumberScheduled) is None: + continue + + if desired_number_scheduled == 0 and status.observedGeneration == sample.metadata.generation: + return + + if ( + desired_number_scheduled > 0 + and status.observedGeneration == sample.metadata.generation + and status.updatedNumberScheduled == desired_number_scheduled + and status.numberAvailable == desired_number_scheduled + ): + return diff --git a/ocp_resources/daemonset.py b/ocp_resources/daemonset.py index e612335bd0..0e28623ebd 100644 --- a/ocp_resources/daemonset.py +++ b/ocp_resources/daemonset.py @@ -1,58 +1,11 @@ -import kubernetes -from timeout_sampler import TimeoutSampler +"""Backward-compatibility shim — use ``ocp_resources.daemon_set`` instead.""" -from ocp_resources.resource import NamespacedResource -from ocp_resources.utils.constants import PROTOCOL_ERROR_EXCEPTION_DICT, TIMEOUT_4MINUTES +import warnings +warnings.warn( + "ocp_resources.daemonset is deprecated, use ocp_resources.daemon_set instead", + DeprecationWarning, + stacklevel=2, +) -class DaemonSet(NamespacedResource): - """ - DaemonSet object. - """ - - api_group = NamespacedResource.ApiGroup.APPS - - def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): - """ - Wait until all Pods are deployed and ready. - - Args: - timeout (int): Time to wait for the Daemonset. - - Raises: - TimeoutExpiredError: If not all the pods are deployed. - """ - self.logger.info(f"Wait for {self.kind} {self.name} to deploy all desired pods") - samples = TimeoutSampler( - wait_timeout=timeout, - sleep=1, - exceptions_dict=PROTOCOL_ERROR_EXCEPTION_DICT, - func=self.api.get, - field_selector=f"metadata.name=={self.name}", - namespace=self.namespace, - ) - for sample in samples: - if sample.items: - status = sample.items[0].status - desired_number_scheduled = status.desiredNumberScheduled - number_ready = status.numberReady - if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: - return - - def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): - """ - Delete Daemonset - - Args: - wait (bool): True to wait for Daemonset to be deleted. - timeout (int): Time to wait for resource deletion - _body (dict): Content to send for delete() - - Returns: - bool: True if delete succeeded, False otherwise. - """ - return super().delete( - wait=wait, - timeout=timeout, - body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), - ) +from ocp_resources.daemon_set import DaemonSet # noqa: F401, E402 diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py new file mode 100644 index 0000000000..d97af3e688 --- /dev/null +++ b/tests/test_daemonset.py @@ -0,0 +1,247 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from ocp_resources.daemon_set import DaemonSet + + +@pytest.fixture() +def daemonset(fake_client): + return DaemonSet(client=fake_client, name="test-ds", namespace="test-ns") + + +def _make_instance(*, generation, observed_generation, desired, updated, available): + """Build a mock object mimicking the structure returned by self.instance.""" + instance = MagicMock() + instance.metadata.generation = generation + instance.status.observedGeneration = observed_generation + instance.status.desiredNumberScheduled = desired + instance.status.updatedNumberScheduled = updated + instance.status.numberAvailable = available + instance.status.numberReady = available + return instance + + +class TestWaitUntilDeployed: + def test_wait_until_deployed_returns_when_all_ready(self, daemonset): + ready = _make_instance( + generation=1, + observed_generation=1, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([ready]), + ): + daemonset.wait_until_deployed(timeout=10) + + def test_wait_until_deployed_waits_when_not_all_ready(self, daemonset): + not_ready = _make_instance( + generation=1, + observed_generation=1, + desired=3, + updated=2, + available=2, + ) + not_ready.status.numberReady = 2 + + ready = _make_instance( + generation=1, + observed_generation=1, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([not_ready, ready]), + ): + daemonset.wait_until_deployed(timeout=10) + + +class TestDelete: + def test_delete_uses_foreground_propagation(self, daemonset): + with patch("ocp_resources.daemon_set.super") as mock_super: + mock_delete = MagicMock(return_value=True) + mock_super.return_value.delete = mock_delete + daemonset.delete(wait=True, timeout=60) + + mock_delete.assert_called_once() + call_kwargs = mock_delete.call_args.kwargs + assert call_kwargs["wait"] is True + assert call_kwargs["timeout"] == 60 + assert call_kwargs["body"].propagation_policy == "Foreground" + + def test_delete_ignores_body_parameter(self, daemonset): + with patch("ocp_resources.daemon_set.super") as mock_super: + mock_delete = MagicMock(return_value=True) + mock_super.return_value.delete = mock_delete + daemonset.delete(_body={"custom": "options"}) + + call_kwargs = mock_delete.call_args.kwargs + assert call_kwargs["body"].propagation_policy == "Foreground" + + +class TestRollout: + def test_rollout_patches_pod_template_annotation(self, daemonset): + with patch.object(DaemonSet, "update") as mock_update: + daemonset.rollout() + + mock_update.assert_called_once() + resource_dict = mock_update.call_args.kwargs.get("resource_dict") or mock_update.call_args[1].get( + "resource_dict" + ) + + assert resource_dict["metadata"]["name"] == "test-ds" + + annotations = resource_dict["spec"]["template"]["metadata"]["annotations"] + assert "kubectl.kubernetes.io/restartedAt" in annotations + assert isinstance(annotations["kubectl.kubernetes.io/restartedAt"], str) + assert len(annotations["kubectl.kubernetes.io/restartedAt"]) > 0 + + def test_rollout_calls_wait_for_rollout_when_requested(self, daemonset): + with ( + patch.object(DaemonSet, "update"), + patch.object(DaemonSet, "wait_for_rollout") as mock_wait, + ): + daemonset.rollout(wait_for_rollout=True, timeout=120) + mock_wait.assert_called_once_with(timeout=120) + + def test_rollout_does_not_wait_by_default(self, daemonset): + with ( + patch.object(DaemonSet, "update"), + patch.object(DaemonSet, "wait_for_rollout") as mock_wait, + ): + daemonset.rollout() + mock_wait.assert_not_called() + + +class TestWaitForRollout: + def test_wait_for_rollout_returns_when_rollout_complete(self, daemonset): + complete = _make_instance( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([complete]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_waits_when_not_all_updated(self, daemonset): + incomplete = _make_instance( + generation=2, + observed_generation=2, + desired=3, + updated=1, + available=1, + ) + complete = _make_instance( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([incomplete, complete]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_waits_when_generation_not_observed(self, daemonset): + stale = _make_instance( + generation=3, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + complete = _make_instance( + generation=3, + observed_generation=3, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([stale, complete]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_waits_when_not_all_available(self, daemonset): + not_available = _make_instance( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=1, + ) + not_available.status.numberReady = 3 + + complete = _make_instance( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + yielded = [] + + def tracking_iter(responses): + for r in responses: + yielded.append(r) + yield r + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=tracking_iter([not_available, complete]), + ): + daemonset.wait_for_rollout(timeout=10) + + assert len(yielded) == 2, "Expected to iterate past not-available response before completing" + + def test_wait_for_rollout_continues_when_status_missing(self, daemonset): + no_status = MagicMock() + no_status.status = None + + complete = _make_instance( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([no_status, complete]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_returns_when_zero_desired(self, daemonset): + zero_desired = _make_instance( + generation=2, + observed_generation=2, + desired=0, + updated=0, + available=0, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([zero_desired]), + ): + daemonset.wait_for_rollout(timeout=10)