From a6c57828c2e675476bc0cbde792cd4b3ae06cb06 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Mon, 4 May 2026 16:14:26 -0400 Subject: [PATCH] feat(ci): add GitHub Actions workflow and Makefile for deploy_gcp Adds a `check` workflow (push to main, all PRs) that runs `make check`, which wraps `ruff check`, `ruff format --check`, and `ty check` against the `deploy_gcp` package. uv picks up the Python version from `.python-version`. Also clears the lint/format/type debt in `deploy_gcp` so the workflow is green on day one: - B904 `raise ... from e` on re-raised exceptions - E501/B007 line-length and unused loop var fixes - `ruff format` applied across the package - narrow `state.public_ip: str | None` with asserts at use sites - cast around seismic_web3's `PrivateKey.__new__` stub bug - `# ty: ignore` for the runtime-attached `w3.seismic` attribute - `dict[str, Any]` for pydantic kwargs in DeploymentConfig.from_conf_file `deploy_tee` is intentionally out of scope; it has its own lint/type debt that will be cleaned up in a follow-up PR before being added to the workflow. --- .github/workflows/ci.yml | 22 + Makefile | 28 ++ deploy_gcp/seismic_deploy/cli.py | 394 ++++++++++++++---- deploy_gcp/seismic_deploy/config.py | 9 +- deploy_gcp/seismic_deploy/gcp/auth.py | 11 +- deploy_gcp/seismic_deploy/gcp/compute.py | 29 +- deploy_gcp/seismic_deploy/gcp/wait.py | 8 +- deploy_gcp/seismic_deploy/genesis.py | 35 +- deploy_gcp/seismic_deploy/network/bootnode.py | 24 +- deploy_gcp/seismic_deploy/network/stake.py | 36 +- deploy_gcp/seismic_deploy/network/sync.py | 31 +- deploy_gcp/seismic_deploy/ssh/connection.py | 53 ++- deploy_gcp/seismic_deploy/ssh/deploy.py | 30 +- deploy_gcp/seismic_deploy/state.py | 10 +- deploy_gcp/seismic_deploy/templates.py | 4 +- 15 files changed, 506 insertions(+), 218 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..37b5e3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + # Also installs the Python version pinned in .python-version. + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: make check + run: make check diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e01f4a6 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +# Convenience targets for local dev and CI. +# Scoped to deploy_gcp; deploy_tee will be added once its checks are clean. + +PKG := deploy_gcp + +.PHONY: help lint format format-check typecheck check + +help: + @echo "Targets:" + @echo " check lint + format-check + typecheck (what CI runs)" + @echo " lint ruff check" + @echo " format ruff format (writes changes)" + @echo " format-check ruff format --check" + @echo " typecheck ty check" + +check: lint format-check typecheck + +lint: + uv run ruff check $(PKG) + +format: + uv run ruff format $(PKG) + +format-check: + uv run ruff format --check $(PKG) + +typecheck: + uv run ty check $(PKG) diff --git a/deploy_gcp/seismic_deploy/cli.py b/deploy_gcp/seismic_deploy/cli.py index ce41c50..5d775cd 100644 --- a/deploy_gcp/seismic_deploy/cli.py +++ b/deploy_gcp/seismic_deploy/cli.py @@ -11,7 +11,13 @@ import click from deploy_gcp.seismic_deploy.config import DeploymentConfig -from deploy_gcp.seismic_deploy.state import ALWAYS_RUN_STEPS, DEPLOY_STEPS, FULL_DEPLOY_STEPS, DeployState, DeployStep +from deploy_gcp.seismic_deploy.state import ( + ALWAYS_RUN_STEPS, + DEPLOY_STEPS, + FULL_DEPLOY_STEPS, + DeployState, + DeployStep, +) # deploy_gcp/seismic_deploy/ -> deploy_gcp/ DEPLOY_GCP_DIR = Path(__file__).resolve().parent.parent @@ -23,6 +29,7 @@ def handle_errors(f): """Catch unexpected exceptions and display clean error messages.""" + @functools.wraps(f) def wrapper(*args, **kwargs): try: @@ -32,17 +39,21 @@ def wrapper(*args, **kwargs): except click.Abort: raise except Exception as e: - raise click.ClickException(str(e)) + raise click.ClickException(str(e)) from e + return wrapper def require_gcp_auth(f): """Validate GCP credentials before running the command.""" + @functools.wraps(f) def wrapper(*args, **kwargs): from deploy_gcp.seismic_deploy.gcp.auth import validate_credentials + validate_credentials() return f(*args, **kwargs) + return wrapper @@ -59,21 +70,43 @@ def cli(): @cli.command() -@click.option("--config", "config_path", type=click.Path(exists=True, path_type=Path), help="Load saved config file") +@click.option( + "--config", + "config_path", + type=click.Path(exists=True, path_type=Path), + help="Load saved config file", +) @click.option("--node-name", help="Node name (VM instance name)") @click.option("--project-id", help="GCP project ID") @click.option("--zone", help="GCP zone") @click.option("--machine-type", help="VM machine type") @click.option("--domain", "domain_name", help="Domain name") -@click.option("--bootnode-rpc", help="Bootnode RPC URL or IP (fetches enode + checkpoint)") -@click.option("--genesis", "genesis_path", type=click.Path(exists=True, path_type=Path), help="Genesis TOML file") -@click.option("--summit-binary", type=click.Path(exists=True, path_type=Path), help="Pre-built summit binary") -@click.option("--reth-binary", type=click.Path(exists=True, path_type=Path), help="Pre-built reth binary") +@click.option( + "--bootnode-rpc", help="Bootnode RPC URL or IP (fetches enode + checkpoint)" +) +@click.option( + "--genesis", + "genesis_path", + type=click.Path(exists=True, path_type=Path), + help="Genesis TOML file", +) +@click.option( + "--summit-binary", + type=click.Path(exists=True, path_type=Path), + help="Pre-built summit binary", +) +@click.option( + "--reth-binary", + type=click.Path(exists=True, path_type=Path), + help="Pre-built reth binary", +) @click.option("--certbot-email", help="Email for Let's Encrypt certificates") @click.option("--ssh-key", "ssh_key_path", help="Path to SSH public key") @click.option("--resume/--fresh", default=True, help="Resume previous partial deploy") @click.option("--verbose", "-v", is_flag=True, help="Verbose output") -@click.option("--no-interactive", is_flag=True, help="Fail on missing values instead of prompting") +@click.option( + "--no-interactive", is_flag=True, help="Fail on missing values instead of prompting" +) @handle_errors @require_gcp_auth def deploy( @@ -120,7 +153,9 @@ def deploy( state = DeployState.load(state_path) state.config = config # allow config updates on resume completed = [s.value for s in state.completed_steps] - click.echo(f"Resuming deployment. Completed steps: {', '.join(completed) or 'none'}") + click.echo( + f"Resuming deployment. Completed steps: {', '.join(completed) or 'none'}" + ) else: state = DeployState.fresh(config) @@ -130,7 +165,10 @@ def deploy( except Exception: state.save(state_path) click.echo(f"\nState saved to {state_path}") - click.echo(f"Resume with: seismic-deploy deploy --node-name {config.node_name} --resume") + click.echo( + f"Resume with: seismic-deploy deploy --node-name {config.node_name} " + "--resume" + ) raise # Success @@ -170,7 +208,10 @@ def _run_deployment( private_key_hex: str | None = None, ) -> None: """Execute the deployment step by step.""" - from deploy_gcp.seismic_deploy.gcp.auth import get_service_account, validate_project_access + from deploy_gcp.seismic_deploy.gcp.auth import ( + get_service_account, + validate_project_access, + ) from deploy_gcp.seismic_deploy.gcp.compute import ( create_vm, ensure_firewall_rules, @@ -208,7 +249,9 @@ def _get_conn() -> SSHConnection: if conn is None: if not state.public_ip: raise click.ClickException("No public IP in state — cannot SSH") - conn = SSHConnection(f"ubuntu@{state.public_ip}", config.ssh_private_key_path) + conn = SSHConnection( + f"ubuntu@{state.public_ip}", config.ssh_private_key_path + ) return conn for step in steps: @@ -238,6 +281,7 @@ def _get_conn() -> SSHConnection: if vm_exists(config.project_id, config.zone, config.node_name): click.echo(f" VM already exists: {config.node_name}") else: + assert state.public_ip is not None, "CREATE_STATIC_IP must run first" service_account = get_service_account(config.project_id) startup_script = read_startup_script() create_vm(config, state.public_ip, startup_script, service_account) @@ -264,7 +308,9 @@ def _get_conn() -> SSHConnection: raise click.ClickException(f"Genesis file not found: {genesis}") deploy_genesis(_get_conn(), genesis) else: - click.secho(" Warning: no genesis file provided, skipping", fg="yellow") + click.secho( + " Warning: no genesis file provided, skipping", fg="yellow" + ) elif step == DeployStep.DEPLOY_SUPERVISOR: # Fetch enode from bootnode RPC if we have one and haven't fetched yet @@ -307,6 +353,7 @@ def _get_conn() -> SSHConnection: click.echo(f" Staking complete. TX: {tx_hash}") elif step == DeployStep.UPDATE_METADATA: + assert state.public_ip is not None, "VM must exist before metadata update" update_gcp_node(config, state.public_ip) state.mark_complete(step) @@ -317,23 +364,53 @@ def _get_conn() -> SSHConnection: @cli.command("deploy-and-stake") -@click.option("--config", "config_path", type=click.Path(exists=True, path_type=Path), help="Load saved config file") +@click.option( + "--config", + "config_path", + type=click.Path(exists=True, path_type=Path), + help="Load saved config file", +) @click.option("--node-name", help="Node name (VM instance name)") @click.option("--project-id", help="GCP project ID") @click.option("--zone", help="GCP zone") @click.option("--machine-type", help="VM machine type") @click.option("--domain", "domain_name", help="Domain name") -@click.option("--bootnode-rpc", required=True, help="Bootnode RPC URL or IP (fetches enode + checkpoint)") -@click.option("--genesis", "genesis_path", type=click.Path(exists=True, path_type=Path), required=True, help="Genesis TOML file") -@click.option("--summit-binary", type=click.Path(exists=True, path_type=Path), help="Pre-built summit binary") -@click.option("--reth-binary", type=click.Path(exists=True, path_type=Path), help="Pre-built reth binary") +@click.option( + "--bootnode-rpc", + required=True, + help="Bootnode RPC URL or IP (fetches enode + checkpoint)", +) +@click.option( + "--genesis", + "genesis_path", + type=click.Path(exists=True, path_type=Path), + required=True, + help="Genesis TOML file", +) +@click.option( + "--summit-binary", + type=click.Path(exists=True, path_type=Path), + help="Pre-built summit binary", +) +@click.option( + "--reth-binary", + type=click.Path(exists=True, path_type=Path), + help="Pre-built reth binary", +) @click.option("--certbot-email", help="Email for Let's Encrypt certificates") @click.option("--ssh-key", "ssh_key_path", help="Path to SSH public key") @click.option("--rpc-url", required=True, help="Execution layer RPC URL for staking") -@click.option("--private-key", "private_key", default=None, help="Wallet private key hex (prompted if not provided)") +@click.option( + "--private-key", + "private_key", + default=None, + help="Wallet private key hex (prompted if not provided)", +) @click.option("--resume/--fresh", default=True, help="Resume previous partial deploy") @click.option("--verbose", "-v", is_flag=True, help="Verbose output") -@click.option("--no-interactive", is_flag=True, help="Fail on missing values instead of prompting") +@click.option( + "--no-interactive", is_flag=True, help="Fail on missing values instead of prompting" +) @handle_errors @require_gcp_auth def deploy_and_stake( @@ -356,33 +433,43 @@ def deploy_and_stake( no_interactive: bool, ): """Deploy a Seismic node to GCP, start services, and stake in one step.""" - from deploy_gcp.seismic_deploy.network.bootnode import _normalize_rpc_base, _rpc_call + from deploy_gcp.seismic_deploy.network.bootnode import ( + _normalize_rpc_base, + _rpc_call, + ) + + # `bootnode_rpc` is `required=True` on the Click option above. + assert bootnode_rpc is not None # Verify bootnode RPC is reachable before starting anything click.echo(f"Checking bootnode RPC at {bootnode_rpc}...") try: base = _normalize_rpc_base(bootnode_rpc) _rpc_call(f"{base}/rpc", "admin_nodeInfo") - click.echo(f"Bootnode RPC is reachable") + click.echo("Bootnode RPC is reachable") except Exception as e: raise click.ClickException( f"Cannot reach bootnode RPC at {bootnode_rpc}. " f"Make sure the bootnode is running before deploying.\n {e}" - ) + ) from e # Verify staking RPC is reachable click.echo(f"Checking staking RPC at {rpc_url}...") try: _rpc_call(rpc_url, "eth_chainId") - click.echo(f"Staking RPC is reachable") + click.echo("Staking RPC is reachable") except Exception as e: raise click.ClickException( f"Cannot reach staking RPC at {rpc_url}. " f"Make sure the execution layer is running.\n {e}" - ) + ) from e # Collect private key upfront so the rest is unattended - pk_hex = (private_key or getpass.getpass("Wallet private key (hex): ")).strip().removeprefix("0x") + pk_hex = ( + (private_key or getpass.getpass("Wallet private key (hex): ")) + .strip() + .removeprefix("0x") + ) if not pk_hex: raise click.ClickException("No private key provided") @@ -405,7 +492,10 @@ def deploy_and_stake( _display_config(config) if not no_interactive: - if not click.confirm(f"\nDeploy, start, and stake node '{config.node_name}' (32 ETH deposit)?", default=False): + if not click.confirm( + f"\nDeploy, start, and stake node '{config.node_name}' (32 ETH deposit)?", + default=False, + ): click.echo("Aborted.") return @@ -414,13 +504,17 @@ def deploy_and_stake( state = DeployState.load(state_path) state.config = config completed = [s.value for s in state.completed_steps] - click.echo(f"Resuming deployment. Completed steps: {', '.join(completed) or 'none'}") + click.echo( + f"Resuming deployment. Completed steps: {', '.join(completed) or 'none'}" + ) else: state = DeployState.fresh(config) try: _run_deployment( - state, state_path, verbose, + state, + state_path, + verbose, steps=FULL_DEPLOY_STEPS, rpc_url=rpc_url, private_key_hex=pk_hex, @@ -428,7 +522,10 @@ def deploy_and_stake( except Exception: state.save(state_path) click.echo(f"\nState saved to {state_path}") - click.echo(f"Resume with: seismic-deploy deploy-and-stake --node-name {config.node_name} --rpc-url {rpc_url} --resume") + click.echo( + "Resume with: seismic-deploy deploy-and-stake " + f"--node-name {config.node_name} --rpc-url {rpc_url} --resume" + ) raise click.echo("") @@ -443,22 +540,53 @@ def deploy_and_stake( @cli.command("bootstrap-network") -@click.option("--network-name", required=True, help="Network name prefix (nodes named -0, -1, ...)") -@click.option("--genesis-nodes", default=3, type=int, help="Number of genesis validator nodes") -@click.option("--extra-nodes", default=0, type=int, help="Additional nodes that join via deploy-and-stake") +@click.option( + "--network-name", + required=True, + help="Network name prefix (nodes named -0, -1, ...)", +) +@click.option( + "--genesis-nodes", default=3, type=int, help="Number of genesis validator nodes" +) +@click.option( + "--extra-nodes", + default=0, + type=int, + help="Additional nodes that join via deploy-and-stake", +) @click.option("--project-id", help="GCP project ID") @click.option("--zone", help="GCP zone") @click.option("--machine-type", help="VM machine type") @click.option("--domain", "domain_name", help="Domain name") @click.option("--certbot-email", help="Email for Let's Encrypt certificates") @click.option("--ssh-key", "ssh_key_path", help="Path to SSH public key") -@click.option("--genesis-template", required=True, type=click.Path(exists=True, path_type=Path), help="Genesis TOML template (global config, no validators)") -@click.option("--summit-binary", type=click.Path(exists=True, path_type=Path), help="Pre-built summit binary") -@click.option("--reth-binary", type=click.Path(exists=True, path_type=Path), help="Pre-built reth binary") -@click.option("--private-key", "private_key", default=None, help="Wallet private key hex (for staking extra nodes)") +@click.option( + "--genesis-template", + required=True, + type=click.Path(exists=True, path_type=Path), + help="Genesis TOML template (global config, no validators)", +) +@click.option( + "--summit-binary", + type=click.Path(exists=True, path_type=Path), + help="Pre-built summit binary", +) +@click.option( + "--reth-binary", + type=click.Path(exists=True, path_type=Path), + help="Pre-built reth binary", +) +@click.option( + "--private-key", + "private_key", + default=None, + help="Wallet private key hex (for staking extra nodes)", +) @click.option("--resume/--fresh", default=True, help="Resume previous partial deploy") @click.option("--verbose", "-v", is_flag=True, help="Verbose output") -@click.option("--no-interactive", is_flag=True, help="Fail on missing values instead of prompting") +@click.option( + "--no-interactive", is_flag=True, help="Fail on missing values instead of prompting" +) @handle_errors @require_gcp_auth def bootstrap_network( @@ -479,7 +607,7 @@ def bootstrap_network( verbose: bool, no_interactive: bool, ): - """Bootstrap an entire network: deploy nodes, generate genesis, start, and optionally stake.""" + """Bootstrap a network: deploy nodes, generate genesis, start, optionally stake.""" from deploy_gcp.seismic_deploy.genesis import ( build_validators, fetch_public_keys, @@ -488,8 +616,6 @@ def bootstrap_network( ) from deploy_gcp.seismic_deploy.network.bootnode import fetch_enode from deploy_gcp.seismic_deploy.network.sync import ( - deploy_genesis as deploy_genesis_to_node, - start_services, wait_for_summit, ) from deploy_gcp.seismic_deploy.ssh.connection import SSHConnection @@ -499,12 +625,18 @@ def bootstrap_network( raise click.ClickException("--genesis-nodes must be at least 1") if extra_nodes > 0 and not private_key and no_interactive: - raise click.ClickException("--private-key is required in non-interactive mode when --extra-nodes > 0") + raise click.ClickException( + "--private-key is required in non-interactive mode when --extra-nodes > 0" + ) # Collect private key upfront if needed for extra nodes pk_hex: str | None = None if extra_nodes > 0: - pk_hex = (private_key or getpass.getpass("Wallet private key (hex): ")).strip().removeprefix("0x") + pk_hex = ( + (private_key or getpass.getpass("Wallet private key (hex): ")) + .strip() + .removeprefix("0x") + ) if not pk_hex: raise click.ClickException("No private key provided") @@ -523,7 +655,10 @@ def bootstrap_network( click.echo("") if not no_interactive: - if not click.confirm(f"Bootstrap network '{network_name}' with {total_nodes} nodes?", default=False): + if not click.confirm( + f"Bootstrap network '{network_name}' with {total_nodes} nodes?", + default=False, + ): click.echo("Aborted.") return @@ -532,7 +667,8 @@ def bootstrap_network( bootstrap_state: dict = {} if resume and bootstrap_state_path.exists(): bootstrap_state = json.loads(bootstrap_state_path.read_text()) - click.echo(f"Resuming bootstrap. Completed phases: {bootstrap_state.get('completed_phases', [])}") + completed = bootstrap_state.get("completed_phases", []) + click.echo(f"Resuming bootstrap. Completed phases: {completed}") def _save_bootstrap_state(): CONF_DIR.mkdir(parents=True, exist_ok=True) @@ -583,7 +719,9 @@ def _save_bootstrap_state(): try: _run_deployment( - state, state_path, verbose, + state, + state_path, + verbose, steps=DEPLOY_STEPS, skip_steps=genesis_skip, ) @@ -602,7 +740,11 @@ def _save_bootstrap_state(): genesis_path = Path(bootstrap_state["genesis_path"]) if genesis_path.exists(): click.echo("") - click.secho("═══ Phase 2: Generate genesis file (already complete) ═══", bold=True, fg="cyan") + click.secho( + "═══ Phase 2: Generate genesis file (already complete) ═══", + bold=True, + fg="cyan", + ) generated_genesis = genesis_path # If file is missing, re-run phase 2 @@ -615,30 +757,35 @@ def _save_bootstrap_state(): # Start reth+summit on each node, fetch keys. Leave them running — # we'll send genesis via RPC in phase 3. node_infos: list[dict] = [] - for state, state_path in genesis_states: + for state, _state_path in genesis_states: + assert state.public_ip is not None, "VM must have public IP after deploy" ip = state.public_ip node_name = state.node_name click.echo(f"\n Fetching public keys from {node_name} ({ip})...") conn = SSHConnection(f"ubuntu@{ip}", state.config.ssh_private_key_path) - # Make sure no stale genesis file exists (summit must start in genesis-waiting mode) - conn.exec("sudo rm -f /home/ubuntu/summit/example_genesis.toml", check=False) + # Summit must start in genesis-waiting mode, so clear any stale file. + conn.exec( + "sudo rm -f /home/ubuntu/summit/example_genesis.toml", check=False + ) # Start reth first (summit depends on it), then summit - click.echo(f" Starting reth + summit on {node_name} (genesis-waiting mode)...") + click.echo( + f" Starting reth + summit on {node_name} (genesis-waiting mode)..." + ) conn.exec("sudo supervisorctl start reth", check=False) time.sleep(3) conn.exec("sudo supervisorctl start summit", check=False) - # Wait for summit to be responding (genesis-waiting mode still serves health + getPublicKeys) + # Wait for summit (genesis-waiting still serves health + getPublicKeys). try: wait_for_summit(ip, timeout=120) - except Exception: + except Exception as e: raise click.ClickException( f"Summit did not start on {node_name} ({ip}). " f"Check logs: ssh ubuntu@{ip} 'sudo tail -30 /var/log/summit.log'" - ) + ) from e try: keys = fetch_public_keys(ip) @@ -647,15 +794,17 @@ def _save_bootstrap_state(): except Exception as e: raise click.ClickException( f"Failed to fetch public keys from {node_name} ({ip}): {e}\n" - f"Make sure summit binary is deployed and keys are generated." - ) - - node_infos.append({ - "node_name": node_name, - "ip": ip, - "node_public_key": keys["node"], - "consensus_public_key": keys["consensus"], - }) + "Make sure summit binary is deployed and keys are generated." + ) from e + + node_infos.append( + { + "node_name": node_name, + "ip": ip, + "node_public_key": keys["node"], + "consensus_public_key": keys["consensus"], + } + ) # Build validators list and generate genesis.toml click.echo("\n Building validators list...") @@ -669,28 +818,41 @@ def _save_bootstrap_state(): validators=validators, ) - bootstrap_state["completed_phases"] = bootstrap_state.get("completed_phases", []) + ["phase2"] + bootstrap_state["completed_phases"] = bootstrap_state.get( + "completed_phases", [] + ) + ["phase2"] bootstrap_state["genesis_path"] = str(generated_genesis) _save_bootstrap_state() # ── Phase 3: Send genesis to nodes via RPC ───────────────────────────── if "phase3" in bootstrap_state.get("completed_phases", []): click.echo("") - click.secho("═══ Phase 3: Distribute genesis and start network (already complete) ═══", bold=True, fg="cyan") + click.secho( + "═══ Phase 3: Distribute genesis and start network (already complete) ═══", + bold=True, + fg="cyan", + ) else: # Summit is already running in genesis-waiting mode on all nodes. # Send the generated genesis via send_genesis RPC — summit will # transition to normal mode automatically. click.echo("") - click.secho("═══ Phase 3: Distribute genesis and start network ═══", bold=True, fg="cyan") + click.secho( + "═══ Phase 3: Distribute genesis and start network ═══", + bold=True, + fg="cyan", + ) genesis_content = generated_genesis.read_text() - # First, update supervisor configs with discovery peers (node-0 as bootnode for others) + # First, update supervisor configs with discovery peers + # (node-0 acts as bootnode for the others). bootnode_ip = genesis_states[0][0].public_ip + assert bootnode_ip is not None, "Bootnode VM must have a public IP" bootnode_enode: str | None = None - for i, (state, state_path) in enumerate(genesis_states): + for i, (state, _state_path) in enumerate(genesis_states): + assert state.public_ip is not None, "VM must have public IP after deploy" ip = state.public_ip node_name = state.node_name conn = SSHConnection(f"ubuntu@{ip}", state.config.ssh_private_key_path) @@ -725,19 +887,24 @@ def _save_bootstrap_state(): # Wait for all genesis nodes to come online in normal mode click.echo("\n Waiting for all genesis nodes to come online...") for state, _ in genesis_states: + assert state.public_ip is not None click.echo(f" Checking {state.node_name}...") wait_for_summit(state.public_ip, timeout=300) # Update metadata for genesis nodes from deploy_gcp.seismic_deploy.metadata import update_gcp_node + for state, state_path in genesis_states: + assert state.public_ip is not None update_gcp_node(state.config, state.public_ip) state.mark_complete(DeployStep.DEPLOY_GENESIS) state.mark_complete(DeployStep.START_SERVICES) state.mark_complete(DeployStep.UPDATE_METADATA) state.save(state_path) - bootstrap_state["completed_phases"] = bootstrap_state.get("completed_phases", []) + ["phase3"] + bootstrap_state["completed_phases"] = bootstrap_state.get( + "completed_phases", [] + ) + ["phase3"] _save_bootstrap_state() click.echo("") @@ -749,7 +916,9 @@ def _save_bootstrap_state(): click.secho("═══ Phase 4: Deploy extra nodes ═══", bold=True, fg="cyan") from deploy_gcp.seismic_deploy.network.bootnode import _normalize_rpc_base + bootnode_ip = genesis_states[0][0].public_ip + assert bootnode_ip is not None bootnode_base = _normalize_rpc_base(bootnode_ip) staking_rpc = f"{bootnode_base}/rpc" @@ -782,7 +951,9 @@ def _save_bootstrap_state(): try: _run_deployment( - state, state_path, verbose, + state, + state_path, + verbose, steps=FULL_DEPLOY_STEPS, rpc_url=staking_rpc, private_key_hex=pk_hex, @@ -800,14 +971,18 @@ def _save_bootstrap_state(): click.echo("") click.echo(" Nodes:") for state, _ in genesis_states: - click.echo(f" {state.node_name:20s} {state.public_ip:16s} https://{state.config.fqdn} (genesis)") + url = f"https://{state.config.fqdn}" + click.echo( + f" {state.node_name:20s} {state.public_ip:16s} {url} (genesis)" + ) if extra_nodes > 0: # Re-read extra node states for summary for node_name in extra_node_names: sp = DeployState.state_path(CONF_DIR, node_name) if sp.exists(): s = DeployState.load(sp) - click.echo(f" {s.node_name:20s} {s.public_ip:16s} https://{s.config.fqdn} (extra)") + url = f"https://{s.config.fqdn}" + click.echo(f" {s.node_name:20s} {s.public_ip:16s} {url} (extra)") click.echo("") @@ -817,7 +992,12 @@ def _save_bootstrap_state(): @cli.command() @click.argument("node_host") @click.option("--rpc-url", default=None, help="Execution layer RPC URL") -@click.option("--private-key", "private_key", default=None, help="Wallet private key hex (prompted if not provided)") +@click.option( + "--private-key", + "private_key", + default=None, + help="Wallet private key hex (prompted if not provided)", +) @handle_errors def stake(node_host: str, rpc_url: str | None, private_key: str | None): """Stake a node via deposit contract. @@ -828,28 +1008,36 @@ def stake(node_host: str, rpc_url: str | None, private_key: str | None): # Verify summit is reachable on the target node before doing anything else click.echo(f"Checking if summit is running on {node_host}...") - from deploy_gcp.seismic_deploy.network.stake import _summit_url from deploy_gcp.seismic_deploy.network.bootnode import _rpc_call + from deploy_gcp.seismic_deploy.network.stake import _summit_url + try: summit_url = _summit_url(node_host) _rpc_call(summit_url, "health") click.echo(f"Summit is running at {summit_url}") - except Exception: + except Exception as e: raise click.ClickException( f"Cannot reach summit on {node_host}. " - f"Make sure the node is running (ssh in and check: sudo supervisorctl status)" - ) + "Make sure the node is running " + "(ssh in and check: sudo supervisorctl status)" + ) from e if not rpc_url: rpc_url = click.prompt( "Execution layer RPC URL (an existing node on the network)", ) - pk_hex = (private_key or getpass.getpass("Wallet private key (hex): ")).strip().removeprefix("0x") + pk_hex = ( + (private_key or getpass.getpass("Wallet private key (hex): ")) + .strip() + .removeprefix("0x") + ) if not pk_hex: raise click.ClickException("No private key provided") - if not click.confirm(f"\nDeposit 32 ETH to stake node at {node_host}?", default=False): + if not click.confirm( + f"\nDeposit 32 ETH to stake node at {node_host}?", default=False + ): click.echo("Aborted.") return @@ -862,14 +1050,34 @@ def stake(node_host: str, rpc_url: str | None, private_key: str | None): @cli.command() @click.option("--source", required=True, help="Summit RPC URL to fetch checkpoint from") -@click.option("--target", required=True, help="SSH host of target node (e.g. ubuntu@1.2.3.4)") -@click.option("--genesis", type=click.Path(exists=True, path_type=Path), help="Genesis file to upload") -@click.option("--wipe", is_flag=True, help="Stop services and wipe reth db + summit store") +@click.option( + "--target", required=True, help="SSH host of target node (e.g. ubuntu@1.2.3.4)" +) +@click.option( + "--genesis", + type=click.Path(exists=True, path_type=Path), + help="Genesis file to upload", +) +@click.option( + "--wipe", is_flag=True, help="Stop services and wipe reth db + summit store" +) @click.option("--restart", is_flag=True, help="Start services after sync") -@click.option("--ssh-key", "ssh_key_path", type=click.Path(exists=True, path_type=Path), help="Path to SSH private key") +@click.option( + "--ssh-key", + "ssh_key_path", + type=click.Path(exists=True, path_type=Path), + help="Path to SSH private key", +) @handle_errors -def sync(source: str, target: str, genesis: Path | None, wipe: bool, restart: bool, ssh_key_path: Path | None): - """Sync a node to an existing network: fetch checkpoint, deploy genesis, wipe state.""" +def sync( + source: str, + target: str, + genesis: Path | None, + wipe: bool, + restart: bool, + ssh_key_path: Path | None, +): + """Sync a node to an existing network: fetch checkpoint, deploy genesis, wipe.""" from deploy_gcp.seismic_deploy.network.sync import ( deploy_checkpoint, deploy_genesis, @@ -917,7 +1125,9 @@ def status(): for name, info in nodes.items(): ip = info.get("public_ip", "?") domain = info.get("domain", {}).get("url", "?") - zone_or_region = info.get("vm", {}).get("zone") or info.get("vm", {}).get("region", "?") + zone_or_region = info.get("vm", {}).get("zone") or info.get("vm", {}).get( + "region", "?" + ) click.echo(f" {name:30s} {ip:16s} {zone_or_region:20s} {domain}") @@ -933,7 +1143,12 @@ def status(): @require_gcp_auth def destroy(node_name: str, project_id: str, zone: str, yes: bool): """Tear down a node's GCP resources (VM, IP, firewall rules).""" - from deploy_gcp.seismic_deploy.gcp.compute import FIREWALL_RULES, delete_firewall_rules, delete_static_ip, delete_vm + from deploy_gcp.seismic_deploy.gcp.compute import ( + FIREWALL_RULES, + delete_firewall_rules, + delete_static_ip, + delete_vm, + ) from deploy_gcp.seismic_deploy.metadata import get_gcp_node, remove_gcp_node # Try to load details from metadata @@ -948,7 +1163,7 @@ def destroy(node_name: str, project_id: str, zone: str, yes: bool): click.echo(f"This will destroy the following resources for '{node_name}':") click.echo(f" VM instance in {zone}") click.echo(f" Static IP: {node_name}-ip") - click.echo(f" Firewall rules:") + click.echo(" Firewall rules:") for base_name, _, _ in FIREWALL_RULES: click.echo(f" - {base_name}-{node_name}") if not click.confirm("\nProceed?", default=False): @@ -1137,7 +1352,8 @@ def _display_config(config: DeploymentConfig) -> None: click.echo(f" Summit Binary: {config.summit_binary}") if config.reth_binary: click.echo(f" Reth Binary: {config.reth_binary}") - click.echo(f"\n VM: {config.disk_size_gb}GB {config.disk_type}, {config.image.split('/')[-1]}") + image_name = config.image.split("/")[-1] + click.echo(f"\n VM: {config.disk_size_gb}GB {config.disk_type}, {image_name}") click.echo("") diff --git a/deploy_gcp/seismic_deploy/config.py b/deploy_gcp/seismic_deploy/config.py index 3a012ed..c117d52 100644 --- a/deploy_gcp/seismic_deploy/config.py +++ b/deploy_gcp/seismic_deploy/config.py @@ -1,6 +1,7 @@ """Pydantic configuration models for GCP node deployment.""" from pathlib import Path +from typing import Any from pydantic import BaseModel, model_validator @@ -105,7 +106,8 @@ def from_conf_file(cls, path: Path) -> "DeploymentConfig": "BOOTNODE_ENODE": "_bootnode_enode", } - kwargs: dict[str, str] = {} + # Pydantic coerces strings to Path/int/etc on construction. + kwargs: dict[str, Any] = {} bootnode_enode = None for conf_key, model_key in field_map.items(): if conf_key in values and values[conf_key]: @@ -117,9 +119,10 @@ def from_conf_file(cls, path: Path) -> "DeploymentConfig": # If old config has BOOTNODE_ENODE but no BOOTNODE_RPC, warn if bootnode_enode and "bootnode_rpc" not in kwargs: import click + click.echo( - f" Warning: Config uses BOOTNODE_ENODE (deprecated). " - f"Use BOOTNODE_RPC instead." + " Warning: Config uses BOOTNODE_ENODE (deprecated). " + "Use BOOTNODE_RPC instead." ) return cls(**kwargs) diff --git a/deploy_gcp/seismic_deploy/gcp/auth.py b/deploy_gcp/seismic_deploy/gcp/auth.py index e1ee4dc..2c54da7 100644 --- a/deploy_gcp/seismic_deploy/gcp/auth.py +++ b/deploy_gcp/seismic_deploy/gcp/auth.py @@ -2,18 +2,19 @@ import click import google.auth +import google.auth.credentials import google.auth.exceptions -from google.cloud import compute_v1, resourcemanager_v3 +from google.cloud import resourcemanager_v3 def validate_credentials() -> google.auth.credentials.Credentials: """Validate that default credentials are available. Returns credentials.""" try: credentials, project = google.auth.default() - except google.auth.exceptions.DefaultCredentialsError: + except google.auth.exceptions.DefaultCredentialsError as e: raise click.ClickException( "No GCP credentials found. Run: gcloud auth application-default login" - ) + ) from e return credentials @@ -24,9 +25,7 @@ def validate_project_access(project_id: str) -> None: try: client.get_project(name=f"projects/{project_id}") except Exception as e: - raise click.ClickException( - f"Cannot access project '{project_id}': {e}" - ) + raise click.ClickException(f"Cannot access project '{project_id}': {e}") from e click.echo(f" Project access validated: {project_id}") diff --git a/deploy_gcp/seismic_deploy/gcp/compute.py b/deploy_gcp/seismic_deploy/gcp/compute.py index 16b700d..aba7192 100644 --- a/deploy_gcp/seismic_deploy/gcp/compute.py +++ b/deploy_gcp/seismic_deploy/gcp/compute.py @@ -8,7 +8,12 @@ import time import click -from google.api_core.exceptions import Forbidden, NotFound, ServiceUnavailable, TooManyRequests +from google.api_core.exceptions import ( + Forbidden, + NotFound, + ServiceUnavailable, + TooManyRequests, +) from google.cloud import compute_v1 from deploy_gcp.seismic_deploy.config import DeploymentConfig @@ -20,9 +25,11 @@ def _retry(fn, max_retries: int = 3, base_delay: float = 10.0): try: return fn() except (TooManyRequests, ServiceUnavailable, Forbidden) as e: - if attempt == max_retries or (isinstance(e, Forbidden) and "Rate Limit" not in str(e)): + if attempt == max_retries or ( + isinstance(e, Forbidden) and "Rate Limit" not in str(e) + ): raise - delay = base_delay * (2 ** attempt) + delay = base_delay * (2**attempt) click.echo(f" Rate limited, retrying in {delay:.0f}s...") time.sleep(delay) @@ -147,9 +154,7 @@ def ensure_snapshot_schedule(project: str, region: str) -> None: ), ), ) - op = client.insert( - project=project, region=region, resource_policy_resource=policy - ) + op = client.insert(project=project, region=region, resource_policy_resource=policy) op.result() click.echo(f" Snapshot schedule created: {name}") @@ -182,13 +187,9 @@ def create_vm( compute_v1.Items(key="startup-script", value=startup_script), ] if config.summit_binary: - metadata_items.append( - compute_v1.Items(key="skip-summit-build", value="true") - ) + metadata_items.append(compute_v1.Items(key="skip-summit-build", value="true")) if config.reth_binary: - metadata_items.append( - compute_v1.Items(key="skip-reth-build", value="true") - ) + metadata_items.append(compute_v1.Items(key="skip-reth-build", value="true")) instance = compute_v1.Instance( name=config.node_name, @@ -227,9 +228,7 @@ def create_vm( ) ], metadata=compute_v1.Metadata(items=metadata_items), - tags=compute_v1.Tags( - items=["http-server", "https-server", config.node_name] - ), + tags=compute_v1.Tags(items=["http-server", "https-server", config.node_name]), service_accounts=[ compute_v1.ServiceAccount( email=service_account, diff --git a/deploy_gcp/seismic_deploy/gcp/wait.py b/deploy_gcp/seismic_deploy/gcp/wait.py index c2a2183..5fc4a2e 100644 --- a/deploy_gcp/seismic_deploy/gcp/wait.py +++ b/deploy_gcp/seismic_deploy/gcp/wait.py @@ -6,19 +6,17 @@ from google.cloud import compute_v1 -def wait_for_vm_running( - project: str, zone: str, name: str, timeout: int = 300 -) -> None: +def wait_for_vm_running(project: str, zone: str, name: str, timeout: int = 300) -> None: """Poll until the VM reaches RUNNING state.""" client = compute_v1.InstancesClient() interval = 10 elapsed = 0 - click.echo(f" Waiting for VM to reach RUNNING state...") + click.echo(" Waiting for VM to reach RUNNING state...") while elapsed < timeout: instance = client.get(project=project, zone=zone, instance=name) if instance.status == "RUNNING": - click.echo(f" VM is running") + click.echo(" VM is running") return time.sleep(interval) diff --git a/deploy_gcp/seismic_deploy/genesis.py b/deploy_gcp/seismic_deploy/genesis.py index 144f21b..6918394 100644 --- a/deploy_gcp/seismic_deploy/genesis.py +++ b/deploy_gcp/seismic_deploy/genesis.py @@ -2,13 +2,10 @@ from __future__ import annotations -import json from pathlib import Path import click -from deploy_gcp.seismic_deploy.ssh.connection import SSHConnection - CONSENSUS_PORT = 18551 # Hardcoded Anvil test addresses for withdrawal credentials. @@ -39,9 +36,7 @@ def fetch_public_keys(host: str) -> dict[str, str]: result = _rpc_call(summit_url, "getPublicKeys") if not result or "node" not in result or "consensus" not in result: - raise click.ClickException( - f"Unexpected response from getPublicKeys: {result}" - ) + raise click.ClickException(f"Unexpected response from getPublicKeys: {result}") return {"node": result["node"], "consensus": result["consensus"]} @@ -66,12 +61,14 @@ def build_validators(nodes: list[dict]) -> list[dict]: """ validators = [] for i, node in enumerate(nodes): - validators.append({ - "node_public_key": node["node_public_key"], - "consensus_public_key": node["consensus_public_key"], - "ip_address": f"{node['ip']}:{CONSENSUS_PORT}", - "withdrawal_credentials": _ANVIL_ADDRESSES[i % len(_ANVIL_ADDRESSES)], - }) + validators.append( + { + "node_public_key": node["node_public_key"], + "consensus_public_key": node["consensus_public_key"], + "ip_address": f"{node['ip']}:{CONSENSUS_PORT}", + "withdrawal_credentials": _ANVIL_ADDRESSES[i % len(_ANVIL_ADDRESSES)], + } + ) return validators @@ -91,9 +88,7 @@ def generate_genesis_file( import tempfile if not genesis_template.exists(): - raise click.ClickException( - f"Genesis template not found: {genesis_template}" - ) + raise click.ClickException(f"Genesis template not found: {genesis_template}") if output_dir is None: output_dir = Path(tempfile.mkdtemp(prefix="seismic-genesis-")) @@ -109,8 +104,12 @@ def generate_genesis_file( in_validator = True continue if in_validator: - # End of validator block: next section header or empty line followed by non-kv - if line.strip() == "" or line.strip().startswith("[[") or line.strip().startswith("["): + # End of validator block: next section header or blank line. + if ( + line.strip() == "" + or line.strip().startswith("[[") + or line.strip().startswith("[") + ): if line.strip().startswith("[[validators]]"): continue if line.strip().startswith("["): @@ -129,7 +128,7 @@ def generate_genesis_file( sorted_validators = sorted(validators, key=lambda v: v["node_public_key"]) for v in sorted_validators: - genesis += f"\n[[validators]]\n" + genesis += "\n[[validators]]\n" genesis += f'node_public_key = "{v["node_public_key"]}"\n' genesis += f'consensus_public_key = "{v["consensus_public_key"]}"\n' genesis += f'ip_address = "{v["ip_address"]}"\n' diff --git a/deploy_gcp/seismic_deploy/network/bootnode.py b/deploy_gcp/seismic_deploy/network/bootnode.py index f0ba1f0..536da50 100644 --- a/deploy_gcp/seismic_deploy/network/bootnode.py +++ b/deploy_gcp/seismic_deploy/network/bootnode.py @@ -11,12 +11,14 @@ def _rpc_call(url: str, method: str, params: list | None = None) -> dict: """Make a JSON-RPC call. Skips SSL verification for IP-based URLs.""" - payload = json.dumps({ - "jsonrpc": "2.0", - "method": method, - "params": params or [], - "id": 1, - }).encode() + payload = json.dumps( + { + "jsonrpc": "2.0", + "method": method, + "params": params or [], + "id": 1, + } + ).encode() req = urllib.request.Request( url, @@ -33,9 +35,7 @@ def _rpc_call(url: str, method: str, params: list | None = None) -> dict: data = json.loads(resp.read()) if "error" in data: - raise click.ClickException( - f"RPC error from {url}: {data['error']}" - ) + raise click.ClickException(f"RPC error from {url}: {data['error']}") return data.get("result") @@ -95,9 +95,7 @@ def fetch_checkpoint(bootnode_rpc: str) -> dict: click.echo(f" Fetching checkpoint from {summit_url}...") result = _rpc_call(summit_url, "getLatestCheckpoint") if not result: - raise click.ClickException( - f"No checkpoint data returned from {summit_url}" - ) + raise click.ClickException(f"No checkpoint data returned from {summit_url}") - click.echo(f" Checkpoint fetched") + click.echo(" Checkpoint fetched") return result diff --git a/deploy_gcp/seismic_deploy/network/stake.py b/deploy_gcp/seismic_deploy/network/stake.py index c2572ef..9708ff6 100644 --- a/deploy_gcp/seismic_deploy/network/stake.py +++ b/deploy_gcp/seismic_deploy/network/stake.py @@ -2,11 +2,10 @@ from __future__ import annotations -import getpass import json import ssl -import sys import urllib.request +from typing import cast import click from eth_account import Account @@ -25,18 +24,21 @@ def _summit_url(node_host: str) -> str: HTTPS vs HTTP. """ from deploy_gcp.seismic_deploy.network.bootnode import _normalize_rpc_base + base = _normalize_rpc_base(node_host) return f"{base}/summit/" def get_deposit_signature(summit_url: str, amount: int, address: str) -> dict: """Call summit's getDepositSignature RPC.""" - payload = json.dumps({ - "jsonrpc": "2.0", - "method": "getDepositSignature", - "params": [amount, address], - "id": 1, - }).encode() + payload = json.dumps( + { + "jsonrpc": "2.0", + "method": "getDepositSignature", + "params": [amount, address], + "id": 1, + } + ).encode() req = urllib.request.Request( summit_url, @@ -78,15 +80,16 @@ def stake_node( # Create web3 client click.echo(f" Connecting to RPC: {rpc_url}...") - w3 = create_wallet_client(rpc_url, private_key=PrivateKey(pk_bytes)) - w3.middleware_onion.inject( - SignAndSendRawMiddlewareBuilder.build(account), layer=0 - ) + # `PrivateKey.__new__` is typed as returning its parent `_SizedHexBytes` + # in seismic_web3's stubs; cast back to `PrivateKey` for the API call. + pk = cast(PrivateKey, PrivateKey(pk_bytes)) + w3 = create_wallet_client(rpc_url, private_key=pk) + w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(account), layer=0) w3.eth.default_account = wallet - click.echo(f" Connected to RPC") + click.echo(" Connected to RPC") # Check balance - click.echo(f" Checking wallet balance...") + click.echo(" Checking wallet balance...") balance = w3.eth.get_balance(wallet) click.echo(f" Balance: {balance / 10**18:.4f} ETH") if balance < amount_wei: @@ -97,7 +100,7 @@ def stake_node( # Get deposit signature click.echo(f" Fetching deposit signature from {summit_url}...") sig = get_deposit_signature(summit_url, amount_gwei, str(wallet)) - click.echo(f" Deposit signature received") + click.echo(" Deposit signature received") node_pubkey = bytes(sig["node_pubkey"]) consensus_pubkey = bytes(sig["consensus_pubkey"]) @@ -112,7 +115,8 @@ def stake_node( # Submit deposit click.echo(" Submitting deposit transaction...") - tx_hash = w3.seismic.deposit( + # `w3.seismic` is registered at runtime by seismic_web3; not in type stubs. + tx_hash = w3.seismic.deposit( # ty: ignore[unresolved-attribute] node_pubkey=node_pubkey, consensus_pubkey=consensus_pubkey, withdrawal_credentials=withdrawal_credentials, diff --git a/deploy_gcp/seismic_deploy/network/sync.py b/deploy_gcp/seismic_deploy/network/sync.py index ad6beb8..94fc378 100644 --- a/deploy_gcp/seismic_deploy/network/sync.py +++ b/deploy_gcp/seismic_deploy/network/sync.py @@ -1,4 +1,4 @@ -"""Checkpoint fetching, genesis deployment, and state wipe for joining existing networks.""" +"""Checkpoint fetch, genesis deploy, and state wipe for joining existing networks.""" from __future__ import annotations @@ -15,12 +15,14 @@ def fetch_checkpoint(rpc_url: str) -> dict: """Call getLatestCheckpoint on a summit RPC endpoint.""" - payload = json.dumps({ - "jsonrpc": "2.0", - "method": "getLatestCheckpoint", - "params": [], - "id": 1, - }).encode() + payload = json.dumps( + { + "jsonrpc": "2.0", + "method": "getLatestCheckpoint", + "params": [], + "id": 1, + } + ).encode() req = urllib.request.Request( rpc_url, @@ -37,15 +39,13 @@ def fetch_checkpoint(rpc_url: str) -> dict: data = json.loads(resp.read()) if "error" in data: - raise click.ClickException( - f"Error from summit RPC: {data['error']}" - ) + raise click.ClickException(f"Error from summit RPC: {data['error']}") result = data.get("result") if not result: raise click.ClickException("No checkpoint data returned from RPC") - click.echo(f" Checkpoint fetched") + click.echo(" Checkpoint fetched") return result @@ -122,13 +122,16 @@ def wait_for_summit(host: str, timeout: int = 300) -> None: _rpc_call(summit_url, "health") click.echo(" Summit is reachable") return - except Exception: + except Exception as e: if time.time() >= deadline: raise click.ClickException( f"Summit did not become reachable at {summit_url} within {timeout}s" - ) + ) from e remaining = int(deadline - time.time()) - click.echo(f" Summit not ready, retrying in {interval}s ({remaining}s remaining)...") + click.echo( + f" Summit not ready, retrying in {interval}s " + f"({remaining}s remaining)..." + ) time.sleep(interval) diff --git a/deploy_gcp/seismic_deploy/ssh/connection.py b/deploy_gcp/seismic_deploy/ssh/connection.py index 5ee87ac..b4d595c 100644 --- a/deploy_gcp/seismic_deploy/ssh/connection.py +++ b/deploy_gcp/seismic_deploy/ssh/connection.py @@ -9,11 +9,16 @@ import click SSH_OPTS = [ - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-o", "BatchMode=yes", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + "-o", + "BatchMode=yes", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", ] @@ -45,22 +50,24 @@ def exec( def upload(self, local_path: Path, remote_path: str) -> None: """Upload a file via SCP.""" - cmd = ["scp", *SSH_OPTS, *self._key_opts, str(local_path), f"{self.host}:{remote_path}"] + cmd = [ + "scp", + *SSH_OPTS, + *self._key_opts, + str(local_path), + f"{self.host}:{remote_path}", + ] subprocess.run(cmd, check=True, capture_output=True, text=True) def upload_content(self, content: str, remote_path: str) -> None: """Upload string content to a remote file via stdin pipe.""" cmd = ["ssh", *SSH_OPTS, *self._key_opts, self.host, f"cat > {remote_path}"] - subprocess.run( - cmd, input=content, check=True, capture_output=True, text=True - ) + subprocess.run(cmd, input=content, check=True, capture_output=True, text=True) def upload_bytes(self, data: bytes, remote_path: str) -> None: """Upload binary data to a remote file via stdin pipe.""" cmd = ["ssh", *SSH_OPTS, *self._key_opts, self.host, f"cat > {remote_path}"] - subprocess.run( - cmd, input=data, check=True, capture_output=True - ) + subprocess.run(cmd, input=data, check=True, capture_output=True) def wait_for_ssh(self, timeout: int = 300) -> None: """Poll until SSH is available.""" @@ -77,7 +84,7 @@ def wait_for_ssh(self, timeout: int = 300) -> None: timeout=15, ) if result.returncode == 0: - click.echo(f" SSH is available") + click.echo(" SSH is available") return except subprocess.TimeoutExpired: pass @@ -101,7 +108,9 @@ def wait_for_file( elapsed = 0 seen_cursor = "" # journalctl cursor to avoid reprinting old lines - click.echo(f" Waiting for {filepath} on remote host (this may take a while)...") + click.echo( + f" Waiting for {filepath} on remote host (this may take a while)..." + ) if verbose: click.echo(" (streaming startup-script logs)") @@ -113,14 +122,17 @@ def wait_for_file( # Check if startup script exited without marker status = self.exec( - "systemctl is-active google-startup-scripts.service 2>/dev/null || true", + "systemctl is-active google-startup-scripts.service " + "2>/dev/null || true", check=False, ) if status.stdout.strip() in ("inactive", "failed"): # Grab last 30 lines for diagnostics logs = self.exec( - "sudo journalctl -u google-startup-scripts.service --no-pager -n 30 2>/dev/null || " - "sudo grep 'startup-script' /var/log/syslog 2>/dev/null | tail -30", + "sudo journalctl -u google-startup-scripts.service " + "--no-pager -n 30 2>/dev/null || " + "sudo grep 'startup-script' /var/log/syslog 2>/dev/null " + "| tail -30", check=False, ) raise click.ClickException( @@ -133,7 +145,8 @@ def wait_for_file( self._print_startup_logs(seen_cursor) # Update cursor to the latest entry so next poll only shows new lines cursor_result = self.exec( - "sudo journalctl -u google-startup-scripts.service --no-pager -n 1 --show-cursor 2>/dev/null || true", + "sudo journalctl -u google-startup-scripts.service " + "--no-pager -n 1 --show-cursor 2>/dev/null || true", check=False, ) for line in cursor_result.stdout.strip().splitlines(): @@ -143,9 +156,7 @@ def wait_for_file( time.sleep(interval) elapsed += interval - raise click.ClickException( - f"Timed out waiting for {filepath} ({timeout}s)" - ) + raise click.ClickException(f"Timed out waiting for {filepath} ({timeout}s)") def _print_startup_logs(self, after_cursor: str) -> None: """Print new startup-script journal lines since the given cursor.""" diff --git a/deploy_gcp/seismic_deploy/ssh/deploy.py b/deploy_gcp/seismic_deploy/ssh/deploy.py index ffe21f7..a37412e 100644 --- a/deploy_gcp/seismic_deploy/ssh/deploy.py +++ b/deploy_gcp/seismic_deploy/ssh/deploy.py @@ -4,20 +4,25 @@ import base64 import os -from pathlib import Path import click from deploy_gcp.seismic_deploy.config import DeploymentConfig from deploy_gcp.seismic_deploy.ssh.connection import SSHConnection -from deploy_gcp.seismic_deploy.templates import read_bash_aliases, render_lua_scripts, render_nginx, render_supervisor +from deploy_gcp.seismic_deploy.templates import ( + read_bash_aliases, + render_lua_scripts, + render_nginx, + render_supervisor, +) def deploy_nginx(conn: SSHConnection, config: DeploymentConfig) -> None: """Render, upload, test, and reload OpenResty configuration.""" # Reuse existing JWT secret from the server if available, otherwise generate + jwt_lua = "/usr/local/openresty/nginx/lua/jwt_auth.lua" result = conn.exec( - "grep -oP '(?<=JWT_SECRET = \")([^\"]+)' /usr/local/openresty/nginx/lua/jwt_auth.lua", + f"grep -oP '(?<=JWT_SECRET = \")([^\"]+)' {jwt_lua}", check=False, ) if result.returncode == 0 and result.stdout.strip(): @@ -28,7 +33,9 @@ def deploy_nginx(conn: SSHConnection, config: DeploymentConfig) -> None: click.echo(f" Generated JWT secret: {jwt_secret}") click.echo(" Uploading Lua scripts...") - for name, content in render_lua_scripts(config.rate_limit_rps, config.rate_limit_burst, jwt_secret).items(): + for name, content in render_lua_scripts( + config.rate_limit_rps, config.rate_limit_burst, jwt_secret + ).items(): conn.upload_content(content, f"/tmp/{name}") conn.exec(f"sudo mv /tmp/{name} /usr/local/openresty/nginx/lua/{name}") @@ -50,7 +57,9 @@ def deploy_ssl(conn: SSHConnection, config: DeploymentConfig) -> None: click.echo(" SSL handled by lua-resty-auto-ssl (no action needed)") -def deploy_supervisor(conn: SSHConnection, config: DeploymentConfig, bootnode_enode: str | None = None) -> None: +def deploy_supervisor( + conn: SSHConnection, config: DeploymentConfig, bootnode_enode: str | None = None +) -> None: """Render and upload supervisor configuration.""" click.echo(" Rendering supervisor config...") content = render_supervisor(bootnode_enode) @@ -93,10 +102,11 @@ def deploy_binaries(conn: SSHConnection, config: DeploymentConfig) -> None: raise click.ClickException(f"Reth binary not found: {path}") click.echo(f" Uploading reth binary ({path.name})...") conn.upload(path, "/tmp/seismic-reth") + reth_dir = "/home/ubuntu/seismic-reth/target/release" conn.exec( - "mkdir -p /home/ubuntu/seismic-reth/target/release && " - "mv /tmp/seismic-reth /home/ubuntu/seismic-reth/target/release/seismic-reth && " - "chmod +x /home/ubuntu/seismic-reth/target/release/seismic-reth" + f"mkdir -p {reth_dir} && " + f"mv /tmp/seismic-reth {reth_dir}/seismic-reth && " + f"chmod +x {reth_dir}/seismic-reth" ) click.echo(" Reth binary deployed") @@ -107,9 +117,7 @@ def deploy_binaries(conn: SSHConnection, config: DeploymentConfig) -> None: def generate_consensus_keys(conn: SSHConnection) -> None: """Generate summit consensus keys if they don't already exist.""" click.echo(" Checking for existing consensus keys...") - result = conn.exec( - "test -f /persistent/summit/consensus/node_key.pem", check=False - ) + result = conn.exec("test -f /persistent/summit/consensus/node_key.pem", check=False) if result.returncode == 0: click.echo(" Consensus keys already exist") return diff --git a/deploy_gcp/seismic_deploy/state.py b/deploy_gcp/seismic_deploy/state.py index ea616c5..13eac03 100644 --- a/deploy_gcp/seismic_deploy/state.py +++ b/deploy_gcp/seismic_deploy/state.py @@ -1,7 +1,6 @@ """Deployment state tracking for resumability.""" -import json -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from pathlib import Path @@ -34,8 +33,7 @@ class DeployStep(str, Enum): # Steps for the basic `deploy` command (no start/stake) DEPLOY_STEPS = [ - s for s in DeployStep - if s not in (DeployStep.START_SERVICES, DeployStep.STAKE) + s for s in DeployStep if s not in (DeployStep.START_SERVICES, DeployStep.STAKE) ] # Steps for `deploy-and-stake` (full pipeline) @@ -70,7 +68,7 @@ def is_complete(self, step: DeployStep) -> bool: def mark_complete(self, step: DeployStep) -> None: if step not in self.completed_steps: self.completed_steps.append(step) - self.last_updated = datetime.now(timezone.utc) + self.last_updated = datetime.now(UTC) def save(self, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -82,7 +80,7 @@ def load(cls, path: Path) -> "DeployState": @classmethod def fresh(cls, config: DeploymentConfig) -> "DeployState": - now = datetime.now(timezone.utc) + now = datetime.now(UTC) return cls( node_name=config.node_name, config=config, diff --git a/deploy_gcp/seismic_deploy/templates.py b/deploy_gcp/seismic_deploy/templates.py index 52753f8..b81afa0 100644 --- a/deploy_gcp/seismic_deploy/templates.py +++ b/deploy_gcp/seismic_deploy/templates.py @@ -37,7 +37,9 @@ def render_supervisor(bootnode_enode: str | None = None) -> str: return content -def render_lua_scripts(rate_limit_rps: int, rate_limit_burst: int, jwt_secret: str) -> dict[str, str]: +def render_lua_scripts( + rate_limit_rps: int, rate_limit_burst: int, jwt_secret: str +) -> dict[str, str]: """Render Lua scripts from templates/lua/ with config substitutions.""" lua_dir = TEMPLATES_DIR / "lua" substitutions = {