Commit 9a2075c8 authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

test: expose lab checkpoint outcomes in JUnit and logs

parent 8599cacf
Loading
Loading
Loading
Loading
Loading
+6 −1
Changes for .gitlab-ci.yml: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+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
@@ -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)
@@ -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"
@@ -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"]
@@ -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)

@@ -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()
+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
+1 −0
Changes for ci/lab/Dockerfile: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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"]
+38 −0
Changes for ci/lab/README.md: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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