Loading .gitlab-ci.yml +6 −1 Changes for .gitlab-ci.yml: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -133,9 +133,14 @@ platform-lab: when: always paths: - .cache/platform-lab/result.json - .cache/platform-lab/junit.xml - .cache/platform-lab/wrapper-junit.xml - .cache/platform-lab/pods.txt - .cache/platform-lab/events.txt - .cache/platform-lab/health/result.json - .cache/platform-lab/health/junit.xml reports: junit: .cache/platform-lab/health/junit.xml junit: - .cache/platform-lab/junit.xml - .cache/platform-lab/wrapper-junit.xml expire_in: 1 week ansible/tests/unit/test_platform_lab.py +43 −3 Changes for ansible/tests/unit/test_platform_lab.py: 43 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -4,10 +4,13 @@ import importlib.util import json from pathlib import Path import subprocess import sys import xml.etree.ElementTree as ET import pytest ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "ci")) spec = importlib.util.spec_from_file_location("platform_lab", ROOT / "ci/platform_lab.py") lab = importlib.util.module_from_spec(spec) spec.loader.exec_module(lab) Loading @@ -26,7 +29,8 @@ def test_ci_rejects_unpinned_images_and_local_exceptions(): lab.validate_profile({**profile, "local_images": []}) @pytest.mark.parametrize("failure", [None, "cluster", "deploy", "identity", "health", "cleanup", "existing"]) @pytest.mark.parametrize("failure", [None, "cluster", "deploy", "identity", "health", "cleanup", "existing", "deploy-and-cleanup", "image-cleanup"]) @pytest.mark.parametrize("uid_map", [" 0 0 4294967295\n", "0 100000 65536\n"]) def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters(tmp_path, monkeypatch, failure, uid_map): name = "oop-ci-123-1234abcd" Loading Loading @@ -57,7 +61,7 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters( "imageID": "sha256:" + "a" * 64}]}} for c in ("oeg", "fm", "srm")]}) elif "ansible/playbooks/scenarios/r2_existing_cluster/deploy.yml" in argv: if failure == "deploy": if failure in ("deploy", "deploy-and-cleanup"): raise subprocess.CalledProcessError(2, argv) inventory = json.loads((tmp_path / "private/inventory.json").read_text()) operator = inventory["all"]["children"]["oop_platform_operators"]["hosts"]["ci_lab"] Loading @@ -68,7 +72,9 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters( raise subprocess.CalledProcessError(1, argv) (tmp_path / "health").mkdir() (tmp_path / "health/result.json").write_text('{"status":"passed","tests":1}') elif argv[:3] == ["k3d", "cluster", "delete"] and failure == "cleanup": elif argv[:3] == ["k3d", "cluster", "delete"] and failure in ("cleanup", "deploy-and-cleanup"): raise subprocess.CalledProcessError(1, argv) elif argv[:3] == ["docker", "image", "rm"] and failure == "image-cleanup": raise subprocess.CalledProcessError(1, argv) return subprocess.CompletedProcess(argv, 0, stdout=output) Loading @@ -94,3 +100,37 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters( assert create[create.index("--servers-memory") + 1] == "6g" if failure == "cleanup": assert summary["cleanup"] == "failed" cases = ET.parse(tmp_path / "junit.xml").getroot().findall(".//testcase") steps = {step["id"]: step for step in summary["steps"]} assert len(cases) == len(steps) == 7 assert len({case.get("name") for case in cases}) == 7 assert all(step["seconds"] >= 0 for step in steps.values()) failed = [step["id"] for step in steps.values() if step["status"] == "failed"] expected_failures = {None: [], "existing": ["preflight"], "deploy-and-cleanup": ["deploy", "cleanup"], "image-cleanup": ["cleanup"]} assert failed == expected_failures.get(failure, [failure]) assert sum(case.find("failure") is not None for case in cases) == len(failed) assert sum(case.find("skipped") is not None for case in cases) == sum( step["status"] == "skipped" for step in steps.values()) if failure == "existing": assert steps["cleanup"]["status"] == "skipped" else: assert steps["cleanup"]["status"] == ("failed" if "cleanup" in str(failure) else "passed") if failure in ("deploy", "deploy-and-cleanup"): assert steps["identity"]["status"] == steps["health"]["status"] == "skipped" assert "deploy" in steps["health"]["reason"] def test_invalid_profile_reports_failure_before_creating_resources(tmp_path, monkeypatch): profile = tmp_path / "profile.json" profile.write_text(json.dumps({**PROFILE, "images": {**PROFILE["images"], "srm": "unpinned:tag"}})) monkeypatch.setattr(lab, "ROOT", tmp_path) monkeypatch.setattr(sys, "argv", ["platform_lab", "--profile", str(profile)]) monkeypatch.setattr(lab, "run", lambda *args, **kwargs: pytest.fail("Input rejection must not call a backend")) assert lab.main() == 1 output = tmp_path / ".cache/platform-lab" cases = ET.parse(output / "junit.xml").getroot().findall(".//testcase") assert len(cases) == 7 assert sum(case.find("failure") is not None for case in cases) == 1 assert sum(case.find("skipped") is not None for case in cases) == 6 assert not (output / "private").exists() ansible/tests/unit/test_platform_wrapper.py 0 → 100644 +72 −0 Changes for ansible/tests/unit/test_platform_wrapper.py: 72 added lines, 0 removed lines. Original line number Diff line number Diff line """Exercise the real shell wrapper's exit status and cleanup report without Docker.""" import json import os from pathlib import Path import subprocess import sys import xml.etree.ElementTree as ET import pytest ROOT = Path(__file__).resolve().parents[3] @pytest.mark.parametrize("fault", ["", "start", "volume-rm"]) def test_wrapper_reports_cleanup_and_preserves_original_failure(tmp_path, fault): state = tmp_path / "state" state.mkdir() docker = tmp_path / "docker" docker.write_text(f"#!{sys.executable}\n" + ''' import json, os, sys from pathlib import Path p = Path(os.environ["FAKE_DOCKER_STATE"]) a = sys.argv[1:] with (p / "calls").open("a") as log: log.write(json.dumps(a) + "\\n") key = None if a[:2] == ["container", "inspect"] or a[:1] == ["create"] or a[:1] == ["rm"]: key = "controller" elif a[:1] == ["build"] or a[:2] in (["image", "inspect"], ["image", "rm"]): key = "image" elif a[:1] == ["volume"]: key = "volume" if "inspect" in a: if not (p / key).exists(): sys.exit(1) if key == "volume" and "--format" in a: print(p / "shared") elif a[:2] == ["volume", "rm"] and os.environ["FAULT"] == "volume-rm": sys.exit(1) elif key: if "rm" in a: (p / key).unlink() else: (p / key).touch() elif a[:1] == ["start"]: sys.exit(int(os.environ["FAULT"] == "start")) elif a[:1] == ["cp"] and ":/work/.cache/" in a[1]: target = Path(a[2]) target.parent.mkdir(parents=True, exist_ok=True) target.write_text("{}" if target.suffix == ".json" else "<testsuite/>") elif a[0] not in ("info", "stop", "cp"): sys.exit(2) ''') docker.chmod(0o700) profile = tmp_path / "profile.json" profile.write_text("{}") reports = tmp_path / "reports" run = subprocess.run(["sh", "ci/run_platform_lab.sh", str(profile), str(reports)], cwd=ROOT, capture_output=True, text=True, timeout=20, env={**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"], "FAKE_DOCKER_STATE": str(state), "FAULT": fault, "CI": "true"}) assert run.returncode == int(bool(fault)), run.stderr cases = ET.parse(reports / "wrapper-junit.xml").getroot().findall(".//testcase") assert len(cases) == 1 assert (cases[0].find("failure") is not None) == (fault == "volume-rm") calls = [json.loads(line) for line in (state / "calls").read_text().splitlines()] assert sum(a[:1] == ["start"] for a in calls) == 1 assert sum(a[:1] == ["rm"] for a in calls) == 1 assert not (state / "controller").exists() assert not (state / "image").exists() assert "section_start:" in run.stdout and "section_end:" in run.stdout ci/lab/Dockerfile +1 −0 Changes for ci/lab/Dockerfile: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -18,4 +18,5 @@ COPY ansible/roles ansible/roles COPY ansible/tests/r2_e2e ansible/tests/r2_e2e COPY ci/platform_smoke.py ci/platform_smoke.py COPY ci/platform_lab.py ci/platform_lab.py COPY ci/lab_report.py ci/lab_report.py CMD ["python", "ci/platform_lab.py", "--profile", "/work/profile.json"] ci/lab/README.md +38 −0 Changes for ci/lab/README.md: 38 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -93,6 +93,44 @@ failure the same cleanup runs. Forced runner/process termination can prevent local cleanup; destroying the CI job's DinD service removes the remaining lab. No host-wide Docker prune or shared-cluster cleanup is used. ## Checkpoint results Keep a single create/deploy/test/cleanup transaction in this job. Each completed checkpoint produces a descriptive result and duration in `result.json` and `junit.xml`, plus a collapsible GitLab log section. Local execution prints the same checkpoint names without GitLab escape codes: 1. Validate lab inputs and ownership. 2. Kubernetes cluster ready. 3. Candidate images loaded. 4. OpenOP deployed and ready. 5. Running component images verified. 6. OEG and FM HTTP health. 7. Remove owned cluster and imported images. 8. Remove lab controller resources (reported by the outer shell wrapper). These eight reported checkpoints include one existing HTTP acceptance test; they do not represent eight independent business scenarios. The original pytest JUnit is retained as an artifact for detail, while the pipeline Tests tab publishes the checkpoint report and wrapper cleanup report once each. The wrapper checks the controller/container, configuration volume and image removal separately because they outlive the controller's own report. Failed checkpoints fail the job. Later dependent checkpoints use JUnit skipped with a `Blocked by ...` reason, and cannot turn the selected suite green. Cleanup is attempted after earlier failures and records its own failure without hiding the original failure. Existing-cluster refusal skips cleanup to preserve resources the command does not own. Missing controller reports also fail the wrapper. Reports remain available on ordinary failure; abrupt runner loss can prevent their production and is still a failed/cancelled job. Authentication, onboarding, zones, application deployment, endpoints and TI remain outside this health profile. Add their real assertions and results as the corresponding profiles are implemented. Reuse fixtures for prerequisites and pair each created resource with teardown; tests must not depend on earlier test execution. Split jobs when suites require independent environments or execution policies, such as a future NEF-backed network suite. ## Verification Default CI keeps its existing behavior and adds offline controller checks. Loading Loading
.gitlab-ci.yml +6 −1 Changes for .gitlab-ci.yml: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -133,9 +133,14 @@ platform-lab: when: always paths: - .cache/platform-lab/result.json - .cache/platform-lab/junit.xml - .cache/platform-lab/wrapper-junit.xml - .cache/platform-lab/pods.txt - .cache/platform-lab/events.txt - .cache/platform-lab/health/result.json - .cache/platform-lab/health/junit.xml reports: junit: .cache/platform-lab/health/junit.xml junit: - .cache/platform-lab/junit.xml - .cache/platform-lab/wrapper-junit.xml expire_in: 1 week
ansible/tests/unit/test_platform_lab.py +43 −3 Changes for ansible/tests/unit/test_platform_lab.py: 43 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -4,10 +4,13 @@ import importlib.util import json from pathlib import Path import subprocess import sys import xml.etree.ElementTree as ET import pytest ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "ci")) spec = importlib.util.spec_from_file_location("platform_lab", ROOT / "ci/platform_lab.py") lab = importlib.util.module_from_spec(spec) spec.loader.exec_module(lab) Loading @@ -26,7 +29,8 @@ def test_ci_rejects_unpinned_images_and_local_exceptions(): lab.validate_profile({**profile, "local_images": []}) @pytest.mark.parametrize("failure", [None, "cluster", "deploy", "identity", "health", "cleanup", "existing"]) @pytest.mark.parametrize("failure", [None, "cluster", "deploy", "identity", "health", "cleanup", "existing", "deploy-and-cleanup", "image-cleanup"]) @pytest.mark.parametrize("uid_map", [" 0 0 4294967295\n", "0 100000 65536\n"]) def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters(tmp_path, monkeypatch, failure, uid_map): name = "oop-ci-123-1234abcd" Loading Loading @@ -57,7 +61,7 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters( "imageID": "sha256:" + "a" * 64}]}} for c in ("oeg", "fm", "srm")]}) elif "ansible/playbooks/scenarios/r2_existing_cluster/deploy.yml" in argv: if failure == "deploy": if failure in ("deploy", "deploy-and-cleanup"): raise subprocess.CalledProcessError(2, argv) inventory = json.loads((tmp_path / "private/inventory.json").read_text()) operator = inventory["all"]["children"]["oop_platform_operators"]["hosts"]["ci_lab"] Loading @@ -68,7 +72,9 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters( raise subprocess.CalledProcessError(1, argv) (tmp_path / "health").mkdir() (tmp_path / "health/result.json").write_text('{"status":"passed","tests":1}') elif argv[:3] == ["k3d", "cluster", "delete"] and failure == "cleanup": elif argv[:3] == ["k3d", "cluster", "delete"] and failure in ("cleanup", "deploy-and-cleanup"): raise subprocess.CalledProcessError(1, argv) elif argv[:3] == ["docker", "image", "rm"] and failure == "image-cleanup": raise subprocess.CalledProcessError(1, argv) return subprocess.CompletedProcess(argv, 0, stdout=output) Loading @@ -94,3 +100,37 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters( assert create[create.index("--servers-memory") + 1] == "6g" if failure == "cleanup": assert summary["cleanup"] == "failed" cases = ET.parse(tmp_path / "junit.xml").getroot().findall(".//testcase") steps = {step["id"]: step for step in summary["steps"]} assert len(cases) == len(steps) == 7 assert len({case.get("name") for case in cases}) == 7 assert all(step["seconds"] >= 0 for step in steps.values()) failed = [step["id"] for step in steps.values() if step["status"] == "failed"] expected_failures = {None: [], "existing": ["preflight"], "deploy-and-cleanup": ["deploy", "cleanup"], "image-cleanup": ["cleanup"]} assert failed == expected_failures.get(failure, [failure]) assert sum(case.find("failure") is not None for case in cases) == len(failed) assert sum(case.find("skipped") is not None for case in cases) == sum( step["status"] == "skipped" for step in steps.values()) if failure == "existing": assert steps["cleanup"]["status"] == "skipped" else: assert steps["cleanup"]["status"] == ("failed" if "cleanup" in str(failure) else "passed") if failure in ("deploy", "deploy-and-cleanup"): assert steps["identity"]["status"] == steps["health"]["status"] == "skipped" assert "deploy" in steps["health"]["reason"] def test_invalid_profile_reports_failure_before_creating_resources(tmp_path, monkeypatch): profile = tmp_path / "profile.json" profile.write_text(json.dumps({**PROFILE, "images": {**PROFILE["images"], "srm": "unpinned:tag"}})) monkeypatch.setattr(lab, "ROOT", tmp_path) monkeypatch.setattr(sys, "argv", ["platform_lab", "--profile", str(profile)]) monkeypatch.setattr(lab, "run", lambda *args, **kwargs: pytest.fail("Input rejection must not call a backend")) assert lab.main() == 1 output = tmp_path / ".cache/platform-lab" cases = ET.parse(output / "junit.xml").getroot().findall(".//testcase") assert len(cases) == 7 assert sum(case.find("failure") is not None for case in cases) == 1 assert sum(case.find("skipped") is not None for case in cases) == 6 assert not (output / "private").exists()
ansible/tests/unit/test_platform_wrapper.py 0 → 100644 +72 −0 Changes for ansible/tests/unit/test_platform_wrapper.py: 72 added lines, 0 removed lines. Original line number Diff line number Diff line """Exercise the real shell wrapper's exit status and cleanup report without Docker.""" import json import os from pathlib import Path import subprocess import sys import xml.etree.ElementTree as ET import pytest ROOT = Path(__file__).resolve().parents[3] @pytest.mark.parametrize("fault", ["", "start", "volume-rm"]) def test_wrapper_reports_cleanup_and_preserves_original_failure(tmp_path, fault): state = tmp_path / "state" state.mkdir() docker = tmp_path / "docker" docker.write_text(f"#!{sys.executable}\n" + ''' import json, os, sys from pathlib import Path p = Path(os.environ["FAKE_DOCKER_STATE"]) a = sys.argv[1:] with (p / "calls").open("a") as log: log.write(json.dumps(a) + "\\n") key = None if a[:2] == ["container", "inspect"] or a[:1] == ["create"] or a[:1] == ["rm"]: key = "controller" elif a[:1] == ["build"] or a[:2] in (["image", "inspect"], ["image", "rm"]): key = "image" elif a[:1] == ["volume"]: key = "volume" if "inspect" in a: if not (p / key).exists(): sys.exit(1) if key == "volume" and "--format" in a: print(p / "shared") elif a[:2] == ["volume", "rm"] and os.environ["FAULT"] == "volume-rm": sys.exit(1) elif key: if "rm" in a: (p / key).unlink() else: (p / key).touch() elif a[:1] == ["start"]: sys.exit(int(os.environ["FAULT"] == "start")) elif a[:1] == ["cp"] and ":/work/.cache/" in a[1]: target = Path(a[2]) target.parent.mkdir(parents=True, exist_ok=True) target.write_text("{}" if target.suffix == ".json" else "<testsuite/>") elif a[0] not in ("info", "stop", "cp"): sys.exit(2) ''') docker.chmod(0o700) profile = tmp_path / "profile.json" profile.write_text("{}") reports = tmp_path / "reports" run = subprocess.run(["sh", "ci/run_platform_lab.sh", str(profile), str(reports)], cwd=ROOT, capture_output=True, text=True, timeout=20, env={**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"], "FAKE_DOCKER_STATE": str(state), "FAULT": fault, "CI": "true"}) assert run.returncode == int(bool(fault)), run.stderr cases = ET.parse(reports / "wrapper-junit.xml").getroot().findall(".//testcase") assert len(cases) == 1 assert (cases[0].find("failure") is not None) == (fault == "volume-rm") calls = [json.loads(line) for line in (state / "calls").read_text().splitlines()] assert sum(a[:1] == ["start"] for a in calls) == 1 assert sum(a[:1] == ["rm"] for a in calls) == 1 assert not (state / "controller").exists() assert not (state / "image").exists() assert "section_start:" in run.stdout and "section_end:" in run.stdout
ci/lab/Dockerfile +1 −0 Changes for ci/lab/Dockerfile: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -18,4 +18,5 @@ COPY ansible/roles ansible/roles COPY ansible/tests/r2_e2e ansible/tests/r2_e2e COPY ci/platform_smoke.py ci/platform_smoke.py COPY ci/platform_lab.py ci/platform_lab.py COPY ci/lab_report.py ci/lab_report.py CMD ["python", "ci/platform_lab.py", "--profile", "/work/profile.json"]
ci/lab/README.md +38 −0 Changes for ci/lab/README.md: 38 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -93,6 +93,44 @@ failure the same cleanup runs. Forced runner/process termination can prevent local cleanup; destroying the CI job's DinD service removes the remaining lab. No host-wide Docker prune or shared-cluster cleanup is used. ## Checkpoint results Keep a single create/deploy/test/cleanup transaction in this job. Each completed checkpoint produces a descriptive result and duration in `result.json` and `junit.xml`, plus a collapsible GitLab log section. Local execution prints the same checkpoint names without GitLab escape codes: 1. Validate lab inputs and ownership. 2. Kubernetes cluster ready. 3. Candidate images loaded. 4. OpenOP deployed and ready. 5. Running component images verified. 6. OEG and FM HTTP health. 7. Remove owned cluster and imported images. 8. Remove lab controller resources (reported by the outer shell wrapper). These eight reported checkpoints include one existing HTTP acceptance test; they do not represent eight independent business scenarios. The original pytest JUnit is retained as an artifact for detail, while the pipeline Tests tab publishes the checkpoint report and wrapper cleanup report once each. The wrapper checks the controller/container, configuration volume and image removal separately because they outlive the controller's own report. Failed checkpoints fail the job. Later dependent checkpoints use JUnit skipped with a `Blocked by ...` reason, and cannot turn the selected suite green. Cleanup is attempted after earlier failures and records its own failure without hiding the original failure. Existing-cluster refusal skips cleanup to preserve resources the command does not own. Missing controller reports also fail the wrapper. Reports remain available on ordinary failure; abrupt runner loss can prevent their production and is still a failed/cancelled job. Authentication, onboarding, zones, application deployment, endpoints and TI remain outside this health profile. Add their real assertions and results as the corresponding profiles are implemented. Reuse fixtures for prerequisites and pair each created resource with teardown; tests must not depend on earlier test execution. Split jobs when suites require independent environments or execution policies, such as a future NEF-backed network suite. ## Verification Default CI keeps its existing behavior and adds offline controller checks. Loading