mirror of
https://github.com/krkn-chaos/krkn.git
synced 2026-09-01 09:37:16 +00:00
* 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>
245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
"""
|
|
Workload deploy and pod/deployment readiness fixtures for CI/tests_v2.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def wait_for_deployment_replicas(k8s_apps, namespace: str, name: str, timeout: int = 120) -> None:
|
|
"""
|
|
Poll until the deployment has ready_replicas >= spec.replicas.
|
|
Raises TimeoutError with diagnostic details on failure.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
last_dep = None
|
|
attempts = 0
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
dep = k8s_apps.read_namespaced_deployment(name=name, namespace=namespace)
|
|
except Exception as e:
|
|
logger.debug("Deployment %s/%s poll attempt %s failed: %s", namespace, name, attempts, e)
|
|
time.sleep(2)
|
|
attempts += 1
|
|
continue
|
|
last_dep = dep
|
|
ready = dep.status.ready_replicas or 0
|
|
desired = dep.spec.replicas or 1
|
|
if ready >= desired:
|
|
logger.debug("Deployment %s/%s ready (%s/%s)", namespace, name, ready, desired)
|
|
return
|
|
logger.debug("Deployment %s/%s not ready yet: %s/%s", namespace, name, ready, desired)
|
|
time.sleep(2)
|
|
attempts += 1
|
|
diag = ""
|
|
if last_dep is not None and last_dep.status:
|
|
diag = f" ready_replicas={last_dep.status.ready_replicas}, desired={last_dep.spec.replicas}"
|
|
raise TimeoutError(
|
|
f"Deployment {namespace}/{name} did not become ready within {timeout}s.{diag}"
|
|
)
|
|
|
|
|
|
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):
|
|
"""
|
|
Poll until all matching pods are Running and all containers ready.
|
|
Uses exponential backoff: 1s, 2s, 4s, ... capped at 10s.
|
|
Raises TimeoutError with diagnostic details on failure.
|
|
"""
|
|
|
|
def _wait(namespace: str, label_selector: str, timeout: int = READINESS_TIMEOUT):
|
|
deadline = time.monotonic() + timeout
|
|
interval = 1.0
|
|
max_interval = 10.0
|
|
last_list = None
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
pod_list = k8s_core.list_namespaced_pod(
|
|
namespace=namespace,
|
|
label_selector=label_selector,
|
|
)
|
|
except Exception:
|
|
time.sleep(min(interval, max_interval))
|
|
interval = min(interval * 2, max_interval)
|
|
continue
|
|
last_list = pod_list
|
|
items = pod_list.items or []
|
|
if not items:
|
|
time.sleep(min(interval, max_interval))
|
|
interval = min(interval * 2, max_interval)
|
|
continue
|
|
all_running = all(
|
|
(p.status and p.status.phase == "Running") for p in items
|
|
)
|
|
if not all_running:
|
|
time.sleep(min(interval, max_interval))
|
|
interval = min(interval * 2, max_interval)
|
|
continue
|
|
all_ready = True
|
|
for p in items:
|
|
if not p.status or not p.status.container_statuses:
|
|
all_ready = False
|
|
break
|
|
for cs in p.status.container_statuses:
|
|
if not getattr(cs, "ready", False):
|
|
all_ready = False
|
|
break
|
|
if all_ready:
|
|
return
|
|
time.sleep(min(interval, max_interval))
|
|
interval = min(interval * 2, max_interval)
|
|
|
|
diag = ""
|
|
if last_list and last_list.items:
|
|
p = last_list.items[0]
|
|
diag = f" e.g. pod {p.metadata.name}: phase={getattr(p.status, 'phase', None)}"
|
|
raise TimeoutError(
|
|
f"Pods in {namespace} with label {label_selector} did not become ready within {timeout}s.{diag}"
|
|
)
|
|
|
|
return _wait
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def deploy_workload(test_namespace, k8s_client, wait_for_pods_running, repo_root, tmp_path):
|
|
"""
|
|
Helper that applies a manifest into the test namespace and waits for pods.
|
|
Yields a callable: deploy(manifest_path_or_content, label_selector, *, is_path=True)
|
|
which applies the manifest, waits for readiness, and returns the namespace name.
|
|
"""
|
|
|
|
def _deploy(manifest_path_or_content, label_selector, *, is_path=True, timeout=READINESS_TIMEOUT):
|
|
try:
|
|
if is_path:
|
|
path = Path(manifest_path_or_content)
|
|
if not path.is_absolute():
|
|
path = repo_root / path
|
|
with open(path) as f:
|
|
docs = list(yaml.safe_load_all(f))
|
|
else:
|
|
docs = list(yaml.safe_load_all(manifest_path_or_content))
|
|
docs = patch_namespace_in_docs(docs, test_namespace)
|
|
k8s_utils.create_from_yaml(
|
|
k8s_client,
|
|
yaml_objects=docs,
|
|
namespace=test_namespace,
|
|
)
|
|
except k8s_utils.FailToCreateError as e:
|
|
msgs = [str(exc) for exc in e.api_exceptions]
|
|
raise RuntimeError(f"Failed to create resources: {'; '.join(msgs)}") from e
|
|
logger.info("Workload applied in namespace=%s, waiting for pods with selector=%s", test_namespace, label_selector)
|
|
wait_for_pods_running(test_namespace, label_selector, timeout=timeout)
|
|
logger.info("Pods ready in namespace=%s", test_namespace)
|
|
return test_namespace
|
|
|
|
return _deploy
|