Commit e43aa6ef authored by Paris Stentoumis's avatar Paris Stentoumis
Browse files

ci: qualify TF-SDK consumer builds in isolated downstream jobs

Co-authored-by: default avatarCursor <cursoragent@cursor.com>
parent 8fdb12a5
Loading
Loading
Loading
Loading
Loading
+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:
@@ -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
@@ -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()
+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
@@ -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
@@ -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