test: migrate test_namespace.sh to CI/tests_v2 namespace_deletion (#1406)

* test: migrate test_namespace.sh to CI/tests_v2 namespace_deletion

Migrate the legacy CI/tests/test_namespace.sh functional test to the pytest-based
v2 framework under CI/tests_v2/scenarios/namespace_deletion/.

Covers the service_disruption (namespace deletion) scenario with happy-path cases
(single-namespace object deletion, multi-namespace delete_count, multiple runs,
wait_time handling, label-selector targeting) and negative cases (no-match regex,
namespace/label mutual exclusion, delete_count exceeding available namespaces).

- Add scenario assets (resource.yaml with Deployment+Service, scenario_base.yaml)
- Register namespace_deletion marker in pytest.ini
- Add namespace_deletion execution-evidence marker in lib/utils.py

Closes #1405

* test: move namespace_deletion helpers into reusable lib modules

Address review feedback: extract the per-test helpers into shared lib
modules so other scenarios can reuse them.

- lib/namespace.py: add POD_SECURITY_PRIVILEGED_LABELS, create_labeled_namespace,
  delete_namespace_quietly, and a make_namespace factory fixture (auto-cleanup)
- lib/deploy.py: add deployment_exists, wait_for_no_deployment,
  wait_for_present_deployment_count, deploy_manifest_to_namespace
- conftest.py: re-export make_namespace fixture
- test_namespace_deletion.py: drop local helpers, use the lib functions/fixture

* test: honor --keep-ns-on-fail in make_namespace factory

The make_namespace finalizer always deleted ad-hoc namespaces, ignoring
--keep-ns-on-fail on failure unlike test_namespace. Extract the keep-on-fail
decision into a shared helper and use it in both fixtures so the documented
debugging workflow works for scenarios that create extra namespaces.

* test: surface FailToCreateError in deploy_manifest_to_namespace

Mirror deploy_workload's error handling so manifest-apply failures in
multi-namespace tests raise a formatted RuntimeError listing the underlying
API exceptions instead of an opaque FailToCreateError.

* test: clarify why label-selector test bypasses run_scenario

The inline comment claimed run_scenario was bypassed because **overrides
would collide with the positional namespace arg. The real reason is that
NAMESPACE_IS_REGEX=True wraps an empty namespace as '^$', whereas
label-selector mode needs a literal empty string.

* test: use actual scenario inputs in negative-test failure context

The no-match and mutual-exclusion tests reported context=namespace=self.ns
(the ephemeral namespace), not the inputs actually under test. Reference the
real namespace (and label_selector) so unexpected-success diagnostics are clear.

* test: use non-default wait_time so override patching is exercised

wait_time=30 matched the scenario_base.yaml default, so the test passed even
if override patching regressed. Use wait_time=5 (non-default) so the override
path is actually validated.

* test: guard cluster post-checks under KRKN_TEST_DRY_RUN

Three namespace_deletion tests asserted cluster side effects (workload
deletion) after the Kraken run. Under KRKN_TEST_DRY_RUN=1 Kraken is
skipped, so the seeded workload is never deleted and wait_for_no_deployment
/ wait_for_present_deployment_count would time out and fail. Guard those
post-checks, and in test_label_selector_targeting (which bypasses
run_scenario and calls run_kraken directly) honor dry-run explicitly by
skipping the invocation and post-check.

* test: tighten no-match assertion, clarify runs-loop test, dry-run-safe negatives

Addresses Deep Code Review feedback on the namespace_deletion suite:

- test_no_match_namespace_fails: drop the dead 'no namespaces matching' OR
  branch; the service_disruption plugin only ever logs 'not enough namespaces
  matching ...', so the assertion now checks that string directly.
- test_multiple_runs_repeat_deletion -> test_multiple_runs_repeat_disruption_loop:
  rename + docstring make explicit that it verifies the outer runs loop iterates
  twice, not that object deletion recurs (Krkn does not redeploy between runs, so
  run 2 re-selects an already-empty namespace). Object removal is asserted in
  test_single_namespace_object_deletion.
- Negative tests now return early under KRKN_TEST_DRY_RUN=1, since run_scenario
  returns a fake rc=0 and the failure path cannot be exercised; this makes the
  whole class consistent under make test-dry-run.

* test: use UUID-based namespace in no-match test to avoid accidental matches

* test: assert correct zero-match error in no-match namespace test

A regex matching zero namespaces makes krkn_lib's check_namespaces raise
'there exists no namespaces matching' before the plugin's delete loop, so
the 'not enough namespaces matching' branch is never reached for this case.
Assert the actual zero-match message instead.

* test: poll for async Service deletion in namespace_deletion test

Kubernetes deletions are asynchronous, so checking the Service immediately
after the scenario run could be flaky. Add wait_for_no_service (mirroring
wait_for_no_deployment) and poll until the Service is actually gone before
asserting.

* test: assert Service deletion in label-selector namespace_deletion test

test_label_selector_targeting deploys both a Deployment and a Service but
only asserted the Deployment was removed. Add wait_for_no_service so the
test fully validates that label-selector mode deletes all objects
(including Services), matching test_single_namespace_object_deletion.

Signed-off-by: augmentcode[bot] <185243770+augmentcode[bot]@users.noreply.github.com>

* test: snapshot namespaces in wait_for_present_deployment_count

list(namespaces) consumed the iterable before the poll loop re-iterated
over the original, so a one-shot iterable (e.g. generator) would be empty
on every poll. Snapshot to a list once and iterate over that snapshot.

Signed-off-by: augmentcode[bot] <185243770+augmentcode[bot]@users.noreply.github.com>

* ci: free up runner disk space before tests_v2 KinD run

The Tests v2 (pytest functional) job intermittently fails with
'System.IO.IOException: No space left on device' on ubuntu-latest runners,
which ship with only ~14GB free. Creating the KinD cluster plus pulling and
kind-loading nginx:alpine and krkn:tools exhausts the disk, failing even the
runner's own diagnostic logging.

Reclaim ~20-30GB by removing the bundled .NET/Android/GHC SDKs and pruning
docker images before the cluster is created.

Signed-off-by: augmentcode[bot] <185243770+augmentcode[bot]@users.noreply.github.com>

---------

Signed-off-by: augmentcode[bot] <185243770+augmentcode[bot]@users.noreply.github.com>
Co-authored-by: augmentcode[bot] <185243770+augmentcode[bot]@users.noreply.github.com>
Co-authored-by: Darshan Jain <darjain@redhat.com>
This commit is contained in:
augmentcode[bot]
2026-06-25 18:39:18 +05:30
committed by GitHub
co-authored by lnx01 Darshan Jain
parent 7c75414dfa
commit cef0d4025d
9 changed files with 470 additions and 20 deletions
+6
View File
@@ -12,6 +12,12 @@ jobs:
- name: Check out code
uses: actions/checkout@v3
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android "$AGENT_TOOLSDIRECTORY"
sudo docker image prune -af
df -h
- name: Create KinD cluster
uses: redhat-chaos/actions/kind@main
+1 -1
View File
@@ -174,5 +174,5 @@ from lib.k8s import ( # noqa: E402, F401
k8s_networking,
kubectl,
)
from lib.namespace import _cleanup_stale_namespaces, test_namespace # noqa: E402, F401
from lib.namespace import _cleanup_stale_namespaces, make_namespace, test_namespace # noqa: E402, F401
from lib.preflight import _preflight_checks # noqa: E402, F401
+99
View File
@@ -9,6 +9,7 @@ from pathlib import Path
import pytest
import yaml
from kubernetes import utils as k8s_utils
from kubernetes.client.rest import ApiException
from lib.base import READINESS_TIMEOUT
from lib.utils import patch_namespace_in_docs
@@ -49,6 +50,104 @@ def wait_for_deployment_replicas(k8s_apps, namespace: str, name: str, timeout: i
)
def deployment_exists(k8s_apps, namespace: str, name: str) -> bool:
"""Return True if the named deployment exists in the namespace, False on 404."""
try:
k8s_apps.read_namespaced_deployment(name=name, namespace=namespace)
return True
except ApiException as e:
if e.status == 404:
return False
raise
def wait_for_no_deployment(k8s_apps, namespace: str, name: str, timeout: int = 45) -> None:
"""Poll until the named deployment is gone from the namespace; raise if it persists."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not deployment_exists(k8s_apps, namespace, name):
return
time.sleep(1)
raise AssertionError(
f"Deployment {name} still present in namespace={namespace} after {timeout}s"
)
def service_exists(k8s_core, namespace: str, name: str) -> bool:
"""Return True if the named service exists in the namespace, False on 404."""
try:
k8s_core.read_namespaced_service(name=name, namespace=namespace)
return True
except ApiException as e:
if e.status == 404:
return False
raise
def wait_for_no_service(k8s_core, namespace: str, name: str, timeout: int = 45) -> None:
"""Poll until the named service is gone from the namespace; raise if it persists."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not service_exists(k8s_core, namespace, name):
return
time.sleep(1)
raise AssertionError(
f"Service {name} still present in namespace={namespace} after {timeout}s"
)
def wait_for_present_deployment_count(
k8s_apps, namespaces, name: str, expected: int, timeout: int = 45
) -> list:
"""
Poll until exactly `expected` of the given namespaces still contain the named deployment.
Returns the namespaces where it is still present. Useful for delete_count assertions.
"""
deadline = time.monotonic() + timeout
# Snapshot once so a one-shot iterable (e.g. generator) is not exhausted by the
# first poll, which would make every subsequent iteration see an empty list.
ns_list = list(namespaces)
present = list(ns_list)
while time.monotonic() < deadline:
present = [n for n in ns_list if deployment_exists(k8s_apps, n, name)]
if len(present) == expected:
return present
time.sleep(1)
raise AssertionError(
f"Expected {expected} namespace(s) with {name} present, "
f"found {len(present)}: {present}"
)
def deploy_manifest_to_namespace(
k8s_client,
k8s_apps,
manifest,
namespace: str,
deployment_name: str,
*,
repo_root=None,
timeout: int = READINESS_TIMEOUT,
) -> None:
"""
Apply a manifest file into `namespace` (overriding each doc's namespace) and wait for the
named deployment to become ready. `manifest` may be absolute or relative to repo_root.
Reusable by any scenario that needs to seed a namespace with a target workload.
"""
path = Path(manifest)
if not path.is_absolute() and repo_root is not None:
path = Path(repo_root) / path
docs = patch_namespace_in_docs(list(yaml.safe_load_all(path.read_text())), namespace)
try:
k8s_utils.create_from_yaml(k8s_client, yaml_objects=docs, namespace=namespace)
except k8s_utils.FailToCreateError as e:
msgs = [str(exc) for exc in e.api_exceptions]
raise RuntimeError(
f"Failed to create resources in namespace={namespace}: {'; '.join(msgs)}"
) from e
wait_for_deployment_replicas(k8s_apps, namespace, deployment_name, timeout=timeout)
@pytest.fixture
def wait_for_pods_running(k8s_core):
"""
+74 -19
View File
@@ -16,6 +16,17 @@ logger = logging.getLogger(__name__)
STALE_NS_AGE_MINUTES = 30
# Privileged pod-security labels applied to ephemeral test namespaces so the same
# workloads are admitted on both Kubernetes and OpenShift. Shared by the test_namespace
# fixture and the make_namespace factory.
POD_SECURITY_PRIVILEGED_LABELS = {
"pod-security.kubernetes.io/audit": "privileged",
"pod-security.kubernetes.io/enforce": "privileged",
"pod-security.kubernetes.io/enforce-version": "v1.24",
"pod-security.kubernetes.io/warn": "privileged",
"security.openshift.io/scc.podSecurityLabelSync": "false",
}
def _namespace_age_minutes(metadata) -> float:
"""Return age of namespace in minutes from its creation_timestamp."""
@@ -47,6 +58,40 @@ def _wait_for_namespace_gone(k8s_core, name: str, timeout: int = 60):
raise TimeoutError(f"Namespace {name} did not disappear within {timeout}s")
def create_labeled_namespace(k8s_core, name: str, extra_labels: dict = None) -> str:
"""Create a namespace with privileged pod-security labels plus any extra_labels.
Reusable across scenarios that need ad-hoc namespaces (e.g. multi-namespace
selection or label-selector targeting). Returns the namespace name.
"""
labels = dict(POD_SECURITY_PRIVILEGED_LABELS)
if extra_labels:
labels.update(extra_labels)
body = client.V1Namespace(metadata=client.V1ObjectMeta(name=name, labels=labels))
k8s_core.create_namespace(body=body)
logger.info("Created test namespace: %s", name)
return name
def delete_namespace_quietly(k8s_core, name: str) -> None:
"""Background-delete a namespace, logging (never raising) on failure. Safe in finalizers."""
try:
k8s_core.delete_namespace(
name=name,
body=client.V1DeleteOptions(propagation_policy="Background"),
)
except Exception as e: # noqa: BLE001 - cleanup must never raise
logger.warning("Failed to delete namespace %s: %s", name, e)
def _keep_namespace_on_fail(request) -> bool:
"""True when --keep-ns-on-fail is set and the test's call phase failed."""
keep_on_fail = request.config.getoption("--keep-ns-on-fail", False)
rep_call = getattr(request.node, "rep_call", None)
failed = rep_call is not None and rep_call.failed
return bool(keep_on_fail and failed)
@pytest.fixture(scope="function")
def test_namespace(request, k8s_core):
"""
@@ -57,13 +102,7 @@ def test_namespace(request, k8s_core):
ns = client.V1Namespace(
metadata=client.V1ObjectMeta(
name=name,
labels={
"pod-security.kubernetes.io/audit": "privileged",
"pod-security.kubernetes.io/enforce": "privileged",
"pod-security.kubernetes.io/enforce-version": "v1.24",
"pod-security.kubernetes.io/warn": "privileged",
"security.openshift.io/scc.podSecurityLabelSync": "false",
},
labels=dict(POD_SECURITY_PRIVILEGED_LABELS),
)
)
k8s_core.create_namespace(body=ns)
@@ -71,21 +110,37 @@ def test_namespace(request, k8s_core):
yield name
keep_on_fail = request.config.getoption("--keep-ns-on-fail", False)
rep_call = getattr(request.node, "rep_call", None)
failed = rep_call is not None and rep_call.failed
if keep_on_fail and failed:
if _keep_namespace_on_fail(request):
logger.info("[keep-ns-on-fail] Keeping namespace %s for debugging", name)
return
try:
k8s_core.delete_namespace(
name=name,
body=client.V1DeleteOptions(propagation_policy="Background"),
)
logger.debug("Scheduled background deletion for namespace: %s", name)
except Exception as e:
logger.warning("Failed to delete namespace %s: %s", name, e)
delete_namespace_quietly(k8s_core, name)
@pytest.fixture(scope="function")
def make_namespace(request, k8s_core):
"""
Factory fixture to create ad-hoc privileged test namespaces during a test.
Returns a callable make(name, extra_labels=None) -> name. Each created namespace
is registered for teardown deletion, honouring --keep-ns-on-fail the same way the
test_namespace fixture does. Useful for scenarios that need several namespaces
(multi-namespace selection) or a uniquely labelled namespace (label-selector targeting).
"""
def _make(name: str, extra_labels: dict = None) -> str:
create_labeled_namespace(k8s_core, name, extra_labels=extra_labels)
def _finalize(ns_name=name):
if _keep_namespace_on_fail(request):
logger.info("[keep-ns-on-fail] Keeping namespace %s for debugging", ns_name)
return
delete_namespace_quietly(k8s_core, ns_name)
request.addfinalizer(_finalize)
return name
return _make
@pytest.fixture(scope="session", autouse=True)
+1
View File
@@ -24,6 +24,7 @@ SCENARIO_EXECUTION_MARKERS = {
"pod_disruption": r"Deleting pod |waiting up to .* seconds for pod recovery",
"application_outage": r"Creating the network policy|Deleting the network policy",
"storage_throttle": r"Setting io\.max|Verified blkio settings|Privileged pod deployed",
"namespace_deletion": r"Delete objects in selected namespace|Deleted all objects in namespace",
}
# nodeid -> {"scenario", "pattern", "verified"}; consumed by conftest to build the
+1
View File
@@ -13,6 +13,7 @@ markers =
cpu_hog: marks a test as a CPU hog scenario test
memory_hog: marks a test as a memory hog scenario test
node_scenarios: marks a test as a node chaos scenario test (node reboot/stop-start)
namespace_deletion: marks a test as a namespace deletion (service_disruption) scenario test
no_workload: skip workload deployment for this test (e.g. negative tests)
order: set test order (pytest-order)
junit_family = xunit2
@@ -0,0 +1,33 @@
# Target workload for namespace_deletion (service_disruption) scenario tests.
# A Deployment plus a Service so the scenario has multiple object kinds to delete.
# Namespace is patched at deploy time by the test framework.
apiVersion: apps/v1
kind: Deployment
metadata:
name: namespace-deletion-target
spec:
replicas: 1
selector:
matchLabels:
app: namespace-deletion-target
template:
metadata:
labels:
app: namespace-deletion-target
spec:
containers:
- name: app
image: nginx:alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: namespace-deletion-target
spec:
selector:
app: namespace-deletion-target
ports:
- port: 80
targetPort: 80
@@ -0,0 +1,12 @@
# Base service_disruption (namespace deletion) scenario.
# Tests load this and patch scenarios[0].namespace with ^<ns>$ (NAMESPACE_IS_REGEX),
# and override delete_count / runs / sleep / wait_time / label_selector as needed.
# The service_disruption plugin deletes all objects (deployments, daemonsets,
# statefulsets, replicasets, services) inside each matched namespace.
scenarios:
- namespace: "^krkn-test-namespace-deletion-placeholder$"
label_selector: ""
delete_count: 1
runs: 1
sleep: 1
wait_time: 30
@@ -0,0 +1,243 @@
"""
Functional test for the namespace_deletion (service_disruption) scenario.
Migrated from CI/tests/test_namespace.sh.
The service_disruption plugin selects namespaces by regex (scenarios[].namespace)
or by namespace label_selector, then deletes all objects (deployments, daemonsets,
statefulsets, replicasets, services) inside each selected namespace. delete_count
controls how many matched namespaces are disrupted per run; runs repeats the loop.
Safety: every namespace targeted here is the per-test ephemeral namespace or one the
test creates with a unique krkn-test-<uuid> prefix, so the regex/label can never match
a namespace the test did not create.
"""
import logging
import os
import uuid
import pytest
from lib.base import BaseScenarioTest
from lib.deploy import (
deploy_manifest_to_namespace,
deployment_exists,
wait_for_no_deployment,
wait_for_no_service,
wait_for_present_deployment_count,
)
from lib.utils import (
assert_kraken_failure,
assert_kraken_success,
assert_scenario_executed,
)
logger = logging.getLogger(__name__)
_TARGET_NAME = "namespace-deletion-target"
@pytest.mark.functional
@pytest.mark.namespace_deletion
class TestNamespaceDeletion(BaseScenarioTest):
"""namespace_deletion scenario: delete all objects in selected namespaces."""
WORKLOAD_MANIFEST = "CI/tests_v2/scenarios/namespace_deletion/resource.yaml"
WORKLOAD_IS_PATH = True
LABEL_SELECTOR = "app=namespace-deletion-target"
SCENARIO_NAME = "namespace_deletion"
SCENARIO_TYPE = "service_disruption_scenarios"
NAMESPACE_KEY_PATH = ["scenarios", 0, "namespace"]
NAMESPACE_IS_REGEX = True
OVERRIDES_KEY_PATH = ["scenarios", 0]
# --- happy-path tests ----------------------------------------------------
@pytest.mark.order(1)
def test_single_namespace_object_deletion(self):
"""Regex matches exactly one namespace; all its objects are deleted, Krkn exits 0."""
ns = self.ns
assert deployment_exists(self.k8s_apps, ns, _TARGET_NAME), f"Workload not deployed in {ns}"
result = self.run_scenario(
self.tmp_path, ns,
overrides={"delete_count": 1, "runs": 1, "sleep": 1},
config_filename="ns_del_single.yaml",
)
assert_kraken_success(result, context=f"namespace={ns}", tmp_path=self.tmp_path)
assert_scenario_executed(
result, self.SCENARIO_NAME, context=f"namespace={ns}", tmp_path=self.tmp_path
)
if os.environ.get("KRKN_TEST_DRY_RUN", "0") != "1":
wait_for_no_deployment(self.k8s_apps, ns, _TARGET_NAME)
wait_for_no_service(self.k8s_core, ns, _TARGET_NAME)
@pytest.mark.no_workload
def test_multiple_namespace_delete_count(self, make_namespace):
"""Regex matches 3 namespaces with delete_count=2; exactly 2 are disrupted, 1 untouched."""
prefix = f"krkn-test-{uuid.uuid4().hex[:8]}-multi"
namespaces = []
for i in range(3):
name = make_namespace(f"{prefix}-{i}")
deploy_manifest_to_namespace(
self.k8s_client, self.k8s_apps, self.WORKLOAD_MANIFEST, name,
_TARGET_NAME, repo_root=self.repo_root,
)
namespaces.append(name)
result = self.run_scenario(
self.tmp_path, f"{prefix}-.*",
overrides={"delete_count": 2, "runs": 1, "sleep": 1},
config_filename="ns_del_multi.yaml",
)
assert_kraken_success(result, context=f"prefix={prefix}", tmp_path=self.tmp_path)
assert_scenario_executed(
result, self.SCENARIO_NAME, context=f"prefix={prefix}", tmp_path=self.tmp_path
)
# Exactly one of the three namespaces should still hold its deployment.
if os.environ.get("KRKN_TEST_DRY_RUN", "0") != "1":
wait_for_present_deployment_count(self.k8s_apps, namespaces, _TARGET_NAME, expected=1)
def test_multiple_runs_repeat_disruption_loop(self):
"""runs=2 executes the disruption loop twice.
The plugin logs "Delete objects in selected namespace" once per run, so the
count proves the outer runs loop iterated twice. Actual object removal is
asserted in test_single_namespace_object_deletion; here the workload is deleted
in run 1 and never recreated (Krkn does not redeploy between runs), so run 2
re-selects the now-empty namespace and deletes nothing. This test therefore
verifies that the loop repeats, not that deletion recurs against live objects.
"""
ns = self.ns
result = self.run_scenario(
self.tmp_path, ns,
overrides={"runs": 2, "delete_count": 1, "sleep": 1},
config_filename="ns_del_runs.yaml",
)
assert_kraken_success(result, context=f"namespace={ns}", tmp_path=self.tmp_path)
assert_scenario_executed(
result, self.SCENARIO_NAME, context=f"namespace={ns}", tmp_path=self.tmp_path
)
if os.environ.get("KRKN_TEST_DRY_RUN", "0") != "1":
combined = f"{result.stdout or ''}\n{result.stderr or ''}"
count = combined.count("Delete objects in selected namespace")
assert count >= 2, (
f"Expected the disruption loop to iterate >=2 times for runs=2, "
f"saw {count} (namespace={ns})"
)
def test_wait_time_accepted(self):
"""A configured wait_time is accepted and the scenario completes successfully."""
ns = self.ns
# Use a non-default wait_time (base scenario_base.yaml defaults to 30) so the test
# actually exercises override patching rather than passing on the base value.
result = self.run_scenario(
self.tmp_path, ns,
overrides={"wait_time": 5, "delete_count": 1, "runs": 1, "sleep": 1},
config_filename="ns_del_wait.yaml",
)
assert_kraken_success(result, context=f"namespace={ns}", tmp_path=self.tmp_path)
assert_scenario_executed(
result, self.SCENARIO_NAME, context=f"namespace={ns}", tmp_path=self.tmp_path
)
@pytest.mark.no_workload
def test_label_selector_targeting(self, make_namespace):
"""With namespace empty and a label_selector set, targeting works by namespace label."""
label_key = "krkn-test-ns"
label_value = uuid.uuid4().hex[:8]
name = make_namespace(
f"krkn-test-{label_value}-label", extra_labels={label_key: label_value}
)
deploy_manifest_to_namespace(
self.k8s_client, self.k8s_apps, self.WORKLOAD_MANIFEST, name,
_TARGET_NAME, repo_root=self.repo_root,
)
# Build the scenario directly: label-selector mode requires the namespace field to be a
# literal empty string (""), but NAMESPACE_IS_REGEX=True makes load_and_patch_scenario wrap
# any namespace as ^...$, so an empty namespace through run_scenario would become "^$".
# We patch with a real name (harmless) and then blank the namespace to "" explicitly.
scenario = self.load_and_patch_scenario(
self.repo_root, name,
label_selector=f"{label_key}={label_value}", delete_count=1, runs=1, sleep=1,
)
scenario["scenarios"][0]["namespace"] = ""
scenario_path = self.write_scenario(self.tmp_path, scenario, suffix="_label")
config_path = self.build_config(
self.SCENARIO_TYPE, str(scenario_path), filename="ns_del_label.yaml"
)
# This test bypasses run_scenario (to blank the namespace field), so the dry-run
# short-circuit in BaseScenarioTest.run_scenario does not apply here. Honor it
# explicitly: skip the real Kraken invocation and the cluster post-check.
if os.environ.get("KRKN_TEST_DRY_RUN", "0") == "1":
logger.info(
"[dry-run] Would run Kraken with config=%s (label-selector mode)", config_path
)
return
result = self.run_kraken(config_path)
assert_kraken_success(result, context=f"namespace={name}", tmp_path=self.tmp_path)
assert_scenario_executed(
result, self.SCENARIO_NAME, context=f"namespace={name}", tmp_path=self.tmp_path
)
wait_for_no_deployment(self.k8s_apps, name, _TARGET_NAME)
wait_for_no_service(self.k8s_core, name, _TARGET_NAME)
# --- negative / failure-mode tests ---------------------------------------
@pytest.mark.no_workload
def test_no_match_namespace_fails(self):
"""A regex matching zero namespaces makes Krkn exit non-zero with a clear error."""
# UUID-based name so the regex is guaranteed not to match any pre-existing
# namespace (e.g. leftover from a previous run); this namespace is never created.
ns = f"krkn-test-nonexistent-{uuid.uuid4().hex}"
result = self.run_scenario(
self.tmp_path, ns,
overrides={"delete_count": 1, "runs": 1},
config_filename="ns_del_nomatch.yaml",
)
if os.environ.get("KRKN_TEST_DRY_RUN", "0") == "1":
return # Krkn is skipped under dry-run; the failure path can't be exercised.
assert_kraken_failure(result, context=f"namespace={ns}", tmp_path=self.tmp_path)
combined = f"{result.stdout or ''}\n{result.stderr or ''}".lower()
# A regex matching zero namespaces makes krkn_lib's check_namespaces raise before the
# plugin's delete loop, surfacing "there exists no namespaces matching: {...}". The plugin's
# own "not enough namespaces matching" message (service_disruption_scenario_plugin.py:82-90)
# only fires when >=1 namespace matches but fewer than delete_count -- that path is covered by
# test_delete_count_exceeds_available_fails.
assert "no namespaces matching" in combined, (
"Expected a 'no namespaces matching' error in Krkn output"
)
@pytest.mark.no_workload
def test_namespace_and_label_mutual_exclusion_fails(self):
"""Setting both namespace and label_selector makes Krkn exit 1 with a mutual-exclusion error."""
ns = "krkn-test-mutual-excl"
label_selector = "app=foo"
result = self.run_scenario(
self.tmp_path, ns,
overrides={"label_selector": label_selector, "delete_count": 1, "runs": 1},
config_filename="ns_del_mutual.yaml",
)
if os.environ.get("KRKN_TEST_DRY_RUN", "0") == "1":
return # Krkn is skipped under dry-run; the failure path can't be exercised.
assert_kraken_failure(
result, context=f"namespace={ns}, label_selector={label_selector}", tmp_path=self.tmp_path
)
combined = f"{result.stdout or ''}\n{result.stderr or ''}".lower()
assert "you can only have namespace or label set" in combined, (
"Expected the mutual-exclusion error in Krkn output"
)
def test_delete_count_exceeds_available_fails(self):
"""delete_count greater than the number of matched namespaces fails with 'not enough namespaces'."""
ns = self.ns
result = self.run_scenario(
self.tmp_path, ns,
overrides={"delete_count": 5, "runs": 1, "sleep": 1},
config_filename="ns_del_exceeds.yaml",
)
if os.environ.get("KRKN_TEST_DRY_RUN", "0") == "1":
return # Krkn is skipped under dry-run; the failure path can't be exercised.
assert_kraken_failure(result, context=f"namespace={ns}", tmp_path=self.tmp_path)
combined = f"{result.stdout or ''}\n{result.stderr or ''}".lower()
assert "not enough namespaces" in combined, (
f"Expected 'not enough namespaces' error in Krkn output (namespace={ns})"
)