feat(pod-disruption): support parallel pod deletion (#1536)

* feat(pod-disruption): support parallel pod deletion

Introduce 'kill_mode' field to pod disruption scenario to support parallel
deletion of pods. By default 'kill_mode' is 'sequential' preserving
existing behavior. When set to 'parallel', it concurrently deletes pods
using threading and queue, enabling effective testing of disruption scenarios
like etcd quorum loss where simultaneous disruption is necessary.

Resolves: #1516

Assisted-by: Claude <noreply@anthropic.com>
Signed-off-by: swagatobauri <swagato731123@gmail.com>

* fix(pod-disruption): validate kill_mode and address PR feedback

- Validate 'kill_mode' at parse time: fallback to 'sequential' and log a
  warning if an unknown string is provided, preventing silent failures.
- Update test_parallel_mode_calls_delete_concurrently to use a
  threading.Barrier to definitively prove concurrent thread execution.
- Revert 'scenarios/openshift/etcd.yml' to default non-destructive behavior
  and extract parallel quorum loss example into a new explicitly named file
  'etcd_quorum_loss.yml'.

Assisted-by: Claude <noreply@anthropic.com>
Signed-off-by: swagatobauri <swagato731123@gmail.com>

* refactor(pod-disruption): optimize imports and cap threads

- Reorganize imports in pod_disruption_scenario_plugin.py to comply with
  standard PEP-8 grouping (stdlib, third-party, local).
- Replace unbounded OS thread spawning in _delete_pods_parallel with
  concurrent.futures.ThreadPoolExecutor. Capped max_workers to 10 to
  prevent excessive concurrent API calls that could overload the Kubernetes
  API server under aggressive scenario configurations.

Assisted-by: Claude <noreply@anthropic.com>
Signed-off-by: swagatobauri <swagato731123@gmail.com>

* refactor(pod-disruption): align execution config with network_chaos_ng

- Renamed config field from 'kill_mode' to 'execution' for consistency
  with the network_chaos_ng plugin.
- Changed valid values from 'sequential|parallel' to 'serial|parallel'.
- Replaced fallback warning with strict validation, raising ValueError on
  invalid execution values, aligning with the strict validation pattern
  used elsewhere in the codebase.
- Updated relevant tests and scenario YAML templates.

Assisted-by: Claude <noreply@anthropic.com>
Signed-off-by: swagatobauri <swagato731123@gmail.com>

* fix(pod-disruption): remove unused logging import in models.py

Following the switch from a warning log to raising a ValueError for
invalid execution types, the logging import was no longer used.

Assisted-by: Claude <noreply@anthropic.com>
Signed-off-by: swagatobauri <swagato731123@gmail.com>

---------

Signed-off-by: swagatobauri <swagato731123@gmail.com>
Co-authored-by: Darshan Jain <darjain@redhat.com>
This commit is contained in:
SWAGATO BAURI
2026-08-06 18:46:25 +05:30
committed by GitHub
co-authored by Darshan Jain
parent 502cf958e6
commit ee32a179cf
5 changed files with 191 additions and 5 deletions
@@ -16,6 +16,7 @@ from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn.scenario_plugins.pod_disruption.pod_disruption_scenario_plugin import PodDisruptionScenarioPlugin
from krkn.scenario_plugins.pod_disruption.models.models import InputParams
class TestPodDisruptionScenarioPlugin(unittest.TestCase):
@@ -39,6 +40,136 @@ class TestPodDisruptionScenarioPlugin(unittest.TestCase):
self.assertEqual(result, ["pod_disruption_scenarios"])
self.assertEqual(len(result), 1)
class TestKillingPodsMode(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for killing_pods mode tests."""
self.plugin = PodDisruptionScenarioPlugin()
self.kubecli = MagicMock(spec=KrknKubernetes)
self.plugin.get_pods = MagicMock()
self.plugin.wait_for_pods = MagicMock(return_value=0)
def tearDown(self):
"""Clean up after each test to prevent state leakage."""
self.plugin = None
self.kubecli = None
# --- InputParams.execution parsing ---
def test_execution_defaults_to_serial(self):
"""execution defaults to 'serial' when not specified in config."""
params = InputParams({"kill": 2})
self.assertEqual(params.execution, "serial")
def test_execution_serial_explicit(self):
"""execution is correctly parsed when explicitly set to 'serial'."""
params = InputParams({"kill": 2, "execution": "serial"})
self.assertEqual(params.execution, "serial")
def test_execution_parallel_explicit(self):
"""execution is correctly parsed when explicitly set to 'parallel'."""
params = InputParams({"kill": 2, "execution": "parallel"})
self.assertEqual(params.execution, "parallel")
def test_execution_invalid_raises_value_error(self):
"""execution raises ValueError on unknown values."""
with self.assertRaises(ValueError) as context:
InputParams({"kill": 2, "execution": "invalid_mode"})
self.assertIn("Unknown execution 'invalid_mode'", str(context.exception))
# --- killing_pods() behaviour ---
def test_not_enough_pods_returns_error(self):
"""Returns 1 and never calls delete_pod when fewer pods exist than kill count."""
config = InputParams({"kill": 3, "execution": "serial"})
self.plugin.get_pods.return_value = [("pod1", "ns1"), ("pod2", "ns1")]
result = self.plugin.killing_pods(config, self.kubecli)
self.assertEqual(result, 1)
self.kubecli.delete_pod.assert_not_called()
def test_serial_mode_calls_delete_in_order(self):
"""Serial mode deletes all selected pods one at a time."""
config = InputParams({"kill": 2, "execution": "serial"})
self.plugin.get_pods.return_value = [("pod1", "ns1"), ("pod2", "ns1")]
result = self.plugin.killing_pods(config, self.kubecli)
self.assertEqual(result, 0)
self.assertEqual(self.kubecli.delete_pod.call_count, 2)
self.kubecli.delete_pod.assert_any_call("pod1", "ns1")
self.kubecli.delete_pod.assert_any_call("pod2", "ns1")
def test_parallel_mode_calls_delete_concurrently(self):
"""Parallel mode deletes all selected pods and calls delete_pod for each concurrently."""
config = InputParams({"kill": 2, "execution": "parallel"})
pods = [("pod1", "ns1"), ("pod2", "ns1")]
self.plugin.get_pods.return_value = pods
# Use a barrier to prove threads run concurrently. If they run serially,
# the first thread will block forever waiting for the second.
import threading
barrier = threading.Barrier(2, timeout=5)
def side_effect(name, namespace):
barrier.wait()
self.kubecli.delete_pod.side_effect = side_effect
result = self.plugin.killing_pods(config, self.kubecli)
self.assertEqual(result, 0)
self.assertEqual(self.kubecli.delete_pod.call_count, 2)
self.kubecli.delete_pod.assert_any_call("pod1", "ns1")
self.kubecli.delete_pod.assert_any_call("pod2", "ns1")
def test_parallel_mode_propagates_delete_exception(self):
"""Exceptions raised during parallel deletion bubble up correctly."""
config = InputParams({"kill": 2, "execution": "parallel"})
self.plugin.get_pods.return_value = [("pod1", "ns1"), ("pod2", "ns1")]
def side_effect(name, namespace):
if name == "pod1":
raise RuntimeError("failed to delete")
self.kubecli.delete_pod.side_effect = side_effect
with self.assertRaises(Exception) as context:
self.plugin.killing_pods(config, self.kubecli)
self.assertIn("parallel pod deletion failed", str(context.exception))
self.assertIn("failed to delete", str(context.exception))
def test_excluded_pods_are_not_deleted_in_serial_mode(self):
"""Pods matched by exclude_label are skipped and never passed to delete_pod (serial)."""
config = InputParams({"kill": 2, "execution": "serial", "exclude_label": "protected=true"})
# get_pods is called twice: first for target pods, then for excluded pods
self.plugin.get_pods.side_effect = [
[("pod1", "ns1"), ("pod2", "ns1")], # target pods
[("pod1", "ns1")], # excluded pods
]
result = self.plugin.killing_pods(config, self.kubecli)
self.assertEqual(result, 0)
# Only pod2 should be deleted; pod1 is excluded
self.kubecli.delete_pod.assert_called_once_with("pod2", "ns1")
def test_excluded_pods_are_not_deleted_in_parallel_mode(self):
"""Pods matched by exclude_label are skipped and never passed to delete_pod (parallel)."""
config = InputParams({"kill": 2, "execution": "parallel", "exclude_label": "protected=true"})
self.plugin.get_pods.side_effect = [
[("pod1", "ns1"), ("pod2", "ns1")], # target pods
[("pod1", "ns1")], # excluded pods
]
result = self.plugin.killing_pods(config, self.kubecli)
self.assertEqual(result, 0)
# Only pod2 should be deleted; pod1 is excluded
self.kubecli.delete_pod.assert_called_once_with("pod2", "ns1")
if __name__ == "__main__":
unittest.main()