From 0dd97a428cdf9757c299b113f14474550f3755cc Mon Sep 17 00:00:00 2001 From: Maksym H Date: Wed, 26 Aug 2026 18:27:58 +0100 Subject: [PATCH 1/2] Publish the fork's own nodes as bootNodes, optionally under a public host --- README.md | 14 +++ src/bootnodes.rs | 259 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 28 +++++ src/config.rs | 5 + src/main.rs | 51 ++++++++-- 5 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 src/bootnodes.rs diff --git a/README.md b/README.md index f095141..fbc3d03 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,20 @@ Storage keys are derived from pallet and item names, and every value is decoded Metadata and the live values are read at the block being bitten (`--rc-bite-at` / a para's `bite_at`), so they match the state being imported. Parachains use a default public endpoint when no `rpc_endpoint` is configured; if it can't be reached, the bite still runs with a warning and those overrides go unverified. Custom parachains are only verified when their config supplies an `rpc_endpoint`. +#### Publishing bootnodes + +A published chain-spec ships with `bootNodes: []` — that is what keeps a fork from dialing the network it was forked from, but it also means a node this process did not start has no way to find the fork. `--publish-bootnodes` fills the list with the fork's own nodes, in the artifacts generated at teardown (the `bite` bundle is left untouched): + +```sh +# same host: publishes the loopback addresses +zombie-bite spawn -d /tmp/base_path --publish-bootnodes + +# a deployment: advertise its public name (or IP) instead +zombie-bite spawn -d /tmp/base_path --publish-bootnodes fork.example.com +``` + +Only the address host is rewritten — port, transport and peer id stay as spawned. Specs are matched by their own `para_id` rather than by file name, since a fork carries the source chain's spec id. + #### Bundle manifest A bite writes a `manifest.json` next to `ready.json` describing the bite bundle: per chain the bite block, source RPC, spec and snapshot file names with sizes, and any carried upgrade, plus the `doppelganger` versions that produced the snapshots. A later `spawn` warns when the local binaries differ, because a snapshot from a newer node fails to restore in ways that otherwise look like corruption. diff --git a/src/bootnodes.rs b/src/bootnodes.rs new file mode 100644 index 0000000..372fec0 --- /dev/null +++ b/src/bootnodes.rs @@ -0,0 +1,259 @@ +//! Publish the fork's own node addresses into the chain-specs it ships. +//! +//! `generate_chain_spec` clears `bootNodes` so a fork can never dial the network +//! it was forked from. That is the right default, but it also means a published +//! spec is unusable to anything that was not started by this process: the peer +//! wiring only exists in the spawned nodes' arguments. Filling the list with the +//! fork's own nodes - optionally advertised under a routable host - makes the +//! artifacts usable without every consumer patching the specs itself. + +use std::path::Path; + +use anyhow::anyhow; +use serde_json::Value; +use tokio::fs; +use tracing::{info, warn}; +use zombienet_sdk::{LocalFileSystem, Network}; + +/// Addresses of a spawned chain's nodes, captured while the network is still up. +#[derive(Debug, Clone)] +pub struct ChainBootnodes { + /// `None` for the relay chain. + pub para_id: Option, + pub addresses: Vec, +} + +/// Collect the running nodes' addresses. Has to happen before teardown, while +/// the network object still describes live nodes. +pub fn collect(network: &Network) -> Vec { + let mut chains = vec![ChainBootnodes { + para_id: None, + addresses: network + .relaychain() + .nodes() + .iter() + .map(|node| node.multiaddr().to_string()) + .collect(), + }]; + + for para in network.parachains() { + chains.push(ChainBootnodes { + para_id: Some(para.para_id()), + addresses: para + .collators() + .iter() + .map(|node| node.multiaddr().to_string()) + .collect(), + }); + } + + chains +} + +/// Rewrite the host of a multiaddr, keeping port, transport and peer id. +/// +/// The addresses zombienet reports are always loopback (the native provider +/// hands out `127.0.0.1`), which is fine on the same box and useless anywhere +/// else - so a deployment advertises its own hostname instead. +fn advertise(addr: &str, host: &str) -> String { + let protocol = if host.parse::().is_ok() { + "ip6" + } else if host.parse::().is_ok() { + "ip4" + } else { + "dns4" + }; + + let mut parts: Vec<&str> = addr.split('/').collect(); + // "/ip4/127.0.0.1/tcp/30333/ws/p2p/" -> ["", "ip4", "127.0.0.1", ...] + if parts.len() < 3 { + return addr.to_string(); + } + parts[1] = protocol; + parts[2] = host; + parts.join("/") +} + +/// Write the collected addresses into the chain-specs of `spec_dir`. +/// +/// Specs are matched by their own contents, not by file name: a fork carries the +/// source chain's spec id, which does not have to match the file the bite wrote +/// (`collectives-polkadot` vs an id of `collectives_polkadot`), and a custom +/// parachain's spec id is whatever its author chose. A raw spec with a `para_id` +/// belongs to that parachain; one without is the relay chain. +pub async fn publish( + chains: &[ChainBootnodes], + spec_dir: &Path, + host: &str, +) -> Result<(), anyhow::Error> { + let mut entries = fs::read_dir(spec_dir) + .await + .map_err(|e| anyhow!("can't read {}: {e}", spec_dir.to_string_lossy()))?; + + let mut patched = 0_usize; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + + let Ok(content) = fs::read_to_string(&path).await else { + continue; + }; + let Ok(mut spec) = serde_json::from_str::(&content) else { + continue; + }; + // A raw chain-spec has an id and a bootNodes list; config.toml, + // ready.json and friends do not. + if spec.get("id").is_none() || !spec["bootNodes"].is_array() { + continue; + } + + let para_id = spec["para_id"].as_u64().map(|id| id as u32); + let Some(chain) = chains.iter().find(|c| c.para_id == para_id) else { + warn!( + "{}: no spawned chain matches this spec, leaving bootNodes empty", + path.to_string_lossy() + ); + continue; + }; + if chain.addresses.is_empty() { + warn!("{}: no running nodes to advertise", path.to_string_lossy()); + continue; + } + + let addresses: Vec = chain + .addresses + .iter() + .map(|addr| advertise(addr, host)) + .collect(); + spec["bootNodes"] = serde_json::to_value(&addresses)?; + // to_string, not to_string_pretty: a raw spec is tens of MB and + // consumers checksum it. + fs::write(&path, serde_json::to_string(&spec)?).await?; + info!( + "{}: {} bootNode(s) advertised as {host}", + path.to_string_lossy(), + addresses.len() + ); + patched += 1; + } + + if patched == 0 { + warn!( + "--publish-bootnodes: no chain-spec in {} was updated", + spec_dir.to_string_lossy() + ); + } + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + const ADDR: &str = + "/ip4/127.0.0.1/tcp/30333/ws/p2p/12D3KooWQCkBm1BYtkHpocxCwMgR8yjitEeHGx8spzcDLGt2gkBm"; + + #[test] + fn advertise_keeps_port_transport_and_peer() { + assert_eq!( + advertise(ADDR, "fork.example.com"), + "/dns4/fork.example.com/tcp/30333/ws/p2p/12D3KooWQCkBm1BYtkHpocxCwMgR8yjitEeHGx8spzcDLGt2gkBm" + ); + assert_eq!( + advertise(ADDR, "10.0.0.7"), + "/ip4/10.0.0.7/tcp/30333/ws/p2p/12D3KooWQCkBm1BYtkHpocxCwMgR8yjitEeHGx8spzcDLGt2gkBm" + ); + assert_eq!(advertise(ADDR, "::1").split('/').nth(1), Some("ip6")); + // same host: unchanged + assert_eq!(advertise(ADDR, "127.0.0.1"), ADDR); + } + + #[tokio::test] + async fn publish_matches_specs_by_para_id_not_file_name() { + let dir = std::env::temp_dir().join("zb-bootnodes-publish"); + let _ = fs::remove_dir_all(&dir).await; + fs::create_dir_all(&dir).await.unwrap(); + + // file names deliberately unrelated to the spec ids + fs::write( + dir.join("relay-spec.json"), + r#"{"id":"kusama","bootNodes":[]}"#, + ) + .await + .unwrap(); + fs::write( + dir.join("collectives-kusama-spec.json"), + r#"{"id":"collectives_kusama","para_id":1001,"bootNodes":[]}"#, + ) + .await + .unwrap(); + // not a chain-spec: must be left alone + fs::write(dir.join("ready.json"), r#"{"rc_start_block":10}"#) + .await + .unwrap(); + + let chains = vec![ + ChainBootnodes { + para_id: None, + addresses: vec![ADDR.to_string()], + }, + ChainBootnodes { + para_id: Some(1001), + addresses: vec![ADDR.to_string()], + }, + ]; + publish(&chains, &dir, "fork.example.com").await.unwrap(); + + let relay: Value = serde_json::from_str( + &fs::read_to_string(dir.join("relay-spec.json")) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + relay["bootNodes"][0].as_str().unwrap(), + advertise(ADDR, "fork.example.com") + ); + + let para: Value = serde_json::from_str( + &fs::read_to_string(dir.join("collectives-kusama-spec.json")) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(para["bootNodes"].as_array().unwrap().len(), 1); + + let ready = fs::read_to_string(dir.join("ready.json")).await.unwrap(); + assert_eq!(ready, r#"{"rc_start_block":10}"#); + + fs::remove_dir_all(&dir).await.unwrap(); + } + + #[tokio::test] + async fn publish_warns_when_no_chain_matches() { + let dir = std::env::temp_dir().join("zb-bootnodes-nomatch"); + let _ = fs::remove_dir_all(&dir).await; + fs::create_dir_all(&dir).await.unwrap(); + fs::write( + dir.join("a-spec.json"), + r#"{"id":"x","para_id":9999,"bootNodes":[]}"#, + ) + .await + .unwrap(); + + let chains = vec![ChainBootnodes { + para_id: None, + addresses: vec![ADDR.to_string()], + }]; + // unmatched spec is left untouched rather than failing the run + publish(&chains, &dir, "127.0.0.1").await.unwrap(); + let spec: Value = + serde_json::from_str(&fs::read_to_string(dir.join("a-spec.json")).await.unwrap()) + .unwrap(); + assert!(spec["bootNodes"].as_array().unwrap().is_empty()); + + fs::remove_dir_all(&dir).await.unwrap(); + } +} diff --git a/src/cli.rs b/src/cli.rs index 4920822..2f318d3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -59,6 +59,13 @@ pub enum Commands { /// Can be set multiple times, once per para. #[arg(long = "para-cores", verbatim_doc_comment)] para_cores: Vec, + /// Advertise this run's own nodes as bootNodes in the published + /// chain-specs, so the artifacts are usable by nodes this process did + /// not start. Pass a hostname or IP to advertise (a deployment's public + /// name); with no value the loopback addresses are published, which only + /// works on the same host. + #[arg(long, num_args = 0..=1, default_missing_value = "127.0.0.1", verbatim_doc_comment)] + publish_bootnodes: Option, /// If provided we will _bite_ the live network at the supplied block hieght #[arg(long = "rc-bite-at", verbatim_doc_comment)] relay_bite_at: Option, @@ -103,6 +110,13 @@ pub enum Commands { /// and wait until it enacts. #[arg(long, default_value_t = false, verbatim_doc_comment)] apply_upgrade: bool, + /// Advertise this run's own nodes as bootNodes in the published + /// chain-specs, so the artifacts are usable by nodes this process did + /// not start. Pass a hostname or IP to advertise (a deployment's public + /// name); with no value the loopback addresses are published, which only + /// works on the same host. + #[arg(long, num_args = 0..=1, default_missing_value = "127.0.0.1", verbatim_doc_comment)] + publish_bootnodes: Option, }, /// [Helper] Generate artifacts to be used by the next step (only 'spawn' and 'post' allowed) GenerateArtifacts { @@ -167,6 +181,7 @@ pub struct ResolvedBiteConfig { pub base_path: PathBuf, pub and_spawn: bool, pub apply_upgrade: bool, + pub publish_bootnodes: Option, pub opts: BiteOptions, } @@ -175,6 +190,7 @@ pub struct ResolvedSpawnConfig { pub base_path: PathBuf, pub with_monitor: bool, pub apply_upgrade: bool, + pub publish_bootnodes: Option, } #[allow(clippy::too_many_arguments)] @@ -192,6 +208,7 @@ pub fn resolve_bite_config( apply_upgrade: bool, keep_messaging_state: bool, para_cores: Vec, + publish_bootnodes: Option, ) -> Result { // Load config file if provided let config_file = if let Some(path) = config_path { @@ -374,6 +391,11 @@ pub fn resolve_bite_config( base_path: resolved_base_path, and_spawn: resolved_and_spawn, apply_upgrade: resolved_apply_upgrade, + publish_bootnodes: publish_bootnodes.or_else(|| { + config_file + .as_ref() + .and_then(|c| c.publish_bootnodes.clone()) + }), opts: BiteOptions { upgrades, cores, @@ -387,6 +409,7 @@ pub fn resolve_spawn_config( base_path: Option, with_monitor: bool, apply_upgrade: bool, + publish_bootnodes: Option, ) -> Result { // Load config file if provided let config_file = if let Some(path) = config_path { @@ -423,6 +446,11 @@ pub fn resolve_spawn_config( base_path: resolved_base_path, with_monitor: resolved_with_monitor, apply_upgrade: resolved_apply_upgrade, + publish_bootnodes: publish_bootnodes.or_else(|| { + config_file + .as_ref() + .and_then(|c| c.publish_bootnodes.clone()) + }), }) } diff --git a/src/config.rs b/src/config.rs index fdcae28..c992ea8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -722,6 +722,9 @@ pub struct ZombieBiteConfig { /// parachains are the ones being bitten); wrong for a shared relay, where /// the mismatch makes cumulus panic with `HRMP head mismatch`. pub keep_messaging_state: Option, + /// Hostname or IP to advertise the spawned nodes under in the published + /// chain-specs. + pub publish_bootnodes: Option, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -1217,6 +1220,7 @@ mod test { with_monitor: None, apply_upgrade: None, keep_messaging_state: None, + publish_bootnodes: None, }; assert_eq!(config.get_parachains().len(), 0); @@ -1272,6 +1276,7 @@ mod test { with_monitor: None, apply_upgrade: None, keep_messaging_state: None, + publish_bootnodes: None, }; let parachains = config.get_parachains(); diff --git a/src/main.rs b/src/main.rs index f9b7827..659b803 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use tracing::{debug, info, level_filters::LevelFilter, trace, warn}; use tracing_subscriber::EnvFilter; use zombienet_sdk::{LocalFileSystem, Network, NetworkNode}; +mod bootnodes; mod cli; mod config; mod doppelganger; @@ -120,8 +121,15 @@ async fn tear_down_and_generate( step: Step, network: Network, base_path: PathBuf, + publish_bootnodes: Option, ) -> Result<(), anyhow::Error> { let rc = Relaychain::new(network.relaychain().chain()); + // Addresses have to be read while the nodes are still up, but they are + // written after the artifacts are generated, so the bite bundle stays as it + // was and only the published one advertises this run's nodes. + let bootnodes = publish_bootnodes + .as_ref() + .map(|_| bootnodes::collect(&network)); let _ = network.destroy().await; let teardown_signal = fs::try_exists(&stop_file).await; @@ -130,9 +138,16 @@ async fn tear_down_and_generate( doppelganger::generate_artifacts(base_path.clone(), step, &rc) .await .expect("generate should works"); - doppelganger::clean_up_dir_for_step(base_path, step, &rc, &[]) + doppelganger::clean_up_dir_for_step(base_path.clone(), step, &rc, &[]) .await .expect("clean-up should works"); + + if let (Some(host), Some(chains)) = (publish_bootnodes, bootnodes) { + let spec_dir = base_path.join(step.dir()); + bootnodes::publish(&chains, &spec_dir, &host).await?; + } + } else if publish_bootnodes.is_some() { + warn!("--publish-bootnodes: no teardown signal, so no artifacts were generated to publish into"); } // signal that the teardown is completed @@ -169,6 +184,7 @@ async fn main() -> Result<(), anyhow::Error> { apply_upgrade, keep_messaging_state, para_cores, + publish_bootnodes, } => { if with_monitor && !and_spawn { bail!("--with-monitor can only be used with --and-spawn"); @@ -188,8 +204,12 @@ async fn main() -> Result<(), anyhow::Error> { apply_upgrade, keep_messaging_state, para_cores, + publish_bootnodes, )?; + if resolved_config.publish_bootnodes.is_some() && !resolved_config.and_spawn { + bail!("--publish-bootnodes can only be used with --and-spawn"); + } if resolved_config.apply_upgrade && !resolved_config.and_spawn { bail!("--apply-upgrade can only be used with --and-spawn"); } @@ -233,8 +253,14 @@ async fn main() -> Result<(), anyhow::Error> { post_spawn_loop(&stop_file, &network, true).await?; - tear_down_and_generate(&stop_file, step, network, resolved_config.base_path) - .await?; + tear_down_and_generate( + &stop_file, + step, + network, + resolved_config.base_path, + resolved_config.publish_bootnodes, + ) + .await?; } } Commands::Spawn { @@ -243,9 +269,15 @@ async fn main() -> Result<(), anyhow::Error> { with_monitor, step, apply_upgrade, + publish_bootnodes, } => { - let resolved_config = - resolve_spawn_config(config, base_path, with_monitor, apply_upgrade)?; + let resolved_config = resolve_spawn_config( + config, + base_path, + with_monitor, + apply_upgrade, + publish_bootnodes, + )?; let step: Step = step.into(); let base_path_str = resolved_config.base_path.to_string_lossy(); @@ -281,7 +313,14 @@ async fn main() -> Result<(), anyhow::Error> { post_spawn_loop(&stop_file, &network, resolved_config.with_monitor).await?; - tear_down_and_generate(&stop_file, step, network, resolved_config.base_path).await?; + tear_down_and_generate( + &stop_file, + step, + network, + resolved_config.base_path, + resolved_config.publish_bootnodes, + ) + .await?; } Commands::GenerateArtifacts { relay, From 2c4de8783cf198c31a958f2a126d614c78484e48 Mon Sep 17 00:00:00 2001 From: Maksym H Date: Wed, 26 Aug 2026 19:02:41 +0100 Subject: [PATCH 2/2] Pack a step's artifacts into one restorable bundle --- README.md | 11 +++ src/bundle.rs | 168 ++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 16 +++++ src/doppelganger.rs | 23 ++++++ src/main.rs | 16 +++++ 5 files changed, 234 insertions(+) create mode 100644 src/bundle.rs diff --git a/README.md b/README.md index fbc3d03..6fa1327 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,17 @@ Storage keys are derived from pallet and item names, and every value is decoded Metadata and the live values are read at the block being bitten (`--rc-bite-at` / a para's `bite_at`), so they match the state being imported. Parachains use a default public endpoint when no `rpc_endpoint` is configured; if it can't be reached, the bite still runs with a warning and those overrides go unverified. Custom parachains are only verified when their config supplies an `rpc_endpoint`. +#### One artifact, restored elsewhere + +`pack` puts everything a spawn needs into a single file — chain-specs, db snapshots, `config.toml`, the overrides that were applied, `manifest.json`, `ready.json` and any carried upgrade blob: + +```sh +zombie-bite pack -d /tmp/base_path -s bite # -> /tmp/base_path/bite-bundle.tgz +zombie-bite spawn -d /other/path --bundle bite-bundle.tgz +``` + +`spawn` re-points the spec and snapshot paths at wherever the bundle was unpacked, so the artifacts do not have to land in the directory they were produced in. + #### Publishing bootnodes A published chain-spec ships with `bootNodes: []` — that is what keeps a fork from dialing the network it was forked from, but it also means a node this process did not start has no way to find the fork. `--publish-bootnodes` fills the list with the fork's own nodes, in the artifacts generated at teardown (the `bite` bundle is left untouched): diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..7e73bdf --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,168 @@ +//! Pack a step's artifacts into one file and restore it elsewhere. +//! +//! A bite is often produced in CI and consumed hours later on another machine, +//! so everything needed to spawn has to travel together: the chain-specs, the +//! db snapshots, the config, the overrides that were applied, the manifest, and +//! any runtime carried as an authorized upgrade. `spawn` re-points the spec and +//! snapshot paths at wherever the bundle was unpacked, so the artifacts do not +//! have to land in the same directory they were produced in. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail}; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use tar::Archive; +use tokio::fs; +use tracing::info; + +use crate::{config::Step, manifest::MANIFEST_FILE}; + +/// Files that live in the base dir rather than the step dir, and are part of the +/// bundle when present. +const BASE_FILES: [&str; 3] = [MANIFEST_FILE, "ready.json", "ports.json"]; + +fn default_bundle_name(step: Step) -> String { + format!("{}-bundle.tgz", step.dir()) +} + +/// Pack `/` plus the base-level files into a single `.tgz`. +pub async fn pack( + base_path: &Path, + step: Step, + out: Option, +) -> Result { + let step_dir = base_path.join(step.dir()); + if !fs::try_exists(&step_dir).await? { + bail!( + "nothing to pack: {} does not exist", + step_dir.to_string_lossy() + ); + } + + let out = out.unwrap_or_else(|| base_path.join(default_bundle_name(step))); + let file = std::fs::File::create(&out) + .map_err(|e| anyhow!("can't create {}: {e}", out.to_string_lossy()))?; + let mut encoder = GzEncoder::new(file, Compression::fast()); + { + let mut archive = tar::Builder::new(&mut encoder); + // Paths inside the archive are relative to the base dir, so unpacking + // into any directory reproduces the same layout. + archive.append_dir_all(step.dir(), &step_dir)?; + for name in BASE_FILES { + let path = base_path.join(name); + if fs::try_exists(&path).await? { + archive.append_path_with_name(&path, name)?; + } + } + // Runtimes carried as an authorized upgrade. + let mut entries = fs::read_dir(base_path).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with("-upgrade.wasm") { + archive.append_path_with_name(entry.path(), &name)?; + } + } + archive.finish()?; + } + encoder.finish()?; + + info!("📦 bundle written to {}", out.to_string_lossy()); + Ok(out) +} + +/// Unpack a bundle into `base_path`. +pub async fn unpack(bundle: &Path, base_path: &Path) -> Result<(), anyhow::Error> { + if !fs::try_exists(bundle).await? { + bail!("bundle {} does not exist", bundle.to_string_lossy()); + } + fs::create_dir_all(base_path).await?; + + let file = std::fs::File::open(bundle) + .map_err(|e| anyhow!("can't open {}: {e}", bundle.to_string_lossy()))?; + Archive::new(GzDecoder::new(file)).unpack(base_path)?; + + info!( + "📦 bundle {} unpacked into {}", + bundle.to_string_lossy(), + base_path.to_string_lossy() + ); + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn pack_then_unpack_reproduces_the_layout() { + let root = std::env::temp_dir().join("zb-bundle-test"); + let (from, to) = (root.join("from"), root.join("to")); + let _ = fs::remove_dir_all(&root).await; + fs::create_dir_all(from.join("bite")).await.unwrap(); + fs::create_dir_all(&to).await.unwrap(); + + // step dir: spec, snapshot, config and the overrides that were applied + for (name, content) in [ + ("kusama-spec.json", "{}"), + ("kusama-snap.tgz", "snap"), + ("config.toml", "[relaychain]"), + ("rc_overrides.json", r#"{"overrides":{}}"#), + ] { + fs::write(from.join("bite").join(name), content) + .await + .unwrap(); + } + // base dir files + fs::write(from.join(MANIFEST_FILE), r#"{"created_at":1}"#) + .await + .unwrap(); + fs::write(from.join("ready.json"), r#"{"rc_start_block":7}"#) + .await + .unwrap(); + fs::write(from.join("kusama-upgrade.wasm"), "wasm") + .await + .unwrap(); + // not part of the bundle + fs::write(from.join("unrelated.log"), "noise") + .await + .unwrap(); + + let bundle = pack(&from, Step::Bite, None).await.unwrap(); + unpack(&bundle, &to).await.unwrap(); + + for name in [ + "kusama-spec.json", + "kusama-snap.tgz", + "config.toml", + "rc_overrides.json", + ] { + assert!( + fs::try_exists(to.join("bite").join(name)).await.unwrap(), + "missing {name}" + ); + } + assert_eq!( + fs::read_to_string(to.join("ready.json")).await.unwrap(), + r#"{"rc_start_block":7}"# + ); + assert!(fs::try_exists(to.join(MANIFEST_FILE)).await.unwrap()); + assert!(fs::try_exists(to.join("kusama-upgrade.wasm")) + .await + .unwrap()); + assert!(!fs::try_exists(to.join("unrelated.log")).await.unwrap()); + + fs::remove_dir_all(&root).await.unwrap(); + } + + #[tokio::test] + async fn packing_a_missing_step_dir_fails() { + let dir = std::env::temp_dir().join("zb-bundle-empty"); + let _ = fs::remove_dir_all(&dir).await; + fs::create_dir_all(&dir).await.unwrap(); + + let err = pack(&dir, Step::Bite, None).await.unwrap_err().to_string(); + assert!(err.contains("nothing to pack"), "got: {err}"); + + fs::remove_dir_all(&dir).await.unwrap(); + } +} diff --git a/src/cli.rs b/src/cli.rs index 2f318d3..89c355f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -117,6 +117,22 @@ pub enum Commands { /// works on the same host. #[arg(long, num_args = 0..=1, default_missing_value = "127.0.0.1", verbatim_doc_comment)] publish_bootnodes: Option, + /// Bundle produced by 'pack' to restore into the base path before + /// spawning, so a bite from another machine can be spawned here. + #[arg(long, verbatim_doc_comment)] + bundle: Option, + }, + /// Pack a step's artifacts (specs, snapshots, overrides, manifest) into a single file. + Pack { + /// Base path holding the artifacts. + #[arg(long, short = 'd', verbatim_doc_comment)] + base_path: Option, + /// Step to pack. + #[arg(short = 's', value_parser = clap::builder::PossibleValuesParser::new(["bite", "spawn", "post"]), default_value="bite")] + step: String, + /// Where to write the bundle. Defaults to '/-bundle.tgz'. + #[arg(long, short = 'o', verbatim_doc_comment)] + out: Option, }, /// [Helper] Generate artifacts to be used by the next step (only 'spawn' and 'post' allowed) GenerateArtifacts { diff --git a/src/doppelganger.rs b/src/doppelganger.rs index a8be6fc..f450c1e 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -697,6 +697,12 @@ pub async fn clean_up_dir_for_step( let mut needed_files: Vec = vec!["config.toml".to_string(), rc_spec.clone()]; + // The overrides that were applied are part of the bundle: without them a + // restored bite cannot show what was changed in the state it carries. + if step == Step::Bite { + needed_files.push("rc_overrides.json".to_string()); + } + // Add parachain files dynamically for para in paras { let para_chain_name = para.as_chain_string(&rc.as_chain_string()); @@ -704,6 +710,9 @@ pub async fn clean_up_dir_for_step( let para_snap = format!("{}-snap.tgz", para_chain_name); needed_files.push(para_spec); needed_files.push(para_snap); + if step == Step::Bite { + needed_files.push(format!("{}_overrides.json", para.id())); + } } if step == Step::Bite { @@ -712,6 +721,20 @@ pub async fn clean_up_dir_for_step( needed_files.push(alice_snap); } + // Overrides are only there when this step generated them; a missing + // spec or snapshot below is still a hard error. + let mut present = vec![]; + for file in needed_files { + if file.ends_with("_overrides.json") + && !fs::try_exists(format!("{debug_path}/{file}")).await? + { + warn!("{file} not found, it will not be part of the bundle"); + continue; + } + present.push(file); + } + let needed_files = present; + for file in &needed_files { let from = format!("{debug_path}/{file}"); let to = format!("{step_path}/{file}"); diff --git a/src/main.rs b/src/main.rs index 659b803..1e09c0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use tracing_subscriber::EnvFilter; use zombienet_sdk::{LocalFileSystem, Network, NetworkNode}; mod bootnodes; +mod bundle; mod cli; mod config; mod doppelganger; @@ -270,6 +271,7 @@ async fn main() -> Result<(), anyhow::Error> { step, apply_upgrade, publish_bootnodes, + bundle, } => { let resolved_config = resolve_spawn_config( config, @@ -279,6 +281,11 @@ async fn main() -> Result<(), anyhow::Error> { publish_bootnodes, )?; let step: Step = step.into(); + + if let Some(bundle) = bundle { + bundle::unpack(Path::new(&bundle), resolved_config.base_path.as_path()).await?; + } + let base_path_str = resolved_config.base_path.to_string_lossy(); if !fs::try_exists(format!("{base_path_str}/{}", step.dir_from())) @@ -322,6 +329,15 @@ async fn main() -> Result<(), anyhow::Error> { ) .await?; } + Commands::Pack { + base_path, + step, + out, + } => { + let base_path = get_base_path(base_path); + let step: Step = step.into(); + bundle::pack(&base_path, step, out.map(PathBuf::from)).await?; + } Commands::GenerateArtifacts { relay, base_path,