From 540a045595cf09d1e1338d8d0ec71d1d82a18a44 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:49:23 +0800 Subject: [PATCH 1/3] fix(deploy_tee): summit_client speaks JSON-RPC, not REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit summit's RPC is jsonrpsee (JSON-RPC 2.0, POST-only, camelCase methods; see summit/rpc/src/api.rs), but summit_client did GET / and text/plain POSTs with snake_case paths. So getPublicKeys / sendGenesis never actually worked against a real node — the genesis ceremony had never run e2e. Rewrite it as a JSON-RPC client: one `_rpc(method, params)` that POSTs the envelope to the `/summit` base (nginx strips the prefix) and returns `result`, using the real method names (getPublicKeys, sendGenesis, health). Drop the dead REST helpers and `send_share` (no such endpoint, no caller). Add mocked tests for the envelope + error path. --- deploy_tee/tests/test_summit_client.py | 49 ++++++++++++++++++++++++++ deploy_tee/utils/summit_client.py | 43 +++++++++++----------- 2 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 deploy_tee/tests/test_summit_client.py diff --git a/deploy_tee/tests/test_summit_client.py b/deploy_tee/tests/test_summit_client.py new file mode 100644 index 00000000..1346f464 --- /dev/null +++ b/deploy_tee/tests/test_summit_client.py @@ -0,0 +1,49 @@ +"""Tests for deploy_tee.utils.summit_client (stdlib unittest; no test deps). + +Run with: + uv run python -m unittest discover -s deploy_tee/tests -v +""" + +import unittest +from unittest import mock + +from deploy_tee.utils import summit_client +from deploy_tee.utils.summit_client import SummitClient + + +class SummitClientTests(unittest.TestCase): + def _resp(self, body: dict): + r = mock.Mock() + r.raise_for_status.return_value = None + r.json.return_value = body + return r + + def _patch(self, resp): + return mock.patch.object(summit_client.requests, "post", return_value=resp) + + def test_get_public_keys_posts_jsonrpc_and_parses(self): + result = {"node": "n", "consensus": "c"} + resp = self._resp({"jsonrpc": "2.0", "id": 1, "result": result}) + with self._patch(resp) as post: + keys = SummitClient("https://x/summit").get_public_keys() + self.assertEqual((keys.node, keys.consensus), ("n", "c")) + # POSTs a JSON-RPC envelope to the /summit base, camelCase method. + self.assertEqual(post.call_args[0][0], "https://x/summit") + self.assertEqual(post.call_args[1]["json"]["method"], "getPublicKeys") + + def test_send_genesis_posts_content_as_param(self): + resp = self._resp({"jsonrpc": "2.0", "id": 1, "result": "ok"}) + with self._patch(resp) as post: + SummitClient("https://x/summit").send_genesis("foo = 1\n") + self.assertEqual(post.call_args[1]["json"]["method"], "sendGenesis") + self.assertEqual(post.call_args[1]["json"]["params"], ["foo = 1\n"]) + + def test_rpc_error_raises(self): + resp = self._resp({"jsonrpc": "2.0", "id": 1, "error": {"message": "boom"}}) + with self._patch(resp): + with self.assertRaises(RuntimeError): + SummitClient("https://x/summit").get_public_keys() + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy_tee/utils/summit_client.py b/deploy_tee/utils/summit_client.py index d666bac2..382a4de1 100644 --- a/deploy_tee/utils/summit_client.py +++ b/deploy_tee/utils/summit_client.py @@ -19,45 +19,42 @@ class PublicKeys: class SummitClient: - def __init__(self, url: str): - self.url = url + """Client for a node's summit RPC. - def _get(self, path: str) -> str: - response = requests.get(f"{self.url}/{path}") - response.raise_for_status() - return response.text + Summit's RPC is jsonrpsee (JSON-RPC 2.0): a single POST endpoint, method + names in camelCase (see summit/rpc/src/api.rs). nginx proxies `/summit` → + localhost:3030 and strips the prefix, so we POST the envelope to the + `/summit` base — there is no per-method REST path or GET. + """ - def _get_json(self, path: str) -> Json: - response = requests.get(f"{self.url}/{path}") - response.raise_for_status() - return response.json() + def __init__(self, url: str): + self.url = url - def _post_text(self, path: str, body: str) -> str: + def _rpc(self, method: str, params: list | None = None) -> Json: response = requests.post( - f"{self.url}/{path}", - data=body, - headers={"Content-Type": "text/plain"}, + self.url, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []}, + timeout=30, ) response.raise_for_status() - return response.text + data = response.json() + if "error" in data: + raise RuntimeError(f"summit RPC {method} failed: {data['error']}") + return data["result"] def health(self) -> str: - return self._get("health") + return self._rpc("health") def get_public_keys(self) -> PublicKeys: - keys = self._get_json("get_public_keys") + keys = self._rpc("getPublicKeys") return PublicKeys(node=keys["node"], consensus=keys["consensus"]) - def send_share(self, share: str) -> str: - return self._post_text("send_share", share) - def send_genesis(self, genesis: GenesisText) -> str: self.validate_genesis_text(genesis) - return self._post_text("send_genesis", genesis) + return self._rpc("sendGenesis", [genesis]) def post_genesis_filepath(self, path: Path): - text = self.load_genesis_file(path) - self.send_genesis(text) + self.send_genesis(self.load_genesis_file(path)) @staticmethod def load_genesis_file(path: Path) -> GenesisText: From 9546ea9c103ea6deccc8c6e046e4a83ec1a276c6 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:49:23 +0800 Subject: [PATCH 2/3] feat(deploy_tee)!: run the genesis binary from PATH with an explicit template genesis.py assumed a dev checkout: it shelled out to ~/summit/target/debug/genesis and ~/summit/example_genesis.toml. Take the `genesis` binary from PATH instead (the pattern summit uses for `reth`), with a clear error if it's missing, and require the template via --summit-template. Drop --code-path, whose only purpose was locating ~/summit. Also log the built genesis's path and each node it's sent to before delivery (paths only, not the contents), mirroring how `configure` logs the config it POSTs. README prereqs + example updated. --- deploy_tee/README.md | 10 +++++---- deploy_tee/genesis.py | 50 ++++++++++++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/deploy_tee/README.md b/deploy_tee/README.md index db3704d8..6881b207 100644 --- a/deploy_tee/README.md +++ b/deploy_tee/README.md @@ -29,8 +29,9 @@ 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`:** a built `summit` - (`$HOME/summit`, for the `genesis` binary). +- **For `seismic-tee-bootstrap genesis`:** 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` CLI on PATH. The passphrase secrets provider needs `PULUMI_CONFIG_PASSPHRASE` — set it, or let `up`/`down` prompt you on @@ -143,8 +144,9 @@ uv run seismic-tee configure --node /tmp/dev-bootstrap-node-1.json --config ./no uv run seismic-tee configure --node /tmp/dev-bootstrap-node-2.json --config ./node-2.toml --manifest ./network-manifest.json # 4. Run the genesis ceremony once: builds genesis.toml from the cohort -# and fans it out to every summit. -uv run seismic-tee-bootstrap genesis --node /tmp/dev-bootstrap-node-1.json /tmp/dev-bootstrap-node-2.json +# (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 ``` After step 3 each node is up; after step 4 they produce blocks. RPC is at diff --git a/deploy_tee/genesis.py b/deploy_tee/genesis.py index 795bbf14..235f2a1e 100644 --- a/deploy_tee/genesis.py +++ b/deploy_tee/genesis.py @@ -12,6 +12,7 @@ import argparse import json +import shutil import subprocess import tempfile from pathlib import Path @@ -59,19 +60,27 @@ def _parse_args() -> argparse.Namespace: ), ) parser.add_argument( - "--code-path", - default="", - type=str, - help="path to code relative to $HOME", + "--summit-template", + type=Path, + required=True, + 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." + ), ) parser.add_argument( "-g", "--genesis-hash", type=str, default=None, - help="Eth genesis hash", + help="Eth genesis hash; overrides the template's. Pin to reth's actual hash.", ) - return parser.parse_args() + args = parser.parse_args() + if not args.summit_template.is_file(): + raise SystemExit(f"--summit-template not found: {args.summit_template}") + return args def _get_pubkeys( @@ -115,13 +124,18 @@ def main(): args = _parse_args() genesis_arg = ["-g", args.genesis_hash] if args.genesis_hash else [] - tmpdir = tempfile.mkdtemp() - home = Path.home() if not args.code_path else Path.home() / args.code_path - - summit_path = str(home / "summit") - summit_genesis_target = f"{summit_path}/target/debug/genesis" - summit_example_genesis = f"{summit_path}/example_genesis.toml" + # `genesis` is summit's binary; expect it on PATH (build summit and symlink + # its target/debug/genesis onto PATH, the same way summit expects `reth`). + # Fail with a clear message instead of a subprocess FileNotFoundError. + genesis_bin = shutil.which("genesis") + if genesis_bin is None: + raise SystemExit( + "`genesis` binary not found on PATH. Build summit and put its " + "`genesis` binary on PATH, e.g. " + "`ln -s /target/debug/genesis ~/.cargo/bin/genesis`." + ) + tmpdir = tempfile.mkdtemp() validators, node_clients = _get_pubkeys(args.node) tmp_validators = f"{tmpdir}/validators.json" @@ -132,11 +146,11 @@ def main(): # Output streams to the terminal (check=True raises on failure). subprocess.run( [ - summit_genesis_target, + genesis_bin, "-o", tmpdir, "-i", - summit_example_genesis, + str(args.summit_template), "-v", tmp_validators, *genesis_arg, @@ -144,8 +158,14 @@ def main(): check=True, ) + # Log the built genesis's path (not its contents) before delivery, mirroring + # how `configure` logs the merged node config it POSTs. + genesis_path = Path(f"{tmpdir}/genesis.toml") + print(f"Built genesis -> {genesis_path}") + for _, client in node_clients: - client.post_genesis_filepath(Path(f"{tmpdir}/genesis.toml")) + print(f"Sending genesis to {client.url}") + client.post_genesis_filepath(genesis_path) if __name__ == "__main__": From 59bea4b6e721dfcab4c13c5a6dce67191ff946b2 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:49:23 +0800 Subject: [PATCH 3/3] feat(deploy_tee): label first-boot disk-provisioning progress The progress bar showed bytes / % / eta but never said what it was doing. Prefix the bar with "encrypting disk", and print a one-time line when provisioning starts explaining it's the first-boot full-disk LUKS + dm-integrity wipe (one-time, can take 1h+) so a long phase doesn't read as a hang. --- deploy_tee/status.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/deploy_tee/status.py b/deploy_tee/status.py index 74cbaa9c..72f0d311 100644 --- a/deploy_tee/status.py +++ b/deploy_tee/status.py @@ -87,9 +87,9 @@ def format_provisioning(status: dict) -> str: done = status.get("bytes_done", 0) total = status.get("bytes_total", 0) if not total: - return "provisioning: starting (no measurement yet)" + return "encrypting disk: starting (no measurement yet)" pct = 100.0 * done / total - line = f"{_bar(pct)} {pct:5.1f}% {_gib(done)}/{_gib(total)} GiB" + line = f"encrypting disk {_bar(pct)} {pct:5.1f}% {_gib(done)}/{_gib(total)} GiB" eta = status.get("eta_seconds") if eta: line += f" eta {_duration(eta)}" @@ -142,7 +142,17 @@ def emit(line: str, *, transient: bool) -> None: state = status.get("state") if state == "provisioning": - seen_provisioning = True + if not seen_provisioning: + seen_provisioning = True + # One-time context: the bar alone doesn't say what's happening, + # and this phase is long enough to look like a hang. + emit( + "First-boot: initializing the encrypted /persistent disk " + "— a one-time full-disk wipe (LUKS + dm-integrity) that can " + "take 1h+ on large disks. The node must finish this before " + "it can boot further.", + transient=False, + ) emit(format_provisioning(status), transient=True) elif state == "error": emit(