Loading .gitlab-ci.yml +54 −0 Changes for .gitlab-ci.yml: 54 added lines, 0 removed lines. Original line number Diff line number Diff line workflow: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_OPEN_MERGE_REQUESTS' when: never - when: always default: image: python:3.12-slim cache: Loading @@ -22,26 +30,46 @@ variables: UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" type: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: type script: - mypy src/ tests/ architecture: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: architecture script: - lint-imports lint: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: lint script: - ruff check src/ tests/ format: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: format script: - ruff format --check src/ tests/ test: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: test variables: DOCKER_HOST: tcp://docker:2375 Loading @@ -68,4 +96,30 @@ build-and-push: - docker buildx build --provenance=false --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" --push . - docker logout "$CI_REGISTRY" rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"' sdk-consumer: stage: test image: python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 timeout: 15 minutes variables: TMPDIR: "$CI_PROJECT_DIR/.cache/sdk-tmp" PYTHONDONTWRITEBYTECODE: "1" rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' - when: never before_script: - mkdir -p "$TMPDIR" - pip install uv==0.11.26 - uv sync --locked --extra dev --no-install-project script: - python ci/sdk_consumer.py artifacts: when: always paths: - .cache/sdk-consumer/qualification.json reports: junit: .cache/sdk-consumer/junit.xml expire_in: 90 days ci/sdk-consumer.md 0 → 100644 +28 −0 Changes for ci/sdk-consumer.md: 28 added lines, 0 removed lines. Original line number Diff line number Diff line # SDK candidate qualification The `sdk-consumer` job accepts candidates only in a downstream pipeline from TF-SDK project 396. CI_JOB_TOKEN downloads a single build job's artifact; source SHA, manifest membership and wheel SHA-256 must match the request. The normal locked environment is prepared without installing SRM's distribution; the candidate wheel forcibly replaces the baseline SDK, including same-version rebuilds and installs its declared runtime dependencies. `uv pip check` must pass. Source imports use SRM's src directory. The normal lock pins the published 2.0.0rc2 candidate. New SDK candidates can be overlaid for qualification before their own registry publication. Registry construction passes each entry's control-path reference to its SDK instance on a copied configuration. Empty or duplicate references and conflicting config identities fail before constructing any adapter. Kubernetes fixtures declare their connection mode and namespace explicitly. SDK construction errors use the existing SRM error translation. These conventions work with the published rc2 baseline and the stricter candidate without changing the dependency lock. All tests/unit tests run, including the real SDK factory/bridge boundary. Missing configuration, skipped tests and failures block qualification. The receipt records both source revisions, the wheel checksum and CI pipeline URL. These offline checks cannot establish cluster, NEF or OEG-driven acceptance. Other SRM pipeline jobs retain their normal behavior outside this explicit consumer mode. Local execution accepts SDK_WHEEL_FILE and SDK_MANIFEST_FILE instead of a remote job; also supply SDK_SOURCE_SHA, SDK_WHEEL_NAME and SDK_WHEEL_SHA256. Prepare `.venv` with `uv sync --locked --extra dev --no-install-project`, then run `python ci/sdk_consumer.py` using a Python 3.12 environment and task TMPDIR. ci/sdk_consumer.py 0 → 100644 +113 −0 Changes for ci/sdk_consumer.py: 113 added lines, 0 removed lines. Original line number Diff line number Diff line """Qualify the actual SDK wheel against SRM's offline consumer suite.""" import hashlib import json import os import re import subprocess import zipfile from pathlib import Path from urllib.request import Request, urlopen from xml.etree import ElementTree ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / ".cache/sdk-consumer" def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def main(): OUT.mkdir(parents=True, exist_ok=True) expected_srm = os.getenv("EXPECTED_SRM_SHA") revision = ( os.getenv("CI_COMMIT_SHA") or subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() ) if expected_srm and revision != expected_srm: raise ValueError("Consumer checkout differs from the requested SRM revision") sha = os.environ["SDK_SOURCE_SHA"] expected_hash = os.environ["SDK_WHEEL_SHA256"] wheel_name = os.environ["SDK_WHEEL_NAME"] if not re.fullmatch(r"[a-f0-9]{40}", sha) or not re.fullmatch(r"[a-f0-9]{64}", expected_hash): raise ValueError("Exact SDK source revision and wheel checksum are required") if Path(wheel_name).name != wheel_name or not wheel_name.endswith(".whl"): raise ValueError("Invalid wheel name") local = os.getenv("SDK_WHEEL_FILE") if local: wheel = Path(local).resolve() manifest = json.loads(Path(os.environ["SDK_MANIFEST_FILE"]).read_text()) else: job = os.environ["SDK_BUILD_JOB_ID"] if not job.isdecimal() or os.environ["SDK_PROJECT_ID"] != "396": raise ValueError("Only a TF-SDK build job can supply the candidate") url = f"{os.environ['CI_API_V4_URL']}/projects/396/jobs/{job}/artifacts" request = Request(url, headers={"JOB-TOKEN": os.environ["CI_JOB_TOKEN"]}) archive = OUT / "sdk-build.zip" with urlopen(request, timeout=90) as response: archive.write_bytes(response.read()) with zipfile.ZipFile(archive) as contents: manifest = json.loads(contents.read(".cache/ci/manifest.json")) wheel = OUT / wheel_name wheel.write_bytes(contents.read(f".cache/ci/dist/{wheel_name}")) if manifest["source_sha"] != sha or digest(wheel) != expected_hash: raise ValueError("SDK artifact identity differs from the requested candidate") if not any(x == {"name": wheel_name, "sha256": expected_hash} for x in manifest["artifacts"]): raise ValueError("Wheel is absent from the build manifest") python = ROOT / ".venv/bin/python" subprocess.run( [ "uv", "pip", "install", "--reinstall-package", "openop-tfsdk", "--python", str(python), str(wheel), ], check=True, ) subprocess.run(["uv", "pip", "check", "--python", str(python)], check=True) environment = dict(os.environ, PYTHONPATH=str(ROOT / "src")) report = OUT / "junit.xml" subprocess.run( [ str(python), "-m", "pytest", "--confcutdir=tests/unit", "tests/unit", f"--junitxml={report}", ], env=environment, check=True, ) suites = ElementTree.parse(report).getroot() if any(int(s.get("skipped", "0")) for s in suites.iter("testsuite")): raise ValueError("Skipped consumer tests cannot qualify the candidate") revision = ( os.getenv("CI_COMMIT_SHA") or subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() ) (OUT / "qualification.json").write_text( json.dumps( { "status": "passed", "source_sha": revision, "pipeline_url": os.getenv("CI_PIPELINE_URL"), "sdk_sha": sha, "wheel_sha256": expected_hash, "build_job_id": manifest.get("build_job_id"), "baseline_lock_overlay": True, "scope": "offline consumer unit and real SDK bridge tests; no live E2E", }, indent=2, ) + "\n" ) if __name__ == "__main__": main() tests/unit/test_tfsdk_registry_wiring.py +57 −0 Changes for tests/unit/test_tfsdk_registry_wiring.py: 57 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import UTC, datetime from pathlib import Path from typing import Any from unittest.mock import Mock import pytest from openop_tfsdk.contract.errors import DomainExecutionFailed as TfSdkDomainExecutionFailed Loading Loading @@ -403,6 +404,62 @@ def test_registry_resolves_multiple_entries() -> None: assert registry.control_path_refs() == ["k8s-athens-edge", "k8s-other"] def test_registry_derives_identity_without_mutating_input() -> None: settings = ControlPathSettings( entries=[ ControlPathEntry( control_path_ref="athens", adapter="opaque", config={"nested": {"value": "original"}}, ) ] ) factory = Mock() build_control_path_registry(settings, factory) kind, config = factory.create_adapter.call_args.args assert kind == "opaque" assert config == {"nested": {"value": "original"}, "control_path_ref": "athens"} config["nested"]["value"] = "changed" assert settings.entries[0].config == {"nested": {"value": "original"}} @pytest.mark.parametrize( "entries", [ [ ControlPathEntry(control_path_ref="same", adapter="opaque"), ControlPathEntry(control_path_ref="same", adapter="opaque"), ], [ ControlPathEntry( control_path_ref="athens", adapter="opaque", config={"control_path_ref": "berlin"} ) ], [ControlPathEntry(control_path_ref=" ", adapter="opaque")], ], ) def test_registry_rejects_ambiguous_identity_before_any_construction( entries: list[ControlPathEntry], ) -> None: factory = Mock() with pytest.raises(ValueError): build_control_path_registry(ControlPathSettings(entries=entries), factory) factory.create_adapter.assert_not_called() def test_factory_translates_sdk_construction_error(monkeypatch: pytest.MonkeyPatch) -> None: build = Mock(side_effect=TfSdkValidationError("invalid connection configuration")) monkeypatch.setattr( "srm.adapters.transformation_functions.tfsdk_execution.build_adapter", build ) with pytest.raises(ValidationError) as caught: OpenOpTfSdkAdapterFactory().create_adapter("kubernetes", {}) assert caught.value.error_code == TfSdkValidationError.error_code assert caught.value.message == "invalid connection configuration" def test_build_topology_discovery_none_without_path() -> None: assert build_topology_discovery(None) is None assert build_topology_discovery("") is None Loading Loading
.gitlab-ci.yml +54 −0 Changes for .gitlab-ci.yml: 54 added lines, 0 removed lines. Original line number Diff line number Diff line workflow: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_OPEN_MERGE_REQUESTS' when: never - when: always default: image: python:3.12-slim cache: Loading @@ -22,26 +30,46 @@ variables: UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" type: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: type script: - mypy src/ tests/ architecture: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: architecture script: - lint-imports lint: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: lint script: - ruff check src/ tests/ format: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: format script: - ruff format --check src/ tests/ test: rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - when: on_success stage: test variables: DOCKER_HOST: tcp://docker:2375 Loading @@ -68,4 +96,30 @@ build-and-push: - docker buildx build --provenance=false --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" --push . - docker logout "$CI_REGISTRY" rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' when: never - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"' sdk-consumer: stage: test image: python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 timeout: 15 minutes variables: TMPDIR: "$CI_PROJECT_DIR/.cache/sdk-tmp" PYTHONDONTWRITEBYTECODE: "1" rules: - if: '$CI_PIPELINE_SOURCE == "pipeline" && $SDK_PROJECT_ID == "396"' - when: never before_script: - mkdir -p "$TMPDIR" - pip install uv==0.11.26 - uv sync --locked --extra dev --no-install-project script: - python ci/sdk_consumer.py artifacts: when: always paths: - .cache/sdk-consumer/qualification.json reports: junit: .cache/sdk-consumer/junit.xml expire_in: 90 days
ci/sdk-consumer.md 0 → 100644 +28 −0 Changes for ci/sdk-consumer.md: 28 added lines, 0 removed lines. Original line number Diff line number Diff line # SDK candidate qualification The `sdk-consumer` job accepts candidates only in a downstream pipeline from TF-SDK project 396. CI_JOB_TOKEN downloads a single build job's artifact; source SHA, manifest membership and wheel SHA-256 must match the request. The normal locked environment is prepared without installing SRM's distribution; the candidate wheel forcibly replaces the baseline SDK, including same-version rebuilds and installs its declared runtime dependencies. `uv pip check` must pass. Source imports use SRM's src directory. The normal lock pins the published 2.0.0rc2 candidate. New SDK candidates can be overlaid for qualification before their own registry publication. Registry construction passes each entry's control-path reference to its SDK instance on a copied configuration. Empty or duplicate references and conflicting config identities fail before constructing any adapter. Kubernetes fixtures declare their connection mode and namespace explicitly. SDK construction errors use the existing SRM error translation. These conventions work with the published rc2 baseline and the stricter candidate without changing the dependency lock. All tests/unit tests run, including the real SDK factory/bridge boundary. Missing configuration, skipped tests and failures block qualification. The receipt records both source revisions, the wheel checksum and CI pipeline URL. These offline checks cannot establish cluster, NEF or OEG-driven acceptance. Other SRM pipeline jobs retain their normal behavior outside this explicit consumer mode. Local execution accepts SDK_WHEEL_FILE and SDK_MANIFEST_FILE instead of a remote job; also supply SDK_SOURCE_SHA, SDK_WHEEL_NAME and SDK_WHEEL_SHA256. Prepare `.venv` with `uv sync --locked --extra dev --no-install-project`, then run `python ci/sdk_consumer.py` using a Python 3.12 environment and task TMPDIR.
ci/sdk_consumer.py 0 → 100644 +113 −0 Changes for ci/sdk_consumer.py: 113 added lines, 0 removed lines. Original line number Diff line number Diff line """Qualify the actual SDK wheel against SRM's offline consumer suite.""" import hashlib import json import os import re import subprocess import zipfile from pathlib import Path from urllib.request import Request, urlopen from xml.etree import ElementTree ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / ".cache/sdk-consumer" def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def main(): OUT.mkdir(parents=True, exist_ok=True) expected_srm = os.getenv("EXPECTED_SRM_SHA") revision = ( os.getenv("CI_COMMIT_SHA") or subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() ) if expected_srm and revision != expected_srm: raise ValueError("Consumer checkout differs from the requested SRM revision") sha = os.environ["SDK_SOURCE_SHA"] expected_hash = os.environ["SDK_WHEEL_SHA256"] wheel_name = os.environ["SDK_WHEEL_NAME"] if not re.fullmatch(r"[a-f0-9]{40}", sha) or not re.fullmatch(r"[a-f0-9]{64}", expected_hash): raise ValueError("Exact SDK source revision and wheel checksum are required") if Path(wheel_name).name != wheel_name or not wheel_name.endswith(".whl"): raise ValueError("Invalid wheel name") local = os.getenv("SDK_WHEEL_FILE") if local: wheel = Path(local).resolve() manifest = json.loads(Path(os.environ["SDK_MANIFEST_FILE"]).read_text()) else: job = os.environ["SDK_BUILD_JOB_ID"] if not job.isdecimal() or os.environ["SDK_PROJECT_ID"] != "396": raise ValueError("Only a TF-SDK build job can supply the candidate") url = f"{os.environ['CI_API_V4_URL']}/projects/396/jobs/{job}/artifacts" request = Request(url, headers={"JOB-TOKEN": os.environ["CI_JOB_TOKEN"]}) archive = OUT / "sdk-build.zip" with urlopen(request, timeout=90) as response: archive.write_bytes(response.read()) with zipfile.ZipFile(archive) as contents: manifest = json.loads(contents.read(".cache/ci/manifest.json")) wheel = OUT / wheel_name wheel.write_bytes(contents.read(f".cache/ci/dist/{wheel_name}")) if manifest["source_sha"] != sha or digest(wheel) != expected_hash: raise ValueError("SDK artifact identity differs from the requested candidate") if not any(x == {"name": wheel_name, "sha256": expected_hash} for x in manifest["artifacts"]): raise ValueError("Wheel is absent from the build manifest") python = ROOT / ".venv/bin/python" subprocess.run( [ "uv", "pip", "install", "--reinstall-package", "openop-tfsdk", "--python", str(python), str(wheel), ], check=True, ) subprocess.run(["uv", "pip", "check", "--python", str(python)], check=True) environment = dict(os.environ, PYTHONPATH=str(ROOT / "src")) report = OUT / "junit.xml" subprocess.run( [ str(python), "-m", "pytest", "--confcutdir=tests/unit", "tests/unit", f"--junitxml={report}", ], env=environment, check=True, ) suites = ElementTree.parse(report).getroot() if any(int(s.get("skipped", "0")) for s in suites.iter("testsuite")): raise ValueError("Skipped consumer tests cannot qualify the candidate") revision = ( os.getenv("CI_COMMIT_SHA") or subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() ) (OUT / "qualification.json").write_text( json.dumps( { "status": "passed", "source_sha": revision, "pipeline_url": os.getenv("CI_PIPELINE_URL"), "sdk_sha": sha, "wheel_sha256": expected_hash, "build_job_id": manifest.get("build_job_id"), "baseline_lock_overlay": True, "scope": "offline consumer unit and real SDK bridge tests; no live E2E", }, indent=2, ) + "\n" ) if __name__ == "__main__": main()
tests/unit/test_tfsdk_registry_wiring.py +57 −0 Changes for tests/unit/test_tfsdk_registry_wiring.py: 57 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import UTC, datetime from pathlib import Path from typing import Any from unittest.mock import Mock import pytest from openop_tfsdk.contract.errors import DomainExecutionFailed as TfSdkDomainExecutionFailed Loading Loading @@ -403,6 +404,62 @@ def test_registry_resolves_multiple_entries() -> None: assert registry.control_path_refs() == ["k8s-athens-edge", "k8s-other"] def test_registry_derives_identity_without_mutating_input() -> None: settings = ControlPathSettings( entries=[ ControlPathEntry( control_path_ref="athens", adapter="opaque", config={"nested": {"value": "original"}}, ) ] ) factory = Mock() build_control_path_registry(settings, factory) kind, config = factory.create_adapter.call_args.args assert kind == "opaque" assert config == {"nested": {"value": "original"}, "control_path_ref": "athens"} config["nested"]["value"] = "changed" assert settings.entries[0].config == {"nested": {"value": "original"}} @pytest.mark.parametrize( "entries", [ [ ControlPathEntry(control_path_ref="same", adapter="opaque"), ControlPathEntry(control_path_ref="same", adapter="opaque"), ], [ ControlPathEntry( control_path_ref="athens", adapter="opaque", config={"control_path_ref": "berlin"} ) ], [ControlPathEntry(control_path_ref=" ", adapter="opaque")], ], ) def test_registry_rejects_ambiguous_identity_before_any_construction( entries: list[ControlPathEntry], ) -> None: factory = Mock() with pytest.raises(ValueError): build_control_path_registry(ControlPathSettings(entries=entries), factory) factory.create_adapter.assert_not_called() def test_factory_translates_sdk_construction_error(monkeypatch: pytest.MonkeyPatch) -> None: build = Mock(side_effect=TfSdkValidationError("invalid connection configuration")) monkeypatch.setattr( "srm.adapters.transformation_functions.tfsdk_execution.build_adapter", build ) with pytest.raises(ValidationError) as caught: OpenOpTfSdkAdapterFactory().create_adapter("kubernetes", {}) assert caught.value.error_code == TfSdkValidationError.error_code assert caught.value.message == "invalid connection configuration" def test_build_topology_discovery_none_without_path() -> None: assert build_topology_discovery(None) is None assert build_topology_discovery("") is None Loading