mirror of
https://github.com/krkn-chaos/krkn.git
synced 2026-04-15 06:57:28 +00:00
* feat: add pytest-based CI test framework v2 with ephemeral namespace isolation Signed-off-by: ddjain <darjain@redhat.com> * feat(ci): add tests_v2 pytest functional test framework Signed-off-by: ddjain <darjain@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> * feat: improve naming convention Signed-off-by: ddjain <darjain@redhat.com> * improve local setup script. Signed-off-by: ddjain <darjain@redhat.com> * added CI job for v2 test Signed-off-by: ddjain <darjain@redhat.com> * disabled broken test Signed-off-by: ddjain <darjain@redhat.com> * improved CI pipeline execution time Signed-off-by: ddjain <darjain@redhat.com> * chore: remove unwanted/generated files from PR Signed-off-by: ddjain <darjain@redhat.com> * clean up gitignore file Signed-off-by: ddjain <darjain@redhat.com> * fix copilot comments Signed-off-by: ddjain <darjain@redhat.com> * fixed copilot suggestion Signed-off-by: ddjain <darjain@redhat.com> * uncommented out test upload stage Signed-off-by: ddjain <darjain@redhat.com> * exclude CI/tests_v2 from test coverage reporting Signed-off-by: ddjain <darjain@redhat.com> * uploading style.css to fix broken report artifacts Signed-off-by: ddjain <darjain@redhat.com> * added openshift supported labels in namespace creatation api Signed-off-by: ddjain <darjain@redhat.com> --------- Signed-off-by: ddjain <darjain@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate boilerplate for a new scenario test in CI/tests_v2.
|
|
|
|
Usage (from repository root):
|
|
python CI/tests_v2/scaffold.py --scenario service_hijacking
|
|
python CI/tests_v2/scaffold.py --scenario node_disruption --scenario-type node_scenarios
|
|
|
|
Creates (folder-per-scenario layout):
|
|
- CI/tests_v2/scenarios/<scenario>/test_<scenario>.py (BaseScenarioTest subclass + stub test)
|
|
- CI/tests_v2/scenarios/<scenario>/resource.yaml (placeholder workload)
|
|
- CI/tests_v2/scenarios/<scenario>/scenario_base.yaml (placeholder Krkn scenario; edit for your scenario_type)
|
|
- Adds the scenario marker to pytest.ini (if not already present)
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def snake_to_camel(snake: str) -> str:
|
|
"""Convert snake_case to CamelCase."""
|
|
return "".join(word.capitalize() for word in snake.split("_"))
|
|
|
|
|
|
def scenario_type_default(scenario: str) -> str:
|
|
"""Default scenario_type for build_config (e.g. service_hijacking -> service_hijacking_scenarios)."""
|
|
return f"{scenario}_scenarios"
|
|
|
|
|
|
TEST_FILE_TEMPLATE = '''"""
|
|
Functional test for {scenario} scenario.
|
|
Each test runs in its own ephemeral namespace with workload deployed automatically.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from lib.base import BaseScenarioTest
|
|
from lib.utils import (
|
|
assert_all_pods_running_and_ready,
|
|
assert_kraken_failure,
|
|
assert_kraken_success,
|
|
assert_pod_count_unchanged,
|
|
get_pods_list,
|
|
)
|
|
|
|
|
|
@pytest.mark.functional
|
|
@pytest.mark.{marker}
|
|
class Test{class_name}(BaseScenarioTest):
|
|
"""{scenario} scenario."""
|
|
|
|
WORKLOAD_MANIFEST = "CI/tests_v2/scenarios/{scenario}/resource.yaml"
|
|
WORKLOAD_IS_PATH = True
|
|
LABEL_SELECTOR = "app={app_label}"
|
|
SCENARIO_NAME = "{scenario}"
|
|
SCENARIO_TYPE = "{scenario_type}"
|
|
NAMESPACE_KEY_PATH = {namespace_key_path}
|
|
NAMESPACE_IS_REGEX = {namespace_is_regex}
|
|
OVERRIDES_KEY_PATH = {overrides_key_path}
|
|
|
|
@pytest.mark.order(1)
|
|
def test_happy_path(self):
|
|
"""Run {scenario} scenario and assert pods remain healthy."""
|
|
ns = self.ns
|
|
before = get_pods_list(self.k8s_core, ns, self.LABEL_SELECTOR)
|
|
|
|
result = self.run_scenario(self.tmp_path, ns)
|
|
assert_kraken_success(result, context=f"namespace={{ns}}", tmp_path=self.tmp_path)
|
|
|
|
after = get_pods_list(self.k8s_core, ns, self.LABEL_SELECTOR)
|
|
assert_pod_count_unchanged(before, after, namespace=ns)
|
|
assert_all_pods_running_and_ready(after, namespace=ns)
|
|
'''
|
|
|
|
RESOURCE_YAML_TEMPLATE = '''# Target workload for {scenario} scenario tests.
|
|
# Namespace is patched at deploy time by the test framework.
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: {app_label}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: {app_label}
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: {app_label}
|
|
spec:
|
|
containers:
|
|
- name: app
|
|
image: nginx:alpine
|
|
ports:
|
|
- containerPort: 80
|
|
'''
|
|
|
|
SCENARIO_BASE_DICT_TEMPLATE = '''# Base scenario for {scenario} (used by build_config with scenario_type: {scenario_type}).
|
|
# Edit this file with the structure expected by Krkn. Top-level key must match SCENARIO_NAME.
|
|
# See scenarios/application_outage/scenario_base.yaml and scenarios/pod_disruption/scenario_base.yaml for examples.
|
|
{scenario}:
|
|
namespace: default
|
|
# Add fields required by your scenario plugin.
|
|
'''
|
|
|
|
SCENARIO_BASE_LIST_TEMPLATE = '''# Base scenario for {scenario} (list format). Tests patch config.namespace_pattern with ^<ns>$.
|
|
# Edit with the structure expected by your scenario plugin. See scenarios/pod_disruption/scenario_base.yaml.
|
|
- id: {scenario}-default
|
|
config:
|
|
namespace_pattern: "^default$"
|
|
# Add fields required by your scenario plugin.
|
|
'''
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Scaffold a new scenario test in CI/tests_v2 (folder-per-scenario)")
|
|
parser.add_argument(
|
|
"--scenario",
|
|
required=True,
|
|
help="Scenario name in snake_case (e.g. service_hijacking)",
|
|
)
|
|
parser.add_argument(
|
|
"--scenario-type",
|
|
default=None,
|
|
help="Kraken scenario_type for build_config (default: <scenario>_scenarios)",
|
|
)
|
|
parser.add_argument(
|
|
"--list-based",
|
|
action="store_true",
|
|
help="Use list-based scenario (NAMESPACE_KEY_PATH [0, 'config', 'namespace_pattern'], OVERRIDES_KEY_PATH [0, 'config'])",
|
|
)
|
|
parser.add_argument(
|
|
"--regex-namespace",
|
|
action="store_true",
|
|
help="Set NAMESPACE_IS_REGEX = True (namespace wrapped in ^...$)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
scenario = args.scenario.strip().lower()
|
|
if not re.match(r"^[a-z][a-z0-9_]*$", scenario):
|
|
print("Error: --scenario must be snake_case (e.g. service_hijacking)", file=sys.stderr)
|
|
return 1
|
|
|
|
scenario_type = args.scenario_type or scenario_type_default(scenario)
|
|
class_name = snake_to_camel(scenario)
|
|
marker = scenario
|
|
app_label = scenario.replace("_", "-")
|
|
|
|
if args.list_based:
|
|
namespace_key_path = [0, "config", "namespace_pattern"]
|
|
namespace_is_regex = True
|
|
overrides_key_path = [0, "config"]
|
|
scenario_base_template = SCENARIO_BASE_LIST_TEMPLATE
|
|
else:
|
|
namespace_key_path = [scenario, "namespace"]
|
|
namespace_is_regex = args.regex_namespace
|
|
overrides_key_path = [scenario]
|
|
scenario_base_template = SCENARIO_BASE_DICT_TEMPLATE
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent.parent
|
|
scenario_dir_path = repo_root / "CI" / "tests_v2" / "scenarios" / scenario
|
|
test_path = scenario_dir_path / f"test_{scenario}.py"
|
|
resource_path = scenario_dir_path / "resource.yaml"
|
|
scenario_base_path = scenario_dir_path / "scenario_base.yaml"
|
|
|
|
if scenario_dir_path.exists() and any(scenario_dir_path.iterdir()):
|
|
print(f"Error: scenario directory already exists and is non-empty: {scenario_dir_path}", file=sys.stderr)
|
|
return 1
|
|
if test_path.exists():
|
|
print(f"Error: {test_path} already exists", file=sys.stderr)
|
|
return 1
|
|
|
|
scenario_dir_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_content = TEST_FILE_TEMPLATE.format(
|
|
scenario=scenario,
|
|
marker=marker,
|
|
class_name=class_name,
|
|
app_label=app_label,
|
|
scenario_type=scenario_type,
|
|
namespace_key_path=repr(namespace_key_path),
|
|
namespace_is_regex=namespace_is_regex,
|
|
overrides_key_path=repr(overrides_key_path),
|
|
)
|
|
resource_content = RESOURCE_YAML_TEMPLATE.format(scenario=scenario, app_label=app_label)
|
|
scenario_base_content = scenario_base_template.format(
|
|
scenario=scenario,
|
|
scenario_type=scenario_type,
|
|
)
|
|
|
|
test_path.write_text(test_content, encoding="utf-8")
|
|
resource_path.write_text(resource_content, encoding="utf-8")
|
|
scenario_base_path.write_text(scenario_base_content, encoding="utf-8")
|
|
|
|
# Auto-add marker to pytest.ini if not already present
|
|
pytest_ini_path = repo_root / "CI" / "tests_v2" / "pytest.ini"
|
|
marker_line = f" {marker}: marks a test as a {scenario} scenario test"
|
|
if pytest_ini_path.exists():
|
|
content = pytest_ini_path.read_text(encoding="utf-8")
|
|
if f" {marker}:" not in content and f"{marker}: marks" not in content:
|
|
lines = content.splitlines(keepends=True)
|
|
insert_at = None
|
|
for i, line in enumerate(lines):
|
|
if re.match(r"^ \w+:\s*.+", line):
|
|
insert_at = i + 1
|
|
if insert_at is not None:
|
|
lines.insert(insert_at, marker_line + "\n")
|
|
pytest_ini_path.write_text("".join(lines), encoding="utf-8")
|
|
print("Added marker to pytest.ini")
|
|
else:
|
|
print("Could not find markers block in pytest.ini; add manually:")
|
|
print(marker_line)
|
|
else:
|
|
print("Marker already in pytest.ini")
|
|
else:
|
|
print("pytest.ini not found; add this marker under 'markers':")
|
|
print(marker_line)
|
|
|
|
print(f"Created: {test_path}")
|
|
print(f"Created: {resource_path}")
|
|
print(f"Created: {scenario_base_path}")
|
|
print()
|
|
print("Then edit scenario_base.yaml with your scenario structure (top-level key should match SCENARIO_NAME).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|