Files
krkn/tests/test_storage_throttle_scenario_plugin.py
T
eb2efa84ea feat: storage I/O throttle scenario (cgroups v1/v2) for PVC-backed workloads (#1296)
* feat(storage-throttle): add storage throttle scenario and tests

Consolidate the storage-throttle implementation, scenario configs, CI v2 coverage, and krkn-lib 6.0.10 dependency update into a single signed commit for cleaner PR history.

Signed-off-by: ddjain <darjain@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: ddjain <darjain@redhat.com>

* adding need dco auto review (#1329)

Signed-off-by: Paige Patton <prubenda@redhat.com>
Signed-off-by: ddjain <darjain@redhat.com>

* fix: start_klusterlet_scenario action calls start instead of stop (#1324)

The start_klusterlet_scenario branch in inject_managedcluster_scenario
was calling stop_klusterlet_scenario on the scenarios object instead of
start_klusterlet_scenario. Any user configuring this action would stop
the klusterlet (scale to 0) instead of starting it (scale to 3).

Fixes #1323

Signed-off-by: v0idheaven <dahiyavarun2007@gmail.com>
Co-authored-by: Paige Patton <64206430+paigerube14@users.noreply.github.com>
Signed-off-by: ddjain <darjain@redhat.com>

* adding links to issue of completed roadmap items (#1328)

Signed-off-by: Paige Patton <prubenda@redhat.com>
Signed-off-by: ddjain <darjain@redhat.com>

* container scenario template image update (#1342)

Signed-off-by: Paige Patton <prubenda@redhat.com>
Signed-off-by: ddjain <darjain@redhat.com>

---------

Signed-off-by: ddjain <darjain@redhat.com>
Signed-off-by: Paige Patton <prubenda@redhat.com>
Signed-off-by: v0idheaven <dahiyavarun2007@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Paige Patton <64206430+paigerube14@users.noreply.github.com>
Co-authored-by: Varun Yadav <dahiyavarun2007@gmail.com>
2026-05-19 00:24:55 +05:30

1160 lines
41 KiB
Python

#!/usr/bin/env python3
"""
Test suite for StorageThrottleScenarioPlugin class
Usage:
python -m coverage run -a -m unittest tests/test_storage_throttle_scenario_plugin.py -v
"""
import base64
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch, call
import yaml
from krkn_lib.k8s import KrknKubernetes
from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift
from krkn.scenario_plugins.storage_throttle.storage_throttle_scenario_plugin import (
StorageThrottleScenarioPlugin,
ThrottleParams,
)
from krkn.scenario_plugins.storage_throttle.storage_throttle_utils import (
validate_cgroup_path as _validate_cgroup_path,
validate_container_id as _validate_container_id,
validate_maj_min as _validate_maj_min,
validate_mount_path as _validate_mount_path,
parse_byte_value as _parse_byte_value,
parse_duration_value as _parse_duration_value,
)
from krkn.rollback.config import RollbackContent
class TestValidateShellInputs(unittest.TestCase):
"""Sanity checks for mount path and major:minor validation."""
def test_validate_mount_path_ok(self):
self.assertTrue(_validate_mount_path("/data"))
self.assertTrue(_validate_mount_path("/var/lib/kubelet/pods/abc/volumes"))
def test_validate_mount_path_rejects_shell_chars(self):
self.assertFalse(_validate_mount_path("/data;rm -rf /"))
self.assertFalse(_validate_mount_path("$(whoami)"))
self.assertFalse(_validate_mount_path(""))
def test_validate_mount_path_rejects_traversal(self):
self.assertFalse(_validate_mount_path("/data/../etc/shadow"))
self.assertFalse(_validate_mount_path("/.."))
def test_validate_cgroup_path_ok(self):
self.assertTrue(_validate_cgroup_path("/kubepods.slice/crio-abc.scope"))
self.assertTrue(_validate_cgroup_path("/kubepods/abc123def456"))
def test_validate_cgroup_path_rejects_bad(self):
self.assertFalse(_validate_cgroup_path(""))
self.assertFalse(_validate_cgroup_path("/kubepods/../etc"))
self.assertFalse(_validate_cgroup_path("relative/path"))
def test_validate_container_id_ok(self):
self.assertTrue(_validate_container_id("abc123def456"))
self.assertTrue(_validate_container_id("0123456789abcdef"))
def test_validate_container_id_rejects_bad(self):
self.assertFalse(_validate_container_id(""))
self.assertFalse(_validate_container_id("abc;rm -rf /"))
self.assertFalse(_validate_container_id("ABC123")) # uppercase
self.assertFalse(_validate_container_id("abc 123"))
def test_validate_maj_min_ok(self):
self.assertTrue(_validate_maj_min("8:16"))
self.assertTrue(_validate_maj_min("259:0"))
def test_validate_maj_min_rejects_bad(self):
self.assertFalse(_validate_maj_min("8:16 foo"))
self.assertFalse(_validate_maj_min("abc"))
class TestParseByteValue(unittest.TestCase):
"""Tests for _parse_byte_value — Kubernetes-style unit parsing.
Supported binary suffixes: Ki (1024), Mi (1024^2), Gi (1024^3)
Supported decimal suffixes: K (1000), M (1000^2), G (1000^3)
"""
def test_plain_int(self):
self.assertEqual(_parse_byte_value(1048576), 1048576)
def test_plain_int_zero(self):
self.assertEqual(_parse_byte_value(0), 0)
def test_string_no_suffix(self):
self.assertEqual(_parse_byte_value("1048576"), 1048576)
def test_ki_suffix(self):
self.assertEqual(_parse_byte_value("512Ki"), 524288)
def test_mi_suffix(self):
self.assertEqual(_parse_byte_value("1Mi"), 1048576)
def test_gi_suffix(self):
self.assertEqual(_parse_byte_value("1Gi"), 1073741824)
def test_decimal_k_suffix(self):
self.assertEqual(_parse_byte_value("500K"), 500000)
def test_decimal_m_suffix(self):
self.assertEqual(_parse_byte_value("5M"), 5000000)
def test_decimal_g_suffix(self):
self.assertEqual(_parse_byte_value("1G"), 1000000000)
def test_fractional_value(self):
self.assertEqual(_parse_byte_value("1.5Mi"), 1572864)
def test_whitespace_handling(self):
self.assertEqual(_parse_byte_value(" 1Mi "), 1048576)
def test_invalid_suffix(self):
with self.assertRaises(ValueError):
_parse_byte_value("100Ti")
def test_invalid_type(self):
with self.assertRaises(ValueError):
_parse_byte_value([100])
def test_float_passthrough(self):
self.assertEqual(_parse_byte_value(1048576.7), 1048576)
class TestParseDurationValue(unittest.TestCase):
"""Tests for _parse_duration_value — time unit parsing.
Supported suffixes: s (seconds), m (minutes x60), h (hours x3600)
"""
def test_plain_int(self):
self.assertEqual(_parse_duration_value(120), 120)
def test_string_no_suffix(self):
self.assertEqual(_parse_duration_value("120"), 120)
def test_seconds_suffix(self):
self.assertEqual(_parse_duration_value("30s"), 30)
def test_minutes_suffix(self):
self.assertEqual(_parse_duration_value("2m"), 120)
def test_hours_suffix(self):
self.assertEqual(_parse_duration_value("1h"), 3600)
def test_fractional_minutes(self):
self.assertEqual(_parse_duration_value("1.5m"), 90)
def test_whitespace_handling(self):
self.assertEqual(_parse_duration_value(" 5m "), 300)
def test_invalid_suffix(self):
with self.assertRaises(ValueError):
_parse_duration_value("10d")
def test_invalid_type(self):
with self.assertRaises(ValueError):
_parse_duration_value([60])
def test_float_passthrough(self):
self.assertEqual(_parse_duration_value(60.9), 60)
class TestStorageThrottleScenarioPlugin(unittest.TestCase):
def setUp(self):
self.plugin = StorageThrottleScenarioPlugin()
def tearDown(self):
self.plugin = None
def test_get_scenario_types(self):
result = self.plugin.get_scenario_types()
self.assertEqual(result, ["storage_throttle_scenarios"])
self.assertEqual(len(result), 1)
class TestResolveTargetPod(unittest.TestCase):
def setUp(self):
self.plugin = StorageThrottleScenarioPlugin()
def tearDown(self):
self.plugin = None
def test_resolve_by_pod_name(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
result = self.plugin._resolve_pod_name(
mock_k8s, "", "my-pod", "default"
)
self.assertEqual(result, "my-pod")
mock_k8s.get_pvc_info.assert_not_called()
def test_resolve_by_pvc_name(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_pvc = MagicMock()
mock_pvc.podNames = ["pvc-pod-1", "pvc-pod-2"]
mock_k8s.get_pvc_info.return_value = mock_pvc
result = self.plugin._resolve_pod_name(
mock_k8s, "my-pvc", "", "default"
)
self.assertIn(result, ["pvc-pod-1", "pvc-pod-2"])
def test_resolve_pvc_no_pods(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_pvc = MagicMock()
mock_pvc.podNames = []
mock_k8s.get_pvc_info.return_value = mock_pvc
result = self.plugin._resolve_pod_name(
mock_k8s, "my-pvc", "", "default"
)
self.assertEqual(result, "")
def test_resolve_pvc_overrides_pod_name(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_pvc = MagicMock()
mock_pvc.podNames = ["from-pvc"]
mock_k8s.get_pvc_info.return_value = mock_pvc
result = self.plugin._resolve_pod_name(
mock_k8s, "my-pvc", "ignored-pod", "default"
)
self.assertEqual(result, "from-pvc")
def test_resolve_pvc_not_found(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.get_pvc_info.return_value = None
result = self.plugin._resolve_pod_name(
mock_k8s, "missing-pvc", "", "default"
)
self.assertEqual(result, "")
class TestFindPvcMount(unittest.TestCase):
def test_find_mount_success(self):
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "vol1"
mock_pod.volumes = [mock_volume]
mock_container = MagicMock()
mock_container.name = "app"
mock_vol_mount = MagicMock()
mock_vol_mount.name = "vol1"
mock_vol_mount.mountPath = "/data"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
container, path = StorageThrottleScenarioPlugin._find_pvc_mount(
mock_pod, ""
)
self.assertEqual(container, "app")
self.assertEqual(path, "/data")
def test_find_mount_specific_path(self):
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "test-pvc"
mock_volume.name = "vol1"
mock_pod.volumes = [mock_volume]
mock_container = MagicMock()
mock_container.name = "app"
mock_mount1 = MagicMock()
mock_mount1.name = "vol1"
mock_mount1.mountPath = "/data"
mock_mount2 = MagicMock()
mock_mount2.name = "other-vol"
mock_mount2.mountPath = "/logs"
mock_container.volumeMounts = [mock_mount2, mock_mount1]
mock_pod.containers = [mock_container]
container, path = StorageThrottleScenarioPlugin._find_pvc_mount(
mock_pod, "/data"
)
self.assertEqual(container, "app")
self.assertEqual(path, "/data")
def test_find_mount_no_pvc(self):
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = None
mock_pod.volumes = [mock_volume]
container, path = StorageThrottleScenarioPlugin._find_pvc_mount(
mock_pod, ""
)
self.assertIsNone(container)
self.assertIsNone(path)
def test_find_mount_filters_by_pvc_name(self):
"""With multiple PVCs, only the requested pvc_name is selected."""
mock_pod = MagicMock()
vol_logs = MagicMock()
vol_logs.pvcName = "logs-pvc"
vol_logs.name = "logs-vol"
vol_data = MagicMock()
vol_data.pvcName = "data-pvc"
vol_data.name = "data-vol"
mock_pod.volumes = [vol_logs, vol_data]
mount_logs = MagicMock()
mount_logs.name = "logs-vol"
mount_logs.mountPath = "/logs"
mount_data = MagicMock()
mount_data.name = "data-vol"
mount_data.mountPath = "/data"
mock_container = MagicMock()
mock_container.name = "app"
mock_container.volumeMounts = [mount_logs, mount_data]
mock_pod.containers = [mock_container]
container, path = StorageThrottleScenarioPlugin._find_pvc_mount(
mock_pod, "", "data-pvc"
)
self.assertEqual(container, "app")
self.assertEqual(path, "/data")
def test_find_mount_pvc_name_not_found(self):
"""Returns None when requested pvc_name doesn't match any volume."""
mock_pod = MagicMock()
vol = MagicMock()
vol.pvcName = "other-pvc"
vol.name = "vol1"
mock_pod.volumes = [vol]
mount = MagicMock()
mount.name = "vol1"
mount.mountPath = "/data"
mock_container = MagicMock()
mock_container.name = "app"
mock_container.volumeMounts = [mount]
mock_pod.containers = [mock_container]
container, path = StorageThrottleScenarioPlugin._find_pvc_mount(
mock_pod, "", "missing-pvc"
)
self.assertIsNone(container)
self.assertIsNone(path)
class TestGetDeviceMajMin(unittest.TestCase):
def test_success(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = "8:16\n"
result = StorageThrottleScenarioPlugin._get_device_maj_min(
mock_k8s, "pod", "ns", "container", "/data"
)
self.assertEqual(result, "8:16")
def test_empty_output(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = ""
result = StorageThrottleScenarioPlugin._get_device_maj_min(
mock_k8s, "pod", "ns", "container", "/data"
)
self.assertEqual(result, "")
def test_none_output(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = None
result = StorageThrottleScenarioPlugin._get_device_maj_min(
mock_k8s, "pod", "ns", "container", "/data"
)
self.assertEqual(result, "")
def test_invalid_mount_path_rejected(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
result = StorageThrottleScenarioPlugin._get_device_maj_min(
mock_k8s, "pod", "ns", "container", "/data/../etc"
)
self.assertEqual(result, "")
mock_k8s.exec_cmd_in_pod.assert_not_called()
class TestGetContainerId(unittest.TestCase):
def test_crio_container_id(self):
mock_pod = MagicMock()
mock_c = MagicMock()
mock_c.name = "app"
mock_c.containerId = "cri-o://abc123def456"
mock_pod.containers = [mock_c]
result = StorageThrottleScenarioPlugin._get_container_id(mock_pod, "app")
self.assertEqual(result, "abc123def456")
def test_containerd_container_id(self):
mock_pod = MagicMock()
mock_c = MagicMock()
mock_c.name = "app"
mock_c.containerId = "containerd://abc123def456"
mock_pod.containers = [mock_c]
result = StorageThrottleScenarioPlugin._get_container_id(mock_pod, "app")
self.assertEqual(result, "abc123def456")
def test_container_not_found(self):
mock_pod = MagicMock()
mock_c = MagicMock()
mock_c.name = "other"
mock_c.containerId = "cri-o://abc123"
mock_pod.containers = [mock_c]
result = StorageThrottleScenarioPlugin._get_container_id(mock_pod, "app")
self.assertEqual(result, "")
def test_empty_container_id(self):
mock_pod = MagicMock()
mock_c = MagicMock()
mock_c.name = "app"
mock_c.containerId = ""
mock_pod.containers = [mock_c]
result = StorageThrottleScenarioPlugin._get_container_id(mock_pod, "app")
self.assertEqual(result, "")
def test_pod_not_found(self):
result = StorageThrottleScenarioPlugin._get_container_id(None, "app")
self.assertEqual(result, "")
def test_non_hex_container_id_rejected(self):
mock_pod = MagicMock()
mock_c = MagicMock()
mock_c.name = "app"
mock_c.containerId = "cri-o://UPPERCASE_NOT_HEX"
mock_pod.containers = [mock_c]
result = StorageThrottleScenarioPlugin._get_container_id(mock_pod, "app")
self.assertEqual(result, "")
def test_shell_injection_container_id_rejected(self):
mock_pod = MagicMock()
mock_c = MagicMock()
mock_c.name = "app"
mock_c.containerId = "cri-o://abc;rm -rf /"
mock_pod.containers = [mock_c]
result = StorageThrottleScenarioPlugin._get_container_id(mock_pod, "app")
self.assertEqual(result, "")
class TestDetectCgroupVersion(unittest.TestCase):
def setUp(self):
self.plugin = StorageThrottleScenarioPlugin()
def tearDown(self):
self.plugin = None
def test_cgroup_v2(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = "cgroup2fs\n"
result = self.plugin._detect_cgroup_version(mock_k8s, "priv-pod", "default")
self.assertEqual(result, "v2")
def test_cgroup_v1(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = "tmpfs\n"
result = self.plugin._detect_cgroup_version(mock_k8s, "priv-pod", "default")
self.assertEqual(result, "v1")
def test_cgroup_unknown(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = None
result = self.plugin._detect_cgroup_version(mock_k8s, "priv-pod", "default")
self.assertEqual(result, "v1")
class TestFindHostCgroupPath(unittest.TestCase):
def setUp(self):
self.plugin = StorageThrottleScenarioPlugin()
def tearDown(self):
self.plugin = None
def test_cgroup_v2_scope_found(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = (
"/sys/fs/cgroup/kubepods.slice/crio-abc123def456.scope\n"
)
result = self.plugin._find_host_cgroup_path(
mock_k8s, "priv-pod", "abc123def456789", "v2", "default"
)
self.assertEqual(result, "/kubepods.slice/crio-abc123def456.scope")
def test_cgroup_v2_dir_fallback(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.side_effect = [
"", # scope search returns nothing
"/sys/fs/cgroup/kubepods/abc123def456\n", # dir search
]
result = self.plugin._find_host_cgroup_path(
mock_k8s, "priv-pod", "abc123def456789", "v2", "default"
)
self.assertEqual(result, "/kubepods/abc123def456")
def test_cgroup_v1(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = (
"/sys/fs/cgroup/blkio/kubepods/abc123def456.scope\n"
)
result = self.plugin._find_host_cgroup_path(
mock_k8s, "priv-pod", "abc123def456789", "v1", "default"
)
self.assertEqual(result, "/kubepods/abc123def456.scope")
def test_not_found(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = ""
result = self.plugin._find_host_cgroup_path(
mock_k8s, "priv-pod", "abc123def456789", "v2", "default"
)
self.assertEqual(result, "")
class TestApplyRemoveThrottle(unittest.TestCase):
def setUp(self):
self.plugin = StorageThrottleScenarioPlugin()
def tearDown(self):
self.plugin = None
def _make_params(self, **overrides):
defaults = dict(
pvc_name="", pod_name="app-pod", namespace="default",
throttle_type="bandwidth", read_iops=100, write_iops=50,
read_bps=1048576, write_bps=524288, duration=60,
mount_path="", image="quay.io/krkn-chaos/krkn:tools",
)
defaults.update(overrides)
return ThrottleParams(**defaults)
def test_apply_throttle_v2_bandwidth(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = "8:16 rbps=1048576 wbps=524288"
self.plugin._apply_throttle(
mock_k8s, "priv-pod", "/kubepods/crio-abc.scope", "v2",
"8:16", self._make_params(throttle_type="bandwidth"), "default",
)
calls = mock_k8s.exec_cmd_in_pod.call_args_list
self.assertEqual(len(calls), 2) # echo + cat
echo_cmd = calls[0][0][0][3]
self.assertIn("rbps=1048576", echo_cmd)
self.assertIn("wbps=524288", echo_cmd)
def test_apply_throttle_v2_iops(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = "8:16 riops=100 wiops=50"
self.plugin._apply_throttle(
mock_k8s, "priv-pod", "/kubepods/crio-abc.scope", "v2",
"8:16", self._make_params(throttle_type="iops"), "default",
)
calls = mock_k8s.exec_cmd_in_pod.call_args_list
echo_cmd = calls[0][0][0][3]
self.assertIn("riops=100", echo_cmd)
self.assertIn("wiops=50", echo_cmd)
def test_apply_throttle_v2_both(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = ""
self.plugin._apply_throttle(
mock_k8s, "priv-pod", "/kubepods/crio-abc.scope", "v2",
"8:16", self._make_params(throttle_type="both"), "default",
)
calls = mock_k8s.exec_cmd_in_pod.call_args_list
echo_cmd = calls[0][0][0][3]
self.assertIn("rbps=1048576", echo_cmd)
self.assertIn("wiops=50", echo_cmd)
def test_apply_throttle_v1_bandwidth(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = ""
self.plugin._apply_throttle(
mock_k8s, "priv-pod", "/kubepods/abc", "v1",
"8:16", self._make_params(throttle_type="bandwidth"), "default",
)
calls = mock_k8s.exec_cmd_in_pod.call_args_list
# 2 writes (read_bps, write_bps) + 4 cat verifications
self.assertEqual(len(calls), 6)
def test_remove_throttle_v2(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = ""
self.plugin._remove_throttle(
mock_k8s, "priv-pod", "/kubepods/crio-abc.scope", "v2", "8:16",
"default",
)
calls = mock_k8s.exec_cmd_in_pod.call_args_list
self.assertEqual(len(calls), 1)
echo_cmd = calls[0][0][0][3]
self.assertIn("rbps=max", echo_cmd)
self.assertIn("wbps=max", echo_cmd)
def test_remove_throttle_v1(self):
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_k8s.exec_cmd_in_pod.return_value = ""
self.plugin._remove_throttle(
mock_k8s, "priv-pod", "/kubepods/abc", "v1", "8:16", "default",
)
calls = mock_k8s.exec_cmd_in_pod.call_args_list
# 4 writes (one for each blkio file)
self.assertEqual(len(calls), 4)
class TestRunScenario(unittest.TestCase):
def setUp(self):
self.plugin = StorageThrottleScenarioPlugin()
def tearDown(self):
self.plugin = None
def create_scenario_file(self, config: dict, temp_dir: str) -> str:
path = os.path.join(temp_dir, "scenario.yaml")
with open(path, "w") as f:
yaml.dump(config, f)
return path
def test_run_missing_namespace(self):
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"pvc_name": "test-pvc",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
def test_run_missing_pvc_and_pod(self):
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
def test_run_invalid_throttle_type(self):
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "test-pod",
"throttle_type": "invalid",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
def test_run_pod_not_found(self):
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "nonexistent",
"throttle_type": "bandwidth",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
mock_k8s.get_pod_info.return_value = None
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
def test_run_scenario_file_not_found(self):
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario="/nonexistent/path.yaml",
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
def test_run_invalid_config_mount_path(self):
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "test-pod",
"mount_path": "/bad path",
"throttle_type": "bandwidth",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_telemetry.get_lib_kubernetes.assert_not_called()
def test_run_zero_duration_rejected(self):
"""run() returns 1 when duration is zero."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "test-pod",
"throttle_type": "bandwidth",
"duration": 0,
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_telemetry.get_lib_kubernetes.assert_not_called()
def test_run_negative_iops_rejected(self):
"""run() returns 1 when an IOPS value is negative."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "test-pod",
"throttle_type": "iops",
"read_iops": -10,
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_telemetry.get_lib_kubernetes.assert_not_called()
def test_run_invalid_byte_suffix_rejected(self):
"""run() returns 1 with clean error when byte value has invalid suffix."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "test-pod",
"throttle_type": "bandwidth",
"read_bps": "10Ti",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_telemetry.get_lib_kubernetes.assert_not_called()
def test_run_invalid_duration_suffix_rejected(self):
"""run() returns 1 with clean error when duration has invalid suffix."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "test-pod",
"throttle_type": "bandwidth",
"duration": "10d",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_telemetry.get_lib_kubernetes.assert_not_called()
def test_run_invalid_maj_min_from_mountinfo(self):
"""run() returns 1 when /proc/self/mountinfo returns invalid major:minor."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "app-pod",
"throttle_type": "bandwidth",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "pvc1"
mock_volume.name = "vol1"
mock_pod.volumes = [mock_volume]
mock_vol_mount = MagicMock()
mock_vol_mount.name = "vol1"
mock_vol_mount.mountPath = "/data"
mock_container = MagicMock()
mock_container.name = "app"
mock_container.volumeMounts = [mock_vol_mount]
mock_pod.containers = [mock_container]
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
mock_k8s.get_pod_info.return_value = mock_pod
mock_k8s.exec_cmd_in_pod.return_value = "BADVALUE\n"
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
def test_run_cgroup_path_not_found(self):
"""run() returns 1 and cleans up when cgroup path discovery fails."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "app-pod",
"throttle_type": "bandwidth",
}
}
path = self.create_scenario_file(config, temp_dir)
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "pvc1"
mock_volume.name = "vol1"
mock_pod.volumes = [mock_volume]
mock_vol_mount = MagicMock()
mock_vol_mount.name = "vol1"
mock_vol_mount.mountPath = "/data"
mock_container = MagicMock()
mock_container.name = "app"
mock_container.volumeMounts = [mock_vol_mount]
mock_container.containerId = "cri-o://abc123def4567890123456789012"
mock_pod.containers = [mock_container]
mock_pod.nodeName = "worker-1"
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
mock_k8s.get_pod_info.return_value = mock_pod
mock_k8s.deploy_io_throttle_pod.return_value = "io-throttle-12345"
mock_k8s.exec_cmd_in_pod.side_effect = [
"8:16\n", # _get_device_maj_min
"cgroup2fs\n", # _detect_cgroup_version
"", # _find_host_cgroup_path scope search
"", # _find_host_cgroup_path dir fallback
]
mock_st = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_k8s.deploy_io_throttle_pod.assert_called_once()
mock_k8s.delete_pod.assert_called_once()
@patch(
"krkn.scenario_plugins.storage_throttle."
"storage_throttle_scenario_plugin.time.sleep"
)
def test_run_happy_path(self, mock_sleep):
"""Full success path through run() with mocks."""
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "app-pod",
"throttle_type": "bandwidth",
"duration": 45,
}
}
path = self.create_scenario_file(config, temp_dir)
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "pvc1"
mock_volume.name = "vol1"
mock_pod.volumes = [mock_volume]
mock_vol_mount = MagicMock()
mock_vol_mount.name = "vol1"
mock_vol_mount.mountPath = "/data"
mock_container = MagicMock()
mock_container.name = "app"
mock_container.volumeMounts = [mock_vol_mount]
mock_container.containerId = "cri-o://abc123def4567890123456789012"
mock_pod.containers = [mock_container]
mock_pod.nodeName = "worker-1"
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
mock_k8s.get_pod_info.return_value = mock_pod
mock_k8s.deploy_io_throttle_pod.return_value = "io-throttle-12345"
mock_k8s.exec_cmd_in_pod.side_effect = [
"8:16\n",
"cgroup2fs\n",
"/sys/fs/cgroup/kubepods.slice/crio-abc.scope\n",
"",
"8:16 rbps=1048576 wbps=524288\n",
"",
]
mock_st = MagicMock()
# Avoid RollbackHandler persisting to disk during unit tests
self.plugin.rollback_handler.set_rollback_callable = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 0)
mock_k8s.deploy_io_throttle_pod.assert_called_once()
mock_k8s.create_pod.assert_not_called()
mock_k8s.delete_pod.assert_called_once()
self.assertEqual(mock_k8s.exec_cmd_in_pod.call_count, 6)
self.assertEqual(mock_sleep.call_count, 2)
@patch(
"krkn.scenario_plugins.storage_throttle."
"storage_throttle_scenario_plugin.time.sleep"
)
def test_run_removes_throttle_on_wait_failure(self, mock_sleep):
"""Throttle is removed even when _wait_with_progress raises."""
mock_sleep.side_effect = RuntimeError("simulated failure")
with tempfile.TemporaryDirectory() as temp_dir:
config = {
"storage_throttle_scenario": {
"namespace": "default",
"pod_name": "app-pod",
"throttle_type": "bandwidth",
"duration": 60,
}
}
path = self.create_scenario_file(config, temp_dir)
mock_pod = MagicMock()
mock_volume = MagicMock()
mock_volume.pvcName = "pvc1"
mock_volume.name = "vol1"
mock_pod.volumes = [mock_volume]
mock_vol_mount = MagicMock()
mock_vol_mount.name = "vol1"
mock_vol_mount.mountPath = "/data"
mock_container = MagicMock()
mock_container.name = "app"
mock_container.volumeMounts = [mock_vol_mount]
mock_container.containerId = "cri-o://abc123def4567890123456789012"
mock_pod.containers = [mock_container]
mock_pod.nodeName = "worker-1"
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock(spec=KrknKubernetes)
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
mock_k8s.get_pod_info.return_value = mock_pod
mock_k8s.deploy_io_throttle_pod.return_value = "io-throttle-12345"
mock_k8s.exec_cmd_in_pod.side_effect = [
"8:16\n", # _get_device_maj_min
"cgroup2fs\n", # _detect_cgroup_version
"/sys/fs/cgroup/kubepods.slice/crio-abc.scope\n", # scope
"", # _apply_throttle echo
"8:16 rbps=1048576 wbps=524288\n", # _apply_throttle cat
"", # _remove_throttle (in finally cleanup)
]
mock_st = MagicMock()
self.plugin.rollback_handler.set_rollback_callable = MagicMock()
result = self.plugin.run(
run_uuid="uuid", scenario=path,
lib_telemetry=mock_telemetry, scenario_telemetry=mock_st,
)
self.assertEqual(result, 1)
mock_k8s.deploy_io_throttle_pod.assert_called_once()
mock_k8s.delete_pod.assert_called_once()
# 6 exec calls: maj_min, cgroup_ver, cgroup_path, apply echo, apply cat, remove in finally
self.assertEqual(mock_k8s.exec_cmd_in_pod.call_count, 6)
class TestRollbackThrottle(unittest.TestCase):
def test_rollback_success_v2_stored_cgroup(self):
"""Rollback uses stored cgroup_path (single chroot exec for v2 reset)."""
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
mock_k8s.exec_cmd_in_pod.return_value = ""
rollback_data = {
"priv_pod_name": "io-throttle-12345",
"maj_min": "8:16",
"cgroup_path": "/kubepods.slice/crio-abc123.scope",
"cgroup_version": "v2",
}
encoded = base64.b64encode(
json.dumps(rollback_data).encode("utf-8")
).decode("utf-8")
content = RollbackContent(
namespace="default", resource_identifier=encoded
)
StorageThrottleScenarioPlugin.rollback_throttle(
content, mock_telemetry
)
mock_k8s.exec_cmd_in_pod.assert_called_once()
mock_k8s.delete_pod.assert_called_once_with(
"io-throttle-12345", "default"
)
def test_rollback_missing_cgroup_data_still_deletes_pod(self):
"""Rollback without cgroup_path logs a warning but still deletes the pod."""
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
mock_k8s = MagicMock()
mock_telemetry.get_lib_kubernetes.return_value = mock_k8s
rollback_data = {
"priv_pod_name": "io-throttle-12345",
"maj_min": "8:16",
}
encoded = base64.b64encode(
json.dumps(rollback_data).encode("utf-8")
).decode("utf-8")
content = RollbackContent(
namespace="default", resource_identifier=encoded
)
StorageThrottleScenarioPlugin.rollback_throttle(
content, mock_telemetry
)
mock_k8s.exec_cmd_in_pod.assert_not_called()
mock_k8s.delete_pod.assert_called_once_with(
"io-throttle-12345", "default"
)
@patch(
"krkn.scenario_plugins.storage_throttle."
"storage_throttle_scenario_plugin.logging"
)
def test_rollback_invalid_data(self, mock_logging):
mock_telemetry = MagicMock(spec=KrknTelemetryOpenshift)
content = RollbackContent(
namespace="default", resource_identifier="bad-data!!!"
)
StorageThrottleScenarioPlugin.rollback_throttle(
content, mock_telemetry
)
mock_logging.error.assert_called_once()
error_msg = mock_logging.error.call_args[0][0]
self.assertIn("Failed to rollback", error_msg)
if __name__ == "__main__":
unittest.main()