diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37b5e3d6..8fb1dbb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: jobs: @@ -10,13 +10,41 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv sync --all-extras --dev - - name: Install uv - uses: astral-sh/setup-uv@v4 + - run: make check - # Also installs the Python version pinned in .python-version. - - name: Install dependencies - run: uv sync --all-extras --dev + live-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv sync --all-extras --dev + + # Without the binary the registry runtime-code pin test skips, and a + # drift-guard job that always skips is dead weight. Built from enclave's + # moving seismic branch — which is the point (guard against enclave + # HEAD), and why it stays out of the required `check` job: the crate is + # small (none of the enclave workspace's TPM deps), and the cache + # re-keys only when enclave moves. + - name: Resolve enclave HEAD + id: enclave + run: >- + echo "rev=$(git ls-remote https://github.com/SeismicSystems/enclave.git + refs/heads/seismic | cut -f1)" >> "$GITHUB_OUTPUT" + - name: Cache seismic-measurement-admission + id: admission-cache + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/seismic-measurement-admission + key: seismic-measurement-admission-${{ steps.enclave.outputs.rev }} + - name: Build seismic-measurement-admission + if: steps.admission-cache.outputs.cache-hit != 'true' + run: >- + cargo install --locked + --git https://github.com/SeismicSystems/enclave + --rev ${{ steps.enclave.outputs.rev }} + seismic-measurement-admission --features cli - - name: make check - run: make check + - run: make test-live diff --git a/Makefile b/Makefile index 95c600c6..def01e17 100644 --- a/Makefile +++ b/Makefile @@ -2,16 +2,17 @@ PKG := deploy_gcp tee -.PHONY: help lint format format-check typecheck test check +.PHONY: help lint format format-check typecheck test test-live check help: @echo "Targets:" - @echo " check lint + format-check + typecheck + test (what CI runs)" + @echo " check lint + format-check + typecheck + test (hermetic; CI runs this)" @echo " lint ruff check" @echo " format ruff format (writes changes)" @echo " format-check ruff format --check" @echo " typecheck ty check" - @echo " test unittest discovery (repo-wide)" + @echo " test unittest discovery (repo-wide, offline)" + @echo " test-live cross-repo drift guards (network-required; own CI step)" check: lint format-check typecheck test @@ -31,3 +32,8 @@ typecheck: # passing runs stay quiet (the code under test prints progress + warnings). test: uv run python -m unittest discover -b + +# Live cross-repo drift guards: modules named live_test_*.py fall outside +# `test`'s default `test*.py` discovery, keeping it hermetic. +test-live: + uv run python -m unittest discover -b -v -p "live_test_*.py" diff --git a/tee/cli/common/tests/live_test_manifest.py b/tee/cli/common/tests/live_test_manifest.py new file mode 100644 index 00000000..1d25d983 --- /dev/null +++ b/tee/cli/common/tests/live_test_manifest.py @@ -0,0 +1,112 @@ +"""Live cross-repo drift guards (network-required; run via `make test-live`). + +These tests fetch pinned artifacts from sibling repos on GitHub and compare +them against what this repo renders or pins. The module name deliberately +does not match `make test`'s `test*.py` discovery pattern, so the default +suite stays hermetic (offline, deterministic); CI runs this module as its +own non-required job, where a failure names the exact cross-repo check. + +The suite never skips — a missing prerequisite is a failure. It needs: + +- network reach to raw.githubusercontent.com (both tests fetch pinned + artifacts from sibling repos); +- `seismic-measurement-admission` on PATH — the enclave repo's admission + CLI (`cargo install --features cli` from crates/measurement-admission; + CI builds it from enclave's seismic branch). + +Run with: + make test-live +""" + +import http.client +import json +import unittest +import urllib.error +import urllib.request + +from eth_utils import keccak + +from tee.cli.common.manifest import compile_measurement_policy, render_manifest +from tee.cli.common.tests.test_manifest import ( + ADMISSION_BIN, + FIXTURE_MANIFEST, + promoted_policy_bytes, +) + + +def _fetch_live(url: str) -> bytes: + """Fetch a cross-repo artifact, failing the calling test if it can't. + + An HTTP 4xx/5xx means the artifact moved or the ref is gone — a real + drift signal, not flaky network — so it propagates directly. A + transport-level failure (unreachable, timeout, truncated body) is + retried once, then fails: this suite never skips. + """ + last: Exception | None = None + for _ in range(2): + try: + with urllib.request.urlopen(url, timeout=10) as resp: + return resp.read() + except urllib.error.HTTPError: + raise + except (urllib.error.URLError, TimeoutError, http.client.HTTPException) as e: + last = e + raise AssertionError( + f"cross-repo artifact unreachable after retry: {last}" + ) from last + + +class ManifestFixtureParityTests(unittest.TestCase): + """Byte-parity with the node-side manifest parser. + + The enclave repo pins the manifest fixture's exact bytes; deploy's + emitter must render the same dict to the same bytes. Fetched from + GitHub (the `seismic` branch) rather than assuming a sibling checkout + on disk, so the check runs in CI too. The fixture's network_id is also + pinned offline by test_manifest's + test_render_matches_enclave_network_id_vector; this adds the live + byte-level drift guard on top. + """ + + ENCLAVE_FIXTURE_URL = ( + "https://raw.githubusercontent.com/SeismicSystems/enclave/seismic/" + "crates/network-manifest/fixtures/network-manifest-v1.json" + ) + + def test_render_matches_enclave_fixture_bytes(self): + fixture = _fetch_live(self.ENCLAVE_FIXTURE_URL) + self.assertEqual(render_manifest(FIXTURE_MANIFEST), fixture) + + +class RuntimeCodeDriftTests(unittest.TestCase): + """Cross-repo drift guard for the registry runtime-code pin. + + The admission CLI pins keccak256 of the canonical MeasurementRegistry + deployed bytecode; the gates enforce that pin against the genesis alloc, + so a stale pin already fails assembly loudly. This test is the early + warning: the pin reported by the binary on PATH must match the artifact + the reth genesis builder installs. + """ + + REGISTRY_ARTIFACT_URL = ( + "https://raw.githubusercontent.com/SeismicSystems/seismic/main/" + "contracts/artifacts/MeasurementRegistry.json" + ) + + def test_admission_crate_pins_current_registry_runtime(self): + self.assertIsNotNone( + ADMISSION_BIN, + "seismic-measurement-admission not on PATH — the live suite fails " + "rather than skips; build the enclave repo's admission CLI", + ) + report = compile_measurement_policy(promoted_policy_bytes()) + artifact = json.loads(_fetch_live(self.REGISTRY_ARTIFACT_URL)) + runtime = artifact["deployedBytecode"]["object"].removeprefix("0x") + self.assertEqual( + report["registry_runtime_code_hash"], + "0x" + keccak(bytes.fromhex(runtime)).hex(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tee/cli/common/tests/test_manifest.py b/tee/cli/common/tests/test_manifest.py index 3fb78c64..accaea38 100644 --- a/tee/cli/common/tests/test_manifest.py +++ b/tee/cli/common/tests/test_manifest.py @@ -5,14 +5,11 @@ """ import hashlib -import http.client import json import shutil import tempfile import tomllib import unittest -import urllib.error -import urllib.request from pathlib import Path from typing import Any @@ -104,21 +101,9 @@ def promoted_policy_bytes(measurement_id: str = "img.vhd") -> bytes: "0x8ef142e3f2bf15f8b201c4d8cda7848a9e846222c62b5615d4d36c7fccd98a24" ) -# The node-side parser pins these exact bytes in the enclave repo. Fetch its -# fixture from GitHub (the `seismic` branch) rather than assuming a sibling -# checkout on disk, so the cross-repo byte-parity check runs in CI too. The -# network_id value is also pinned offline by -# test_render_matches_enclave_network_id_vector, so this only adds a live drift -# guard; it skips when GitHub is unreachable. -ENCLAVE_FIXTURE_URL = ( - "https://raw.githubusercontent.com/SeismicSystems/enclave/seismic/" - "crates/network-manifest/fixtures/network-manifest-v1.json" -) - - -def _fetch_enclave_fixture() -> bytes: - with urllib.request.urlopen(ENCLAVE_FIXTURE_URL, timeout=10) as resp: - return resp.read() +# The node-side parser pins the fixture's exact bytes in the enclave repo; +# the live byte-parity check against it lives in live_test_manifest.py +# (network-required, `make test-live`). class RenderTests(unittest.TestCase): @@ -126,17 +111,6 @@ def test_render_matches_enclave_network_id_vector(self): rendered = render_manifest(FIXTURE_MANIFEST) self.assertEqual(compute_network_id(rendered), FIXTURE_NETWORK_ID) - def test_render_matches_enclave_fixture_bytes(self): - try: - fixture = _fetch_enclave_fixture() - except urllib.error.HTTPError: - # A 4xx/5xx means the fixture moved or the ref is gone — a real - # drift signal, not flaky network, so fail loudly. - raise - except (urllib.error.URLError, TimeoutError) as e: - self.skipTest(f"enclave fixture unreachable: {e}") - self.assertEqual(render_manifest(FIXTURE_MANIFEST), fixture) - def test_render_is_deterministic_under_key_order(self): shuffled = dict(reversed(list(FIXTURE_MANIFEST.items()))) self.assertEqual(render_manifest(shuffled), render_manifest(FIXTURE_MANIFEST)) @@ -438,42 +412,9 @@ def test_report_without_storage(self): inject_registry_genesis_storage(genesis, self.REGISTRY, report) -class RuntimeCodeDriftTests(unittest.TestCase): - """Cross-repo drift guard for the registry runtime-code pin. - - The admission CLI pins keccak256 of the canonical MeasurementRegistry - deployed bytecode; the gates enforce that pin against the genesis alloc, - so a stale pin already fails assembly loudly. This test is the early - warning: the pin reported by the binary on PATH must match the artifact - the reth genesis builder installs. Online-only, like the - manifest-fixture byte-parity test. - """ - - REGISTRY_ARTIFACT_URL = ( - "https://raw.githubusercontent.com/SeismicSystems/seismic/main/" - "contracts/artifacts/MeasurementRegistry.json" - ) - - def _fetch(self, url: str) -> bytes: - try: - with urllib.request.urlopen(url, timeout=10) as resp: - return resp.read() - except urllib.error.HTTPError: - # A 4xx/5xx means the artifact moved — a real drift signal, - # not flaky network, so fail loudly. - raise - except (urllib.error.URLError, TimeoutError, http.client.HTTPException) as e: - self.skipTest(f"cross-repo artifact unreachable: {e}") - - @unittest.skipUnless(ADMISSION_BIN, "seismic-measurement-admission not in PATH") - def test_admission_crate_pins_current_registry_runtime(self): - report = compile_measurement_policy(promoted_policy_bytes()) - artifact = json.loads(self._fetch(self.REGISTRY_ARTIFACT_URL)) - runtime = artifact["deployedBytecode"]["object"].removeprefix("0x") - self.assertEqual( - report["registry_runtime_code_hash"], - "0x" + keccak(bytes.fromhex(runtime)).hex(), - ) +# The registry runtime-code drift guard (admission binary's pin vs the +# monorepo's MeasurementRegistry artifact) lives in live_test_manifest.py +# (network-required, `make test-live`). class GateTests(unittest.TestCase):