Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 76 additions & 38 deletions deploy_tee/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ config to the VM via tdx-init's HTTP receiver before any node
service starts.

This README is intentionally architectural. By the end you should
understand the layering (Pulumi owns provisioning; this CLI is the
config/network/manifest "stuff on top"), what each subcommand does
vs. what the operator does, and what's still pending cleanup.
understand the layering and the two CLIs split by audience —
`seismic-tee` (operator-facing: act on your own node) and
`seismic-tee-bootstrap` (Seismic-internal: found a network — provision a
cohort and run the genesis ceremony) — what each does vs. what the
operator does, and what's still pending cleanup.

## What gets deployed

A "Seismic TEE node" is a TDX-confidential VM running a [seismic-images](https://github.com/SeismicSystems/seismic-images) mkosi image.

On first boot, the VM runs a small HTTP service `tdx-init` that's waiting on port `:8080`
for its [InitConfig](https://github.com/SeismicSystems/enclave/blob/seismic/crates/tdx-init/src/config.rs#L8). Attesting the VM and POSTing that config is the job of this CLI's `operator configure` subcommand.
for its [InitConfig](https://github.com/SeismicSystems/enclave/blob/seismic/crates/tdx-init/src/config.rs#L8). Attesting the VM and POSTing that config is the job of the `seismic-tee configure` command.

## Prerequisites

Expand All @@ -27,8 +29,13 @@ 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 `network genesis`:** a built `summit` (`$HOME/summit`, for the
`genesis` binary).
- **For `seismic-tee-bootstrap genesis`:** a built `summit`
(`$HOME/summit`, for the `genesis` binary).
- **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
first run (empty = no passphrase). Required as an env var only in
non-interactive contexts (e.g. CI).

(Attestation verification has no prerequisite right now — it's disabled
pending the attested-tls migration; see "Attestation verification" below.)
Expand All @@ -39,29 +46,57 @@ pending the attested-tls migration; see "Attestation verification" below.)
uv sync
```

The CLI is then available as `seismic-tee`:
The two CLIs are then available:

```bash
uv run seismic-tee --help
uv run seismic-tee --help # operator-facing
uv run seismic-tee-bootstrap --help # Seismic-internal
```

## How it's organized: by audience

Provisioning is owned by **Pulumi, run standalone** — there is no
`deploy`/`provision` subcommand. The CLI is the "stuff on top," grouped
by who runs it. It consumes a *brought* node descriptor (and, once
attestation verification returns, a brought measurements file) rather
than reaching into local state to produce them.

| Command | Run by | Purpose |
|---|---|---|
| `operator configure` | any operator, on first boot | POST the node TOML to tdx-init. Reads the node address from `--node <descriptor.json>`. (Attestation verification is disabled pending attested-tls — see below.) |
| `network genesis` | network creator, once | Gather the cohort's summit pubkeys, build `genesis.toml`, POST it to each node. |
| `network manifest` | network creator | Assemble / validate / embed the network manifest. |

A **node descriptor** is a small JSON (`public_ip`/`fqdn`) describing one
provisioned node — see `deploy_tee/descriptor.py`. Today it's just
`pulumi stack output --json`; a BYO-infra operator can hand-write it.
## How it's organized: two CLIs, by audience

The split is "act on my own node" vs "bring a whole network into
existence." Only the first is ever external, so it's its own CLI; the
second is Seismic-internal. The boundary between them is the **node
descriptor** file: `seismic-tee-bootstrap` *produces* descriptors (by
provisioning) and *consumes* them (genesis ceremony), while `seismic-tee`
only consumes one to reach an already-running node.

| CLI | Command | Run by | Purpose |
|---|---|---|---|
| `seismic-tee` | `configure` | any operator, on first boot | POST the node TOML to tdx-init. Reads the node address from `--node <descriptor.json>`. (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` | `manifest` | Seismic | Assemble / validate / embed the network manifest. |

Only `seismic-tee` is cloud-agnostic and **never wraps Pulumi**;
`seismic-tee-bootstrap` is the one allowed to. A **node descriptor** is a
small JSON (`public_ip`/`fqdn`) describing one provisioned node — see
`deploy_tee/descriptor.py`. It's what `seismic-tee-bootstrap up` emits
(just `{public_ip, fqdn}`); a BYO-infra operator can hand-write it (or use
`pulumi stack output --json` — only those two keys are read).

### Where this is heading: a public operator repo

Longer-term these two CLIs are intended to live in **two repos**, split on
the same audience line:

- **Public (operator):** `seismic-tee` + the `pulumi/seismic_node/`
program — the primitives for configuring and provisioning *one* node.
- **Private (this repo):** `seismic-tee-bootstrap` + `pulumi/playground/`
— founding a *network* (orchestrating a cohort, the genesis ceremony),
which only Seismic does.

The current layout already anticipates this: the operator path
(`cli.py` → `configure.py` → `descriptor.py`) has no dependency on the
founding side and never imports Pulumi, so it lifts out cleanly, and the
dependency direction is one-way (private builds on public). The seam is
the node descriptor. At extraction the shared bits (`descriptor.py`,
`cli_common.py`) go to the public side and this repo depends on them.

Not done yet — **don't pre-split within this repo.** The rule to keep
honoring until then: the operator CLI stays cloud-agnostic and never wraps
Pulumi, and a new file goes on whichever side it would ultimately land.

### Joining an existing network (the common case)

Expand All @@ -75,10 +110,11 @@ cd pulumi/seismic_node
pulumi up --stack my-node && pulumi stack output --json --stack my-node > /tmp/node.json
cd -

uv run seismic-tee operator configure --node /tmp/node.json --config ./node.toml
uv run seismic-tee configure --node /tmp/node.json --config ./node.toml
```

You never run `network genesis` — that's a network-creation step.
You never run `seismic-tee-bootstrap` — that's Seismic-internal network
creation.

### Creating a new network (genesis ceremony)

Expand All @@ -90,20 +126,22 @@ You never run `network genesis` — that's a network-creation step.
# Node 1: genesis_node = true, peers = []
# Nodes 2..N: genesis_node = false, peers = [<node-1-url>]

# 2. Provision each VM with Pulumi (one stack per node) and capture
# its descriptor. See pulumi/seismic_node/README.md.
cd pulumi/seismic_node
pulumi up --stack az-1 && pulumi stack output --json --stack az-1 > /tmp/node-1.json
pulumi up --stack az-2 && pulumi stack output --json --stack az-2 > /tmp/node-2.json
cd -
# 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-<i>. 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

# 3. Configure each node: POST its TOML.
uv run seismic-tee operator configure --node /tmp/node-1.json --config ./node-1.toml
uv run seismic-tee operator configure --node /tmp/node-2.json --config ./node-2.toml
uv run seismic-tee configure --node /tmp/dev-bootstrap-node-1.json --config ./node-1.toml
uv run seismic-tee configure --node /tmp/dev-bootstrap-node-2.json --config ./node-2.toml

# 4. Run the genesis ceremony once: builds genesis.toml from the cohort
# and fans it out to every summit.
uv run seismic-tee network genesis --node /tmp/node-1.json /tmp/node-2.json
uv run seismic-tee-bootstrap genesis --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
Expand Down Expand Up @@ -157,8 +195,8 @@ boots no-ops.

## Attestation verification (currently disabled)

`operator configure` does **not** verify the node's TDX attestation today
— it only POSTs the config. The `--measurements` flag is gated off and
`seismic-tee configure` does **not** verify the node's TDX attestation
today — it only POSTs the config. The `--measurements` flag is gated off and
errors if passed, rather than pretending to verify.

Why: the old path (`proxy.py`, Flashbots' `cvm-reverse-proxy`
Expand Down
62 changes: 62 additions & 0 deletions deploy_tee/bootstrap_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""`seismic-tee-bootstrap` — internal CLI to found a Seismic TEE network.

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 —
`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
(via provisioning) and consumes (during the genesis ceremony).

Wired via [project.scripts] in pyproject.toml. Each leaf forwards its argv
to that module's argparse `main()`; see deploy_tee/cli_common.py.
"""

import click

from deploy_tee.cli_common import PASSTHROUGH, forward


@click.group()
def app() -> None:
"""Seismic network founding + provisioning (internal)."""


@app.command(name="up", context_settings=PASSTHROUGH, add_help_option=False)
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def up(argv: tuple[str, ...]) -> None:
"""Provision a cohort of TDX nodes (one independent Pulumi stack each)."""
from deploy_tee import orchestrator

forward(orchestrator.up_main, "seismic-tee-bootstrap up", argv)


@app.command(name="down", context_settings=PASSTHROUGH, add_help_option=False)
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def down(argv: tuple[str, ...]) -> None:
"""Tear down cohort node(s); each stack destroys independently."""
from deploy_tee import orchestrator

forward(orchestrator.down_main, "seismic-tee-bootstrap down", argv)


@app.command(name="genesis", 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."""
from deploy_tee import genesis as genesis_mod

forward(genesis_mod.main, "seismic-tee-bootstrap genesis", 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 / embed a network manifest."""
from deploy_tee import manifest as manifest_mod

forward(manifest_mod.main, "seismic-tee-bootstrap manifest", argv)


if __name__ == "__main__":
app()
78 changes: 13 additions & 65 deletions deploy_tee/cli.py
Original file line number Diff line number Diff line change
@@ -1,84 +1,32 @@
"""Unified `seismic-tee` command.
"""`seismic-tee` — operator CLI for a single Seismic TEE node.

A thin dispatcher that exposes the deploy_tee entry points as subcommands
of one installed CLI (wired via [project.scripts] in pyproject.toml).
Each leaf forwards its arguments verbatim to that module's own argparse
`main()`, so per-command flags and `--help` are unchanged — this just
gives them a single front door.
Operator-facing: the commands a node operator runs against their own
already-provisioned node (configure today; stake / sync later). It is
cloud-agnostic and descriptor-based — it never wraps Pulumi. Standing up a
network (provisioning + genesis ceremony) is a Seismic-internal act and
lives in the separate `seismic-tee-bootstrap` CLI.

Imports are deferred into each subcommand so the group itself loads
without the heavier deps any one path pulls in.

Commands are grouped by audience, which is the real boundary here:

operator … one operator acting on their own node (configure, and
later stake / sync). Provisioning is NOT here — it's
Pulumi, run standalone; `configure` consumes a node
descriptor file (see deploy_tee/descriptor.py).

network … network creation / management, run rarely by whoever
brings a network up (genesis ceremony, manifest). An
operator joining an existing network never runs these.
Wired via [project.scripts] in pyproject.toml. Each leaf forwards its argv
to that module's argparse `main()`; see deploy_tee/cli_common.py.
"""

import sys

import click

# ignore_unknown_options + UNPROCESSED args let us capture the whole
# remainder (flags included) and hand it to the inner argparse parser.
_PASSTHROUGH = {"ignore_unknown_options": True}
from deploy_tee.cli_common import PASSTHROUGH, forward


@click.group()
def app() -> None:
"""Seismic TEE node deployment."""


def _forward(module_main, name: str, argv: tuple[str, ...]) -> None:
# argparse reads sys.argv directly, so make it look like the
# subcommand was invoked on its own (argv[0] is only usage text).
sys.argv = [f"seismic-tee {name}", *argv]
module_main()


@app.group()
def operator() -> None:
"""Single-operator commands acting on your own node."""

"""Seismic TEE node operator commands."""

@app.group()
def network() -> None:
"""Network creation / management (genesis ceremony, manifest)."""


@operator.command(
name="configure", context_settings=_PASSTHROUGH, add_help_option=False
)
@app.command(name="configure", context_settings=PASSTHROUGH, add_help_option=False)
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def operator_configure(argv: tuple[str, ...]) -> None:
def configure(argv: tuple[str, ...]) -> None:
"""POST node TOML to tdx-init + optionally verify attestation."""
from deploy_tee import configure as configure_mod

_forward(configure_mod.main, "operator configure", argv)


@network.command(name="genesis", context_settings=_PASSTHROUGH, add_help_option=False)
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def network_genesis(argv: tuple[str, ...]) -> None:
"""Genesis ceremony: build genesis.toml from the cohort and fan it out."""
from deploy_tee import genesis as genesis_mod

_forward(genesis_mod.main, "network genesis", argv)


@network.command(name="manifest", context_settings=_PASSTHROUGH, add_help_option=False)
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def network_manifest(argv: tuple[str, ...]) -> None:
"""Assemble / validate / embed a network manifest."""
from deploy_tee import manifest as manifest_mod

_forward(manifest_mod.main, "network manifest", argv)
forward(configure_mod.main, "seismic-tee configure", argv)


if __name__ == "__main__":
Expand Down
21 changes: 21 additions & 0 deletions deploy_tee/cli_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Shared plumbing for the deploy_tee click front-ends.

Both CLIs — `seismic-tee` (operator) and `seismic-tee-bootstrap`
(internal network founding) — are thin click groups that forward each
leaf's arguments verbatim to that module's own argparse `main()`, so
per-command flags and `--help` are unchanged. Subcommand imports are
deferred so loading the group doesn't pull in any one path's heavy deps.
"""

import sys

# ignore_unknown_options + UNPROCESSED args let us capture the whole
# remainder (flags included) and hand it to the inner argparse parser.
PASSTHROUGH = {"ignore_unknown_options": True}


def forward(module_main, prog: str, argv: tuple[str, ...]) -> None:
# argparse reads sys.argv directly, so make it look like the subcommand
# was invoked on its own (argv[0] is only usage text).
sys.argv = [prog, *argv]
module_main()
2 changes: 1 addition & 1 deletion deploy_tee/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
are security-relevant inputs that should not depend on implicit local
state.

seismic-tee operator configure \\
seismic-tee configure \\
--node node-1.json --config node-1.toml --measurements node-1.measurements.json
"""

Expand Down
22 changes: 9 additions & 13 deletions deploy_tee/descriptor.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
"""Node descriptor: the handoff from provisioning to configuration.

A *descriptor* is a small JSON file describing one provisioned node —
the public IP to reach it at and the FQDN clients use. It is the
boundary between the infrastructure layer (provisioning, owned by Pulumi
and run as a standalone tool) and this CLI: the CLI consumes a descriptor
A *descriptor* is a small JSON file describing one provisioned node — the
public IP to reach it at (`public_ip`) and the FQDN clients use (`fqdn`).
It is the boundary between the infrastructure layer (provisioning, owned
by Pulumi and run standalone) and this CLI: the CLI consumes a descriptor
and never shells out to or wraps Pulumi.

Today the descriptor is exactly what `pulumi stack output --json` emits
for the seismic_node project (same `public_ip` / `fqdn` keys), so there's
no schema to produce by hand:
`seismic-tee-bootstrap up` emits exactly `{public_ip, fqdn}`. A
bring-your-own-infra operator (Terraform, manual console, …) can
hand-write the same shape — `pulumi stack output --json` works too, since
only `public_ip`/`fqdn` are read and any extra keys are ignored:

pulumi stack output --json > node-1.json
seismic-tee configure --node node-1.json --config node-1.toml

A bring-your-own-infra operator (Terraform, manual console, …) can
hand-write the same shape from any provisioning tool. Formalizing the
schema is a follow-up (see plans/tee §1 and the deploy_tee README).
seismic-tee configure --node dev-bootstrap-node-1.json --config node-1.toml
"""

import json
Expand Down
Loading
Loading