From 46a1543b8a8dd6595abbecd84ae550b64841fada Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:24:51 +0800 Subject: [PATCH] feat(runtime): add OCI JIT shadow preflight Co-Authored-By: Codex --- .../workflows/oci-jit-shadow-preflight.yml | 123 ++++++ docs/operator_runbook.md | 10 + docs/runtime_isolation_migration.md | 8 + infra/oci-jit-shadow/README.md | 99 +++++ infra/oci-jit-shadow/contract.json | 86 ++++ .../preflight-attestations.example.json | 34 ++ scripts/oci_jit_shadow_preflight.py | 369 ++++++++++++++++++ .../oci_jit_shadow/clean_inventory.json | 16 + .../oci_jit_shadow/orphan_inventory.json | 31 ++ tests/test_oci_jit_shadow_preflight.py | 143 +++++++ 10 files changed, 919 insertions(+) create mode 100644 .github/workflows/oci-jit-shadow-preflight.yml create mode 100644 infra/oci-jit-shadow/README.md create mode 100644 infra/oci-jit-shadow/contract.json create mode 100644 infra/oci-jit-shadow/preflight-attestations.example.json create mode 100644 scripts/oci_jit_shadow_preflight.py create mode 100644 tests/fixtures/oci_jit_shadow/clean_inventory.json create mode 100644 tests/fixtures/oci_jit_shadow/orphan_inventory.json create mode 100644 tests/test_oci_jit_shadow_preflight.py diff --git a/.github/workflows/oci-jit-shadow-preflight.yml b/.github/workflows/oci-jit-shadow-preflight.yml new file mode 100644 index 00000000..c498dd86 --- /dev/null +++ b/.github/workflows/oci-jit-shadow-preflight.yml @@ -0,0 +1,123 @@ +name: OCI JIT Shadow Preflight + +on: + workflow_dispatch: + inputs: + require_ready: + description: "Fail unless all redacted OCI variables and attestations are configured" + required: false + default: false + type: boolean + +permissions: + contents: read + +jobs: + preflight-and-no-order-shadow: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout BinancePlatform + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Render redacted non-applying OCI lifecycle plan + env: + OCI_JIT_REGION: ${{ vars.OCI_JIT_REGION }} + OCI_JIT_COMPARTMENT_OCID: ${{ vars.OCI_JIT_COMPARTMENT_OCID }} + OCI_JIT_AVAILABILITY_DOMAIN: ${{ vars.OCI_JIT_AVAILABILITY_DOMAIN }} + OCI_JIT_SUBNET_OCID: ${{ vars.OCI_JIT_SUBNET_OCID }} + OCI_JIT_NAT_GATEWAY_OCID: ${{ vars.OCI_JIT_NAT_GATEWAY_OCID }} + OCI_JIT_RESERVED_PUBLIC_IP_OCID: ${{ vars.OCI_JIT_RESERVED_PUBLIC_IP_OCID }} + OCI_JIT_IMAGE_OCID: ${{ vars.OCI_JIT_IMAGE_OCID }} + OCI_JIT_SHAPE: ${{ vars.OCI_JIT_SHAPE }} + OCI_JIT_RUNNER_GROUP: ${{ vars.OCI_JIT_RUNNER_GROUP }} + OCI_JIT_DEFINED_TAG_NAMESPACE: ${{ vars.OCI_JIT_DEFINED_TAG_NAMESPACE }} + OCI_JIT_DEFINED_TAG_KEY: ${{ vars.OCI_JIT_DEFINED_TAG_KEY }} + OCI_JIT_DEFINED_TAG_VALUE: ${{ vars.OCI_JIT_DEFINED_TAG_VALUE }} + OCI_JIT_RUNTIME_DYNAMIC_GROUP_OCID: ${{ vars.OCI_JIT_RUNTIME_DYNAMIC_GROUP_OCID }} + OCI_JIT_RUNTIME_POLICY_OCID: ${{ vars.OCI_JIT_RUNTIME_POLICY_OCID }} + OCI_JIT_LAUNCHER_PRINCIPAL_OCID: ${{ vars.OCI_JIT_LAUNCHER_PRINCIPAL_OCID }} + OCI_JIT_EVIDENCE_BUCKET_NAME: ${{ vars.OCI_JIT_EVIDENCE_BUCKET_NAME }} + OCI_JIT_ATTESTATIONS_JSON: ${{ vars.OCI_JIT_ATTESTATIONS_JSON }} + REQUIRE_READY: ${{ inputs.require_ready }} + run: | + set -euo pipefail + args=( + preflight + --contract infra/oci-jit-shadow/contract.json + --attestations-env OCI_JIT_ATTESTATIONS_JSON + --output reports/oci_jit_shadow_preflight.json + ) + if [[ "$REQUIRE_READY" == "true" ]]; then + args+=(--require-ready) + fi + python scripts/oci_jit_shadow_preflight.py "${args[@]}" + + - name: Exercise fail-closed orphan audit contract + run: | + set -euo pipefail + python scripts/oci_jit_shadow_preflight.py audit-orphans \ + --contract infra/oci-jit-shadow/contract.json \ + --inventory tests/fixtures/oci_jit_shadow/clean_inventory.json \ + --output reports/oci_jit_shadow_orphan_audit.json \ + --require-ready + + - name: Resolve pinned dependency refs + id: refs + shell: bash + run: | + set -euo pipefail + qpk_ref="$(grep -Eo 'QuantPlatformKit\.git@[0-9a-f]+' pyproject.toml | head -n1 | sed 's/.*@//')" + strategies_ref="$(grep -Eo 'CryptoStrategies\.git@[0-9a-f]+' pyproject.toml | head -n1 | sed 's/.*@//')" + test -n "$qpk_ref" + test -n "$strategies_ref" + echo "qpk_ref=$qpk_ref" >> "$GITHUB_OUTPUT" + echo "strategies_ref=$strategies_ref" >> "$GITHUB_OUTPUT" + + - name: Checkout QuantPlatformKit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: ${{ steps.refs.outputs.qpk_ref }} + path: external/QuantPlatformKit + + - name: Checkout CryptoStrategies + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: QuantStrategyLab/CryptoStrategies + ref: ${{ steps.refs.outputs.strategies_ref }} + path: external/CryptoStrategies + + - name: Install locked runtime + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check uv==0.11.6 + uv sync --frozen --no-dev + uv pip install --no-deps -e external/QuantPlatformKit -e external/CryptoStrategies + + - name: Run fixed-input no-order replay + env: + BINANCE_DRY_RUN: "true" + run: | + set -euo pipefail + uv run --no-sync python scripts/run_isolation_shadow_fixture.py \ + --output reports/oci_jit_shadow_report.json \ + --digest-output reports/oci_jit_shadow_report.sha256 + + - name: Upload redacted preflight and shadow evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: oci-jit-shadow-preflight-${{ github.run_id }} + path: | + reports/oci_jit_shadow_preflight.json + reports/oci_jit_shadow_orphan_audit.json + reports/oci_jit_shadow_report.json + reports/oci_jit_shadow_report.sha256 + if-no-files-found: warn + retention-days: 7 diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index aa6a8b2c..c21f4fa5 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -109,6 +109,16 @@ and compare semantic report digests. Neither job references Binance credentials, the `binance-runtime` environment, or Google OIDC. Passing proves fixture parity, not live readiness or host ephemerality. +`OCI JIT Shadow Preflight` implements the next non-applying preparation step. It +checks the committed private-subnet, reserved-NAT, defined-tag, +instance-principal, one-job runner, and delete-on-termination contract; reports +which operator-owned OCI repository variables are still missing; exercises the +fail-closed orphan audit; and reruns the no-order fixture. It has no OCI OIDC, +broker secret, environment, or mutation authority. The required variables and +redacted attestation format are listed in +[`infra/oci-jit-shadow/README.md`](../infra/oci-jit-shadow/README.md). A clean +fixture audit proves the checker, not the absence of real OCI or GitHub orphans. + ## Degraded Mode Ladder Healthy mode: diff --git a/docs/runtime_isolation_migration.md b/docs/runtime_isolation_migration.md index 2afdc506..1588ddb8 100644 --- a/docs/runtime_isolation_migration.md +++ b/docs/runtime_isolation_migration.md @@ -97,6 +97,14 @@ compartment, current instance/VNIC, route table, and allowlisted egress ownershi outside public artifacts. Confirm the new reserved NAT address and OCI IAM policy before choosing an image or Vault implementation. +The first implementation batch now lives under `infra/oci-jit-shadow/`. Its +committed contract and preflight script render the bounded launch/terminate +lifecycle, validate redacted private-subnet/NAT/reserved-IP/defined-tag and +instance-principal attestations, and fail closed on stale tagged resources. The +manual workflow has no OCI mutation authority or broker secret and runs the +same fixed-input no-order fixture. This is deployment readiness evidence only; +it does not provision the selected architecture or authorize a live cutover. + ### Phase 2 validation evidence - Host profile run `32644765084`: `PARTIAL`, QEMU VM, no secret value read, diff --git a/infra/oci-jit-shadow/README.md b/infra/oci-jit-shadow/README.md new file mode 100644 index 00000000..f6eace06 --- /dev/null +++ b/infra/oci-jit-shadow/README.md @@ -0,0 +1,99 @@ +# OCI JIT shadow deployment preflight + +This directory is the first implementation batch for the disposable Oracle +Cloud Compute runner selected in `docs/runtime_isolation_migration.md`. It is a +**non-applying control-plane contract**. It does not call OCI, register a GitHub +runner, read a secret, change the Binance allowlist, or alter the current live +runner. + +`contract.json` records the reviewed launch and termination invariants. The +preflight script validates those invariants, reports whether the operator-owned +OCI inputs are configured, consumes a redacted read-only attestation, and emits +only variable names and readiness findings. It never emits variable values or +resource OCIDs. + +## Repository variables the operator must provide later + +Configure these as GitHub repository variables only after the corresponding OCI +resources have been reviewed. None is a broker credential. + +| Variable | Required meaning | +| --- | --- | +| `OCI_JIT_REGION` | OCI region containing the candidate runtime | +| `OCI_JIT_COMPARTMENT_OCID` | Dedicated candidate compartment | +| `OCI_JIT_AVAILABILITY_DOMAIN` | Availability domain for the on-demand instance | +| `OCI_JIT_SUBNET_OCID` | Private subnet with no instance public IP | +| `OCI_JIT_NAT_GATEWAY_OCID` | NAT gateway used by that private subnet | +| `OCI_JIT_RESERVED_PUBLIC_IP_OCID` | Reserved public IP attached to the NAT gateway | +| `OCI_JIT_IMAGE_OCID` | Reviewed immutable custom image | +| `OCI_JIT_SHAPE` | Right-sized on-demand flexible shape | +| `OCI_JIT_RUNNER_GROUP` | Dedicated GitHub runner group that accepts only the reviewed workflow | +| `OCI_JIT_DEFINED_TAG_NAMESPACE` | Defined-tag namespace for candidate ownership | +| `OCI_JIT_DEFINED_TAG_KEY` | Defined-tag key matched by IAM and orphan audit | +| `OCI_JIT_DEFINED_TAG_VALUE` | Defined-tag value for the Binance candidate runtime | +| `OCI_JIT_RUNTIME_DYNAMIC_GROUP_OCID` | Dynamic group limited by compartment and defined tag | +| `OCI_JIT_RUNTIME_POLICY_OCID` | Runtime policy with no Compute/IAM/Vault administration | +| `OCI_JIT_LAUNCHER_PRINCIPAL_OCID` | Separate launcher identity, unable to read broker secrets | +| `OCI_JIT_EVIDENCE_BUCKET_NAME` | External destination for redacted runner and terminal evidence | +| `OCI_JIT_ATTESTATIONS_JSON` | Redacted JSON matching `preflight-attestations.example.json` | + +Keep the exact OCIDs and the reserved public address out of repository files and +workflow artifacts. Do not store `BINANCE_API_KEY`, `BINANCE_API_SECRET`, GitHub +JIT registration tokens, OCI API private keys, or Vault secret material in any +of these variables. Broker credentials remain prohibited in the fixture-shadow +phase. + +## What the attestation means + +The example attestation is a schema example, not evidence that any OCI resource +exists. Before setting `OCI_JIT_ATTESTATIONS_JSON`, use the OCI Console or a +read-only OCI identity to verify all represented facts: + +- the subnet is private, assigns no public IP, and routes outbound traffic + through the expected NAT gateway; +- the NAT gateway is available and owns the reviewed reserved public IP; +- no inbound security rule is required for the runner; +- the dynamic-group rule includes both the dedicated compartment and defined + tag; +- the no-order instance principal can write only the evidence destination and + cannot read the broker secret or manage Compute, IAM, dynamic groups, + policies, Vault, or secret versions; +- the launcher is limited to tagged candidates and cannot read the broker + secret or manage network/IAM resources; +- the image is pinned, capacity is on-demand, runner assignment is one job, and + instance termination deletes the boot volume. + +The preflight validates the redacted statements. It does not replace an OCI IAM +policy review and does not prove the raw OCI resources by itself. + +## Safe preflight and orphan audit + +The manual `OCI JIT Shadow Preflight` workflow has `contents: read`, no OIDC, +environment, or secret references. It renders a redacted launch/terminate plan, +exercises the orphan-audit engine with a clean fixture, and runs the fixed-input +no-order strategy replay. With `require_ready=false`, missing OCI inputs produce +a `PARKED` terminal report while the no-order replay still runs. Use +`require_ready=true` only after all variables and the read-only attestation are +configured. + +To check a real inventory without mutating it, export a JSON object matching +`qsl.oci_jit_shadow_inventory.v1` from read-only OCI and GitHub API queries, then +run: + +```bash +python scripts/oci_jit_shadow_preflight.py audit-orphans \ + --contract infra/oci-jit-shadow/contract.json \ + --inventory /secure/path/oci-jit-inventory.json \ + --output reports/oci_jit_shadow_orphan_audit.json \ + --require-ready +``` + +Only resources matching the candidate defined tag belong in that inventory. +The report replaces resource identifiers with short one-way fingerprints. It +does not delete anything: a stale instance, unattached boot volume, detached +VNIC attachment, lingering GitHub runner registration, incomplete collection, +or malformed evidence results in `PARKED`. + +Actual OCI launch/termination, JIT-token delivery, instance-principal secret +retrieval, and automated cleanup remain a later reviewed phase. No live cutover +is authorized by a successful preflight. diff --git a/infra/oci-jit-shadow/contract.json b/infra/oci-jit-shadow/contract.json new file mode 100644 index 00000000..59fa14c1 --- /dev/null +++ b/infra/oci-jit-shadow/contract.json @@ -0,0 +1,86 @@ +{ + "schema_version": "qsl.oci_jit_shadow_contract.v1", + "deployment_mode": "no_order_shadow", + "apply_authorized": false, + "required_repository_variables": [ + "OCI_JIT_REGION", + "OCI_JIT_COMPARTMENT_OCID", + "OCI_JIT_AVAILABILITY_DOMAIN", + "OCI_JIT_SUBNET_OCID", + "OCI_JIT_NAT_GATEWAY_OCID", + "OCI_JIT_RESERVED_PUBLIC_IP_OCID", + "OCI_JIT_IMAGE_OCID", + "OCI_JIT_SHAPE", + "OCI_JIT_RUNNER_GROUP", + "OCI_JIT_DEFINED_TAG_NAMESPACE", + "OCI_JIT_DEFINED_TAG_KEY", + "OCI_JIT_DEFINED_TAG_VALUE", + "OCI_JIT_RUNTIME_DYNAMIC_GROUP_OCID", + "OCI_JIT_RUNTIME_POLICY_OCID", + "OCI_JIT_LAUNCHER_PRINCIPAL_OCID", + "OCI_JIT_EVIDENCE_BUCKET_NAME", + "OCI_JIT_ATTESTATIONS_JSON" + ], + "network": { + "private_subnet_required": true, + "assign_public_ip": false, + "nat_gateway_required": true, + "reserved_public_ip_required": true, + "inbound_rules_allowed": false + }, + "compute": { + "capacity_type": "ON_DEMAND", + "runner_assignment": "ONE_JOB", + "pinned_custom_image_required": true, + "max_instance_age_minutes": 60, + "delete_boot_volume_on_termination": true + }, + "identity": { + "instance_principal_required": true, + "dynamic_group_requires_compartment": true, + "dynamic_group_requires_defined_tag": true, + "launcher_scope_defined_tag_only": true, + "launcher_can_read_broker_secret": false, + "launcher_can_manage_network": false, + "launcher_can_manage_iam": false, + "runtime_can_read_broker_secret": false, + "runtime_can_write_evidence": true, + "runtime_can_manage_compute": false, + "runtime_can_manage_iam": false, + "runtime_can_manage_vault": false + }, + "shadow": { + "broker_secret_allowed": false, + "github_oidc_allowed": false, + "dry_run_required": true, + "executed_call_count_required": 0, + "platform_retries": 0 + }, + "cleanup": { + "delete_boot_volume": true, + "deregister_runner": true, + "orphan_states": [ + "RUNNING", + "STARTING", + "STOPPED", + "STOPPING" + ], + "orphan_resource_types": [ + "instance", + "boot_volume", + "vnic_attachment", + "github_runner" + ] + }, + "planned_operations": [ + "validate_configuration", + "validate_read_only_oci_attestations", + "launch_tagged_private_instance", + "register_one_job_jit_runner", + "run_no_order_shadow", + "persist_redacted_terminal_evidence", + "deregister_runner", + "terminate_instance_with_boot_volume_deletion", + "audit_orphans" + ] +} diff --git a/infra/oci-jit-shadow/preflight-attestations.example.json b/infra/oci-jit-shadow/preflight-attestations.example.json new file mode 100644 index 00000000..950af480 --- /dev/null +++ b/infra/oci-jit-shadow/preflight-attestations.example.json @@ -0,0 +1,34 @@ +{ + "schema_version": "qsl.oci_jit_shadow_attestations.v1", + "evidence_source": "oci_read_only_export", + "collection_complete": true, + "resource_identifiers_recorded": false, + "network": { + "subnet_is_private": true, + "instance_assigns_public_ip": false, + "nat_gateway_state": "AVAILABLE", + "reserved_public_ip_state": "ASSIGNED", + "private_subnet_routes_through_nat": true, + "inbound_rules_present": false + }, + "identity": { + "instance_principal_enabled": true, + "dynamic_group_scoped_to_compartment": true, + "dynamic_group_scoped_to_defined_tag": true, + "launcher_scope_is_defined_tag_only": true, + "launcher_can_read_broker_secret": false, + "launcher_can_manage_network": false, + "launcher_can_manage_iam": false, + "runtime_can_read_broker_secret": false, + "runtime_can_write_evidence": true, + "runtime_can_manage_compute": false, + "runtime_can_manage_iam": false, + "runtime_can_manage_vault": false + }, + "compute": { + "capacity_type": "ON_DEMAND", + "custom_image_is_pinned": true, + "runner_assignment": "ONE_JOB", + "boot_volume_delete_on_termination": true + } +} diff --git a/scripts/oci_jit_shadow_preflight.py b/scripts/oci_jit_shadow_preflight.py new file mode 100644 index 00000000..5cd92c77 --- /dev/null +++ b/scripts/oci_jit_shadow_preflight.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + + +CONTRACT_SCHEMA = "qsl.oci_jit_shadow_contract.v1" +ATTESTATION_SCHEMA = "qsl.oci_jit_shadow_attestations.v1" +INVENTORY_SCHEMA = "qsl.oci_jit_shadow_inventory.v1" + +REQUIRED_CONTRACT_VALUES: dict[tuple[str, ...], Any] = { + ("deployment_mode",): "no_order_shadow", + ("apply_authorized",): False, + ("network", "private_subnet_required"): True, + ("network", "assign_public_ip"): False, + ("network", "nat_gateway_required"): True, + ("network", "reserved_public_ip_required"): True, + ("network", "inbound_rules_allowed"): False, + ("compute", "capacity_type"): "ON_DEMAND", + ("compute", "runner_assignment"): "ONE_JOB", + ("compute", "pinned_custom_image_required"): True, + ("compute", "delete_boot_volume_on_termination"): True, + ("identity", "instance_principal_required"): True, + ("identity", "dynamic_group_requires_compartment"): True, + ("identity", "dynamic_group_requires_defined_tag"): True, + ("identity", "launcher_scope_defined_tag_only"): True, + ("identity", "launcher_can_read_broker_secret"): False, + ("identity", "launcher_can_manage_network"): False, + ("identity", "launcher_can_manage_iam"): False, + ("identity", "runtime_can_read_broker_secret"): False, + ("identity", "runtime_can_write_evidence"): True, + ("identity", "runtime_can_manage_compute"): False, + ("identity", "runtime_can_manage_iam"): False, + ("identity", "runtime_can_manage_vault"): False, + ("shadow", "broker_secret_allowed"): False, + ("shadow", "github_oidc_allowed"): False, + ("shadow", "dry_run_required"): True, + ("shadow", "executed_call_count_required"): 0, + ("shadow", "platform_retries"): 0, + ("cleanup", "delete_boot_volume"): True, + ("cleanup", "deregister_runner"): True, +} + +REQUIRED_ATTESTATIONS: dict[tuple[str, ...], Any] = { + ("network", "subnet_is_private"): True, + ("network", "instance_assigns_public_ip"): False, + ("network", "nat_gateway_state"): "AVAILABLE", + ("network", "reserved_public_ip_state"): "ASSIGNED", + ("network", "private_subnet_routes_through_nat"): True, + ("network", "inbound_rules_present"): False, + ("identity", "instance_principal_enabled"): True, + ("identity", "dynamic_group_scoped_to_compartment"): True, + ("identity", "dynamic_group_scoped_to_defined_tag"): True, + ("identity", "launcher_scope_is_defined_tag_only"): True, + ("identity", "launcher_can_read_broker_secret"): False, + ("identity", "launcher_can_manage_network"): False, + ("identity", "launcher_can_manage_iam"): False, + ("identity", "runtime_can_read_broker_secret"): False, + ("identity", "runtime_can_write_evidence"): True, + ("identity", "runtime_can_manage_compute"): False, + ("identity", "runtime_can_manage_iam"): False, + ("identity", "runtime_can_manage_vault"): False, + ("compute", "capacity_type"): "ON_DEMAND", + ("compute", "custom_image_is_pinned"): True, + ("compute", "runner_assignment"): "ONE_JOB", + ("compute", "boot_volume_delete_on_termination"): True, +} + +OCID_VARIABLES = { + "OCI_JIT_COMPARTMENT_OCID", + "OCI_JIT_SUBNET_OCID", + "OCI_JIT_NAT_GATEWAY_OCID", + "OCI_JIT_RESERVED_PUBLIC_IP_OCID", + "OCI_JIT_IMAGE_OCID", + "OCI_JIT_RUNTIME_DYNAMIC_GROUP_OCID", + "OCI_JIT_RUNTIME_POLICY_OCID", + "OCI_JIT_LAUNCHER_PRINCIPAL_OCID", +} + + +def _read_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path} must contain a JSON object") + return payload + + +def _get(payload: Mapping[str, Any], path: tuple[str, ...]) -> Any: + value: Any = payload + for part in path: + if not isinstance(value, Mapping) or part not in value: + return None + value = value[part] + return value + + +def _validate_expected( + payload: Mapping[str, Any], expected: Mapping[tuple[str, ...], Any] +) -> list[str]: + failures: list[str] = [] + for path, expected_value in expected.items(): + actual = _get(payload, path) + if actual != expected_value: + failures.append(f"{'.'.join(path)} must be {expected_value!r}") + return failures + + +def validate_contract(contract: Mapping[str, Any]) -> list[str]: + failures: list[str] = [] + if contract.get("schema_version") != CONTRACT_SCHEMA: + failures.append(f"schema_version must be {CONTRACT_SCHEMA}") + failures.extend(_validate_expected(contract, REQUIRED_CONTRACT_VALUES)) + + variables = contract.get("required_repository_variables") + if not isinstance(variables, list) or not variables: + failures.append("required_repository_variables must be a non-empty list") + elif any(not isinstance(name, str) or not name.startswith("OCI_JIT_") for name in variables): + failures.append("repository variable names must use the OCI_JIT_ prefix") + elif len(variables) != len(set(variables)): + failures.append("required_repository_variables must not contain duplicates") + + max_age = _get(contract, ("compute", "max_instance_age_minutes")) + if not isinstance(max_age, int) or isinstance(max_age, bool) or not 5 <= max_age <= 180: + failures.append("compute.max_instance_age_minutes must be between 5 and 180") + + operations = contract.get("planned_operations") + required_operations = { + "launch_tagged_private_instance", + "register_one_job_jit_runner", + "run_no_order_shadow", + "terminate_instance_with_boot_volume_deletion", + "audit_orphans", + } + if not isinstance(operations, list) or not required_operations.issubset(set(operations)): + failures.append("planned_operations must include the bounded launch/shadow/teardown lifecycle") + return failures + + +def inspect_repository_variables( + contract: Mapping[str, Any], environ: Mapping[str, str] +) -> dict[str, Any]: + names = contract.get("required_repository_variables", []) + configured: list[str] = [] + missing: list[str] = [] + invalid_format: list[str] = [] + for name in names: + value = environ.get(name, "").strip() + if not value: + missing.append(name) + continue + configured.append(name) + if name in OCID_VARIABLES and not value.startswith("ocid1."): + invalid_format.append(name) + return { + "configured_variable_names": configured, + "missing_variable_names": missing, + "invalid_format_variable_names": invalid_format, + "values_recorded": False, + } + + +def validate_attestations(attestations: Mapping[str, Any] | None) -> list[str]: + if attestations is None: + return ["read-only OCI attestations were not supplied"] + failures: list[str] = [] + if attestations.get("schema_version") != ATTESTATION_SCHEMA: + failures.append(f"attestation schema_version must be {ATTESTATION_SCHEMA}") + if attestations.get("evidence_source") != "oci_read_only_export": + failures.append("attestation evidence_source must be oci_read_only_export") + if attestations.get("collection_complete") is not True: + failures.append("read-only OCI attestation collection must be complete") + if attestations.get("resource_identifiers_recorded") is not False: + failures.append("read-only OCI attestations must not record resource identifiers") + failures.extend(_validate_expected(attestations, REQUIRED_ATTESTATIONS)) + return failures + + +def build_preflight_report( + contract: Mapping[str, Any], + *, + environ: Mapping[str, str], + attestations: Mapping[str, Any] | None, +) -> dict[str, Any]: + contract_failures = validate_contract(contract) + variables = inspect_repository_variables(contract, environ) + attestation_failures = validate_attestations(attestations) + ready = not ( + contract_failures + or variables["missing_variable_names"] + or variables["invalid_format_variable_names"] + or attestation_failures + ) + return { + "schema_version": "qsl.oci_jit_shadow_preflight.v1", + "status": "READY" if ready else "PARKED", + "no_order": True, + "apply_authorized": False, + "secret_values_read": False, + "contract_failures": contract_failures, + "repository_variables": variables, + "attestation_failures": attestation_failures, + "provisioning_plan": { + "capacity_type": _get(contract, ("compute", "capacity_type")), + "private_subnet": _get(contract, ("network", "private_subnet_required")), + "assign_public_ip": _get(contract, ("network", "assign_public_ip")), + "reserved_nat_egress": _get(contract, ("network", "reserved_public_ip_required")), + "defined_tag_required": True, + "instance_principal_required": _get( + contract, ("identity", "instance_principal_required") + ), + "runner_assignment": _get(contract, ("compute", "runner_assignment")), + }, + "termination_plan": { + "deregister_runner": _get(contract, ("cleanup", "deregister_runner")), + "terminate_instance": True, + "delete_boot_volume": _get(contract, ("cleanup", "delete_boot_volume")), + "audit_orphans": True, + }, + "planned_operations": contract.get("planned_operations", []), + } + + +def _parse_time(value: Any, field: str) -> datetime: + if not isinstance(value, str): + raise ValueError(f"{field} must be an ISO-8601 string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field} must be an ISO-8601 string") from exc + if parsed.tzinfo is None: + raise ValueError(f"{field} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def _fingerprint(value: Any) -> str: + return hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:12] + + +def audit_orphans(contract: Mapping[str, Any], inventory: Mapping[str, Any]) -> dict[str, Any]: + contract_failures = validate_contract(contract) + failures: list[str] = [] + if inventory.get("schema_version") != INVENTORY_SCHEMA: + failures.append(f"inventory schema_version must be {INVENTORY_SCHEMA}") + if inventory.get("collection_complete") is not True: + failures.append("OCI and GitHub inventory collection must be complete") + collected_at = _parse_time(inventory.get("collected_at"), "collected_at") + max_age = int(_get(contract, ("compute", "max_instance_age_minutes")) or 0) + orphan_states = set(_get(contract, ("cleanup", "orphan_states")) or []) + findings: list[dict[str, Any]] = [] + + def inspect(resources: Any, resource_type: str) -> None: + if not isinstance(resources, list): + failures.append(f"{resource_type} inventory must be a list") + return + for resource in resources: + if not isinstance(resource, Mapping) or resource.get("candidate_tag_matches") is not True: + continue + created_at = _parse_time(resource.get("created_at"), f"{resource_type}.created_at") + age_minutes = int((collected_at - created_at).total_seconds() // 60) + is_orphan = False + reason = "" + if resource_type == "instance": + state = str(resource.get("lifecycle_state", "")) + is_orphan = state in orphan_states and age_minutes > max_age + reason = f"candidate instance remained {state} beyond max age" + elif resource_type == "boot_volume": + is_orphan = ( + resource.get("attached_instance_id") in (None, "") + and age_minutes > max_age + ) + reason = "candidate boot volume is unattached beyond max age" + elif resource_type == "vnic_attachment": + is_orphan = ( + str(resource.get("lifecycle_state", "")) != "ATTACHED" + and age_minutes > max_age + ) + reason = "candidate VNIC attachment is not attached beyond max age" + elif resource_type == "github_runner": + is_orphan = age_minutes > max_age + reason = "candidate GitHub runner registration remained beyond max age" + if is_orphan: + findings.append( + { + "resource_type": resource_type, + "resource_fingerprint": _fingerprint(resource.get("id", "missing-id")), + "age_minutes": age_minutes, + "reason": reason, + } + ) + + inspect(inventory.get("instances"), "instance") + inspect(inventory.get("boot_volumes"), "boot_volume") + inspect(inventory.get("vnic_attachments"), "vnic_attachment") + inspect(inventory.get("github_runners"), "github_runner") + ready = not contract_failures and not failures and not findings + return { + "schema_version": "qsl.oci_jit_shadow_orphan_audit.v1", + "status": "READY" if ready else "PARKED", + "no_order": True, + "cleanup_authorized": False, + "raw_resource_ids_recorded": False, + "contract_failures": contract_failures, + "inventory_failures": failures, + "orphan_count": len(findings), + "findings": findings, + } + + +def _write_report(path: Path, report: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate the non-applying OCI JIT shadow control-plane contract." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + preflight = subparsers.add_parser("preflight") + preflight.add_argument("--contract", type=Path, required=True) + preflight.add_argument("--attestations", type=Path) + preflight.add_argument( + "--attestations-env", + help="Environment variable containing a redacted read-only OCI attestation JSON object.", + ) + preflight.add_argument("--output", type=Path, required=True) + preflight.add_argument("--require-ready", action="store_true") + + orphan_audit = subparsers.add_parser("audit-orphans") + orphan_audit.add_argument("--contract", type=Path, required=True) + orphan_audit.add_argument("--inventory", type=Path, required=True) + orphan_audit.add_argument("--output", type=Path, required=True) + orphan_audit.add_argument("--require-ready", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + contract = _read_json(args.contract) + if args.command == "preflight": + if args.attestations and args.attestations_env: + raise SystemExit("choose either --attestations or --attestations-env") + if args.attestations: + attestations = _read_json(args.attestations) + elif args.attestations_env: + raw_attestations = os.environ.get(args.attestations_env, "").strip() + attestations = json.loads(raw_attestations) if raw_attestations else None + if attestations is not None and not isinstance(attestations, dict): + raise SystemExit("attestations environment variable must contain a JSON object") + else: + attestations = None + report = build_preflight_report(contract, environ=os.environ, attestations=attestations) + else: + report = audit_orphans(contract, _read_json(args.inventory)) + _write_report(args.output, report) + print(f"OCI JIT shadow {args.command}: status={report['status']} no_order=true") + if args.require_ready and report["status"] != "READY": + raise SystemExit("OCI JIT shadow check is PARKED") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/oci_jit_shadow/clean_inventory.json b/tests/fixtures/oci_jit_shadow/clean_inventory.json new file mode 100644 index 00000000..f1c14835 --- /dev/null +++ b/tests/fixtures/oci_jit_shadow/clean_inventory.json @@ -0,0 +1,16 @@ +{ + "schema_version": "qsl.oci_jit_shadow_inventory.v1", + "collected_at": "2026-08-23T12:00:00Z", + "collection_complete": true, + "instances": [ + { + "id": "ocid1.instance.oc1.example.redacted", + "created_at": "2026-08-23T11:45:00Z", + "lifecycle_state": "TERMINATED", + "candidate_tag_matches": true + } + ], + "boot_volumes": [], + "vnic_attachments": [], + "github_runners": [] +} diff --git a/tests/fixtures/oci_jit_shadow/orphan_inventory.json b/tests/fixtures/oci_jit_shadow/orphan_inventory.json new file mode 100644 index 00000000..01392a29 --- /dev/null +++ b/tests/fixtures/oci_jit_shadow/orphan_inventory.json @@ -0,0 +1,31 @@ +{ + "schema_version": "qsl.oci_jit_shadow_inventory.v1", + "collected_at": "2026-08-23T12:00:00Z", + "collection_complete": true, + "instances": [ + { + "id": "ocid1.instance.oc1.example.orphan", + "created_at": "2026-08-23T10:00:00Z", + "lifecycle_state": "RUNNING", + "candidate_tag_matches": true + } + ], + "boot_volumes": [ + { + "id": "ocid1.bootvolume.oc1.example.orphan", + "created_at": "2026-08-23T10:00:00Z", + "lifecycle_state": "AVAILABLE", + "candidate_tag_matches": true, + "attached_instance_id": null + } + ], + "vnic_attachments": [], + "github_runners": [ + { + "id": "999999", + "created_at": "2026-08-23T10:00:00Z", + "status": "offline", + "candidate_tag_matches": true + } + ] +} diff --git a/tests/test_oci_jit_shadow_preflight.py b/tests/test_oci_jit_shadow_preflight.py new file mode 100644 index 00000000..4c1667c7 --- /dev/null +++ b/tests/test_oci_jit_shadow_preflight.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import importlib.util +import json +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "oci_jit_shadow_preflight.py" +CONTRACT = ROOT / "infra" / "oci-jit-shadow" / "contract.json" +ATTESTATIONS = ROOT / "infra" / "oci-jit-shadow" / "preflight-attestations.example.json" +FIXTURES = ROOT / "tests" / "fixtures" / "oci_jit_shadow" +WORKFLOW = ROOT / ".github" / "workflows" / "oci-jit-shadow-preflight.yml" +README = ROOT / "infra" / "oci-jit-shadow" / "README.md" +FULL_SHA_ACTION = re.compile(r"(?:-\s+)?uses:\s+[^\s@]+@[0-9a-f]{40}(?:\s+#\s+v\d+)?$") + + +def load_module(): + spec = importlib.util.spec_from_file_location("oci_jit_shadow_preflight", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError("Unable to load OCI JIT shadow preflight module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class OciJitShadowPreflightTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + cls.contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + cls.attestations = json.loads(ATTESTATIONS.read_text(encoding="utf-8")) + + def configured_environment(self) -> dict[str, str]: + environment: dict[str, str] = {} + for name in self.contract["required_repository_variables"]: + environment[name] = f"configured-{name.lower()}" + for name in self.module.OCID_VARIABLES: + environment[name] = f"ocid1.example.oc1..{name.lower()}" + return environment + + def test_contract_is_non_applying_no_order_and_has_bounded_teardown(self) -> None: + self.assertEqual(self.module.validate_contract(self.contract), []) + self.assertFalse(self.contract["apply_authorized"]) + self.assertFalse(self.contract["shadow"]["broker_secret_allowed"]) + self.assertFalse(self.contract["network"]["assign_public_ip"]) + self.assertTrue(self.contract["cleanup"]["delete_boot_volume"]) + self.assertTrue(self.contract["cleanup"]["deregister_runner"]) + + def test_preflight_is_ready_only_with_variables_and_attestations(self) -> None: + ready = self.module.build_preflight_report( + self.contract, + environ=self.configured_environment(), + attestations=self.attestations, + ) + self.assertEqual(ready["status"], "READY") + self.assertFalse(ready["apply_authorized"]) + self.assertFalse(ready["secret_values_read"]) + self.assertFalse(ready["repository_variables"]["values_recorded"]) + + parked = self.module.build_preflight_report( + self.contract, + environ={}, + attestations=None, + ) + self.assertEqual(parked["status"], "PARKED") + self.assertEqual( + set(parked["repository_variables"]["missing_variable_names"]), + set(self.contract["required_repository_variables"]), + ) + self.assertTrue(parked["attestation_failures"]) + + def test_network_or_permission_regression_parks_preflight(self) -> None: + attestations = json.loads(json.dumps(self.attestations)) + attestations["network"]["instance_assigns_public_ip"] = True + attestations["identity"]["launcher_can_read_broker_secret"] = True + + report = self.module.build_preflight_report( + self.contract, + environ=self.configured_environment(), + attestations=attestations, + ) + + self.assertEqual(report["status"], "PARKED") + self.assertIn( + "network.instance_assigns_public_ip must be False", + report["attestation_failures"], + ) + self.assertIn( + "identity.launcher_can_read_broker_secret must be False", + report["attestation_failures"], + ) + + def test_orphan_audit_is_fail_closed_and_redacts_resource_ids(self) -> None: + clean_inventory = json.loads( + (FIXTURES / "clean_inventory.json").read_text(encoding="utf-8") + ) + clean = self.module.audit_orphans(self.contract, clean_inventory) + self.assertEqual(clean["status"], "READY") + self.assertEqual(clean["orphan_count"], 0) + + orphan_inventory = json.loads( + (FIXTURES / "orphan_inventory.json").read_text(encoding="utf-8") + ) + orphan = self.module.audit_orphans(self.contract, orphan_inventory) + serialized = json.dumps(orphan) + self.assertEqual(orphan["status"], "PARKED") + self.assertEqual(orphan["orphan_count"], 3) + self.assertFalse(orphan["cleanup_authorized"]) + self.assertNotIn("ocid1.instance.oc1.example.orphan", serialized) + self.assertNotIn("ocid1.bootvolume.oc1.example.orphan", serialized) + self.assertTrue(all(len(item["resource_fingerprint"]) == 12 for item in orphan["findings"])) + + def test_workflow_is_manual_read_only_secretless_and_pinned(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + action_lines = [line.strip() for line in workflow.splitlines() if "uses:" in line] + + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("contents: read", workflow) + self.assertNotIn("id-token: write", workflow) + self.assertNotIn("environment:", workflow) + self.assertNotIn("secrets.", workflow) + self.assertNotIn("BINANCE_API_KEY", workflow) + self.assertNotIn("BINANCE_API_SECRET", workflow) + self.assertNotIn("oci compute instance launch", workflow) + self.assertNotIn("oci compute instance terminate", workflow) + self.assertIn("run_isolation_shadow_fixture.py", workflow) + self.assertIn("audit-orphans", workflow) + self.assertTrue(action_lines) + self.assertTrue(all(FULL_SHA_ACTION.fullmatch(line) for line in action_lines)) + + def test_operator_documentation_lists_every_required_variable(self) -> None: + documentation = README.read_text(encoding="utf-8") + for name in self.contract["required_repository_variables"]: + self.assertIn(f"`{name}`", documentation) + self.assertIn("does not delete anything", documentation) + self.assertIn("No live cutover", documentation) + + +if __name__ == "__main__": + unittest.main()