mirror of
https://github.com/krkn-chaos/krkn.git
synced 2026-09-08 09:47:17 +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>
484 lines
20 KiB
Python
484 lines
20 KiB
Python
"""
|
|
Shared helpers for CI/tests_v2 functional tests.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import List, Optional, Union
|
|
|
|
import pytest
|
|
import yaml
|
|
from kubernetes.client import V1NetworkPolicy, V1NetworkPolicyList, V1Pod, V1PodList
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Per-scenario regex markers that prove the scenario actually executed its core logic.
|
|
# A scenario exiting rc=0 without one of these lines in its stdout/stderr is a silent
|
|
# no-op (e.g. a selector matched nothing) and the happy-path test should fail.
|
|
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
|
|
# HTML report evidence line and the GitHub Actions execution-evidence summary table.
|
|
# Last-write-wins per nodeid so multi-run tests and reruns report their final state.
|
|
EXECUTION_EVIDENCE = {}
|
|
|
|
|
|
def _record_execution_evidence(scenario_name: str, pattern: str, verified: bool) -> None:
|
|
"""Record evidence result keyed by the current test nodeid (from PYTEST_CURRENT_TEST)."""
|
|
nodeid = os.environ.get("PYTEST_CURRENT_TEST", "").split(" (")[0]
|
|
if nodeid:
|
|
EXECUTION_EVIDENCE[nodeid] = {
|
|
"scenario": scenario_name,
|
|
"pattern": pattern,
|
|
"verified": verified,
|
|
}
|
|
|
|
|
|
def _pods(pod_list: Union[V1PodList, List[V1Pod]]) -> List[V1Pod]:
|
|
"""Normalize V1PodList or list of V1Pod to list of V1Pod."""
|
|
return pod_list.items if hasattr(pod_list, "items") else pod_list
|
|
|
|
|
|
def _policies(
|
|
policy_list: Union[V1NetworkPolicyList, List[V1NetworkPolicy]],
|
|
) -> List[V1NetworkPolicy]:
|
|
"""Normalize V1NetworkPolicyList or list to list of V1NetworkPolicy."""
|
|
return policy_list.items if hasattr(policy_list, "items") else policy_list
|
|
|
|
|
|
def scenario_dir(repo_root: Path, scenario_name: str) -> Path:
|
|
"""Return the path to a scenario folder under CI/tests_v2/scenarios/."""
|
|
return repo_root / "CI" / "tests_v2" / "scenarios" / scenario_name
|
|
|
|
|
|
def load_scenario_base(
|
|
repo_root: Path,
|
|
scenario_name: str,
|
|
filename: str = "scenario_base.yaml",
|
|
) -> Union[dict, list]:
|
|
"""
|
|
Load and parse the scenario base YAML for a scenario.
|
|
Returns dict or list depending on the YAML structure.
|
|
"""
|
|
path = scenario_dir(repo_root, scenario_name) / filename
|
|
text = path.read_text()
|
|
data = yaml.safe_load(text)
|
|
if data is None:
|
|
raise ValueError(f"Empty or invalid YAML in {path}")
|
|
return data
|
|
|
|
|
|
def patch_namespace_in_docs(docs: list, namespace: str) -> list:
|
|
"""Override metadata.namespace in each doc so create_from_yaml respects target namespace."""
|
|
for doc in docs:
|
|
if isinstance(doc, dict) and doc.get("metadata") is not None:
|
|
doc["metadata"]["namespace"] = namespace
|
|
return docs
|
|
|
|
|
|
def get_pods_list(k8s_core, namespace: str, label_selector: str) -> V1PodList:
|
|
"""Return V1PodList from the Kubernetes API."""
|
|
return k8s_core.list_namespaced_pod(
|
|
namespace=namespace,
|
|
label_selector=label_selector,
|
|
)
|
|
|
|
|
|
def get_pods_or_skip(
|
|
k8s_core,
|
|
namespace: str,
|
|
label_selector: str,
|
|
no_pods_reason: Optional[str] = None,
|
|
) -> V1PodList:
|
|
"""
|
|
Get pods via Kubernetes API or skip if cluster unreachable or no matching pods.
|
|
Use at test start when prerequisites may be missing.
|
|
no_pods_reason: message when no pods match; if None, a default message is used.
|
|
"""
|
|
try:
|
|
pod_list = k8s_core.list_namespaced_pod(
|
|
namespace=namespace,
|
|
label_selector=label_selector,
|
|
)
|
|
except Exception as e:
|
|
pytest.skip(f"Cluster unreachable: {e}")
|
|
if not pod_list.items or len(pod_list.items) == 0:
|
|
reason = (
|
|
no_pods_reason
|
|
if no_pods_reason
|
|
else f"No pods in {namespace} with label {label_selector}. "
|
|
"Start a KinD cluster with default storage (local-path-provisioner)."
|
|
)
|
|
pytest.skip(reason)
|
|
return pod_list
|
|
|
|
|
|
def pod_uids(pod_list: Union[V1PodList, List[V1Pod]]) -> list:
|
|
"""Return list of pod UIDs from V1PodList or list of V1Pod."""
|
|
return [p.metadata.uid for p in _pods(pod_list)]
|
|
|
|
|
|
def restart_counts(pod_list: Union[V1PodList, List[V1Pod]]) -> int:
|
|
"""Return total restart count across all containers in V1PodList or list of V1Pod."""
|
|
total = 0
|
|
for p in _pods(pod_list):
|
|
if not p.status or not p.status.container_statuses:
|
|
continue
|
|
for cs in p.status.container_statuses:
|
|
total += getattr(cs, "restart_count", 0)
|
|
return total
|
|
|
|
|
|
def list_pods_by_prefix(k8s_core, namespace: str, name_prefix: str) -> List[V1Pod]:
|
|
"""Return pods in the namespace whose name starts with name_prefix."""
|
|
pods = k8s_core.list_namespaced_pod(namespace=namespace)
|
|
return [
|
|
p for p in _pods(pods)
|
|
if p.metadata and p.metadata.name and p.metadata.name.startswith(name_prefix)
|
|
]
|
|
|
|
|
|
def wait_for_scheduled_pod_by_prefix(
|
|
k8s_core, namespace: str, name_prefix: str, timeout: float
|
|
) -> Optional[V1Pod]:
|
|
"""
|
|
Poll until a pod with name_prefix exists and is scheduled (spec.node_name set).
|
|
Return it, or the last seen matching pod (may be None) if none get scheduled in time.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
last = None
|
|
while time.monotonic() < deadline:
|
|
for p in list_pods_by_prefix(k8s_core, namespace, name_prefix):
|
|
last = p
|
|
if p.spec and p.spec.node_name:
|
|
return p
|
|
time.sleep(0.5)
|
|
return last
|
|
|
|
|
|
def wait_for_no_pods_by_prefix(
|
|
k8s_core, namespace: str, name_prefix: str, timeout: float
|
|
) -> None:
|
|
"""Assert all pods with name_prefix are removed from the namespace within timeout."""
|
|
deadline = time.monotonic() + timeout
|
|
last = []
|
|
while time.monotonic() < deadline:
|
|
last = list_pods_by_prefix(k8s_core, namespace, name_prefix)
|
|
if not last:
|
|
return
|
|
time.sleep(1)
|
|
raise AssertionError(
|
|
f"Pods with prefix {name_prefix!r} still present in namespace={namespace} "
|
|
f"after {timeout}s: {[p.metadata.name for p in last]}"
|
|
)
|
|
|
|
|
|
def schedulable_worker_nodes(k8s_core) -> List[str]:
|
|
"""Return names of Ready nodes that are not control-plane/master and carry no NoSchedule/NoExecute taint.
|
|
|
|
The list is sorted by node name so selection is deterministic: the Kubernetes API does not
|
|
guarantee node ordering, and callers rely on a stable order to keep the hog tests (first
|
|
worker) and the destructive node tests (last worker) on separate nodes.
|
|
"""
|
|
names = []
|
|
for node in k8s_core.list_node().items:
|
|
labels = (node.metadata.labels or {}) if node.metadata else {}
|
|
if (
|
|
"node-role.kubernetes.io/control-plane" in labels
|
|
or "node-role.kubernetes.io/master" in labels
|
|
):
|
|
continue
|
|
taints = (node.spec.taints or []) if node.spec else []
|
|
if any(getattr(t, "effect", None) in ("NoSchedule", "NoExecute") for t in taints):
|
|
continue
|
|
ready = any(
|
|
c.type == "Ready" and c.status == "True"
|
|
for c in ((node.status.conditions or []) if node.status else [])
|
|
)
|
|
if ready:
|
|
names.append(node.metadata.name)
|
|
return sorted(names)
|
|
|
|
|
|
def wait_node_ready(k8s_core, node: str, timeout: float) -> bool:
|
|
"""Poll until the node reports Ready=True. Return True if it does within timeout."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
n = k8s_core.read_node(node)
|
|
except Exception: # noqa: BLE001 - node may be mid-restart / API briefly unreachable
|
|
time.sleep(2)
|
|
continue
|
|
conditions = (n.status.conditions or []) if n.status else []
|
|
if any(c.type == "Ready" and c.status == "True" for c in conditions):
|
|
return True
|
|
time.sleep(2)
|
|
return False
|
|
|
|
|
|
def container_runtime() -> Optional[str]:
|
|
"""Return the container runtime backing the KinD cluster, or None if none is on PATH.
|
|
|
|
A KinD node is a container. KinD's provider is selected by ``KIND_EXPERIMENTAL_PROVIDER``;
|
|
when it is set to ``podman`` we must inspect/start with ``podman`` (and vice versa), otherwise
|
|
we'd point the wrong CLI at the node container. Honor that env var first, then fall back to
|
|
whichever runtime is available, preferring ``docker`` (KinD's default provider).
|
|
"""
|
|
preferred = (os.environ.get("KIND_EXPERIMENTAL_PROVIDER") or "").strip().lower()
|
|
order = ("podman", "docker") if preferred == "podman" else ("docker", "podman")
|
|
for runtime in order:
|
|
if shutil.which(runtime):
|
|
return runtime
|
|
return None
|
|
|
|
|
|
def container_started_at(node: str) -> Optional[str]:
|
|
"""Return the node container's State.StartedAt string, or None if it cannot be read.
|
|
|
|
A KinD node is a container, so a successful stop/start or restart advances StartedAt. This
|
|
gives a deterministic, runtime-level proof that a destructive action actually cycled the
|
|
node -- independent of Kubernetes node-status timing and of Krkn's in-process kube_check.
|
|
"""
|
|
runtime = container_runtime()
|
|
if not runtime:
|
|
return None
|
|
try:
|
|
proc = subprocess.run(
|
|
[runtime, "inspect", "-f", "{{.State.StartedAt}}", node],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("Could not inspect container %s via %s: %s", node, runtime, e)
|
|
return None
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"'%s inspect %s' exited %s: %s", runtime, node, proc.returncode,
|
|
(proc.stderr or "").strip(),
|
|
)
|
|
return None
|
|
return (proc.stdout or "").strip() or None
|
|
|
|
|
|
def assert_container_cycled(node: str, started_before, action: str) -> None:
|
|
"""Assert the node container was actually restarted (StartedAt advanced).
|
|
|
|
No-op when the container runtime is unavailable or StartedAt could not be read on either
|
|
side, so the check never yields a false negative off-cluster; on KinD (a runtime is always
|
|
present) it deterministically proves the action disrupted the node.
|
|
"""
|
|
started_after = container_started_at(node)
|
|
if started_before is None or started_after is None:
|
|
logger.warning(
|
|
"Skipping container-cycle check for %s (%s): StartedAt unavailable "
|
|
"(before=%r, after=%r)", node, action, started_before, started_after,
|
|
)
|
|
return
|
|
assert started_after != started_before, (
|
|
f"Node container {node} StartedAt did not advance ({started_before!r}); "
|
|
f"{action} did not actually cycle the container"
|
|
)
|
|
|
|
|
|
def ensure_node_container_running(node, k8s_core=None, timeout: float = 180) -> None:
|
|
"""Best-effort restore of a KinD node container after a destructive test (finalizer).
|
|
|
|
Starts the container and, when ``k8s_core`` is given, waits for the node to report Ready so
|
|
a later rerun (``--reruns``) never picks up an unrecovered node. Never raises -- a non-zero
|
|
``start`` exit and any exception are logged so the finalizer cannot mask the test result.
|
|
"""
|
|
runtime = container_runtime()
|
|
if not runtime:
|
|
logger.warning("No container runtime on PATH; cannot ensure node %s is running", node)
|
|
return
|
|
try:
|
|
running = subprocess.run(
|
|
[runtime, "inspect", "-f", "{{.State.Running}}", node],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if running.returncode == 0 and (running.stdout or "").strip() == "true":
|
|
# Already running (e.g. after a reboot scenario) -- nothing to restore, stay quiet.
|
|
pass
|
|
else:
|
|
proc = subprocess.run([runtime, "start", node], capture_output=True, text=True, timeout=60)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"'%s start %s' exited %s: %s", runtime, node, proc.returncode,
|
|
(proc.stderr or "").strip(),
|
|
)
|
|
except Exception as e: # noqa: BLE001 - a finalizer must never raise
|
|
logger.warning("Failed to start container for node %s via %s: %s", node, runtime, e)
|
|
return
|
|
if k8s_core is not None and not wait_node_ready(k8s_core, node, timeout):
|
|
logger.warning(
|
|
"Node %s did not report Ready within %ss during finalizer restore", node, timeout
|
|
)
|
|
|
|
|
|
def assert_kraken_marker(result, marker: str, context: str = "", tmp_path=None) -> None:
|
|
"""Assert Krkn stdout/stderr contains a literal action marker (real execution evidence, not a silent no-op)."""
|
|
combined = f"{result.stdout or ''}\n{result.stderr or ''}"
|
|
if re.search(re.escape(marker), combined):
|
|
return
|
|
if tmp_path is not None:
|
|
try:
|
|
(tmp_path / "kraken_stdout.log").write_text(result.stdout or "")
|
|
(tmp_path / "kraken_stderr.log").write_text(result.stderr or "")
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("Could not write Kraken logs to tmp_path: %s", e)
|
|
tail = "\n".join(combined.splitlines()[-30:]) or "(empty)"
|
|
raise AssertionError(
|
|
f"Expected marker {marker!r} in Krkn output ({context}) but it was missing.\n"
|
|
f"--- krkn output (last 30 lines) ---\n{tail}"
|
|
)
|
|
|
|
|
|
def get_network_policies_list(k8s_networking, namespace: str) -> V1NetworkPolicyList:
|
|
"""Return V1NetworkPolicyList from the Kubernetes API."""
|
|
return k8s_networking.list_namespaced_network_policy(namespace=namespace)
|
|
|
|
|
|
def find_network_policy_by_prefix(
|
|
policy_list: Union[V1NetworkPolicyList, List[V1NetworkPolicy]],
|
|
name_prefix: str,
|
|
) -> Optional[V1NetworkPolicy]:
|
|
"""Return the first NetworkPolicy whose name starts with name_prefix, or None."""
|
|
for policy in _policies(policy_list):
|
|
if (
|
|
policy.metadata
|
|
and policy.metadata.name
|
|
and policy.metadata.name.startswith(name_prefix)
|
|
):
|
|
return policy
|
|
return None
|
|
|
|
|
|
def assert_all_pods_running_and_ready(
|
|
pod_list: Union[V1PodList, List[V1Pod]],
|
|
namespace: str = "",
|
|
) -> None:
|
|
"""
|
|
Assert all pods are Running and all containers Ready.
|
|
Include namespace in assertion messages for debugging.
|
|
"""
|
|
ns_suffix = f" (namespace={namespace})" if namespace else ""
|
|
for pod in _pods(pod_list):
|
|
assert pod.status and pod.status.phase == "Running", (
|
|
f"Pod {pod.metadata.name} not Running after scenario: {pod.status}{ns_suffix}"
|
|
)
|
|
if pod.status.container_statuses:
|
|
for cs in pod.status.container_statuses:
|
|
assert getattr(cs, "ready", False) is True, (
|
|
f"Container {getattr(cs, 'name', '?')} not ready in pod {pod.metadata.name}{ns_suffix}"
|
|
)
|
|
|
|
|
|
def assert_pod_count_unchanged(
|
|
before: Union[V1PodList, List[V1Pod]],
|
|
after: Union[V1PodList, List[V1Pod]],
|
|
namespace: str = "",
|
|
) -> None:
|
|
"""Assert pod count is unchanged; include namespace in failure message."""
|
|
before_items = _pods(before)
|
|
after_items = _pods(after)
|
|
ns_suffix = f" (namespace={namespace})" if namespace else ""
|
|
assert len(after_items) == len(before_items), (
|
|
f"Pod count changed after scenario: expected {len(before_items)}, got {len(after_items)}.{ns_suffix}"
|
|
)
|
|
|
|
|
|
def assert_kraken_success(result, context: str = "", tmp_path=None, allowed_codes=(0,)) -> None:
|
|
"""
|
|
Assert Kraken run succeeded (returncode in allowed_codes). On failure, include stdout and stderr
|
|
in the assertion message and optionally write full output to tmp_path.
|
|
Default allowed_codes=(0,). For alert-aware tests, use allowed_codes=(0, 2).
|
|
"""
|
|
if result.returncode in allowed_codes:
|
|
return
|
|
if tmp_path is not None:
|
|
try:
|
|
(tmp_path / "kraken_stdout.log").write_text(result.stdout or "")
|
|
(tmp_path / "kraken_stderr.log").write_text(result.stderr or "")
|
|
except Exception as e:
|
|
logger.warning("Could not write Kraken logs to tmp_path: %s", e)
|
|
lines = (result.stdout or "").splitlines()
|
|
tail_stdout = "\n".join(lines[-20:]) if lines else "(empty)"
|
|
context_str = f" {context}" if context else ""
|
|
path_hint = f"\nFull logs: {tmp_path}/kraken_stdout.log, {tmp_path}/kraken_stderr.log" if tmp_path else ""
|
|
raise AssertionError(
|
|
f"Krkn failed (rc={result.returncode}){context_str}.{path_hint}\n"
|
|
f"--- stderr ---\n{result.stderr or '(empty)'}\n"
|
|
f"--- stdout (last 20 lines) ---\n{tail_stdout}"
|
|
)
|
|
|
|
|
|
def assert_kraken_failure(result, context: str = "", tmp_path=None) -> None:
|
|
"""
|
|
Assert Kraken run failed (returncode != 0). On failure (Kraken unexpectedly succeeded),
|
|
raise AssertionError with stdout/stderr and optional tmp_path log files for diagnostics.
|
|
"""
|
|
if result.returncode != 0:
|
|
return
|
|
if tmp_path is not None:
|
|
try:
|
|
(tmp_path / "kraken_stdout.log").write_text(result.stdout or "")
|
|
(tmp_path / "kraken_stderr.log").write_text(result.stderr or "")
|
|
except Exception as e:
|
|
logger.warning("Could not write Kraken logs to tmp_path: %s", e)
|
|
lines = (result.stdout or "").splitlines()
|
|
tail_stdout = "\n".join(lines[-20:]) if lines else "(empty)"
|
|
context_str = f" {context}" if context else ""
|
|
path_hint = f"\nFull logs: {tmp_path}/kraken_stdout.log, {tmp_path}/kraken_stderr.log" if tmp_path else ""
|
|
raise AssertionError(
|
|
f"Expected Krkn to fail but it succeeded (rc=0){context_str}.{path_hint}\n"
|
|
f"--- stderr ---\n{result.stderr or '(empty)'}\n"
|
|
f"--- stdout (last 20 lines) ---\n{tail_stdout}"
|
|
)
|
|
|
|
|
|
def assert_scenario_executed(result, scenario_name: str, context: str = "", tmp_path=None) -> None:
|
|
"""
|
|
Assert that Krkn actually executed the scenario's core logic by matching a
|
|
scenario-specific marker in stdout/stderr. Guards against false positives where
|
|
Krkn exits 0 but silently did nothing (e.g. a selector that matches no targets).
|
|
|
|
Skipped when KRKN_TEST_DRY_RUN=1. Negative tests must not call this helper.
|
|
"""
|
|
if os.environ.get("KRKN_TEST_DRY_RUN", "0") == "1":
|
|
return
|
|
pattern = SCENARIO_EXECUTION_MARKERS.get(scenario_name)
|
|
if pattern is None:
|
|
raise AssertionError(
|
|
f"No execution-evidence marker defined for scenario {scenario_name!r}. "
|
|
"Add one to SCENARIO_EXECUTION_MARKERS in CI/tests_v2/lib/utils.py."
|
|
)
|
|
combined = f"{result.stdout or ''}\n{result.stderr or ''}"
|
|
found = re.search(pattern, combined) is not None
|
|
_record_execution_evidence(scenario_name, pattern, found)
|
|
if found:
|
|
return
|
|
if tmp_path is not None:
|
|
try:
|
|
(tmp_path / "kraken_stdout.log").write_text(result.stdout or "")
|
|
(tmp_path / "kraken_stderr.log").write_text(result.stderr or "")
|
|
except Exception as e:
|
|
logger.warning("Could not write Kraken logs to tmp_path: %s", e)
|
|
lines = combined.splitlines()
|
|
tail = "\n".join(lines[-30:]) if lines else "(empty)"
|
|
context_str = f" {context}" if context else ""
|
|
path_hint = f"\nFull logs: {tmp_path}/kraken_stdout.log, {tmp_path}/kraken_stderr.log" if tmp_path else ""
|
|
raise AssertionError(
|
|
f"Scenario {scenario_name!r} exited {result.returncode} but no execution evidence found"
|
|
f"{context_str}.{path_hint}\nExpected pattern: {pattern}\n"
|
|
f"--- krkn output (last 30 lines) ---\n{tail}"
|
|
)
|