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

ci: add protected isolated SDK acceptance mode with artifact-bound receipts

parent 36a623a3
Loading
Loading
Loading
Loading
Loading
+58 −1
Changes for .gitlab-ci.yml: 58 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -23,6 +23,10 @@ cache:
    - ansible-galaxy collection install -r ansible/requirements.yml

secret-scan:
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1"'
      when: never
    - when: on_success
  stage: lint
  image: alpine:latest
  before_script:
@@ -39,18 +43,30 @@ secret-scan:
      fi

yamllint:
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1"'
      when: never
    - when: on_success
  stage: lint
  extends: .ansible_setup
  script:
    - yamllint .

ansible-lint:
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1"'
      when: never
    - when: on_success
  stage: lint
  extends: .ansible_setup
  script:
    - ansible-lint -c .ansible-lint ansible/playbooks ansible/roles ansible/role-template

syntax-check:
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1"'
      when: never
    - when: on_success
  stage: test
  extends: .ansible_setup
  script:
@@ -62,18 +78,59 @@ syntax-check:
      done

python-smoke:
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1"'
      when: never
    - when: on_success
  stage: test
  script:
    - python3 -m py_compile ansible/roles/oop-platform/files/fm_partners.py ansible/tests/r2_e2e/*.py

# Pure gate/observation checks; no inventory, cluster access or deployment secrets.
sdk-acceptance-checks:
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1"'
      when: never
    - when: on_success
  stage: test
  variables:
    TMPDIR: "$CI_PROJECT_DIR/.cache/sdk-tmp"
    PYTHONDONTWRITEBYTECODE: "1"
  script:
    - mkdir -p "$TMPDIR"
    - pip install pytest==8.3.2 requests==2.32.5
    - pip install pytest==8.3.2 requests==2.33.0
    - python -m pytest --confcutdir=ansible/tests/unit -q -p no:cacheprovider ansible/tests/unit/test_sdk_acceptance.py
    - python -m pytest --collect-only -q -p no:cacheprovider ansible/tests/r2_e2e

# Explicit live mode leaves all default component tests and deployment jobs alone.
sdk-live-acceptance:
  stage: test
  image: python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
  tags: [sdk-lab]
  interruptible: false
  timeout: 30 minutes
  resource_group: dedicated-sdk-lab
  environment:
    name: sdk-acceptance
    action: verify
  cache: []
  variables:
    TMPDIR: "$CI_PROJECT_DIR/.cache/sdk-tmp"
    PYTHONDONTWRITEBYTECODE: "1"
  rules:
    - if: '$RUN_SDK_ACCEPTANCE == "1" && $CI_COMMIT_REF_PROTECTED == "true"'
    - when: never
  before_script:
    - mkdir -p "$TMPDIR"
    - pip install pytest==8.3.2 requests==2.33.0
    - kubectl version --client
  script:
    - python ci/sdk_live.py
  artifacts:
    when: always
    paths:
      - .cache/sdk-live/qualification.json
      - .cache/sdk-e2e/*/run.json
    reports:
      junit: .cache/sdk-live/junit.xml
    expire_in: 90 days
+20 −0
Changes for ansible/tests/r2_e2e/SDK_ACCEPTANCE.md: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -94,3 +94,23 @@ The first checks the new gate/observation helpers with controlled inputs. The
second must retain the same 23 default cases and deselect the two SDK cases.
Neither is evidence of a live OEG/SRM/SDK/backend run. No live test is added to
the existing CI jobs by this change.


## Protected CI execution

The `sdk-live-acceptance` job runs only with RUN_SDK_ACCEPTANCE=1 on a protected
lab ref and a dedicated `sdk-lab` runner with kubectl and lab network access.
Use SDK_CHECKPOINT=endpoints first, then ti after agreeing the NEF profile.
Supply protected file variables SDK_LAB_PROFILE, OOP_E2E_SETTINGS and KUBECONFIG,
and SDK_BUILD_JOB_ID for the exact SDK build artifact. Scope these credentials
to sdk-acceptance. Grant Ansible project 443 job-token access to SDK project 396.
The job verifies the source revision and fingerprint of the actual wheel before
calling the lab; the in-cluster SDK must match too. Missing inputs fail before
cluster calls. No deployment or namespace is provisioned implicitly.

A resource group serializes the dedicated lab; active acceptance runs are not
cancelled by later source pushes. Existing suites stay unchanged in normal mode.
JUnit and a qualification receipt record the selected checkpoint, source SHA,
SDK wheel checksum and running SRM/OEG image digests. No credentials or complete
settings/profile are exported as CI artifacts. A passing endpoints checkpoint
cannot substitute for Traffic Influence or other release acceptance scenarios.

ci/sdk_live.py

0 → 100644
+99 −0
Changes for ci/sdk_live.py: 99 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Run an explicitly selected SDK acceptance checkpoint in a dedicated lab."""

import hashlib
import json
import os
import subprocess
import sys
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-live"
sys.path.insert(0, str(ROOT / "ansible/tests/r2_e2e"))
from sdk_artifact import wheel as fingerprint  # noqa: E402
from sdk_scenario import load_profile  # noqa: E402


def required_file(name):
    value = os.getenv(name)
    if not value or not Path(value).is_file():
        raise ValueError(f"Supply the protected file variable {name}")
    return Path(value)


def main():
    if os.getenv("CI_COMMIT_REF_PROTECTED") != "true":
        raise ValueError("Live acceptance requires a protected lab ref")
    checkpoint = os.getenv("SDK_CHECKPOINT", "endpoints")
    if checkpoint not in ("endpoints", "ti"):
        raise ValueError("Select endpoints or ti")
    profile_file = required_file("SDK_LAB_PROFILE")
    settings_file = required_file("OOP_E2E_SETTINGS")
    kubeconfig = required_file("KUBECONFIG")
    profile = load_profile(profile_file, checkpoint)
    settings = json.loads(settings_file.read_text())
    operators = settings.get("operators", [])
    if len(operators) != 1:
        raise ValueError("Use one dedicated lab operator")
    operator = operators[0]
    if any(operator.get(k) != profile[v] for k, v in (
        ("host", "operator"), ("namespace", "platform_namespace"), ("kube_context", "kube_context")
    )):
        raise ValueError("Lab settings and profile identify different targets")
    OUT.mkdir(parents=True, exist_ok=True)
    job = os.environ["SDK_BUILD_JOB_ID"]
    if not job.isdecimal():
        raise ValueError("A numeric SDK build job ID is required")
    url = f"{os.environ['CI_API_V4_URL']}/projects/396/jobs/{job}/artifacts"
    request = Request(url, headers={"JOB-TOKEN": os.environ["CI_JOB_TOKEN"]})
    with urlopen(request, timeout=90) as response:
        raw = response.read()
    archive = OUT / "build.zip"
    archive.write_bytes(raw)
    with zipfile.ZipFile(archive) as contents:
        manifest = json.loads(contents.read(".cache/ci/manifest.json"))
        rows = [x for x in manifest["artifacts"] if x["name"].endswith(".whl")]
        if len(rows) != 1:
            raise ValueError("Expected one wheel")
        row = rows[0]
        wheel_path = OUT / "candidate.whl"
        wheel_path.write_bytes(contents.read(".cache/ci/dist/" + row["name"]))
    if manifest["source_sha"] != profile["sdk"]["revision"]:
        raise ValueError("Profile and SDK build source differ")
    if hashlib.sha256(wheel_path.read_bytes()).hexdigest() != row["sha256"]:
        raise ValueError("SDK build artifact checksum mismatch")
    observed = fingerprint(wheel_path)
    if any(observed[k] != profile["sdk"][k] for k in ("version", "sha256")):
        raise ValueError("Profile and actual SDK wheel contents differ")
    # Never persist the operator credentials or kubeconfig in CI artifacts.
    operator["kubeconfig"] = str(kubeconfig.resolve())
    temporary_settings = OUT / "settings.private.json"
    temporary_settings.write_text(json.dumps(settings))
    temporary_settings.chmod(0o600)
    report = OUT / "junit.xml"
    try:
        subprocess.run([
            sys.executable, "-m", "pytest", "-q", "ansible/tests/r2_e2e",
            "--sdk-checkpoint=" + checkpoint, "--sdk-profile=" + str(profile_file),
            "--junitxml=" + str(report),
        ], env=dict(os.environ, OOP_E2E_SETTINGS=str(temporary_settings)), check=True)
    finally:
        temporary_settings.unlink(missing_ok=True)
    suites = list(ElementTree.parse(report).getroot().iter("testsuite"))
    if sum(int(s.get("tests", "0")) for s in suites) != 1 or any(
        int(s.get(k, "0")) for s in suites for k in ("skipped", "errors", "failures")
    ):
        raise ValueError("A complete passing acceptance checkpoint is required")
    (OUT / "qualification.json").write_text(json.dumps({
        "status": "passed", "source_sha": os.environ["CI_COMMIT_SHA"],
        "pipeline_url": os.environ["CI_PIPELINE_URL"], "sdk_sha": manifest["source_sha"],
        "wheel_sha256": row["sha256"], "checkpoint": checkpoint,
        "srm_image": profile["srm"]["image_digest"], "oeg_image": profile["oeg"]["image_digest"],
    }, indent=2) + "\n")


if __name__ == "__main__":
    main()