88* ``release_hosts`` selective path: when the victims are the top-of-stack
99 ordinals, no warning is emitted and ``spec.replicas`` is patched down
1010 by the victim count. Pods are never deleted directly.
11- * ``release_hosts`` ordinal caveat: when victims include non-highest
12- ordinals, a WARNING is logged and the replicas patch still proceeds
13- (the controller will pick highest ordinals to evict) .
11+ * ``release_hosts`` ordinal caveat: when victims are NOT the top-of-stack
12+ ordinals, a ``K8sError`` is raised and no scale patch is issued.
13+ Silently evicting different pods would cause data loss .
1414* ``release_hosts`` full-release path: ``spec.replicas`` is patched to
1515 zero and the StatefulSet is deleted.
1616* ``check_hosts_status`` reads both the pod list and the StatefulSet
3131from orb .domain .request .value_objects import RequestId , RequestType
3232from orb .domain .template .template_aggregate import Template
3333from orb .providers .k8s .configuration .config import K8sProviderConfig
34+ from orb .providers .k8s .exceptions .k8s_errors import K8sError
3435from orb .providers .k8s .infrastructure .handlers .statefulset_handler import K8sStatefulSetHandler
3536
3637# ---------------------------------------------------------------------------
@@ -246,11 +247,15 @@ async def test_release_hosts_selective_highest_ordinal_no_warning() -> None:
246247
247248
248249@pytest .mark .asyncio
249- async def test_release_hosts_selective_non_highest_ordinal_warns_and_still_scales () -> None :
250- """When the victims include non-highest ordinals, a WARNING is logged
251- and the controller still scales down by the victim count (it will
252- pick the highest-ordinal pods regardless of what the caller asked
253- for)."""
250+ async def test_release_hosts_selective_non_highest_ordinal_refused () -> None :
251+ """When the victims are not the top-of-stack ordinals, the handler
252+ raises K8sError and issues no scale patch.
253+
254+ Commit b4a77b1a deliberately changed this behaviour: silently evicting
255+ different pods (the controller always picks the highest-ordinal set)
256+ would cause data loss, so the handler refuses the request and tells
257+ the caller which victims to supply instead.
258+ """
254259 core_v1 = MagicMock ()
255260 apps_v1 = MagicMock ()
256261 apps_v1 .read_namespaced_stateful_set .return_value = _make_statefulset_status (spec_replicas = 5 )
@@ -266,35 +271,30 @@ async def test_release_hosts_selective_non_highest_ordinal_warns_and_still_scale
266271 )
267272
268273 # current=5, victims=[ordinal-1, ordinal-2] — non-highest ordinals.
269- # The controller will evict ordinals 3 and 4 instead.
270- await handler .release_hosts (["orb-deadbeef-1" , "orb-deadbeef-2" ], request .provider_data )
274+ # The controller would evict ordinals 3 and 4 instead, so the handler
275+ # refuses rather than silently releasing the wrong pods.
276+ with pytest .raises (K8sError ) as exc_info :
277+ await handler .release_hosts (["orb-deadbeef-1" , "orb-deadbeef-2" ], request .provider_data )
271278
272- # WARNING logged.
273- warning_messages = [
274- call .args [0 ]
275- for call in handler ._logger .warning .call_args_list # type: ignore[attr-defined]
276- ]
277- assert any ("non-highest-ordinal" in msg for msg in warning_messages ), warning_messages
279+ # The error message must mention the correct top-of-stack victims.
280+ assert "orb-deadbeef-3" in str (exc_info .value ) or "orb-deadbeef-4" in str (exc_info .value )
278281
279- # Replicas still patched down by the victim count (5 -> 3) — the
280- # caller asked to release 2 pods and 2 pods will be released; the
281- # only thing that changes is *which* ordinals.
282- apps_v1 .patch_namespaced_stateful_set_scale .assert_called_once ()
283- assert (
284- apps_v1 .patch_namespaced_stateful_set_scale .call_args .kwargs ["body" ]["spec" ]["replicas" ]
285- == 3
286- )
282+ # Refusal happens before any scale patch — the StatefulSet must be
283+ # left untouched.
284+ apps_v1 .patch_namespaced_stateful_set_scale .assert_not_called ()
287285
288286 # Pods are NEVER deleted directly.
289287 core_v1 .delete_namespaced_pod .assert_not_called ()
290288 apps_v1 .delete_namespaced_stateful_set .assert_not_called ()
291289
292290
293291@pytest .mark .asyncio
294- async def test_release_hosts_selective_unparseable_victim_names_warns () -> None :
295- """Victim names that do not parse as ``<statefulset>-<ordinal>``
296- should also trigger the WARNING (since they cannot be the top-of-
297- stack ordinals)."""
292+ async def test_release_hosts_selective_unparseable_victim_names_refused () -> None :
293+ """Victim names that do not parse as ``<statefulset>-<ordinal>`` are
294+ refused with K8sError — an unparseable name cannot be the top-of-
295+ stack ordinal, so honouring the request would silently evict the
296+ wrong pods.
297+ """
298298 core_v1 = MagicMock ()
299299 apps_v1 = MagicMock ()
300300 apps_v1 .read_namespaced_stateful_set .return_value = _make_statefulset_status (spec_replicas = 3 )
@@ -310,19 +310,16 @@ async def test_release_hosts_selective_unparseable_victim_names_warns() -> None:
310310 )
311311
312312 # Victim name does not match the StatefulSet's name prefix — its
313- # ordinal cannot be parsed.
314- await handler .release_hosts (["some-other-pod" ], request .provider_data )
313+ # ordinal cannot be parsed, so the handler refuses rather than
314+ # silently releasing the wrong pod.
315+ with pytest .raises (K8sError ):
316+ await handler .release_hosts (["some-other-pod" ], request .provider_data )
315317
316- warning_messages = [
317- call .args [0 ]
318- for call in handler ._logger .warning .call_args_list # type: ignore[attr-defined]
319- ]
320- assert any ("non-highest-ordinal" in msg for msg in warning_messages )
321- # Still scales down by 1 victim (3 -> 2).
322- assert (
323- apps_v1 .patch_namespaced_stateful_set_scale .call_args .kwargs ["body" ]["spec" ]["replicas" ]
324- == 2
325- )
318+ # Refusal happens before any scale patch.
319+ apps_v1 .patch_namespaced_stateful_set_scale .assert_not_called ()
320+ # Pods are NEVER deleted directly.
321+ core_v1 .delete_namespaced_pod .assert_not_called ()
322+ apps_v1 .delete_namespaced_stateful_set .assert_not_called ()
326323
327324
328325# ---------------------------------------------------------------------------
0 commit comments