diff --git a/deploy_tee/README.md b/deploy_tee/README.md index edb2c9c..5dc88f9 100644 --- a/deploy_tee/README.md +++ b/deploy_tee/README.md @@ -29,7 +29,7 @@ for its [InitConfig](https://github.com/SeismicSystems/enclave/blob/seismic/crat never provisions; it consumes a descriptor of an already-running node. VHD upload to the Azure image registry is a seismic-images concern (`make push-azure-*`), not this tool. -- **For `seismic-tee-bootstrap genesis`:** the `genesis` binary (built from +- **For `seismic-tee-bootstrap genesis-ceremony`:** the `genesis` binary (built from summit) on PATH, plus a summit genesis template passed via `--summit-template`. - **For `seismic-tee-bootstrap up`** (cohort provisioning): the `pulumi` @@ -67,7 +67,7 @@ only consumes one to reach an already-running node. |---|---|---|---| | `seismic-tee` | `configure` | any operator, on first boot | POST the node TOML to tdx-init. Reads the node address from `--node `. (Attestation verification is disabled pending attested-tls — see below.) | | `seismic-tee-bootstrap` | `up` / `down` | Seismic, internal | Provision / tear down a cohort of TDX nodes, one independent Pulumi stack each (drives Pulumi via the Automation API). | -| `seismic-tee-bootstrap` | `genesis` | Seismic, once per network | Gather the cohort's summit pubkeys, build `genesis.toml`, POST it to each node. | +| `seismic-tee-bootstrap` | `genesis-ceremony` | Seismic, once per network | Gather the cohort's summit pubkeys, build `genesis.toml`, POST it to each node. One-shot: `send_genesis` only exists in the pre-genesis node API. | | `seismic-tee-bootstrap` | `manifest` | Seismic | Assemble / validate the network manifest. | Only `seismic-tee` is cloud-agnostic and **never wraps Pulumi**; @@ -118,8 +118,11 @@ uv run seismic-tee configure --node /tmp/node.json \ `http://host:7878` URL (or a node descriptor JSON, which resolves to `http://:7878`); repeatable. The `--manifest` is the network identity doc you're given for the network you're joining; it pins the -`network_id`. You never run `seismic-tee-bootstrap` — that's -Seismic-internal network creation. +`network_id`. The reth genesis JSON is POSTed alongside it (from +`--reth-genesis`, defaulting to `reth-genesis.json` beside the manifest — +the artifact-set layout); tdx-init writes it for reth's `--chain`. You +never run `seismic-tee-bootstrap` — that's Seismic-internal network +creation. ### Creating a new network (genesis ceremony) @@ -127,37 +130,64 @@ Seismic-internal network creation. # 0. (Upstream) Build the image in seismic-images (CI or `make build`) # and publish it + its measurements. This tool does not build. -# 1. Assemble the one shared network manifest (all nodes use the same bytes). -# There is no per-node TOML — the genesis node is founded via -# `seismic-tee-bootstrap configure`, joiners via `seismic-tee configure` -# (step 3). uv run seismic-tee-bootstrap manifest assemble ... -o network-manifest.json +# 1. Create the network directory: `init` scaffolds the three authored +# inputs (chain spec, summit template, measurements); you edit them; then +# `assemble --dir` derives the artifact set (manifest, filled template, +# promoted policy) into the same directory. Edits go to the *inputs* + +# re-assemble — the manifest itself is pure output, never hand-edited. +# Commit the directory for any network that matters: a fresh assemble +# mints a fresh genesis_nonce, so the same network_id can never be +# regenerated from the inputs — the directory is everything needed to +# (re)configure, join, or debug the network later. assemble needs +# `seismic-reth` on PATH for the offline genesis-hash gate. +uv run seismic-tee-bootstrap manifest init deploy_tee/networks/devnet-3 \ + --reth-genesis ../seismic-reth/crates/seismic/chainspec/res/genesis/dev.json \ + --measurements ../seismic-images/build/measurements.json \ + --measurement-id seismic_2026-06-11.abc123.vhd +# → edit deploy_tee/networks/devnet-3/summit-template.toml (namespace, …) +uv run seismic-tee-bootstrap manifest assemble deploy_tee/networks/devnet-3 # 2. Provision the cohort — one independent Pulumi stack per node — and # capture each node's descriptor. Shared settings inherit from the dev # stack config (pulumi/seismic_node/Pulumi.dev.yaml); the prefix derives # from it (→ dev-bootstrap-node), so each node's stack, resource group, -# VM, and DNS record are dev-bootstrap-node-. Descriptors default to a -# gitignored descriptors/ dir next to the Pulumi project; --out-dir overrides. -uv run seismic-tee-bootstrap up --count 2 --out-dir /tmp -# → /tmp/dev-bootstrap-node-1.json, /tmp/dev-bootstrap-node-2.json +# VM, and DNS record are dev-bootstrap-node-. +# --network ties the cohort to the network directory: it cross-checks the +# config's vhd_blob_url basename against the measurement policy's +# measurement_id records, refusing a stale image pin before any resource +# exists (name check only — attestation is the cryptographic gate), and +# writes the descriptors into /nodes/ (gitignored: infra state, not +# identity). Without --network they land in a shared gitignored +# descriptors/ dir next to the Pulumi project; --out-dir overrides either. +uv run seismic-tee-bootstrap up --count 2 --network deploy_tee/networks/devnet-3 +# → deploy_tee/networks/devnet-3/nodes/dev-bootstrap-node-{1,2}.json # 3. Configure each node against the shared manifest and POST. Node 1 founds # the network (genesis, via the bootstrap CLI); node 2 joins (operator CLI), # fetching root_key from node 1. -uv run seismic-tee-bootstrap configure --genesis /tmp/dev-bootstrap-node-1.json --manifest ./network-manifest.json -uv run seismic-tee configure --node /tmp/dev-bootstrap-node-2.json \ - --peer /tmp/dev-bootstrap-node-1.json --manifest ./network-manifest.json +uv run seismic-tee-bootstrap configure \ + --genesis deploy_tee/networks/devnet-3/nodes/dev-bootstrap-node-1.json \ + --manifest deploy_tee/networks/devnet-3/network-manifest.json +uv run seismic-tee configure \ + --node deploy_tee/networks/devnet-3/nodes/dev-bootstrap-node-2.json \ + --peer deploy_tee/networks/devnet-3/nodes/dev-bootstrap-node-1.json \ + --manifest deploy_tee/networks/devnet-3/network-manifest.json # 4. Run the genesis ceremony once: builds genesis.toml from the cohort # (needs the `genesis` binary on PATH) and fans it out to every summit. -uv run seismic-tee-bootstrap genesis --summit-template ./summit-genesis-template.toml \ - --node /tmp/dev-bootstrap-node-1.json /tmp/dev-bootstrap-node-2.json +# One-shot: send_genesis only exists in the pre-genesis node API, so this +# can never be re-run against a live network. --summit-template defaults +# to the artifact-set copy beside --manifest (verified against the +# manifest's summit.genesis_template_hash); --node defaults to the +# descriptors in the nodes/ dir beside it. +uv run seismic-tee-bootstrap genesis-ceremony \ + --manifest deploy_tee/networks/devnet-3/network-manifest.json ``` After step 3 each node is up; after step 4 they produce blocks. RPC is at `https:///rpc` (the `fqdn` from each descriptor). -## The config: `--peer` (`[enclave]`), `--manifest`, `--email` +## The config: `--peer` (`[enclave]`), `--manifest`, `--reth-genesis`, `--email` There is no per-node TOML. The config is assembled from flags + the descriptor, so nothing is copy-pasted per node (and can't drift): @@ -168,6 +198,9 @@ so nothing is copy-pasted per node (and can't drift): - `[domain]` — `name` from the descriptor's `fqdn` (the cert domain), `email` from `--email` (default `ops@seismic.systems`). - `[network].manifest_base64` — from `--manifest`. +- `[network].reth_genesis_base64` — from `--reth-genesis` (default: + `reth-genesis.json` beside `--manifest`). Checked client-side against the + manifest's `eth.chain_id` before POSTing. The combined schema is owned by `tdx-init` (`#[serde(deny_unknown_fields)]`); this is the body a joining node POSTs: @@ -181,8 +214,9 @@ peers = ["http://az-1.seismicdev.net:7878"] # from --peer name = "az-1.seismicdev.net" email = "ops@seismic.systems" -[network] # injected: --manifest; tdx-init 400s if absent -manifest_base64 = "..." +[network] # injected: --manifest + --reth-genesis; +manifest_base64 = "..." # tdx-init 400s if either is absent +reth_genesis_base64 = "..." ``` Authoritative reference: @@ -195,13 +229,16 @@ back as 4xx and the deploy script raises. up (LUKS format + mkfs + TPM enroll takes ~20–40s on first boot). On success tdx-init writes: -| File | Content | Consumer | -| ------------------------------ | --------------------------------------------------------------- | --------------------------- | -| `/persistent/conf/domain.env` | `DOMAIN_NAME=...`, `DOMAIN_EMAIL=...` | `setup-nginx-ssl` (certbot) | -| `/persistent/conf/enclave.env` | `SEISMIC_ENCLAVE_GENESIS_NODE=...`, `SEISMIC_ENCLAVE_PEERS=...` | `enclave.service` | +| File | Content | Consumer | +| ----------------------------------------- | --------------------------------------------------------------- | --------------------------- | +| `/run/seismic/conf/domain.env` | `DOMAIN_NAME=...`, `DOMAIN_EMAIL=...` | `setup-nginx-ssl` (certbot) | +| `/run/seismic/conf/enclave.env` | `SEISMIC_ENCLAVE_GENESIS_NODE=...`, `SEISMIC_ENCLAVE_PEERS=...` | `enclave.service` | +| `/run/seismic/conf/network-manifest.json` | verbatim manifest bytes (hashed into `network_id`) | `enclave.service` | +| `/run/seismic/conf/reth-genesis.json` | verbatim reth genesis bytes | `reth.service` (`--chain`) | -A sentinel at `/persistent/conf/.tdx-init-done` makes subsequent -boots no-ops. +The conf dir is tmpfs, so every boot needs a fresh POST (`configure` is +recurring, not one-shot); the sentinel `/run/seismic/conf/.tdx-init-done` +only gates against a second POST within the same boot. ### Operator footguns to be aware of diff --git a/deploy_tee/bootstrap_cli.py b/deploy_tee/bootstrap_cli.py index 3d226d2..1940ab8 100644 --- a/deploy_tee/bootstrap_cli.py +++ b/deploy_tee/bootstrap_cli.py @@ -2,7 +2,8 @@ Seismic-internal, NOT a tool node operators run: it provisions a cohort of TDX nodes (`up` / `down`) and runs the one-time network-creation steps -(`manifest`, `genesis`). This is the CLI that is *allowed* to wrap Pulumi — +(`manifest`, `genesis-ceremony`). This is the CLI that is *allowed* to wrap +Pulumi — `up` / `down` drive the seismic_node Automation-API orchestrator. The operator CLI (`seismic-tee`) deliberately is not; the boundary is the node descriptor file (see deploy_tee/descriptor.py), which this CLI produces @@ -49,19 +50,21 @@ def configure(argv: tuple[str, ...]) -> None: forward(cohort_configure.main, "seismic-tee-bootstrap configure", argv) -@app.command(name="genesis", context_settings=PASSTHROUGH, add_help_option=False) +@app.command( + name="genesis-ceremony", context_settings=PASSTHROUGH, add_help_option=False +) @click.argument("argv", nargs=-1, type=click.UNPROCESSED) -def genesis(argv: tuple[str, ...]) -> None: - """Genesis ceremony: build genesis.toml from the cohort and fan it out.""" +def genesis_ceremony(argv: tuple[str, ...]) -> None: + """One-shot genesis ceremony: build genesis.toml from the cohort, fan it out.""" from deploy_tee import genesis as genesis_mod - forward(genesis_mod.main, "seismic-tee-bootstrap genesis", argv) + forward(genesis_mod.main, "seismic-tee-bootstrap genesis-ceremony", argv) @app.command(name="manifest", context_settings=PASSTHROUGH, add_help_option=False) @click.argument("argv", nargs=-1, type=click.UNPROCESSED) def manifest(argv: tuple[str, ...]) -> None: - """Assemble / validate a network manifest.""" + """Scaffold (init) / assemble / validate a network artifact-set dir.""" from deploy_tee import manifest as manifest_mod forward(manifest_mod.main, "seismic-tee-bootstrap manifest", argv) diff --git a/deploy_tee/cohort_configure.py b/deploy_tee/cohort_configure.py index badbc41..7e6b422 100644 --- a/deploy_tee/cohort_configure.py +++ b/deploy_tee/cohort_configure.py @@ -31,6 +31,7 @@ TDX_INIT_PORT, build_config, post_config_to_tdx_init, + resolve_reth_genesis, ) from deploy_tee.descriptor import load_descriptor, require from deploy_tee.status import poll_provisioning @@ -92,6 +93,7 @@ def build_cohort(genesis_path: Path, join_paths: list[Path]) -> list[Node]: def _configure_node( node: Node, manifest_path: Path, + reth_genesis_path: Path, email: str, no_wait: bool, states: dict[str, str], @@ -107,7 +109,12 @@ def _configure_node( try: states[node.name] = "building config…" config = build_config( - manifest_path, node.fqdn, email, genesis_node=node.genesis, peers=node.peers + manifest_path, + node.fqdn, + email, + genesis_node=node.genesis, + peers=node.peers, + reth_genesis_path=reth_genesis_path, ) states[node.name] = f"POSTing config to tdx-init :{TDX_INIT_PORT}…" post_config_to_tdx_init(node.public_ip, config) @@ -162,7 +169,11 @@ def render(self, states: dict[str, str]) -> None: def _run_cohort( - nodes: list[Node], manifest_path: Path, email: str, no_wait: bool + nodes: list[Node], + manifest_path: Path, + reth_genesis_path: Path, + email: str, + no_wait: bool, ) -> dict[str, bool]: """Configure every node concurrently, refreshing the dashboard until all workers finish. Returns {node name: ok}. Threads suit this — the work is @@ -180,7 +191,14 @@ def _run_cohort( with ThreadPoolExecutor(max_workers=len(nodes)) as pool: for node in nodes: futures[node.name] = pool.submit( - _configure_node, node, manifest_path, email, no_wait, states, stop + _configure_node, + node, + manifest_path, + reth_genesis_path, + email, + no_wait, + states, + stop, ) try: # Refresh while workers block on POST/poll. @@ -257,6 +275,17 @@ def parse_args() -> argparse.Namespace: metavar="FILE", help="Network manifest JSON (from `manifest assemble`); → [network].", ) + parser.add_argument( + "--reth-genesis", + type=Path, + default=None, + metavar="FILE", + help=( + "reth genesis JSON (chain spec) POSTed to every node; → " + "[network].reth_genesis_base64. Default: reth-genesis.json " + "beside --manifest (the artifact-set layout)." + ), + ) parser.add_argument( "--email", default="ops@seismic.systems", @@ -279,12 +308,17 @@ def main() -> None: setup_logging() args = parse_args() - # Validate the shared manifest once, so a bad one fails fast here rather - # than as N identical per-worker errors mid-dashboard. + # Validate the shared network artifacts once, so a bad one fails fast here + # rather than as N identical per-worker errors mid-dashboard. try: - manifest_mod.validate_manifest_schema(args.manifest.read_bytes()) + manifest = manifest_mod.validate_manifest_schema(args.manifest.read_bytes()) except manifest_mod.ManifestSchemaError as e: raise SystemExit(f"--manifest {args.manifest}: invalid manifest: {e}") from None + reth_genesis = resolve_reth_genesis(args.reth_genesis, args.manifest) + try: + manifest_mod.validate_reth_genesis_matches(manifest, reth_genesis.read_bytes()) + except manifest_mod.GateError as e: + raise SystemExit(f"--reth-genesis {reth_genesis}: {e}") from None nodes = build_cohort(args.genesis, args.join) joiners = [n.name for n in nodes if not n.genesis] @@ -293,7 +327,7 @@ def main() -> None: + (f", joining={joiners}" if joiners else " (genesis-only)") ) - results = _run_cohort(nodes, args.manifest, args.email, args.no_wait) + results = _run_cohort(nodes, args.manifest, reth_genesis, args.email, args.no_wait) _report(nodes, results) diff --git a/deploy_tee/configure.py b/deploy_tee/configure.py index 37b072a..3005f6b 100644 --- a/deploy_tee/configure.py +++ b/deploy_tee/configure.py @@ -15,10 +15,11 @@ There is no per-node `node.toml`: `[enclave]` (genesis_node + peers) comes from flags, `[domain]` from the descriptor fqdn + `--email`, and `[network]` -from `--manifest`. The manifest (shared across every node) stays a standalone -file, merged only at POST time. The node address is *brought by the operator* -via a descriptor file (see deploy_tee/descriptor.py), typically `pulumi stack -output --json`. The CLI never provisions infrastructure (Pulumi's job). +from `--manifest` + `--reth-genesis`. Those network-wide artifacts stay +standalone files, merged only at POST time. The node address is *brought by +the operator* via a descriptor file (see deploy_tee/descriptor.py), typically +`pulumi stack output --json`. The CLI never provisions infrastructure +(Pulumi's job). The POSTed TOML shape (`[domain]`/`[enclave]`/`[network]`) is unchanged, so tdx-init and the `SEISMIC_ENCLAVE_*` env-var contract it emits are untouched. @@ -78,6 +79,24 @@ def resolve_peer(peer: str) -> str: return f"http://{public_ip}:{ENCLAVE_PEER_PORT}" +def resolve_reth_genesis(reth_genesis: Path | None, manifest_path: Path) -> Path: + """Resolve `--reth-genesis`, defaulting to the artifact-set convention: + `reth-genesis.json` beside the manifest, exactly where `manifest assemble + --out` writes its byte-verbatim copy — so the file POSTed is the one the + manifest's `eth.genesis_hash` was computed from. + """ + path = reth_genesis or manifest_path.parent / manifest_mod.RETH_GENESIS_FILENAME + if not path.is_file(): + hint = ( + "" + if reth_genesis + else " (the default is reth-genesis.json beside --manifest; pass " + "--reth-genesis if it lives elsewhere)" + ) + raise SystemExit(f"reth genesis not found: {path}{hint}") + return path + + def build_config( manifest_path: Path, fqdn: str, @@ -85,13 +104,15 @@ def build_config( *, genesis_node: bool, peers: list[str], + reth_genesis_path: Path, ) -> Path: """Assemble the config POSTed to tdx-init, mutating no source. The fields - come from four inputs: the role (`genesis_node` + `peers` → `[enclave]`), + come from five inputs: the role (`genesis_node` + `peers` → `[enclave]`), the descriptor's fqdn (→ `[domain].name`, the cert domain), `--email` - (→ `[domain].email`), and the network manifest (`--manifest` → - `[network]`). Written fresh, so there is no operator-supplied TOML that - could carry a conflicting `[domain]`/`[network]` and fork the network. + (→ `[domain].email`), and the network manifest + reth genesis + (`--manifest`/`--reth-genesis` → `[network]`). Written fresh, so there is + no operator-supplied TOML that could carry a conflicting + `[domain]`/`[network]` and fork the network. `genesis_node=True` (peers empty) is only ever passed by the bootstrap founding command; the operator `configure` always joins (False + peers). @@ -99,10 +120,16 @@ def build_config( manifest_bytes = manifest_path.read_bytes() try: # Never POST bytes tdx-init would 400 at the far end. - manifest_mod.validate_manifest_schema(manifest_bytes) + manifest = manifest_mod.validate_manifest_schema(manifest_bytes) except manifest_mod.ManifestSchemaError as e: raise SystemExit(f"--manifest {manifest_path}: invalid manifest: {e}") from None + reth_genesis_bytes = reth_genesis_path.read_bytes() + try: + manifest_mod.validate_reth_genesis_matches(manifest, reth_genesis_bytes) + except manifest_mod.GateError as e: + raise SystemExit(f"--reth-genesis {reth_genesis_path}: {e}") from None + # json.dumps emits valid TOML basic strings for these simple ASCII values. peers_toml = ", ".join(json.dumps(p) for p in peers) merged = ( @@ -110,7 +137,7 @@ def build_config( f"genesis_node = {str(genesis_node).lower()}\n" f"peers = [{peers_toml}]\n\n" f"[domain]\nname = {json.dumps(fqdn)}\nemail = {json.dumps(email)}\n\n" - + manifest_mod.render_network_section(manifest_bytes) + + manifest_mod.render_network_section(manifest_bytes, reth_genesis_bytes) ) with tempfile.NamedTemporaryFile( "w", @@ -167,6 +194,7 @@ def deliver_config( *, genesis_node: bool, peers: list[str], + reth_genesis_path: Path, no_wait: bool, ) -> None: """Build + POST one node's config, then (unless `no_wait`) watch its @@ -187,7 +215,12 @@ def deliver_config( # Assemble the POST config before contacting the node, so bad local input # (invalid manifest, unresolvable peer) fails fast. config = build_config( - manifest_path, fqdn, email, genesis_node=genesis_node, peers=peers + manifest_path, + fqdn, + email, + genesis_node=genesis_node, + peers=peers, + reth_genesis_path=reth_genesis_path, ) logger.info(f"Built {role} config for {fqdn} -> {config}") @@ -284,6 +317,20 @@ def parse_args() -> argparse.Namespace: "across every node, so it lives outside the per-node flags." ), ) + parser.add_argument( + "--reth-genesis", + type=Path, + default=None, + metavar="FILE", + help=( + "reth genesis JSON (chain spec) POSTed to the node as " + "[network].reth_genesis_base64; tdx-init writes it to " + "/run/seismic/conf/reth-genesis.json for reth's --chain. Must be " + "the file the manifest's eth.genesis_hash was computed from. " + "Default: reth-genesis.json beside --manifest (the artifact-set " + "layout `manifest assemble --out` produces)." + ), + ) parser.add_argument( "--email", default="ops@seismic.systems", @@ -338,12 +385,14 @@ def main() -> None: ) peers = [resolve_peer(p) for p in args.peer] + reth_genesis = resolve_reth_genesis(args.reth_genesis, args.manifest) deliver_config( args.node, args.manifest, args.email, genesis_node=False, peers=peers, + reth_genesis_path=reth_genesis, no_wait=args.no_wait, ) diff --git a/deploy_tee/genesis.py b/deploy_tee/genesis.py index b9cfe60..fbdcda1 100644 --- a/deploy_tee/genesis.py +++ b/deploy_tee/genesis.py @@ -18,6 +18,7 @@ """ import argparse +import hashlib import json import shutil import subprocess @@ -62,7 +63,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: type=Path, nargs="+", action="append", - required=True, + default=None, metavar="DESCRIPTOR", help=( "Node descriptor JSON file(s), one per cohort node in validator " @@ -70,18 +71,23 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: "n2.json` both work (with plain nargs, a repeated flag silently " "*replaces* the earlier one and drops nodes from the ceremony). " "Each descriptor's fqdn/public_ip locates that node; produce them " - "standalone, e.g. `pulumi stack output --json > node-1.json`." + "standalone, e.g. `pulumi stack output --json > node-1.json`. " + "Default: every *.json in the nodes/ dir beside --manifest " + "(written by `up --network`), sorted by name." ), ) parser.add_argument( "--summit-template", type=Path, - required=True, + default=None, metavar="FILE", help=( "Summit genesis template TOML (network-params without " - "[[validators]]) — the same file fed to `manifest assemble`. The " - "`genesis` binary fills in the cohort's validators." + "[[validators]]) the `genesis` binary fills validators into. Must " + "be the copy the manifest commits to (summit.genesis_template_" + "hash) — verified before building. Default: summit-genesis-" + "template.toml beside --manifest (the artifact-set layout " + "`manifest assemble --out` produces)." ), ) parser.add_argument( @@ -106,19 +112,39 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: ), ) args = parser.parse_args(argv) - # append+nargs yields one list per --node occurrence; flatten to the - # cohort list callers expect. - args.node = [path for group in args.node for path in group] - dupes = sorted({str(p) for p in args.node if args.node.count(p) > 1}) - if dupes: - raise SystemExit(f"duplicate --node descriptor(s): {', '.join(dupes)}") + if not args.manifest.is_file(): + raise SystemExit(f"--manifest file not found: {args.manifest}") + if args.node is None: + nodes_dir = args.manifest.parent / manifest_mod.NODES_DIRNAME + args.node = sorted(nodes_dir.glob("*.json")) + if not args.node: + raise SystemExit( + f"no --node given and no descriptors in {nodes_dir} (written " + "by `up --network`); pass --node explicitly" + ) + else: + # append+nargs yields one list per --node occurrence; flatten to the + # cohort list callers expect. + args.node = [path for group in args.node for path in group] + dupes = sorted({str(p) for p in args.node if args.node.count(p) > 1}) + if dupes: + raise SystemExit(f"duplicate --node descriptor(s): {', '.join(dupes)}") for path in args.node: if not path.is_file(): raise SystemExit(f"--node descriptor not found: {path}") + defaulted = args.summit_template is None + if defaulted: + args.summit_template = ( + args.manifest.parent / manifest_mod.SUMMIT_TEMPLATE_FILENAME + ) if not args.summit_template.is_file(): - raise SystemExit(f"--summit-template not found: {args.summit_template}") - if not args.manifest.is_file(): - raise SystemExit(f"--manifest file not found: {args.manifest}") + hint = ( + " (the default is summit-genesis-template.toml beside --manifest; " + "pass --summit-template if it lives elsewhere)" + if defaulted + else "" + ) + raise SystemExit(f"--summit-template not found: {args.summit_template}{hint}") return args @@ -159,6 +185,29 @@ def _get_pubkeys( return validators, node_clients +def _verify_template_commitment(template_path: Path, manifest: dict) -> None: + """Assert the template is the one the manifest commits to + (summit.genesis_template_hash). + + The `-g` override protects only `eth_genesis_hash`; everything else in + the template (namespace, timeouts, stake bounds) flows into genesis.toml + as-is, so building from uncommitted bytes would start the chain on + parameters the manifest never pinned. + """ + computed = "0x" + hashlib.sha256(template_path.read_bytes()).hexdigest() + committed = manifest["summit"]["genesis_template_hash"] + if computed != committed.lower(): + raise SystemExit( + f"--summit-template {template_path} is not the template the " + "manifest commits to (summit.genesis_template_hash); refusing to " + "build genesis.toml:\n" + f" committed: {committed}\n" + f" computed: {computed}\n" + "Use the artifact-set copy written by `manifest assemble --out` " + "(the default when it sits beside --manifest)." + ) + + def _assert_cohort_genesis_hash(descriptors: list[Path], expected: str) -> None: """Assert every cohort node's reth serves `expected` as block 0. @@ -230,6 +279,7 @@ def main(): manifest = manifest_mod.validate_manifest_schema(args.manifest.read_bytes()) except manifest_mod.ManifestSchemaError as e: raise SystemExit(f"--manifest {args.manifest}: invalid manifest: {e}") from None + _verify_template_commitment(args.summit_template, manifest) manifest_hash = manifest["eth"]["genesis_hash"] genesis_hash = args.genesis_hash or manifest_hash diff --git a/deploy_tee/manifest.py b/deploy_tee/manifest.py index 5d691a3..2099bca 100644 --- a/deploy_tee/manifest.py +++ b/deploy_tee/manifest.py @@ -17,20 +17,17 @@ output and committed to by the manifest via measurements.bootstrap_policy_hash -Usage: - uv run python -m deploy_tee.manifest assemble \ - --name seismic-devnet-3 \ - --reth-genesis reth-genesis.json \ - --summit-template summit-genesis-template.toml \ +Usage (one directory per network: `init` gathers the authored inputs — the +only command that takes loose files — then `assemble`/`validate` operate on +the directory): + + uv run python -m deploy_tee.manifest init deploy_tee/networks/seismic-devnet-3 \ + --reth-genesis dev.json \ --measurements ../seismic-images/build/measurements.json \ - --measurement-id seismic_2026-06-11.abc123.vhd \ - --out networks/seismic-devnet-3 - - uv run python -m deploy_tee.manifest validate \ - --manifest networks/devnet-3/network-manifest.json \ - --reth-genesis networks/devnet-3/reth-genesis.json \ - --summit-template networks/devnet-3/summit-genesis-template.toml \ - --measurement-policy networks/devnet-3/measurement-policy.json + --measurement-id seismic_2026-06-11.abc123.vhd + # edit deploy_tee/networks/seismic-devnet-3/summit-template.toml, then: + uv run python -m deploy_tee.manifest assemble deploy_tee/networks/seismic-devnet-3 + uv run python -m deploy_tee.manifest validate deploy_tee/networks/seismic-devnet-3 """ import argparse @@ -38,6 +35,7 @@ import hashlib import json import logging +import re import secrets import subprocess import sys @@ -71,6 +69,19 @@ RETH_GENESIS_FILENAME = "reth-genesis.json" SUMMIT_TEMPLATE_FILENAME = "summit-genesis-template.toml" +# Authored-input filenames inside a network directory (`manifest init`). +# Distinct from the shipped artifact-set names above so `assemble --dir` +# never overwrites an authored input. reth-genesis.json is shared +# deliberately: its artifact copy is byte-verbatim, so the same-name write +# is an identity. +INPUT_SUMMIT_TEMPLATE_FILENAME = "summit-template.toml" +INPUT_MEASUREMENTS_FILENAME = "measurements.json" + +# Cohort descriptors (`up --network` output) live under this subdir of a +# network directory. Mutable infra state — regenerated per deploy, deleted by +# `down` — so it stays gitignored while the artifact set around it commits. +NODES_DIRNAME = "nodes" + class ManifestSchemaError(Exception): """Manifest bytes don't satisfy the strict v1 schema.""" @@ -235,6 +246,10 @@ def promote_measurements( pcrs = raw.get("measurements", raw) measurement_id = measurement_id or raw.get("measurement_id") + if measurement_id: + # Accept a path to the artifact; the published record id is the bare + # filename (a real id never contains a separator). + measurement_id = Path(measurement_id).name attestation_type = raw.get("attestation_type", attestation_type) if not measurement_id: raise GateError( @@ -321,6 +336,10 @@ class GateContext: reth_bin: str = "seismic-reth" # Injectable for tests; defaults to shelling out to seismic-reth. genesis_hash_fn: Callable[[Path], str] | None = None + # Set by `assemble` to its filled template copy (eth_genesis_hash injected + # when the authored file omits it); gates then check these bytes instead + # of re-reading summit_template from disk. + summit_template_bytes: bytes | None = None warnings: list[str] = field(default_factory=list) def warn(self, message: str) -> None: @@ -360,7 +379,11 @@ def run_validation_gates(manifest: dict[str, Any], ctx: GateContext) -> None: ) # summit.genesis_template_hash == SHA-256(template bytes) - template_bytes = ctx.summit_template.read_bytes() + template_bytes = ( + ctx.summit_template_bytes + if ctx.summit_template_bytes is not None + else ctx.summit_template.read_bytes() + ) template_hash = _sha256_hex(template_bytes) if manifest["summit"]["genesis_template_hash"] != template_hash: raise GateError( @@ -394,11 +417,13 @@ def run_validation_gates(manifest: dict[str, Any], ctx: GateContext) -> None: "running the same image can cross-replay BLS signatures " "(Summit TODO 3)" ) - if "validators" in template: + # The shipped copy always carries a `validators` key (assemble fills an + # empty placeholder for summit's parser); only *entries* are suspect. + if template.get("validators"): ctx.warn( - "summit template still contains [[validators]]; the boot-time " - "fill-genesis-template flow (Summit TODOs 1-2) expects a " - "template without them" + "summit template still contains [[validators]] entries; the " + "boot-time fill-genesis-template flow (Summit TODOs 1-2) expects " + "a template without them" ) # measurements.bootstrap_policy_hash == SHA-256(policy bytes) @@ -450,10 +475,44 @@ class AssembledManifest: manifest_bytes: bytes manifest: dict[str, Any] policy_bytes: bytes + # The template copy the manifest commits to (eth_genesis_hash filled if + # the authored file omitted it) — what write_artifact_set ships. + summit_template_bytes: bytes network_id: str warnings: list[str] +def fill_template_genesis_hash(template_bytes: bytes, eth_genesis_hash: str) -> bytes: + """Set `eth_genesis_hash` in a summit template to the computed value. + + The hash is derived from reth-genesis.json — never authored — but summit's + genesis-binary parser requires the field to be present in the TOML it + reads, so the shipped copy must carry it. Any declared value is dropped + (it can only be stale copy-paste, e.g. summit's example_genesis.toml) and + the computed one is prepended — always valid TOML for a top-level key, and + deterministic, so the filled copy is what `genesis_template_hash` commits + to and the ceremony ships. + """ + lines, in_table = [], False + for line in template_bytes.splitlines(keepends=True): + stripped = line.lstrip() + # Top-level keys can only appear before the first table header; a + # same-named key inside a table (none exists today) is left alone. + if stripped.startswith(b"["): + in_table = True + if not in_table and re.match(rb"eth_genesis_hash\s*=", stripped): + continue + lines.append(line) + stripped_bytes = b"".join(lines) + if "eth_genesis_hash" in tomllib.loads(stripped_bytes.decode("utf-8")): + raise GateError( + "could not replace the template's declared eth_genesis_hash " + "(unusual TOML layout); delete the line by hand — the value is " + "derived from the reth genesis" + ) + return f'eth_genesis_hash = "{eth_genesis_hash}"\n'.encode() + stripped_bytes + + def assemble( name: str, reth_genesis: Path, @@ -470,6 +529,11 @@ def assemble( genesis_nonce defaults to fresh OsRng bytes — the clone-deployment uniquifier; two networks spun from otherwise identical artifacts must not share a network_id. Only tests should pass an explicit nonce. + + The summit template's `eth_genesis_hash` is derived from reth-genesis.json, + never authored: whatever the input declares (if anything) is replaced with + the computed value in the copy that `genesis_template_hash` commits to and + the artifact set ships — committed bytes never carry a stale hash. """ genesis = json.loads(reth_genesis.read_bytes()) chain_id = genesis.get("config", {}).get("chainId") @@ -483,6 +547,21 @@ def assemble( raise GateError(f"summit template has no namespace string (got {namespace!r})") hash_fn = genesis_hash_fn or (lambda p: reth_genesis_hash(p, reth_bin=reth_bin)) + eth_hash = hash_fn(reth_genesis).lower() + if "eth_genesis_hash" in template and template["eth_genesis_hash"] != eth_hash: + logger.info( + "replacing the template's declared eth_genesis_hash %s with the " + "computed %s (the value is derived from the reth genesis)", + template["eth_genesis_hash"], + eth_hash, + ) + if "validators" not in template: + # summit's genesis binary requires the field to *parse* the template + # (its GenesisConfig has no serde default) even though it replaces the + # value from -v; authored templates rightly omit validators, so the + # shipped copy carries an empty placeholder set. + template_bytes = b"validators = []\n" + template_bytes + template_bytes = fill_template_genesis_hash(template_bytes, eth_hash) nonce = genesis_nonce if genesis_nonce is not None else secrets.token_bytes(32) if len(nonce) != 32: raise GateError(f"genesis_nonce must be 32 bytes, got {len(nonce)}") @@ -493,7 +572,7 @@ def assemble( "genesis_nonce": "0x" + nonce.hex(), "eth": { "chain_id": chain_id, - "genesis_hash": hash_fn(reth_genesis).lower(), + "genesis_hash": eth_hash, }, "summit": { "genesis_template_hash": _sha256_hex(template_bytes), @@ -518,6 +597,7 @@ def assemble( policy_bytes=policy_bytes, reth_bin=reth_bin, genesis_hash_fn=genesis_hash_fn, + summit_template_bytes=template_bytes, ) run_validation_gates(parsed, ctx) @@ -525,6 +605,7 @@ def assemble( manifest_bytes=manifest_bytes, manifest=parsed, policy_bytes=policy_bytes, + summit_template_bytes=template_bytes, network_id=compute_network_id(manifest_bytes), warnings=ctx.warnings, ) @@ -552,60 +633,218 @@ def write_artifact_set( out_dir.mkdir(parents=True, exist_ok=True) manifest_path.write_bytes(assembled.manifest_bytes) (out_dir / POLICY_FILENAME).write_bytes(assembled.policy_bytes) - # Byte-verbatim copies, so the directory is a self-contained artifact set - # whose hashes all re-verify with sha256sum. + # The reth genesis is a byte-verbatim copy of the input; the template is + # assemble's copy (eth_genesis_hash filled if the author omitted it). + # Every file re-verifies against the manifest's hashes with sha256sum. (out_dir / RETH_GENESIS_FILENAME).write_bytes(ctx.reth_genesis.read_bytes()) - (out_dir / SUMMIT_TEMPLATE_FILENAME).write_bytes(ctx.summit_template.read_bytes()) + (out_dir / SUMMIT_TEMPLATE_FILENAME).write_bytes(assembled.summit_template_bytes) + + +def starter_summit_template(name: str) -> str: + """Starter authored summit template written by `manifest init` (values + from summit's example_genesis.toml). Every value is a per-network choice + for the founder to review; nothing in it is derived. + """ + # json.dumps emits a valid TOML basic string for these simple values. + return f"""\ +# Summit network-params template — authored input for `manifest assemble`. +# Review every value before founding a real network. Two fields are filled +# elsewhere and do not belong here: eth_genesis_hash (derived from +# reth-genesis.json at assemble time) and [[validators]] (TEE-born, filled +# by the genesis ceremony). +leader_timeout_ms = 2000 +notarization_timeout_ms = 4000 +nullify_timeout_ms = 4000 +activity_timeout_views = 256 +skip_timeout_views = 32 +max_message_size_bytes = 10485760 +namespace = {json.dumps(name)} +validator_minimum_stake = 32000000000 +validator_maximum_stake = 32000000000 +blocks_per_epoch = 10000 +allowed_timestamp_future_ms = 10000 +max_deposits_per_epoch = 3 +max_withdrawals_per_epoch = 16 +observers_per_validator = 5 +""" + +def stamp_measurement_id(raw_bytes: bytes, measurement_id: str) -> bytes: + """Stamp the image artifact id into a make-measure measurements file. -def render_network_section(manifest_bytes: bytes) -> str: + The id is a fact about the measurements (which image these PCRs measure), + known when the file is copied in — so `init` records it in the file and + `assemble` needs no --measurement-id. The measurements input is not + hash-committed (the promoted policy derived from it is), so re-serializing + it is safe. + """ + raw = json.loads(raw_bytes) + if isinstance(raw, list): + raise GateError( + "--measurement-id is meaningless for an already-promoted policy " + "(each record carries its own measurement_id)" + ) + if not isinstance(raw, dict): + raise GateError(f"unrecognized measurements shape: {type(raw).__name__}") + wrapper: dict[str, Any] = raw if "measurements" in raw else {"measurements": raw} + # Accept a path to the artifact; the stamped id is the bare filename. + wrapper["measurement_id"] = Path(measurement_id).name + return (json.dumps(wrapper, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def init_network_dir( + out_dir: Path, + name: str, + reth_genesis: Path, + measurements: Path, + summit_template: Path | None = None, + measurement_id: str | None = None, +) -> list[Path]: + """Scaffold a network directory's three authored inputs. + + Copies the chain spec and measurements in (stamping measurement_id into + the latter when given), and writes a starter summit template + (namespace = name) unless one is supplied to copy. The founder edits + these in place, then `assemble --dir` derives the artifact set into the + same directory — inputs and the committed outputs live together, so the + directory is the whole network (commit it for networks that matter). + """ + measurements_bytes = measurements.read_bytes() + if measurement_id is not None: + measurements_bytes = stamp_measurement_id(measurements_bytes, measurement_id) + contents = { + RETH_GENESIS_FILENAME: reth_genesis.read_bytes(), + INPUT_MEASUREMENTS_FILENAME: measurements_bytes, + INPUT_SUMMIT_TEMPLATE_FILENAME: ( + summit_template.read_bytes() + if summit_template is not None + else starter_summit_template(name).encode() + ), + } + existing = [n for n in contents if (out_dir / n).exists()] + if existing: + raise GateError( + f"refusing to overwrite existing input(s) in {out_dir}: " + f"{', '.join(existing)}" + ) + out_dir.mkdir(parents=True, exist_ok=True) + written = [] + for filename, data in contents.items(): + path = out_dir / filename + path.write_bytes(data) + written.append(path) + return written + + +def render_network_section(manifest_bytes: bytes, reth_genesis_bytes: bytes) -> str: """Render the `[network]` config section tdx-init consumes. - base64 keeps the manifest opaque through the TOML hop (byte-exactness - rule): tdx-init decodes and writes these exact bytes verbatim. + base64 keeps both artifacts opaque through the TOML hop (byte-exactness + rule): tdx-init decodes and writes these exact bytes verbatim — the + manifest to `network-manifest.json`, the genesis to `reth-genesis.json` + (reth's `--chain`). + """ + manifest_b64 = base64.standard_b64encode(manifest_bytes).decode("ascii") + genesis_b64 = base64.standard_b64encode(reth_genesis_bytes).decode("ascii") + return ( + f'[network]\nmanifest_base64 = "{manifest_b64}"\n' + f'reth_genesis_base64 = "{genesis_b64}"\n' + ) + + +def validate_reth_genesis_matches( + manifest: dict[str, Any], genesis_bytes: bytes +) -> None: + """Client-side mirror of tdx-init's POST-time reth-genesis check: valid + JSON whose config.chainId equals the manifest's eth.chain_id. Structural + only — the genesis *hash* commitment (manifest eth.genesis_hash) is + enforced by `assemble`/`validate` (via `seismic-reth genesis-hash`) and + re-asserted against every node's reth at ceremony time. """ - encoded = base64.standard_b64encode(manifest_bytes).decode("ascii") - return f'[network]\nmanifest_base64 = "{encoded}"\n' + try: + genesis = json.loads(genesis_bytes) + except json.JSONDecodeError as e: + raise GateError(f"reth genesis is not valid JSON: {e}") from None + config = genesis.get("config") if isinstance(genesis, dict) else None + chain_id = config.get("chainId") if isinstance(config, dict) else None + if not isinstance(chain_id, int) or isinstance(chain_id, bool): + raise GateError(f"reth genesis config.chainId is {chain_id!r}, not an int") + if chain_id != manifest["eth"]["chain_id"]: + raise GateError( + f"reth genesis config.chainId {chain_id} does not match the " + f"manifest's eth.chain_id {manifest['eth']['chain_id']}" + ) -def _parse_args() -> argparse.Namespace: +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="python -m deploy_tee.manifest", description=__doc__ ) sub = parser.add_subparsers(dest="command", required=True) - def add_common(p: argparse.ArgumentParser) -> None: - p.add_argument( - "--reth-genesis", - type=Path, - required=True, - help="reth genesis JSON the network boots from", - ) - p.add_argument( - "--summit-template", - type=Path, - required=True, - help="summit network-params template TOML (no [[validators]])", - ) + def add_reth_bin(p: argparse.ArgumentParser) -> None: p.add_argument( "--reth-bin", default="seismic-reth", help="seismic-reth binary used to recompute eth_genesis_hash", ) - asm = sub.add_parser("assemble", help="assemble a new network manifest") - add_common(asm) - asm.add_argument("--name", required=True, help="network name, e.g. devnet-3") - asm.add_argument( + ini = sub.add_parser("init", help="scaffold a network directory's authored inputs") + ini.add_argument("dir", type=Path, help="network directory to create") + ini.add_argument( + "--name", + default=None, + help="network name for the starter template's namespace; default: the " + "directory's basename (which is also what assemble uses as the " + "manifest name)", + ) + ini.add_argument( + "--reth-genesis", + type=Path, + required=True, + help=f"chain spec, copied in as {RETH_GENESIS_FILENAME}. Required: " + "an external fact (chain state + contract alloc) init cannot invent", + ) + ini.add_argument( "--measurements", type=Path, required=True, - help="seismic-images build/measurements.json (make measure output) " - "or an already-promoted measurement-policy.json", + help="seismic-images make-measure output (or promoted policy), " + f"copied in as {INPUT_MEASUREMENTS_FILENAME}. Required: the PCRs of " + "a real published image, never generated", + ) + ini.add_argument( + "--summit-template", + type=Path, + default=None, + help="summit template to copy in verbatim. Optional: unlike the two " + "inputs above it holds only per-network parameter choices, so the " + "default writes an editable starter with namespace = ", + ) + ini.add_argument( + "--measurement-id", + default=None, + help="image artifact filename the measurements belong to; stamped " + f"into {INPUT_MEASUREMENTS_FILENAME} so assemble needs no " + "--measurement-id", + ) + + asm = sub.add_parser( + "assemble", + help="derive the artifact set from a network directory's inputs", + ) + asm.add_argument( + "dir", + type=Path, + help="network directory from `manifest init`: reads its " + f"{RETH_GENESIS_FILENAME} / {INPUT_SUMMIT_TEMPLATE_FILENAME} / " + f"{INPUT_MEASUREMENTS_FILENAME}, takes the network name from its " + "basename, and writes the artifact set beside them", ) asm.add_argument( "--measurement-id", - help="policy record id; conventionally the image artifact filename", + help="policy record id (image artifact filename); overrides the one " + f"init stamped into {INPUT_MEASUREMENTS_FILENAME}", ) asm.add_argument("--attestation-type", default=DEFAULT_ATTESTATION_TYPE) asm.add_argument( @@ -618,31 +857,68 @@ def add_common(p: argparse.ArgumentParser) -> None: default=DEFAULT_AUTHORITY, help="registry mutation-authority contract address", ) - asm.add_argument( - "--out", - type=Path, - required=True, - help="output directory for the network artifact set", - ) asm.add_argument( "--force", action="store_true", help="overwrite an existing manifest (a new network identity)", ) + add_reth_bin(asm) - val = sub.add_parser("validate", help="re-run gates on an existing manifest") - add_common(val) - val.add_argument("--manifest", type=Path, required=True) - val.add_argument("--measurement-policy", type=Path, required=True) - - return parser.parse_args() + val = sub.add_parser( + "validate", help="re-run all gates over an assembled network directory" + ) + val.add_argument( + "dir", + type=Path, + help="network directory: audits the artifact set `assemble` wrote " + "there (manifest, shipped template, policy) against its reth genesis", + ) + add_reth_bin(val) + + args = parser.parse_args(argv) + + if args.command == "init": + args.name = args.name or args.dir.resolve().name + elif args.command == "assemble": + args.name = args.dir.resolve().name + args.reth_genesis = args.dir / RETH_GENESIS_FILENAME + args.summit_template = args.dir / INPUT_SUMMIT_TEMPLATE_FILENAME + args.measurements = args.dir / INPUT_MEASUREMENTS_FILENAME + args.out = args.dir + elif args.command == "validate": + args.manifest = args.dir / MANIFEST_FILENAME + args.reth_genesis = args.dir / RETH_GENESIS_FILENAME + args.summit_template = args.dir / SUMMIT_TEMPLATE_FILENAME + args.measurement_policy = args.dir / POLICY_FILENAME + return args def main() -> None: setup_logging() args = _parse_args() try: - if args.command == "assemble": + if args.command == "init": + written = init_network_dir( + args.dir, + args.name, + args.reth_genesis, + args.measurements, + args.summit_template, + args.measurement_id, + ) + for path in written: + logger.info("wrote %s", path) + id_hint = ( + "" + if args.measurement_id + else " --measurement-id " + ) + print( + f"Scaffolded {args.dir}. Edit the inputs (at minimum review " + f"{INPUT_SUMMIT_TEMPLATE_FILENAME}), then:\n" + f" seismic-tee-bootstrap manifest assemble {args.dir}{id_hint}" + ) + elif args.command == "assemble": policy_bytes = promote_measurements( args.measurements.read_bytes(), args.measurement_id, diff --git a/deploy_tee/networks/.gitignore b/deploy_tee/networks/.gitignore new file mode 100644 index 0000000..567b46d --- /dev/null +++ b/deploy_tee/networks/.gitignore @@ -0,0 +1,4 @@ +# Cohort descriptors written by `up --network` — runtime output (live IPs, +# regenerated each deploy; `down --network` deletes them), never committed. +# Everything else in a network directory is its committable identity. +*/nodes/ diff --git a/deploy_tee/orchestrator.py b/deploy_tee/orchestrator.py index a71fb15..0ce5372 100644 --- a/deploy_tee/orchestrator.py +++ b/deploy_tee/orchestrator.py @@ -37,6 +37,7 @@ import argparse import getpass +import hashlib import json import os import re @@ -48,6 +49,8 @@ import yaml from pulumi import automation as auto +from deploy_tee import manifest as manifest_mod + # The single-node program this orchestrator fans out over. A local-program # workspace points at this dir, so the project name / runtime / venv all # come from its Pulumi.yaml — identical to running `pulumi` in that dir. @@ -56,12 +59,25 @@ # Shared settings are inherited from this stack config unless --config overrides. DEFAULT_CONFIG = SEISMIC_NODE_DIR / "Pulumi.dev.yaml" -# Per-node descriptors land here by default (gitignored) so a cohort's outputs -# stay corralled instead of scattering into the cwd. The generated +# Per-node descriptors land here when no --network ties the cohort to a +# network directory (gitignored either way) so a cohort's outputs stay +# corralled instead of scattering into the cwd. The generated # Pulumi..yaml configs can't join them — Pulumi pins those to the # project dir (SEISMIC_NODE_DIR). DEFAULT_OUT_DIR = SEISMIC_NODE_DIR.parent / "descriptors" + +def _resolve_out_dir(out_dir: str | None, network: Path | None) -> Path: + """Descriptor destination: explicit --out-dir wins; a --network cohort's + descriptors live under the network directory's nodes/ subdir (where + genesis-ceremony finds them by default); else the shared descriptors/.""" + if out_dir: + return Path(out_dir) + if network is not None: + return network / manifest_mod.NODES_DIRNAME + return DEFAULT_OUT_DIR + + # Fields the configure/genesis handoff actually reads (see descriptor.py); the # rest of the stack outputs (vm_size, vhd_blob_url, …) are deploy-input echoes, # noise for the descriptor. @@ -93,6 +109,58 @@ def _node_config(template: Mapping, name: str) -> dict[str, auto.ConfigValue]: return out +def _check_vhd_matches_network(template: Mapping, network_dir: Path) -> str: + """Refuse to provision when the image pin names an artifact the network's + measurement policy doesn't cover. + + Name-level tripwire only: the basename of the config's `vhd_blob_url` is + the image artifact filename (`seismic[-dev]_..vhd`), which is + also the policy records' `measurement_id`. Comparing the two catches a + stale or typo'd image pin before any cloud resource exists — hours before + the genesis ceremony's block-0 assert would surface it. No VHD bytes are + inspected; the *running* VM is verified cryptographically at attestation + time, never here. + """ + vhd_url = next( + ( + str(value) + for key, value in template.get("config", {}).items() + if key.split(":", 1)[-1] == "vhd_blob_url" + ), + None, + ) + if vhd_url is None: + raise SystemExit( + "--network given but the stack config carries no vhd_blob_url to check" + ) + manifest_path = network_dir / manifest_mod.MANIFEST_FILENAME + policy_path = network_dir / manifest_mod.POLICY_FILENAME + for path in (manifest_path, policy_path): + if not path.is_file(): + raise SystemExit( + f"--network {network_dir}: missing {path.name} " + "(run `manifest assemble` first)" + ) + manifest = manifest_mod.validate_manifest_schema(manifest_path.read_bytes()) + policy_bytes = policy_path.read_bytes() + policy_hash = "0x" + hashlib.sha256(policy_bytes).hexdigest() + if policy_hash != manifest["measurements"]["bootstrap_policy_hash"]: + raise SystemExit( + f"--network {network_dir}: {policy_path.name} does not hash to the " + "manifest's bootstrap_policy_hash — stale or edited artifact set " + "(re-run `manifest assemble`, or `manifest validate` to diagnose)" + ) + ids = [record["measurement_id"] for record in json.loads(policy_bytes)] + vhd_name = vhd_url.rsplit("/", 1)[-1] + if vhd_name not in ids: + raise SystemExit( + f"vhd_blob_url points at {vhd_name!r} but the network's measurement " + f"policy covers only: {', '.join(ids)}. Stale image pin or wrong " + "--network dir; refusing to provision." + ) + return vhd_name + + def _descriptor_from_outputs(outputs: Mapping[str, auto.OutputValue]) -> dict: # Just the handoff fields (DESCRIPTOR_KEYS), not every stack output. return {key: outputs[key].value for key in DESCRIPTOR_KEYS if key in outputs} @@ -172,10 +240,25 @@ def _parse_up_args() -> argparse.Namespace: ) parser.add_argument( "--out-dir", - default=str(DEFAULT_OUT_DIR), + default=None, help=( - "Directory for the emitted .json descriptors (default: a " - "gitignored descriptors/ dir next to the Pulumi project)." + "Directory for the emitted .json descriptors. Default: " + "/nodes/ when --network is given, else a gitignored " + "descriptors/ dir next to the Pulumi project." + ), + ) + parser.add_argument( + "--network", + type=Path, + default=None, + metavar="DIR", + help=( + "Network directory (from `manifest assemble`) this cohort is for: " + "refuse to provision unless the config's vhd_blob_url basename is " + "one of the measurement policy's measurement_id records (catches a " + "stale image pin before any resource exists; name check only — " + "attestation is the cryptographic gate), and write the descriptors " + "to /nodes/, where genesis-ceremony finds them by default." ), ) return parser.parse_args() @@ -186,8 +269,13 @@ def up_main() -> None: _ensure_passphrase(confirm=True) with open(args.config) as f: template = yaml.safe_load(f) + if args.network is not None: + vhd_name = _check_vhd_matches_network(template, args.network) + print( + f"Image pin {vhd_name} is covered by the {args.network} measurement policy." + ) prefix = args.stack_prefix or f"{_env_from_config(args.config)}-bootstrap-node" - out_dir = Path(args.out_dir) + out_dir = _resolve_out_dir(args.out_dir, args.network) out_dir.mkdir(parents=True, exist_ok=True) # Sequential on purpose: clearer logs and gentler on Azure quota for a @@ -209,10 +297,48 @@ def up_main() -> None: path.write_text(json.dumps(descriptor, indent=2) + "\n") print(f"=== {name}: wrote descriptor {path} ===") + genesis_desc = out_dir / f"{prefix}-1.json" + join_flags = "".join( + f" --join {out_dir / f'{prefix}-{i}.json'}" for i in range(2, args.count + 1) + ) + if args.network is not None: + # The network dir exists and holds the descriptors, so the remaining + # steps need no placeholders — and genesis-ceremony finds the cohort + # in /nodes/ on its own. + manifest_arg = args.network / manifest_mod.MANIFEST_FILENAME + print( + f"\nProvisioned {args.count} node(s) for {args.network}. Next:\n" + "\n" + "1. Configure the cohort (re-run on every node reboot):\n" + f" seismic-tee-bootstrap configure --genesis {genesis_desc}" + f"{join_flags} \\\n" + f" --manifest {manifest_arg}\n" + "2. Run the genesis ceremony (one-shot; --node defaults to the\n" + f" descriptors in {out_dir}):\n" + f" seismic-tee-bootstrap genesis-ceremony --manifest {manifest_arg}" + ) + return + node_flags = " ".join( + f"--node {out_dir / f'{prefix}-{i}.json'}" for i in range(1, args.count + 1) + ) + net = "deploy_tee/networks/" print( - f"\nProvisioned {args.count} node(s). Next: configure each node, then " - f"run the genesis ceremony (`seismic-tee-bootstrap genesis`) over the " - f"descriptors." + f"\nProvisioned {args.count} node(s). To found a network on them:\n" + "\n" + "1. Create the network directory (once per network; assemble shells out\n" + " to `seismic-reth genesis-hash`, so seismic-reth must be on PATH):\n" + f" seismic-tee-bootstrap manifest init {net} \\\n" + " --reth-genesis \\\n" + " --measurements --measurement-id \n" + f" # edit {net}/summit-template.toml, then:\n" + f" seismic-tee-bootstrap manifest assemble {net}\n" + "2. Configure the cohort (re-run on every node reboot):\n" + f" seismic-tee-bootstrap configure --genesis {genesis_desc}" + f"{join_flags} \\\n" + f" --manifest {net}/network-manifest.json\n" + "3. Run the genesis ceremony (one-shot) over the same descriptors:\n" + f" seismic-tee-bootstrap genesis-ceremony {node_flags} \\\n" + f" --manifest {net}/network-manifest.json" ) @@ -247,10 +373,21 @@ def _parse_down_args() -> argparse.Namespace: ) parser.add_argument( "--out-dir", - default=str(DEFAULT_OUT_DIR), + default=None, help=( "Where `up` wrote the descriptors; each torn-down stack's " - ".json is removed from here too (default: matches `up`)." + ".json is removed from here too (default: matches `up` — " + "/nodes/ with --network, else the shared descriptors/)." + ), + ) + parser.add_argument( + "--network", + type=Path, + default=None, + metavar="DIR", + help=( + "Network directory the cohort was provisioned for (`up --network`); " + "its nodes/ subdir is where the descriptors get deleted from." ), ) return parser.parse_args() @@ -281,7 +418,7 @@ def down_main() -> None: if args.stack else [f"{args.stack_prefix}-{i}" for i in range(1, args.count + 1)] ) - out_dir = Path(args.out_dir) + out_dir = _resolve_out_dir(args.out_dir, args.network) # Independent stacks (separate state + per-stack locks) → tear them down # concurrently. A failure on one is reported but doesn't abort the rest. diff --git a/deploy_tee/pulumi/seismic_node/Pulumi.dev.yaml b/deploy_tee/pulumi/seismic_node/Pulumi.dev.yaml index ec751a4..269b5ac 100644 --- a/deploy_tee/pulumi/seismic_node/Pulumi.dev.yaml +++ b/deploy_tee/pulumi/seismic_node/Pulumi.dev.yaml @@ -15,7 +15,7 @@ config: # Required so Azure can authorize the cross-RG blob read for managed disk Import. # Get via: az storage account show -n seismicimages -g seismic-images --query id -o tsv seismic-tee-deploy:vhd_storage_account_id: /subscriptions/214887ea-51a7-4ca7-9cec-29b3cf3d311c/resourceGroups/seismic-images/providers/Microsoft.Storage/storageAccounts/seismicimages - seismic-tee-deploy:vhd_blob_url: https://seismicimages.blob.core.windows.net/dev/seismic-dev_2026-07-02.7b7342.vhd + seismic-tee-deploy:vhd_blob_url: https://seismicimages.blob.core.windows.net/dev/seismic-dev_2026-07-02.5c3b5e.vhd # Stack-specific deploy config. seismic-tee-deploy:dns_record_name: tee-dev-az-1 # → tee-dev-az-1.seismicdev.net seismic-tee-deploy:resource_group: seismic-tee-dev diff --git a/deploy_tee/tests/test_configure.py b/deploy_tee/tests/test_configure.py index 8edae7a..7944a2d 100644 --- a/deploy_tee/tests/test_configure.py +++ b/deploy_tee/tests/test_configure.py @@ -10,7 +10,12 @@ import unittest from pathlib import Path -from deploy_tee.configure import ENCLAVE_PEER_PORT, build_config, resolve_peer +from deploy_tee.configure import ( + ENCLAVE_PEER_PORT, + build_config, + resolve_peer, + resolve_reth_genesis, +) from deploy_tee.manifest import render_manifest # Reuse the canonical valid manifest from the manifest tests rather than @@ -33,6 +38,11 @@ def setUp(self): self._tmp = [] self.manifest = _write(".json", render_manifest(FIXTURE_MANIFEST)) self._tmp.append(self.manifest) + # chainId matches FIXTURE_MANIFEST's eth.chain_id. + self.reth_genesis = _write( + ".json", json.dumps({"config": {"chainId": 5124}, "alloc": {}}) + ) + self._tmp.append(self.reth_genesis) def tearDown(self): for p in self._tmp: @@ -40,7 +50,12 @@ def tearDown(self): def _build(self, *, genesis_node: bool, peers: list[str]) -> dict: out = build_config( - self.manifest, FQDN, EMAIL, genesis_node=genesis_node, peers=peers + self.manifest, + FQDN, + EMAIL, + genesis_node=genesis_node, + peers=peers, + reth_genesis_path=self.reth_genesis, ) self._tmp.append(out) return tomllib.loads(out.read_text()) @@ -54,6 +69,7 @@ def test_genesis_mode(self): self.assertEqual(merged["domain"]["name"], FQDN) self.assertEqual(merged["domain"]["email"], EMAIL) self.assertTrue(merged["network"]["manifest_base64"]) + self.assertTrue(merged["network"]["reth_genesis_base64"]) def test_join_mode(self): # join: genesis_node=false and the resolved peer URL(s) survive verbatim. @@ -63,12 +79,58 @@ def test_join_mode(self): self.assertEqual(merged["enclave"]["peers"], peers) self.assertEqual(merged["domain"]["name"], FQDN) self.assertTrue(merged["network"]["manifest_base64"]) + self.assertTrue(merged["network"]["reth_genesis_base64"]) def test_rejects_invalid_manifest(self): bad = _write(".json", "{not json") self._tmp.append(bad) with self.assertRaises(SystemExit): - build_config(bad, FQDN, EMAIL, genesis_node=True, peers=[]) + build_config( + bad, + FQDN, + EMAIL, + genesis_node=True, + peers=[], + reth_genesis_path=self.reth_genesis, + ) + + def test_rejects_chain_id_mismatch(self): + # A genesis file other than the one the manifest was assembled from + # must fail the POST build, not boot a forked node. + wrong = _write(".json", json.dumps({"config": {"chainId": 9999}})) + self._tmp.append(wrong) + with self.assertRaises(SystemExit): + build_config( + self.manifest, + FQDN, + EMAIL, + genesis_node=True, + peers=[], + reth_genesis_path=wrong, + ) + + +class ResolveRethGenesisTests(unittest.TestCase): + def test_explicit_path(self): + with tempfile.TemporaryDirectory() as d: + genesis = Path(d) / "custom.json" + genesis.write_text("{}") + manifest = Path(d) / "network-manifest.json" + self.assertEqual(resolve_reth_genesis(genesis, manifest), genesis) + + def test_defaults_to_manifest_sibling(self): + # The artifact-set layout `manifest assemble --out` writes. + with tempfile.TemporaryDirectory() as d: + manifest = Path(d) / "network-manifest.json" + sibling = Path(d) / "reth-genesis.json" + sibling.write_text("{}") + self.assertEqual(resolve_reth_genesis(None, manifest), sibling) + + def test_missing_default_errors(self): + with tempfile.TemporaryDirectory() as d: + manifest = Path(d) / "network-manifest.json" + with self.assertRaises(SystemExit): + resolve_reth_genesis(None, manifest) class ResolvePeerTests(unittest.TestCase): diff --git a/deploy_tee/tests/test_genesis.py b/deploy_tee/tests/test_genesis.py index 6a86b8a..9ec2fa5 100644 --- a/deploy_tee/tests/test_genesis.py +++ b/deploy_tee/tests/test_genesis.py @@ -4,6 +4,7 @@ uv run python -m unittest discover -s deploy_tee/tests -v """ +import hashlib import json import tempfile import unittest @@ -54,6 +55,65 @@ def test_duplicate_descriptor_rejected(self): ) self.assertIn("duplicate", str(ctx.exception)) + def test_node_defaults_to_manifest_sibling_nodes_dir(self): + # The layout `up --network` writes; sorted for a deterministic cohort. + nodes = Path(self._tmp.name) / "nodes" + nodes.mkdir() + (nodes / "b.json").write_text("{}") + (nodes / "a.json").write_text("{}") + args = genesis._parse_args(list(self.common)) + self.assertEqual(args.node, [nodes / "a.json", nodes / "b.json"]) + + def test_no_node_and_no_nodes_dir_errors(self): + with self.assertRaises(SystemExit) as ctx: + genesis._parse_args(list(self.common)) + self.assertIn("--node", str(ctx.exception)) + + def test_summit_template_defaults_to_manifest_sibling(self): + # The artifact-set layout `manifest assemble --out` writes. + sibling = Path(self._tmp.name) / "summit-genesis-template.toml" + sibling.write_text("") + args = genesis._parse_args( + ["--node", str(self.n1), "--manifest", str(self.manifest)] + ) + self.assertEqual(args.summit_template, sibling) + + def test_missing_default_template_errors_with_hint(self): + with self.assertRaises(SystemExit) as ctx: + genesis._parse_args( + ["--node", str(self.n1), "--manifest", str(self.manifest)] + ) + self.assertIn("beside --manifest", str(ctx.exception)) + + +class TemplateCommitmentTests(unittest.TestCase): + """The ceremony must build genesis.toml only from the template the + manifest commits to — the -g override protects the eth hash, but the + namespace/timeouts/stake bounds flow into genesis.toml as-is.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.template = Path(self._tmp.name) / "summit-genesis-template.toml" + self.template.write_text('namespace = "testnet-1"\n') + + def _manifest_committing_to(self, data: bytes) -> dict: + return { + "summit": {"genesis_template_hash": "0x" + hashlib.sha256(data).hexdigest()} + } + + def test_committed_template_passes(self): + genesis._verify_template_commitment( + self.template, self._manifest_committing_to(self.template.read_bytes()) + ) + + def test_uncommitted_template_exits(self): + with self.assertRaises(SystemExit) as ctx: + genesis._verify_template_commitment( + self.template, self._manifest_committing_to(b"other bytes") + ) + self.assertIn("genesis_template_hash", str(ctx.exception)) + class AssertCohortGenesisHashTests(unittest.TestCase): """The manifest declares eth_genesis_hash; every node's live reth must diff --git a/deploy_tee/tests/test_manifest.py b/deploy_tee/tests/test_manifest.py index 011bd92..aa328f8 100644 --- a/deploy_tee/tests/test_manifest.py +++ b/deploy_tee/tests/test_manifest.py @@ -6,11 +6,13 @@ import json import tempfile +import tomllib import unittest import urllib.error import urllib.request from pathlib import Path +from deploy_tee import manifest as manifest_mod from deploy_tee.manifest import ( AssembledManifest, GateContext, @@ -18,11 +20,13 @@ ManifestSchemaError, assemble, compute_network_id, + init_network_dir, promote_measurements, render_manifest, render_network_section, run_validation_gates, validate_manifest_schema, + validate_reth_genesis_matches, write_artifact_set, ) @@ -188,6 +192,13 @@ def test_already_promoted_policy_passes_through_verbatim(self): ) self.assertEqual(promote_measurements(raw, None), raw) + def test_normalizes_path_measurement_id_to_basename(self): + # A path to the artifact is a common slip; the published id is the + # bare filename (a real id never contains a separator). + raw = json.dumps({"measurements": self.PCRS}).encode() + policy = json.loads(promote_measurements(raw, "../images/build/img.vhd")) + self.assertEqual(policy[0]["measurement_id"], "img.vhd") + def test_requires_measurement_id(self): raw = json.dumps({"measurements": self.PCRS}).encode() with self.assertRaisesRegex(GateError, "measurement_id"): @@ -205,9 +216,38 @@ def test_network_section_round_trips_exact_bytes(self): import tomllib manifest_bytes = render_manifest(FIXTURE_MANIFEST) - section = tomllib.loads(render_network_section(manifest_bytes)) + genesis_bytes = json.dumps({"config": {"chainId": 5124}}).encode() + section = tomllib.loads(render_network_section(manifest_bytes, genesis_bytes)) decoded = base64.standard_b64decode(section["network"]["manifest_base64"]) self.assertEqual(decoded, manifest_bytes) + decoded_genesis = base64.standard_b64decode( + section["network"]["reth_genesis_base64"] + ) + self.assertEqual(decoded_genesis, genesis_bytes) + + +class RethGenesisMatchTests(unittest.TestCase): + """validate_reth_genesis_matches — the client-side mirror of tdx-init's + POST-time chainId cross-check.""" + + def _genesis(self, chain_id) -> bytes: + return json.dumps({"config": {"chainId": chain_id}, "alloc": {}}).encode() + + def test_matching_chain_id_passes(self): + validate_reth_genesis_matches(FIXTURE_MANIFEST, self._genesis(5124)) + + def test_chain_id_mismatch(self): + with self.assertRaises(GateError): + validate_reth_genesis_matches(FIXTURE_MANIFEST, self._genesis(5125)) + + def test_rejects_non_json(self): + with self.assertRaises(GateError): + validate_reth_genesis_matches(FIXTURE_MANIFEST, b"{not json") + + def test_rejects_missing_or_bool_chain_id(self): + for genesis in (b"{}", b'{"config": {}}', self._genesis(True)): + with self.assertRaises(GateError): + validate_reth_genesis_matches(FIXTURE_MANIFEST, genesis) class GateTests(unittest.TestCase): @@ -235,10 +275,9 @@ def setUp(self): } ) ) + # Authored templates carry no eth_genesis_hash — assemble fills it. self.summit_template = root / "summit-genesis-template.toml" - self.summit_template.write_text( - f'eth_genesis_hash = "{self.ETH_HASH}"\nnamespace = "testnet-1"\n' - ) + self.summit_template.write_text('namespace = "testnet-1"\n') self.policy_bytes = promote_measurements( json.dumps({"measurements": {"4": {"expected": "ab" * 24}}}).encode(), "img.vhd", @@ -310,23 +349,37 @@ def test_gate_template_hash_mismatch(self): run_validation_gates(manifest, self._ctx()) def test_gate_template_namespace_mismatch(self): - manifest = self._assemble().manifest + # The template-bytes override stands in for the filled artifact-set + # copy, so the earlier template-hash gate passes and this one fires. + assembled = self._assemble() + manifest = assembled.manifest manifest["summit"]["namespace"] = "other" with self.assertRaisesRegex(GateError, "namespace"): - run_validation_gates(manifest, self._ctx()) + run_validation_gates( + manifest, + self._ctx(summit_template_bytes=assembled.summit_template_bytes), + ) def test_gate_contract_missing_from_alloc(self): - manifest = self._assemble().manifest + assembled = self._assemble() + manifest = assembled.manifest contracts = manifest["measurements"]["contracts"] contracts["registry"] = "0x" + "99" * 20 with self.assertRaisesRegex(GateError, "not in the reth genesis alloc"): - run_validation_gates(manifest, self._ctx()) + run_validation_gates( + manifest, + self._ctx(summit_template_bytes=assembled.summit_template_bytes), + ) def test_gate_policy_hash_mismatch(self): - manifest = self._assemble().manifest + assembled = self._assemble() with self.assertRaisesRegex(GateError, "bootstrap_policy_hash mismatch"): run_validation_gates( - manifest, self._ctx(policy_bytes=self.policy_bytes + b"\n") + assembled.manifest, + self._ctx( + policy_bytes=self.policy_bytes + b"\n", + summit_template_bytes=assembled.summit_template_bytes, + ), ) def test_gate_populated_operator_storage_trips_unimplemented_check(self): @@ -337,13 +390,88 @@ def test_gate_populated_operator_storage_trips_unimplemented_check(self): with self.assertRaisesRegex(GateError, "consistency check"): self._assemble() - def test_warns_on_default_summit_namespace(self): + def test_assemble_fills_template_hash(self): + # eth_genesis_hash is derived from reth-genesis.json, not authored: + # the computed hash gets prepended, and the filled copy is what the + # manifest commits to and the set ships. + assembled = self._assemble() + filled_line = f'eth_genesis_hash = "{self.ETH_HASH}"\n'.encode() + self.assertTrue(assembled.summit_template_bytes.startswith(filled_line)) + write_artifact_set(self.out_dir, assembled, self._ctx()) + written = self.out_dir / "summit-genesis-template.toml" + self.assertEqual(written.read_bytes(), assembled.summit_template_bytes) + # validate-style round trip: gates re-pass over the written copy. + run_validation_gates(assembled.manifest, self._ctx(summit_template=written)) + + def test_assemble_fills_empty_validators_placeholder(self): + # summit's genesis binary requires the key to *parse* the template + # (no serde default) though it replaces the value; entries stay out. + assembled = self._assemble() + template = tomllib.loads(assembled.summit_template_bytes.decode()) + self.assertEqual(template["validators"], []) + self.assertFalse(any("[[validators]]" in w for w in assembled.warnings)) + + def test_assemble_replaces_declared_genesis_hash(self): + # A declared value (e.g. from summit's example_genesis.toml) is stale + # copy-paste by definition: the shipped copy carries the computed + # value instead, and never the declared one. + stale = "0x" + "34" * 32 + self.summit_template.write_text( + f'eth_genesis_hash = "{stale}"\nnamespace = "testnet-1"\n' + ) + assembled = self._assemble() + shipped = assembled.summit_template_bytes + self.assertNotIn(stale.encode(), shipped) + self.assertTrue( + shipped.startswith(f'eth_genesis_hash = "{self.ETH_HASH}"\n'.encode()) + ) + # Exactly one occurrence: the declared line was removed, not shadowed. + self.assertEqual(shipped.count(b"eth_genesis_hash"), 1) + + def test_replace_leaves_table_keys_alone(self): + # Only the top-level key is derived; a same-named key inside a table + # (hypothetical) must survive untouched. self.summit_template.write_text( - f'eth_genesis_hash = "{self.ETH_HASH}"\nnamespace = "_SUMMIT"\n' + 'namespace = "testnet-1"\n[extra]\neth_genesis_hash = "0xdead"\n' + ) + assembled = self._assemble() + self.assertIn( + b'[extra]\neth_genesis_hash = "0xdead"\n', assembled.summit_template_bytes ) + + def test_warns_on_default_summit_namespace(self): + self.summit_template.write_text('namespace = "_SUMMIT"\n') assembled = self._assemble() self.assertTrue(any("_SUMMIT" in w for w in assembled.warnings)) + def test_init_then_assemble_shares_directory(self): + # The `manifest init` → edit → `assemble --dir` loop: authored inputs + # and derived outputs coexist in one network directory. + net = Path(self.tmp.name) / "networks" / "testnet-1" + raw = Path(self.tmp.name) / "raw-measurements.json" + raw.write_text(json.dumps({"measurements": {"4": {"expected": "ab" * 24}}})) + init_network_dir(net, "testnet-1", self.reth_genesis, raw) + authored = (net / "summit-template.toml").read_bytes() + policy = promote_measurements((net / "measurements.json").read_bytes(), "i.vhd") + assembled = self._assemble( + reth_genesis=net / "reth-genesis.json", + summit_template=net / "summit-template.toml", + policy_bytes=policy, + ) + ctx = self._ctx( + reth_genesis=net / "reth-genesis.json", + summit_template=net / "summit-template.toml", + policy_bytes=policy, + ) + write_artifact_set(net, assembled, ctx) + # Authored input untouched; the shipped filled copy sits beside it; + # the same-name reth genesis write is byte-identical. + self.assertEqual((net / "summit-template.toml").read_bytes(), authored) + self.assertTrue((net / "summit-genesis-template.toml").exists()) + self.assertEqual( + (net / "reth-genesis.json").read_bytes(), self.reth_genesis.read_bytes() + ) + def test_write_artifact_set_refuses_overwrite(self): assembled = self._assemble() write_artifact_set(self.out_dir, assembled, self._ctx()) @@ -362,5 +490,131 @@ def test_write_artifact_set_refuses_overwrite(self): write_artifact_set(self.out_dir, assembled, self._ctx(), force=True) +class InitTests(unittest.TestCase): + """`manifest init` scaffolds a network directory's authored inputs.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.reth_genesis = root / "dev.json" + self.reth_genesis.write_text('{"config": {"chainId": 5124}}') + self.measurements = root / "measurements.json" + self.measurements.write_text('{"measurements": {"4": {"expected": "ab"}}}') + self.out = root / "networks" / "testnet-1" + + def test_scaffolds_inputs_with_starter_template(self): + written = init_network_dir( + self.out, "testnet-1", self.reth_genesis, self.measurements + ) + self.assertEqual( + sorted(p.name for p in written), + ["measurements.json", "reth-genesis.json", "summit-template.toml"], + ) + self.assertEqual( + (self.out / "reth-genesis.json").read_bytes(), + self.reth_genesis.read_bytes(), + ) + template = tomllib.loads((self.out / "summit-template.toml").read_text()) + self.assertEqual(template["namespace"], "testnet-1") + self.assertNotIn("eth_genesis_hash", template) + self.assertNotIn("validators", template) + + def test_copies_supplied_template_verbatim(self): + src = Path(self.tmp.name) / "custom.toml" + src.write_text('namespace = "custom"\n# comment\n') + init_network_dir( + self.out, "testnet-1", self.reth_genesis, self.measurements, src + ) + self.assertEqual( + (self.out / "summit-template.toml").read_bytes(), src.read_bytes() + ) + + def test_refuses_overwrite(self): + init_network_dir(self.out, "t", self.reth_genesis, self.measurements) + with self.assertRaisesRegex(GateError, "refusing to overwrite"): + init_network_dir(self.out, "t", self.reth_genesis, self.measurements) + + def test_stamps_measurement_id(self): + init_network_dir( + self.out, + "testnet-1", + self.reth_genesis, + self.measurements, + measurement_id="img.vhd", + ) + stamped = (self.out / "measurements.json").read_bytes() + # assemble's promotion picks the id up from the file — no flag needed. + policy = json.loads(promote_measurements(stamped, None)) + self.assertEqual(policy[0]["measurement_id"], "img.vhd") + + def test_rejects_measurement_id_for_promoted_policy(self): + promoted = Path(self.tmp.name) / "policy.json" + promoted.write_text( + json.dumps( + [ + { + "measurement_id": "x", + "attestation_type": "azure-tdx", + "measurements": {"4": {"expected": "ab"}}, + } + ] + ) + ) + with self.assertRaisesRegex(GateError, "already-promoted"): + init_network_dir( + self.out, "t", self.reth_genesis, promoted, measurement_id="img.vhd" + ) + + +class DirCliTests(unittest.TestCase): + """assemble/validate take the network directory as their sole positional + argument (`_parse_args`); only init handles loose files.""" + + def test_assemble_dir_resolution(self): + args = manifest_mod._parse_args(["assemble", "networks/testnet-1"]) + self.assertEqual(args.name, "testnet-1") + self.assertEqual( + args.reth_genesis, Path("networks/testnet-1/reth-genesis.json") + ) + # assemble reads the *authored* input template. + self.assertEqual( + args.summit_template, Path("networks/testnet-1/summit-template.toml") + ) + self.assertEqual( + args.measurements, Path("networks/testnet-1/measurements.json") + ) + self.assertEqual(args.out, Path("networks/testnet-1")) + + def test_assemble_requires_dir(self): + with self.assertRaises(SystemExit): + manifest_mod._parse_args(["assemble"]) + + def test_init_dir_positional_defaults_name(self): + args = manifest_mod._parse_args( + [ + "init", + "networks/testnet-1", + "--reth-genesis", + "g.json", + "--measurements", + "m.json", + ] + ) + self.assertEqual(args.dir, Path("networks/testnet-1")) + self.assertEqual(args.name, "testnet-1") + + def test_validate_dir_resolution(self): + args = manifest_mod._parse_args(["validate", "networks/t"]) + self.assertEqual(args.manifest, Path("networks/t/network-manifest.json")) + # validate reads the *shipped* template copy, not the authored input. + self.assertEqual( + args.summit_template, Path("networks/t/summit-genesis-template.toml") + ) + self.assertEqual( + args.measurement_policy, Path("networks/t/measurement-policy.json") + ) + + if __name__ == "__main__": unittest.main() diff --git a/deploy_tee/tests/test_orchestrator.py b/deploy_tee/tests/test_orchestrator.py new file mode 100644 index 0000000..edd8a10 --- /dev/null +++ b/deploy_tee/tests/test_orchestrator.py @@ -0,0 +1,108 @@ +"""Tests for deploy_tee.orchestrator (stdlib unittest; no test deps). + +Run with: + uv run python -m unittest discover -s deploy_tee/tests -v +""" + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from deploy_tee.manifest import render_manifest +from deploy_tee.orchestrator import ( + DEFAULT_OUT_DIR, + _check_vhd_matches_network, + _resolve_out_dir, +) + +POLICY = json.dumps( + [ + { + "measurement_id": "seismic-dev_2026-07-02.5c3b5e.vhd", + "attestation_type": "azure-tdx", + "measurements": {"4": {"expected": "ab" * 24}}, + } + ] +).encode() + + +def _manifest_bytes(policy: bytes) -> bytes: + return render_manifest( + { + "manifest_version": 1, + "name": "t", + "genesis_nonce": "0x" + "aa" * 32, + "eth": {"chain_id": 5124, "genesis_hash": "0x" + "12" * 32}, + "summit": {"genesis_template_hash": "0x" + "bb" * 32, "namespace": "t"}, + "measurements": { + "bootstrap_policy_hash": "0x" + hashlib.sha256(policy).hexdigest(), + "contracts": { + "registry": "0x" + "10" * 20, + "authority": "0x" + "11" * 20, + }, + }, + } + ) + + +class VhdNetworkCheckTests(unittest.TestCase): + """`up --network` refuses a VHD pin the measurement policy doesn't cover.""" + + URL = "https://acct.blob.core.windows.net/dev/seismic-dev_2026-07-02.5c3b5e.vhd" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.net = Path(self._tmp.name) + (self.net / "measurement-policy.json").write_bytes(POLICY) + (self.net / "network-manifest.json").write_bytes(_manifest_bytes(POLICY)) + + def _template(self, url: str) -> dict: + return {"config": {"seismic-tee-deploy:vhd_blob_url": url}} + + def test_matching_pin_passes(self): + name = _check_vhd_matches_network(self._template(self.URL), self.net) + self.assertEqual(name, "seismic-dev_2026-07-02.5c3b5e.vhd") + + def test_uncovered_pin_refused(self): + bad = self.URL.replace("5c3b5e", "999999") + with self.assertRaises(SystemExit) as ctx: + _check_vhd_matches_network(self._template(bad), self.net) + self.assertIn("refusing to provision", str(ctx.exception)) + + def test_tampered_policy_refused(self): + # Policy bytes not hashing to the manifest's commitment must refuse + # before the measurement_id comparison is even attempted. + (self.net / "measurement-policy.json").write_bytes(POLICY + b"\n") + with self.assertRaises(SystemExit) as ctx: + _check_vhd_matches_network(self._template(self.URL), self.net) + self.assertIn("bootstrap_policy_hash", str(ctx.exception)) + + def test_missing_artifact_set_refused(self): + (self.net / "network-manifest.json").unlink() + with self.assertRaises(SystemExit) as ctx: + _check_vhd_matches_network(self._template(self.URL), self.net) + self.assertIn("manifest assemble", str(ctx.exception)) + + def test_config_without_vhd_url_refused(self): + with self.assertRaises(SystemExit): + _check_vhd_matches_network({"config": {}}, self.net) + + +class ResolveOutDirTests(unittest.TestCase): + """Descriptor destination: --out-dir > /nodes/ > shared default.""" + + def test_explicit_out_dir_wins(self): + self.assertEqual(_resolve_out_dir("/tmp/x", Path("net")), Path("/tmp/x")) + + def test_network_puts_descriptors_under_nodes(self): + self.assertEqual(_resolve_out_dir(None, Path("net")), Path("net/nodes")) + + def test_default_shared_dir(self): + self.assertEqual(_resolve_out_dir(None, None), DEFAULT_OUT_DIR) + + +if __name__ == "__main__": + unittest.main()