Loading .gitlab-ci.yml +58 −1 Changes for .gitlab-ci.yml: 58 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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: Loading @@ -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: Loading @@ -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 ansible/tests/r2_e2e/SDK_ACCEPTANCE.md +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 Loading @@ -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() Loading
.gitlab-ci.yml +58 −1 Changes for .gitlab-ci.yml: 58 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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: Loading @@ -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: Loading @@ -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
ansible/tests/r2_e2e/SDK_ACCEPTANCE.md +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 Loading @@ -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()