From 38f6281fdbe07dddf44a4d00a8c8004a106e0dd4 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 09:52:58 +0300 Subject: [PATCH 01/18] feat(daemonset): add restart and rollout-aware wait methods Add restart() method that patches the pod template with a kubectl.kubernetes.io/restartedAt annotation, triggering a rolling restart (equivalent to `oc rollout restart`). Add wait_for_rollout() method that checks observedGeneration, updatedNumberScheduled, and numberAvailable to reliably wait for a DaemonSet rollout to complete (unlike wait_until_deployed which only checks numberReady and is racy after restarts). Closes #2752 Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemonset.py | 56 +++++++++++++++ tests/test_daemonset.py | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 tests/test_daemonset.py diff --git a/ocp_resources/daemonset.py b/ocp_resources/daemonset.py index e612335bd0..260458cc04 100644 --- a/ocp_resources/daemonset.py +++ b/ocp_resources/daemonset.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + import kubernetes from timeout_sampler import TimeoutSampler @@ -39,6 +41,60 @@ def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: return + def restart(self) -> None: + """ + Restart the DaemonSet by patching the pod template with a restartedAt annotation. + """ + self.logger.info(f"Restarting {self.kind} {self.name}") + self.update( + resource_dict={ + "spec": { + "template": { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/restartedAt": datetime.now(tz=timezone.utc).isoformat() + } + } + } + } + } + ) + + 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 ready. + + 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=self.api.get, + field_selector=f"metadata.name=={self.name}", + namespace=self.namespace, + ) + for sample in samples: + if sample.items: + item = sample.items[0] + status = item.status + desired_number_scheduled = status.desiredNumberScheduled + if ( + desired_number_scheduled > 0 + and status.observedGeneration == item.metadata.generation + and status.updatedNumberScheduled == desired_number_scheduled + and status.numberAvailable == desired_number_scheduled + ): + return + def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): """ Delete Daemonset diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py new file mode 100644 index 0000000000..300c35b5bc --- /dev/null +++ b/tests/test_daemonset.py @@ -0,0 +1,135 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from ocp_resources.daemonset import DaemonSet + + +@pytest.fixture() +def daemonset(fake_client): + return DaemonSet(client=fake_client, name="test-ds", namespace="test-ns") + + +def _make_api_response(*, generation, observed_generation, desired, updated, available): + """Build a mock object mimicking the structure returned by self.api.get.""" + item = MagicMock() + item.metadata.generation = generation + item.status.observedGeneration = observed_generation + item.status.desiredNumberScheduled = desired + item.status.updatedNumberScheduled = updated + item.status.numberAvailable = available + item.status.numberReady = available + + response = MagicMock() + response.items = [item] + return response + + +class TestRestart: + def test_restart_patches_pod_template_annotation(self, daemonset): + with patch.object(DaemonSet, "update") as mock_update: + daemonset.restart() + + mock_update.assert_called_once() + resource_dict = mock_update.call_args.kwargs.get("resource_dict") or mock_update.call_args[1].get( + "resource_dict" + ) + + 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 + + +class TestWaitForRollout: + def test_wait_for_rollout_returns_when_rollout_complete(self, daemonset): + complete_response = _make_api_response( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemonset.TimeoutSampler", + return_value=iter([complete_response]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_waits_when_not_all_updated(self, daemonset): + incomplete_response = _make_api_response( + generation=2, + observed_generation=2, + desired=3, + updated=1, + available=1, + ) + complete_response = _make_api_response( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemonset.TimeoutSampler", + return_value=iter([incomplete_response, complete_response]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_waits_when_generation_not_observed(self, daemonset): + stale_response = _make_api_response( + generation=3, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + complete_response = _make_api_response( + generation=3, + observed_generation=3, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemonset.TimeoutSampler", + return_value=iter([stale_response, complete_response]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_waits_when_not_all_available(self, daemonset): + not_available_response = _make_api_response( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=1, + ) + not_available_response.items[0].status.numberReady = 3 # ready but not available yet + + complete_response = _make_api_response( + 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.daemonset.TimeoutSampler", + return_value=tracking_iter([not_available_response, complete_response]), + ): + daemonset.wait_for_rollout(timeout=10) + + assert len(yielded) == 2, "Expected to iterate past not-available response before completing" From 4ecf7444d065ee60a2b7c33d16eebd53914ee722 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 10:20:41 +0300 Subject: [PATCH 02/18] fix(daemonset): add defensive checks to wait_for_rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard against missing/None status during early reconciliation - Handle zero-desired DaemonSets (no matching nodes) without timeout - Use `or 0` fallbacks for optional status fields - Fix docstring: "ready" → "available" - Add tests for status-missing and zero-desired edge cases Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemonset.py | 14 ++++++++++---- tests/test_daemonset.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/ocp_resources/daemonset.py b/ocp_resources/daemonset.py index 260458cc04..7793511b40 100644 --- a/ocp_resources/daemonset.py +++ b/ocp_resources/daemonset.py @@ -65,7 +65,7 @@ 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 ready. + updated, and all updated pods are available. Args: timeout (int): Time to wait for the rollout to complete. @@ -86,12 +86,18 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: if sample.items: item = sample.items[0] status = item.status - desired_number_scheduled = status.desiredNumberScheduled + if not status: + continue + + desired_number_scheduled = status.desiredNumberScheduled or 0 + if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: + return + if ( desired_number_scheduled > 0 and status.observedGeneration == item.metadata.generation - and status.updatedNumberScheduled == desired_number_scheduled - and status.numberAvailable == desired_number_scheduled + and (status.updatedNumberScheduled or 0) == desired_number_scheduled + and (status.numberAvailable or 0) == desired_number_scheduled ): return diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 300c35b5bc..8298a010b6 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -133,3 +133,37 @@ def tracking_iter(responses): 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_response = MagicMock() + no_status_response.items = [MagicMock()] + no_status_response.items[0].status = None + + complete_response = _make_api_response( + generation=2, + observed_generation=2, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemonset.TimeoutSampler", + return_value=iter([no_status_response, complete_response]), + ): + daemonset.wait_for_rollout(timeout=10) + + def test_wait_for_rollout_returns_when_zero_desired(self, daemonset): + zero_desired_response = _make_api_response( + generation=2, + observed_generation=2, + desired=0, + updated=0, + available=0, + ) + + with patch( + "ocp_resources.daemonset.TimeoutSampler", + return_value=iter([zero_desired_response]), + ): + daemonset.wait_for_rollout(timeout=10) From e73ef6836b601a59c3597401a3e98bc81434efa7 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 14:41:00 +0300 Subject: [PATCH 03/18] fix(daemonset): include name in restart patch for api.patch The kubernetes DynamicClient.patch() requires `name` in the body or as a parameter. Include `metadata.name` in the resource_dict to fix ValueError on restart(). Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemonset.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ocp_resources/daemonset.py b/ocp_resources/daemonset.py index 7793511b40..90851aaea6 100644 --- a/ocp_resources/daemonset.py +++ b/ocp_resources/daemonset.py @@ -48,6 +48,7 @@ def restart(self) -> None: self.logger.info(f"Restarting {self.kind} {self.name}") self.update( resource_dict={ + "metadata": {"name": self.name}, "spec": { "template": { "metadata": { @@ -56,7 +57,7 @@ def restart(self) -> None: } } } - } + }, } ) From 801dc5945af56511bcacad3d8c94aecf8da4d323 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 14:51:10 +0300 Subject: [PATCH 04/18] test(daemonset): verify metadata.name in restart patch Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- tests/test_daemonset.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 8298a010b6..7cd7321550 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -35,6 +35,8 @@ def test_restart_patches_pod_template_annotation(self, daemonset): "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) From 322cf8c00919a2844536603b9a8f3ef915d0d41f Mon Sep 17 00:00:00 2001 From: rnetser Date: Tue, 7 Jul 2026 14:53:57 +0300 Subject: [PATCH 05/18] refactor(daemonset): regenerate with class-generator Replace hand-written DaemonSet class with class-generator output. Custom functions (wait_until_deployed, restart, wait_for_rollout, delete) preserved after `# End of generated code` marker. Old `daemonset.py` becomes a deprecation shim re-exporting from `daemon_set.py` with DeprecationWarning. Co-authored-by: pi Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 412 ++++++++++++++++++++++++++++++++++++ ocp_resources/daemonset.py | 126 +---------- tests/test_daemonset.py | 14 +- 3 files changed, 427 insertions(+), 125 deletions(-) create mode 100644 ocp_resources/daemon_set.py diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py new file mode 100644 index 0000000000..28a8e660ba --- /dev/null +++ b/ocp_resources/daemon_set.py @@ -0,0 +1,412 @@ +# 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=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 + if not status: + continue + + desired_number_scheduled = status.desiredNumberScheduled or 0 + number_ready = status.numberReady or 0 + if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: + return + + def restart(self) -> None: + """ + Restart the DaemonSet by patching the pod template with a restartedAt annotation. + """ + 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() + } + } + } + }, + } + ) + + 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=self.api.get, + field_selector=f"metadata.name=={self.name}", + namespace=self.namespace, + ) + for sample in samples: + if sample.items: + item = sample.items[0] + status = item.status + if not status: + continue + + desired_number_scheduled = status.desiredNumberScheduled or 0 + if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: + return + + if ( + desired_number_scheduled > 0 + and status.observedGeneration == item.metadata.generation + and (status.updatedNumberScheduled or 0) == desired_number_scheduled + and (status.numberAvailable or 0) == desired_number_scheduled + ): + return + + def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, body: dict[str, Any] | None = None) -> bool: # noqa: ARG002 + """ + Delete Daemonset. + + Always uses Foreground propagation policy to ensure child pods are + cleaned up before the DaemonSet itself is removed. + + Args: + wait (bool): True to wait for Daemonset to be deleted. + timeout (int): Time to wait for resource deletion. + body (dict[str, Any]): Ignored — Foreground propagation is always enforced. + + Returns: + bool: True if delete succeeded, False otherwise. + """ + return super().delete( + wait=wait, + timeout=timeout, + body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), + ) diff --git a/ocp_resources/daemonset.py b/ocp_resources/daemonset.py index 90851aaea6..0e28623ebd 100644 --- a/ocp_resources/daemonset.py +++ b/ocp_resources/daemonset.py @@ -1,121 +1,11 @@ -from datetime import datetime, timezone +"""Backward-compatibility shim — use ``ocp_resources.daemon_set`` instead.""" -import kubernetes -from timeout_sampler import TimeoutSampler +import warnings -from ocp_resources.resource import NamespacedResource -from ocp_resources.utils.constants import PROTOCOL_ERROR_EXCEPTION_DICT, TIMEOUT_4MINUTES +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 restart(self) -> None: - """ - Restart the DaemonSet by patching the pod template with a restartedAt annotation. - """ - 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() - } - } - } - }, - } - ) - - 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=self.api.get, - field_selector=f"metadata.name=={self.name}", - namespace=self.namespace, - ) - for sample in samples: - if sample.items: - item = sample.items[0] - status = item.status - if not status: - continue - - desired_number_scheduled = status.desiredNumberScheduled or 0 - if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: - return - - if ( - desired_number_scheduled > 0 - and status.observedGeneration == item.metadata.generation - and (status.updatedNumberScheduled or 0) == desired_number_scheduled - and (status.numberAvailable or 0) == desired_number_scheduled - ): - 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 index 7cd7321550..633242fd02 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -2,7 +2,7 @@ import pytest -from ocp_resources.daemonset import DaemonSet +from ocp_resources.daemon_set import DaemonSet @pytest.fixture() @@ -54,7 +54,7 @@ def test_wait_for_rollout_returns_when_rollout_complete(self, daemonset): ) with patch( - "ocp_resources.daemonset.TimeoutSampler", + "ocp_resources.daemon_set.TimeoutSampler", return_value=iter([complete_response]), ): daemonset.wait_for_rollout(timeout=10) @@ -76,7 +76,7 @@ def test_wait_for_rollout_waits_when_not_all_updated(self, daemonset): ) with patch( - "ocp_resources.daemonset.TimeoutSampler", + "ocp_resources.daemon_set.TimeoutSampler", return_value=iter([incomplete_response, complete_response]), ): daemonset.wait_for_rollout(timeout=10) @@ -98,7 +98,7 @@ def test_wait_for_rollout_waits_when_generation_not_observed(self, daemonset): ) with patch( - "ocp_resources.daemonset.TimeoutSampler", + "ocp_resources.daemon_set.TimeoutSampler", return_value=iter([stale_response, complete_response]), ): daemonset.wait_for_rollout(timeout=10) @@ -129,7 +129,7 @@ def tracking_iter(responses): yield r with patch( - "ocp_resources.daemonset.TimeoutSampler", + "ocp_resources.daemon_set.TimeoutSampler", return_value=tracking_iter([not_available_response, complete_response]), ): daemonset.wait_for_rollout(timeout=10) @@ -150,7 +150,7 @@ def test_wait_for_rollout_continues_when_status_missing(self, daemonset): ) with patch( - "ocp_resources.daemonset.TimeoutSampler", + "ocp_resources.daemon_set.TimeoutSampler", return_value=iter([no_status_response, complete_response]), ): daemonset.wait_for_rollout(timeout=10) @@ -165,7 +165,7 @@ def test_wait_for_rollout_returns_when_zero_desired(self, daemonset): ) with patch( - "ocp_resources.daemonset.TimeoutSampler", + "ocp_resources.daemon_set.TimeoutSampler", return_value=iter([zero_desired_response]), ): daemonset.wait_for_rollout(timeout=10) From 7b81383e747cbd17028baca15ab93e61ec3c9765 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 8 Jul 2026 21:57:22 +0300 Subject: [PATCH 06/18] fix(daemonset): restore original wait_until_deployed and delete signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve backward compatibility — keep original method signatures and ordering (wait_until_deployed, delete first after generated code). New methods (restart, wait_for_rollout) follow after. Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 48 +++++++++++++--------------- test_cluster.py | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 test_cluster.py diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 28a8e660ba..a673350b88 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -299,7 +299,7 @@ def to_dict(self) -> None: # End of generated code - def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: + def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): """ Wait until all Pods are deployed and ready. @@ -321,14 +321,29 @@ def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: for sample in samples: if sample.items: status = sample.items[0].status - if not status: - continue - - desired_number_scheduled = status.desiredNumberScheduled or 0 - number_ready = status.numberReady or 0 + 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"), + ) + def restart(self) -> None: """ Restart the DaemonSet by patching the pod template with a restartedAt annotation. @@ -389,24 +404,3 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: and (status.numberAvailable or 0) == desired_number_scheduled ): return - - def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, body: dict[str, Any] | None = None) -> bool: # noqa: ARG002 - """ - Delete Daemonset. - - Always uses Foreground propagation policy to ensure child pods are - cleaned up before the DaemonSet itself is removed. - - Args: - wait (bool): True to wait for Daemonset to be deleted. - timeout (int): Time to wait for resource deletion. - body (dict[str, Any]): Ignored — Foreground propagation is always enforced. - - Returns: - bool: True if delete succeeded, False otherwise. - """ - return super().delete( - wait=wait, - timeout=timeout, - body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), - ) diff --git a/test_cluster.py b/test_cluster.py new file mode 100644 index 0000000000..9748ca8f13 --- /dev/null +++ b/test_cluster.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Test DaemonSet restart() and wait_for_rollout() on a live cluster.""" + +from ocp_resources.daemonset import DaemonSet +from ocp_resources.namespace import Namespace +from ocp_resources.resource import get_client + +client = get_client() + +NS = "test-ds-restart" + +# 1. Create test namespace +with Namespace(client=client, name=NS) as ns: + ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=60) + print(f"✅ Namespace {NS} created") + + # 2. Create a simple DaemonSet + ds_dict = { + "metadata": {"name": "test-ds", "namespace": NS}, + "spec": { + "selector": {"matchLabels": {"app": "test-ds"}}, + "template": { + "metadata": {"labels": {"app": "test-ds"}}, + "spec": { + "containers": [ + { + "name": "pause", + "image": "registry.k8s.io/pause:3.9", + } + ], + "tolerations": [{"operator": "Exists"}], + }, + }, + }, + } + + with DaemonSet(client=client, kind_dict=ds_dict) as ds: + print(f"✅ DaemonSet {ds.name} created") + + # 3. Wait for initial deployment + ds.wait_until_deployed(timeout=120) + s = ds.instance.status + print(f"✅ Deployed — desired={s.desiredNumberScheduled}, available={s.numberAvailable}") + + # 4. Test restart() + print("\n--- Testing restart() ---") + ds.restart() + ann = ds.instance.spec.template.metadata.annotations + print(f"✅ restartedAt: {ann.get('kubectl.kubernetes.io/restartedAt')}") + print(f" Generation bumped to: {ds.instance.metadata.generation}") + + # 5. Test wait_for_rollout() + print("\n--- Testing wait_for_rollout() ---") + ds.wait_for_rollout(timeout=300) + s = ds.instance.status + print( + f"✅ Rollout complete — desired={s.desiredNumberScheduled}, " + f"updated={s.updatedNumberScheduled}, available={s.numberAvailable}" + ) + + print("\n✅ DaemonSet cleaned up") +print(f"✅ Namespace {NS} cleaned up") +print("\n🎉 All tests passed!") From d1e2df155e25300a97abd7af96dff8207a1e019a Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 8 Jul 2026 22:00:25 +0300 Subject: [PATCH 07/18] test(daemonset): add cluster test for both import paths Tests restart() and wait_for_rollout() via ocp_resources.daemon_set and ocp_resources.daemonset (deprecated shim) on a live cluster. Co-authored-by: pi-coding-agent Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- test_cluster.py | 153 +++++++++++++++++++++++++++++++----------------- 1 file changed, 98 insertions(+), 55 deletions(-) diff --git a/test_cluster.py b/test_cluster.py index 9748ca8f13..fa4edf8e7a 100644 --- a/test_cluster.py +++ b/test_cluster.py @@ -1,63 +1,106 @@ #!/usr/bin/env python3 -"""Test DaemonSet restart() and wait_for_rollout() on a live cluster.""" +"""Test DaemonSet restart() and wait_for_rollout() on a live cluster. + +Tests both import paths: + - ocp_resources.daemon_set (current module) + - ocp_resources.daemonset (deprecated backward-compatible shim) +""" + +import warnings -from ocp_resources.daemonset import DaemonSet from ocp_resources.namespace import Namespace from ocp_resources.resource import get_client -client = get_client() - -NS = "test-ds-restart" - -# 1. Create test namespace -with Namespace(client=client, name=NS) as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=60) - print(f"✅ Namespace {NS} created") - - # 2. Create a simple DaemonSet - ds_dict = { - "metadata": {"name": "test-ds", "namespace": NS}, - "spec": { - "selector": {"matchLabels": {"app": "test-ds"}}, - "template": { - "metadata": {"labels": {"app": "test-ds"}}, - "spec": { - "containers": [ - { - "name": "pause", - "image": "registry.k8s.io/pause:3.9", - } - ], - "tolerations": [{"operator": "Exists"}], +# --- Test 1: Import from current module --- +print("--- Testing import from ocp_resources.daemon_set ---") +from ocp_resources.daemon_set import DaemonSet # noqa: E402 + +assert hasattr(DaemonSet, "restart") +assert hasattr(DaemonSet, "wait_for_rollout") +assert hasattr(DaemonSet, "wait_until_deployed") +assert hasattr(DaemonSet, "delete") +print("✅ ocp_resources.daemon_set — all methods present") + +# --- Test 2: Import from deprecated module --- +print("\n--- Testing import from ocp_resources.daemonset (deprecated) ---") +with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from ocp_resources.daemonset import DaemonSet as DaemonSetOld # noqa: E402 + + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "ocp_resources.daemonset is deprecated" in str(w[0].message) + assert DaemonSetOld is DaemonSet + assert hasattr(DaemonSetOld, "restart") + assert hasattr(DaemonSetOld, "wait_for_rollout") + assert hasattr(DaemonSetOld, "wait_until_deployed") + assert hasattr(DaemonSetOld, "delete") + print("✅ ocp_resources.daemonset — deprecation warning raised") + print("✅ ocp_resources.daemonset — same class, all methods present") + + +def run_cluster_test(ds_class, label): + """Run the full cluster test with the given DaemonSet class.""" + print(f"\n{'=' * 60}") + print(f"Cluster test: {label}") + print(f"{'=' * 60}") + + client = get_client() + ns_name = f"test-ds-{label.replace(' ', '-').lower()}" + + with Namespace(client=client, name=ns_name) as ns: + ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=60) + print(f"✅ Namespace {ns_name} created") + + ds_dict = { + "metadata": {"name": "test-ds", "namespace": ns_name}, + "spec": { + "selector": {"matchLabels": {"app": "test-ds"}}, + "template": { + "metadata": {"labels": {"app": "test-ds"}}, + "spec": { + "containers": [ + { + "name": "pause", + "image": "registry.k8s.io/pause:3.9", + } + ], + "tolerations": [{"operator": "Exists"}], + }, }, }, - }, - } - - with DaemonSet(client=client, kind_dict=ds_dict) as ds: - print(f"✅ DaemonSet {ds.name} created") - - # 3. Wait for initial deployment - ds.wait_until_deployed(timeout=120) - s = ds.instance.status - print(f"✅ Deployed — desired={s.desiredNumberScheduled}, available={s.numberAvailable}") - - # 4. Test restart() - print("\n--- Testing restart() ---") - ds.restart() - ann = ds.instance.spec.template.metadata.annotations - print(f"✅ restartedAt: {ann.get('kubectl.kubernetes.io/restartedAt')}") - print(f" Generation bumped to: {ds.instance.metadata.generation}") - - # 5. Test wait_for_rollout() - print("\n--- Testing wait_for_rollout() ---") - ds.wait_for_rollout(timeout=300) - s = ds.instance.status - print( - f"✅ Rollout complete — desired={s.desiredNumberScheduled}, " - f"updated={s.updatedNumberScheduled}, available={s.numberAvailable}" - ) - - print("\n✅ DaemonSet cleaned up") -print(f"✅ Namespace {NS} cleaned up") + } + + with ds_class(client=client, kind_dict=ds_dict) as ds: + print(f"✅ DaemonSet {ds.name} created") + + # wait_until_deployed + ds.wait_until_deployed(timeout=120) + s = ds.instance.status + print(f"✅ Deployed — desired={s.desiredNumberScheduled}, available={s.numberAvailable}") + + # restart() + print("\n--- Testing restart() ---") + ds.restart() + ann = ds.instance.spec.template.metadata.annotations + print(f"✅ restartedAt: {ann.get('kubectl.kubernetes.io/restartedAt')}") + print(f" Generation bumped to: {ds.instance.metadata.generation}") + + # wait_for_rollout() + print("\n--- Testing wait_for_rollout() ---") + ds.wait_for_rollout(timeout=300) + s = ds.instance.status + print( + f"✅ Rollout complete — desired={s.desiredNumberScheduled}, " + f"updated={s.updatedNumberScheduled}, available={s.numberAvailable}" + ) + + print("\n✅ DaemonSet cleaned up") + print(f"✅ Namespace {ns_name} cleaned up") + + +# --- Cluster tests --- +run_cluster_test(DaemonSet, "daemon-set") +run_cluster_test(DaemonSetOld, "daemonset-compat") + print("\n🎉 All tests passed!") From c9509490c5400289f28607e1a4257fcc79f47772 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 8 Jul 2026 22:02:40 +0300 Subject: [PATCH 08/18] chore: remove test_cluster.py Signed-off-by: rnetser --- test_cluster.py | 106 ------------------------------------------------ 1 file changed, 106 deletions(-) delete mode 100644 test_cluster.py diff --git a/test_cluster.py b/test_cluster.py deleted file mode 100644 index fa4edf8e7a..0000000000 --- a/test_cluster.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Test DaemonSet restart() and wait_for_rollout() on a live cluster. - -Tests both import paths: - - ocp_resources.daemon_set (current module) - - ocp_resources.daemonset (deprecated backward-compatible shim) -""" - -import warnings - -from ocp_resources.namespace import Namespace -from ocp_resources.resource import get_client - -# --- Test 1: Import from current module --- -print("--- Testing import from ocp_resources.daemon_set ---") -from ocp_resources.daemon_set import DaemonSet # noqa: E402 - -assert hasattr(DaemonSet, "restart") -assert hasattr(DaemonSet, "wait_for_rollout") -assert hasattr(DaemonSet, "wait_until_deployed") -assert hasattr(DaemonSet, "delete") -print("✅ ocp_resources.daemon_set — all methods present") - -# --- Test 2: Import from deprecated module --- -print("\n--- Testing import from ocp_resources.daemonset (deprecated) ---") -with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - from ocp_resources.daemonset import DaemonSet as DaemonSetOld # noqa: E402 - - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "ocp_resources.daemonset is deprecated" in str(w[0].message) - assert DaemonSetOld is DaemonSet - assert hasattr(DaemonSetOld, "restart") - assert hasattr(DaemonSetOld, "wait_for_rollout") - assert hasattr(DaemonSetOld, "wait_until_deployed") - assert hasattr(DaemonSetOld, "delete") - print("✅ ocp_resources.daemonset — deprecation warning raised") - print("✅ ocp_resources.daemonset — same class, all methods present") - - -def run_cluster_test(ds_class, label): - """Run the full cluster test with the given DaemonSet class.""" - print(f"\n{'=' * 60}") - print(f"Cluster test: {label}") - print(f"{'=' * 60}") - - client = get_client() - ns_name = f"test-ds-{label.replace(' ', '-').lower()}" - - with Namespace(client=client, name=ns_name) as ns: - ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=60) - print(f"✅ Namespace {ns_name} created") - - ds_dict = { - "metadata": {"name": "test-ds", "namespace": ns_name}, - "spec": { - "selector": {"matchLabels": {"app": "test-ds"}}, - "template": { - "metadata": {"labels": {"app": "test-ds"}}, - "spec": { - "containers": [ - { - "name": "pause", - "image": "registry.k8s.io/pause:3.9", - } - ], - "tolerations": [{"operator": "Exists"}], - }, - }, - }, - } - - with ds_class(client=client, kind_dict=ds_dict) as ds: - print(f"✅ DaemonSet {ds.name} created") - - # wait_until_deployed - ds.wait_until_deployed(timeout=120) - s = ds.instance.status - print(f"✅ Deployed — desired={s.desiredNumberScheduled}, available={s.numberAvailable}") - - # restart() - print("\n--- Testing restart() ---") - ds.restart() - ann = ds.instance.spec.template.metadata.annotations - print(f"✅ restartedAt: {ann.get('kubectl.kubernetes.io/restartedAt')}") - print(f" Generation bumped to: {ds.instance.metadata.generation}") - - # wait_for_rollout() - print("\n--- Testing wait_for_rollout() ---") - ds.wait_for_rollout(timeout=300) - s = ds.instance.status - print( - f"✅ Rollout complete — desired={s.desiredNumberScheduled}, " - f"updated={s.updatedNumberScheduled}, available={s.numberAvailable}" - ) - - print("\n✅ DaemonSet cleaned up") - print(f"✅ Namespace {ns_name} cleaned up") - - -# --- Cluster tests --- -run_cluster_test(DaemonSet, "daemon-set") -run_cluster_test(DaemonSetOld, "daemonset-compat") - -print("\n🎉 All tests passed!") From ab92dbf864abfa92ff599479c448ae9aa61e0fc8 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 09:53:30 +0300 Subject: [PATCH 09/18] feat(daemonset): add wait_for_rollout option to restart() Allow restart(wait_for_rollout=True) to automatically wait for the rollout to complete after triggering the restart. Addresses maintainer review feedback. Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 8 +++++++- tests/test_daemonset.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index a673350b88..6416329bf1 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -344,9 +344,13 @@ def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), ) - def restart(self) -> None: + def restart(self, wait_for_rollout=False, timeout=TIMEOUT_4MINUTES): """ 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( @@ -363,6 +367,8 @@ def restart(self) -> None: }, } ) + if wait_for_rollout: + self.wait_for_rollout(timeout=timeout) def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: """ diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 633242fd02..620ff8cbd3 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -42,6 +42,22 @@ def test_restart_patches_pod_template_annotation(self, daemonset): assert isinstance(annotations["kubectl.kubernetes.io/restartedAt"], str) assert len(annotations["kubectl.kubernetes.io/restartedAt"]) > 0 + def test_restart_calls_wait_for_rollout_when_requested(self, daemonset): + with ( + patch.object(DaemonSet, "update"), + patch.object(DaemonSet, "wait_for_rollout") as mock_wait, + ): + daemonset.restart(wait_for_rollout=True, timeout=120) + mock_wait.assert_called_once_with(timeout=120) + + def test_restart_does_not_wait_by_default(self, daemonset): + with ( + patch.object(DaemonSet, "update"), + patch.object(DaemonSet, "wait_for_rollout") as mock_wait, + ): + daemonset.restart() + mock_wait.assert_not_called() + class TestWaitForRollout: def test_wait_for_rollout_returns_when_rollout_complete(self, daemonset): From 717c558e1cfd65a029a32a0f35690e3d28e2c819 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 13:47:46 +0300 Subject: [PATCH 10/18] fix(daemonset): add null-safety and non-existent resource warning Add `or 0` null-safety to wait_until_deployed for consistency with wait_for_rollout. Log warning when resource not found in both wait methods instead of looping silently. Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 6416329bf1..675ff73dfa 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -321,10 +321,12 @@ def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): for sample in samples: if sample.items: status = sample.items[0].status - desired_number_scheduled = status.desiredNumberScheduled - number_ready = status.numberReady + desired_number_scheduled = status.desiredNumberScheduled or 0 + number_ready = status.numberReady or 0 if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: return + else: + self.logger.warning(f"{self.kind} {self.name} not found yet, waiting...") def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): """ @@ -398,6 +400,9 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: status = item.status if not status: continue + else: + self.logger.warning(f"{self.kind} {self.name} not found yet, waiting...") + continue desired_number_scheduled = status.desiredNumberScheduled or 0 if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: From 10a9008625659a11da1f68be05e54485bcc6e516 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 13:52:47 +0300 Subject: [PATCH 11/18] fix(daemonset): use len() for empty items check in wait methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResourceField wrapping an empty list is truthy — use len() == 0 to reliably detect non-existent resources. Co-authored-by: pi-coding-agent Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 675ff73dfa..5199104884 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -319,14 +319,15 @@ def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): namespace=self.namespace, ) for sample in samples: - if sample.items: - status = sample.items[0].status - desired_number_scheduled = status.desiredNumberScheduled or 0 - number_ready = status.numberReady or 0 - if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: - return - else: + if len(sample.items) == 0: self.logger.warning(f"{self.kind} {self.name} not found yet, waiting...") + continue + + status = sample.items[0].status + desired_number_scheduled = status.desiredNumberScheduled or 0 + number_ready = status.numberReady or 0 + if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: + return def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): """ @@ -395,15 +396,15 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: namespace=self.namespace, ) for sample in samples: - if sample.items: - item = sample.items[0] - status = item.status - if not status: - continue - else: + if len(sample.items) == 0: self.logger.warning(f"{self.kind} {self.name} not found yet, waiting...") continue + item = sample.items[0] + status = item.status + if not status: + continue + desired_number_scheduled = status.desiredNumberScheduled or 0 if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: return From a8a203c655ffd47d0024148a4d4c2f83fa5c48db Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 13:57:17 +0300 Subject: [PATCH 12/18] test(daemonset): add tests for wait_until_deployed and delete Co-authored-by: pi-coding-agent Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- tests/test_daemonset.py | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 620ff8cbd3..31858fba25 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -25,6 +25,88 @@ def _make_api_response(*, generation, observed_generation, desired, updated, ava return response +class TestWaitUntilDeployed: + def test_wait_until_deployed_returns_when_all_ready(self, daemonset): + ready_response = _make_api_response( + generation=1, + observed_generation=1, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([ready_response]), + ): + daemonset.wait_until_deployed(timeout=10) + + def test_wait_until_deployed_waits_when_not_all_ready(self, daemonset): + not_ready = _make_api_response( + generation=1, + observed_generation=1, + desired=3, + updated=2, + available=2, + ) + not_ready.items[0].status.numberReady = 2 + + ready = _make_api_response( + 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) + + def test_wait_until_deployed_handles_empty_items(self, daemonset): + empty_response = MagicMock() + empty_response.items = [] + + ready_response = _make_api_response( + generation=1, + observed_generation=1, + desired=3, + updated=3, + available=3, + ) + + with patch( + "ocp_resources.daemon_set.TimeoutSampler", + return_value=iter([empty_response, ready_response]), + ): + 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 TestRestart: def test_restart_patches_pod_template_annotation(self, daemonset): with patch.object(DaemonSet, "update") as mock_update: From 463c4c2bdebbb6677a1a58f357892561404cbbaf Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 14:00:58 +0300 Subject: [PATCH 13/18] fix(daemonset): revert manual empty-items check in wait methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove len(sample.items) guard — the wrapper pattern relies on TimeoutSampler to handle non-existent resources via timeout. Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 28 +++++++++++----------------- tests/test_daemonset.py | 18 ------------------ 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 5199104884..634ebb2551 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -319,15 +319,12 @@ def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): namespace=self.namespace, ) for sample in samples: - if len(sample.items) == 0: - self.logger.warning(f"{self.kind} {self.name} not found yet, waiting...") - continue - - status = sample.items[0].status - desired_number_scheduled = status.desiredNumberScheduled or 0 - number_ready = status.numberReady or 0 - if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: - return + if sample.items: + status = sample.items[0].status + desired_number_scheduled = status.desiredNumberScheduled or 0 + number_ready = status.numberReady or 0 + if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: + return def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): """ @@ -396,14 +393,11 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: namespace=self.namespace, ) for sample in samples: - if len(sample.items) == 0: - self.logger.warning(f"{self.kind} {self.name} not found yet, waiting...") - continue - - item = sample.items[0] - status = item.status - if not status: - continue + if sample.items: + item = sample.items[0] + status = item.status + if not status: + continue desired_number_scheduled = status.desiredNumberScheduled or 0 if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 31858fba25..05bb31ee26 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -65,24 +65,6 @@ def test_wait_until_deployed_waits_when_not_all_ready(self, daemonset): ): daemonset.wait_until_deployed(timeout=10) - def test_wait_until_deployed_handles_empty_items(self, daemonset): - empty_response = MagicMock() - empty_response.items = [] - - ready_response = _make_api_response( - generation=1, - observed_generation=1, - desired=3, - updated=3, - available=3, - ) - - with patch( - "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([empty_response, ready_response]), - ): - daemonset.wait_until_deployed(timeout=10) - class TestDelete: def test_delete_uses_foreground_propagation(self, daemonset): From f3624d2e2d6a0f57843a31d645754b5b70e9d574 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 14:03:34 +0300 Subject: [PATCH 14/18] refactor(daemonset): add type annotations to all custom methods Co-authored-by: pi-coding-agent Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 634ebb2551..11b9170d40 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -299,7 +299,7 @@ def to_dict(self) -> None: # End of generated code - def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): + def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: """ Wait until all Pods are deployed and ready. @@ -326,7 +326,7 @@ def wait_until_deployed(self, timeout=TIMEOUT_4MINUTES): if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: return - def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): + def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, _body: Any = None) -> bool: """ Delete Daemonset @@ -344,7 +344,7 @@ def delete(self, wait=False, timeout=TIMEOUT_4MINUTES, _body=None): body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), ) - def restart(self, wait_for_rollout=False, timeout=TIMEOUT_4MINUTES): + def restart(self, wait_for_rollout: bool = False, timeout: int = TIMEOUT_4MINUTES) -> None: """ Restart the DaemonSet by patching the pod template with a restartedAt annotation. From 935161b36d715460e887d5e069d44b3e91e31cac Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 14:09:07 +0300 Subject: [PATCH 15/18] refactor(daemonset): use self.instance pattern in wait methods Replace self.api.get with field_selector by lambda: self.instance to match the standard wrapper pattern (if sample: instead of if sample.items:). Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 21 +++++------ tests/test_daemonset.py | 70 +++++++++++++++++-------------------- 2 files changed, 41 insertions(+), 50 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 11b9170d40..258e5295ed 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -314,13 +314,11 @@ def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: 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, + func=lambda: self.instance, ) for sample in samples: - if sample.items: - status = sample.items[0].status + if sample: + status = sample.status desired_number_scheduled = status.desiredNumberScheduled or 0 number_ready = status.numberReady or 0 if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: @@ -388,24 +386,21 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: 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, + func=lambda: self.instance, ) for sample in samples: - if sample.items: - item = sample.items[0] - status = item.status + if sample: + status = sample.status if not status: continue desired_number_scheduled = status.desiredNumberScheduled or 0 - if desired_number_scheduled == 0 and status.observedGeneration == item.metadata.generation: + if desired_number_scheduled == 0 and status.observedGeneration == sample.metadata.generation: return if ( desired_number_scheduled > 0 - and status.observedGeneration == item.metadata.generation + and status.observedGeneration == sample.metadata.generation and (status.updatedNumberScheduled or 0) == desired_number_scheduled and (status.numberAvailable or 0) == desired_number_scheduled ): diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 05bb31ee26..37f17edc05 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -10,24 +10,21 @@ def daemonset(fake_client): return DaemonSet(client=fake_client, name="test-ds", namespace="test-ns") -def _make_api_response(*, generation, observed_generation, desired, updated, available): - """Build a mock object mimicking the structure returned by self.api.get.""" - item = MagicMock() - item.metadata.generation = generation - item.status.observedGeneration = observed_generation - item.status.desiredNumberScheduled = desired - item.status.updatedNumberScheduled = updated - item.status.numberAvailable = available - item.status.numberReady = available - - response = MagicMock() - response.items = [item] - return response +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_response = _make_api_response( + ready = _make_instance( generation=1, observed_generation=1, desired=3, @@ -37,21 +34,21 @@ def test_wait_until_deployed_returns_when_all_ready(self, daemonset): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([ready_response]), + 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_api_response( + not_ready = _make_instance( generation=1, observed_generation=1, desired=3, updated=2, available=2, ) - not_ready.items[0].status.numberReady = 2 + not_ready.status.numberReady = 2 - ready = _make_api_response( + ready = _make_instance( generation=1, observed_generation=1, desired=3, @@ -125,7 +122,7 @@ def test_restart_does_not_wait_by_default(self, daemonset): class TestWaitForRollout: def test_wait_for_rollout_returns_when_rollout_complete(self, daemonset): - complete_response = _make_api_response( + complete = _make_instance( generation=2, observed_generation=2, desired=3, @@ -135,19 +132,19 @@ def test_wait_for_rollout_returns_when_rollout_complete(self, daemonset): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([complete_response]), + return_value=iter([complete]), ): daemonset.wait_for_rollout(timeout=10) def test_wait_for_rollout_waits_when_not_all_updated(self, daemonset): - incomplete_response = _make_api_response( + incomplete = _make_instance( generation=2, observed_generation=2, desired=3, updated=1, available=1, ) - complete_response = _make_api_response( + complete = _make_instance( generation=2, observed_generation=2, desired=3, @@ -157,19 +154,19 @@ def test_wait_for_rollout_waits_when_not_all_updated(self, daemonset): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([incomplete_response, complete_response]), + return_value=iter([incomplete, complete]), ): daemonset.wait_for_rollout(timeout=10) def test_wait_for_rollout_waits_when_generation_not_observed(self, daemonset): - stale_response = _make_api_response( + stale = _make_instance( generation=3, observed_generation=2, desired=3, updated=3, available=3, ) - complete_response = _make_api_response( + complete = _make_instance( generation=3, observed_generation=3, desired=3, @@ -179,21 +176,21 @@ def test_wait_for_rollout_waits_when_generation_not_observed(self, daemonset): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([stale_response, complete_response]), + 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_response = _make_api_response( + not_available = _make_instance( generation=2, observed_generation=2, desired=3, updated=3, available=1, ) - not_available_response.items[0].status.numberReady = 3 # ready but not available yet + not_available.status.numberReady = 3 - complete_response = _make_api_response( + complete = _make_instance( generation=2, observed_generation=2, desired=3, @@ -210,18 +207,17 @@ def tracking_iter(responses): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=tracking_iter([not_available_response, complete_response]), + 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_response = MagicMock() - no_status_response.items = [MagicMock()] - no_status_response.items[0].status = None + no_status = MagicMock() + no_status.status = None - complete_response = _make_api_response( + complete = _make_instance( generation=2, observed_generation=2, desired=3, @@ -231,12 +227,12 @@ def test_wait_for_rollout_continues_when_status_missing(self, daemonset): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([no_status_response, complete_response]), + 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_response = _make_api_response( + zero_desired = _make_instance( generation=2, observed_generation=2, desired=0, @@ -246,6 +242,6 @@ def test_wait_for_rollout_returns_when_zero_desired(self, daemonset): with patch( "ocp_resources.daemon_set.TimeoutSampler", - return_value=iter([zero_desired_response]), + return_value=iter([zero_desired]), ): daemonset.wait_for_rollout(timeout=10) From cbc1110d91e00f806e3d58b3b205f0a8e8206cd1 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 15:31:59 +0300 Subject: [PATCH 16/18] refactor(daemonset): rename restart to rollout_restart Co-authored-by: pi-coding-agent Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 2 +- tests/test_daemonset.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 258e5295ed..8f7f497524 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -342,7 +342,7 @@ def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, _body: Any body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), ) - def restart(self, wait_for_rollout: bool = False, timeout: int = TIMEOUT_4MINUTES) -> None: + def rollout_restart(self, wait_for_rollout: bool = False, timeout: int = TIMEOUT_4MINUTES) -> None: """ Restart the DaemonSet by patching the pod template with a restartedAt annotation. diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 37f17edc05..569b083750 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -86,10 +86,10 @@ def test_delete_ignores_body_parameter(self, daemonset): assert call_kwargs["body"].propagation_policy == "Foreground" -class TestRestart: - def test_restart_patches_pod_template_annotation(self, daemonset): +class TestRolloutRestart: + def test_rollout_restart_patches_pod_template_annotation(self, daemonset): with patch.object(DaemonSet, "update") as mock_update: - daemonset.restart() + daemonset.rollout_restart() mock_update.assert_called_once() resource_dict = mock_update.call_args.kwargs.get("resource_dict") or mock_update.call_args[1].get( @@ -103,20 +103,20 @@ def test_restart_patches_pod_template_annotation(self, daemonset): assert isinstance(annotations["kubectl.kubernetes.io/restartedAt"], str) assert len(annotations["kubectl.kubernetes.io/restartedAt"]) > 0 - def test_restart_calls_wait_for_rollout_when_requested(self, daemonset): + def test_rollout_restart_calls_wait_for_rollout_when_requested(self, daemonset): with ( patch.object(DaemonSet, "update"), patch.object(DaemonSet, "wait_for_rollout") as mock_wait, ): - daemonset.restart(wait_for_rollout=True, timeout=120) + daemonset.rollout_restart(wait_for_rollout=True, timeout=120) mock_wait.assert_called_once_with(timeout=120) - def test_restart_does_not_wait_by_default(self, daemonset): + def test_rollout_restart_does_not_wait_by_default(self, daemonset): with ( patch.object(DaemonSet, "update"), patch.object(DaemonSet, "wait_for_rollout") as mock_wait, ): - daemonset.restart() + daemonset.rollout_restart() mock_wait.assert_not_called() From b76fb3e68e94d0406ad6817b2a31fa95fcf0fde1 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 15:55:44 +0300 Subject: [PATCH 17/18] refactor(daemonset): rename restart to rollout Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 17 +++++++++-------- tests/test_daemonset.py | 14 +++++++------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index 8f7f497524..ade014355e 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -319,10 +319,9 @@ def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: for sample in samples: if sample: status = sample.status - desired_number_scheduled = status.desiredNumberScheduled or 0 - number_ready = status.numberReady or 0 - if desired_number_scheduled > 0 and desired_number_scheduled == number_ready: - return + 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: """ @@ -342,7 +341,7 @@ def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, _body: Any body=kubernetes.client.V1DeleteOptions(propagation_policy="Foreground"), ) - def rollout_restart(self, wait_for_rollout: bool = False, timeout: int = TIMEOUT_4MINUTES) -> None: + 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. @@ -394,14 +393,16 @@ def wait_for_rollout(self, timeout: int = TIMEOUT_4MINUTES) -> None: if not status: continue - desired_number_scheduled = status.desiredNumberScheduled or 0 + 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 or 0) == desired_number_scheduled - and (status.numberAvailable or 0) == desired_number_scheduled + and status.updatedNumberScheduled == desired_number_scheduled + and status.numberAvailable == desired_number_scheduled ): return diff --git a/tests/test_daemonset.py b/tests/test_daemonset.py index 569b083750..d97af3e688 100644 --- a/tests/test_daemonset.py +++ b/tests/test_daemonset.py @@ -86,10 +86,10 @@ def test_delete_ignores_body_parameter(self, daemonset): assert call_kwargs["body"].propagation_policy == "Foreground" -class TestRolloutRestart: - def test_rollout_restart_patches_pod_template_annotation(self, daemonset): +class TestRollout: + def test_rollout_patches_pod_template_annotation(self, daemonset): with patch.object(DaemonSet, "update") as mock_update: - daemonset.rollout_restart() + daemonset.rollout() mock_update.assert_called_once() resource_dict = mock_update.call_args.kwargs.get("resource_dict") or mock_update.call_args[1].get( @@ -103,20 +103,20 @@ def test_rollout_restart_patches_pod_template_annotation(self, daemonset): assert isinstance(annotations["kubectl.kubernetes.io/restartedAt"], str) assert len(annotations["kubectl.kubernetes.io/restartedAt"]) > 0 - def test_rollout_restart_calls_wait_for_rollout_when_requested(self, daemonset): + 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_restart(wait_for_rollout=True, timeout=120) + daemonset.rollout(wait_for_rollout=True, timeout=120) mock_wait.assert_called_once_with(timeout=120) - def test_rollout_restart_does_not_wait_by_default(self, daemonset): + 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_restart() + daemonset.rollout() mock_wait.assert_not_called() From df9b37bdfe5ba5c2cb5d32d17d49bd0786a3b288 Mon Sep 17 00:00:00 2001 From: rnetser Date: Thu, 9 Jul 2026 17:10:28 +0300 Subject: [PATCH 18/18] docs(daemonset): explain Foreground propagation in delete docstring Co-authored-by: pi-coding-agent Co-authored-by: PI (claude-opus-4-6) Signed-off-by: rnetser --- ocp_resources/daemon_set.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ocp_resources/daemon_set.py b/ocp_resources/daemon_set.py index ade014355e..7bbd79a201 100644 --- a/ocp_resources/daemon_set.py +++ b/ocp_resources/daemon_set.py @@ -325,7 +325,10 @@ def wait_until_deployed(self, timeout: int = TIMEOUT_4MINUTES) -> None: def delete(self, wait: bool = False, timeout: int = TIMEOUT_4MINUTES, _body: Any = None) -> bool: """ - Delete Daemonset + 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.