From cd5052551ba3441f7b51aa8f87e91ac8ae680421 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 09:52:58 +0300 Subject: [PATCH 1/4] 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 52667bd7603e775e208beb70acf895038015b8a2 Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 10:20:41 +0300 Subject: [PATCH 2/4] 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 18266a7757fcb927e705de4f8e93b81febfa11ad Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 14:41:00 +0300 Subject: [PATCH 3/4] 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 0be7b378b210541f57a1d036672ba1bf65480c8e Mon Sep 17 00:00:00 2001 From: rnetser Date: Wed, 1 Jul 2026 14:51:10 +0300 Subject: [PATCH 4/4] 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)