Skip to content

Commit ee0eefa

Browse files
committed
Support signed snapshot manifests in CLI
1 parent bd5187f commit ee0eefa

5 files changed

Lines changed: 70 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ uv run xian doctor devnet-node --skip-live-checks
7878
uv run xian snapshot restore devnet-node
7979
```
8080

81+
For remote snapshot bootstrap, prefer a signed snapshot manifest plus trusted
82+
snapshot signing keys in the network manifest or node profile.
83+
8184
## Principles
8285

8386
- `xian-cli` owns operator UX. Deterministic node logic stays in `xian-abci`,

src/xian_cli/cli.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ def _handle_network_create(args: argparse.Namespace) -> int:
639639
),
640640
genesis_source=genesis_source,
641641
snapshot_url=args.snapshot_url,
642+
snapshot_signing_keys=args.snapshot_signing_key or [],
642643
seed_nodes=args.seed or [],
643644
block_policy_mode=_pick_template_value(
644645
args.block_policy_mode,
@@ -705,6 +706,11 @@ def _handle_network_create(args: argparse.Namespace) -> int:
705706
snapshot_url=(
706707
args.snapshot_url if validator["is_bootstrap"] else None
707708
),
709+
snapshot_signing_keys=(
710+
list(args.snapshot_signing_key or [])
711+
if validator["is_bootstrap"]
712+
else []
713+
),
708714
service_node=(
709715
_pick_template_value(
710716
args.service_node,
@@ -1133,6 +1139,7 @@ def _handle_network_join(args: argparse.Namespace) -> int:
11331139
seeds=args.seed or [],
11341140
genesis_url=args.genesis_url,
11351141
snapshot_url=args.snapshot_url,
1142+
snapshot_signing_keys=args.snapshot_signing_key or [],
11361143
service_node=_pick_template_value(
11371144
args.service_node,
11381145
None if template is None else template.get("service_node"),
@@ -1623,6 +1630,20 @@ def _resolve_effective_snapshot_url(
16231630
)
16241631

16251632

1633+
def _resolve_effective_snapshot_signing_keys(
1634+
*,
1635+
profile: dict,
1636+
network: dict,
1637+
) -> list[str]:
1638+
profile_keys = profile.get("snapshot_signing_keys")
1639+
if isinstance(profile_keys, list) and profile_keys:
1640+
return list(profile_keys)
1641+
network_keys = network.get("snapshot_signing_keys")
1642+
if isinstance(network_keys, list):
1643+
return list(network_keys)
1644+
return []
1645+
1646+
16261647
def _restore_snapshot(
16271648
*,
16281649
base_dir: Path,
@@ -1654,6 +1675,10 @@ def _restore_snapshot(
16541675
network=network,
16551676
explicit_snapshot_url=explicit_snapshot_url,
16561677
)
1678+
snapshot_signing_keys = _resolve_effective_snapshot_signing_keys(
1679+
profile=profile,
1680+
network=network,
1681+
)
16571682
if not snapshot_url:
16581683
raise ValueError(
16591684
"no snapshot source configured; "
@@ -1662,7 +1687,10 @@ def _restore_snapshot(
16621687

16631688
node_admin = get_node_admin_module()
16641689
snapshot_archive_name = node_admin.apply_snapshot_archive(
1665-
snapshot_url, home
1690+
snapshot_url,
1691+
home,
1692+
trusted_manifest_public_keys=snapshot_signing_keys,
1693+
expected_chain_id=network.get("chain_id"),
16661694
)
16671695
return {
16681696
"home": str(home),

src/xian_cli/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,9 @@ def normalize_network_manifest(payload: dict) -> dict:
409409
"genesis_preset": genesis_preset,
410410
"genesis_time": genesis_time,
411411
"snapshot_url": _require_optional_str(payload, "snapshot_url"),
412+
"snapshot_signing_keys": _require_str_list(
413+
payload, "snapshot_signing_keys"
414+
),
412415
"seed_nodes": _require_str_list(payload, "seed_nodes"),
413416
"block_policy_mode": _require_block_policy_mode(
414417
payload, "block_policy_mode"
@@ -464,6 +467,9 @@ def normalize_node_profile(payload: dict) -> dict:
464467
"seeds": _require_str_list(payload, "seeds"),
465468
"genesis_url": _require_optional_str(payload, "genesis_url"),
466469
"snapshot_url": _require_optional_str(payload, "snapshot_url"),
470+
"snapshot_signing_keys": _require_str_list(
471+
payload, "snapshot_signing_keys"
472+
),
467473
"service_node": _require_bool(payload, "service_node", default=False),
468474
"home": _require_optional_str(payload, "home"),
469475
"pruning_enabled": _require_bool(
@@ -844,6 +850,7 @@ class NetworkManifest:
844850
genesis_preset: str | None = None
845851
genesis_time: str | None = None
846852
snapshot_url: str | None = None
853+
snapshot_signing_keys: list[str] = field(default_factory=list)
847854
seed_nodes: list[str] = field(default_factory=list)
848855
block_policy_mode: str = "on_demand"
849856
block_policy_interval: str = "0s"
@@ -869,6 +876,7 @@ class NodeProfile:
869876
seeds: list[str] = field(default_factory=list)
870877
genesis_url: str | None = None
871878
snapshot_url: str | None = None
879+
snapshot_signing_keys: list[str] = field(default_factory=list)
872880
service_node: bool = False
873881
home: str | None = None
874882
pruning_enabled: bool = False

src/xian_cli/parser.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,14 @@ def build_parser() -> argparse.ArgumentParser:
306306
help="voting power for the generated initial validator entry",
307307
)
308308
create_parser.add_argument("--snapshot-url", help="optional snapshot URL")
309+
create_parser.add_argument(
310+
"--snapshot-signing-key",
311+
action="append",
312+
help=(
313+
"trusted Ed25519 public key for signed snapshot manifests; "
314+
"may be repeated"
315+
),
316+
)
309317
create_parser.add_argument(
310318
"--seed",
311319
action="append",
@@ -673,6 +681,14 @@ def build_parser() -> argparse.ArgumentParser:
673681
"--snapshot-url",
674682
help="node-local snapshot URL override",
675683
)
684+
join_parser.add_argument(
685+
"--snapshot-signing-key",
686+
action="append",
687+
help=(
688+
"trusted Ed25519 public key for signed snapshot manifests; "
689+
"may be repeated"
690+
),
691+
)
676692
join_parser.add_argument(
677693
"--init-node",
678694
action="store_true",

tests/test_cli.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1990,7 +1990,9 @@ def test_node_init_can_restore_snapshot_from_manifest(self) -> None:
19901990
"--genesis-source",
19911991
str(genesis_source),
19921992
"--snapshot-url",
1993-
"https://example.invalid/snapshot.tar.gz",
1993+
"https://example.invalid/snapshot-manifest.json",
1994+
"--snapshot-signing-key",
1995+
"a" * 64,
19941996
"--output",
19951997
str(
19961998
base_dir
@@ -2052,14 +2054,16 @@ def test_node_init_can_restore_snapshot_from_manifest(self) -> None:
20522054

20532055
self.assertEqual(exit_code, 0)
20542056
snapshot_mock.assert_called_once_with(
2055-
"https://example.invalid/snapshot.tar.gz",
2057+
"https://example.invalid/snapshot-manifest.json",
20562058
home,
2059+
trusted_manifest_public_keys=["a" * 64],
2060+
expected_chain_id="xian-testnet-12",
20572061
)
20582062
result = json.loads(stdout.getvalue())
20592063
self.assertTrue(result["snapshot_restored"])
20602064
self.assertEqual(
20612065
result["effective_snapshot_url"],
2062-
"https://example.invalid/snapshot.tar.gz",
2066+
"https://example.invalid/snapshot-manifest.json",
20632067
)
20642068
self.assertEqual(
20652069
result["snapshot"]["snapshot_archive_name"],
@@ -3169,7 +3173,9 @@ def test_snapshot_restore_uses_effective_snapshot_url(self) -> None:
31693173
"--chain-id",
31703174
"xian-local-1",
31713175
"--snapshot-url",
3172-
"https://example.invalid/network-snapshot.tar.gz",
3176+
"https://example.invalid/network-snapshot-manifest.json",
3177+
"--snapshot-signing-key",
3178+
"b" * 64,
31733179
"--output",
31743180
str(
31753181
base_dir
@@ -3219,14 +3225,16 @@ def test_snapshot_restore_uses_effective_snapshot_url(self) -> None:
32193225

32203226
self.assertEqual(exit_code, 0)
32213227
snapshot_mock.assert_called_once_with(
3222-
"https://example.invalid/network-snapshot.tar.gz",
3228+
"https://example.invalid/network-snapshot-manifest.json",
32233229
home,
3230+
trusted_manifest_public_keys=["b" * 64],
3231+
expected_chain_id="xian-local-1",
32243232
)
32253233
result = json.loads(stdout.getvalue())
32263234
self.assertEqual(result["home"], str(home))
32273235
self.assertEqual(
32283236
result["snapshot_url"],
3229-
"https://example.invalid/network-snapshot.tar.gz",
3237+
"https://example.invalid/network-snapshot-manifest.json",
32303238
)
32313239
self.assertEqual(
32323240
result["snapshot_archive_name"],

0 commit comments

Comments
 (0)