Commit 3f677b42 authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

ci: qualify exact TF-SDK wheel in isolated downstream consumer jobs

parent 5112691d
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
+20 −0
Changes for ci/sdk-consumer.md: 20 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 replaces the baseline SDK and installs its declared runtime
dependencies. `uv pip check` must pass. Source imports use SRM's src directory.
The lock remains the published 1.1.1 baseline until a qualified 2.0 package exists.

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
+95 −0
Changes for ci/sdk_consumer.py: 95 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 = 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", "--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 = 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()