diff --git a/README.md b/README.md index c7cd541..51ce5c2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,35 @@ 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): + +```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. + #### Spawn Spawn a new instance of the _bited_ network with the following cmd: 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/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 4920822..89c355f 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,29 @@ 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, + /// 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 { @@ -167,6 +197,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 +206,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 +224,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 +407,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 +425,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 +462,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/doppelganger.rs b/src/doppelganger.rs index e6c6386..87b745e 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -40,6 +40,7 @@ use crate::utils::{ use crate::config::{ get_assigned_cores, get_state_pruning_config, BiteOptions, Context, Parachain, Relaychain, Step, }; +use crate::manifest::{self, ChainEntry, Manifest}; use crate::metadata::ChainMetadata; use crate::overrides::{generate_default_overrides_for_para, generate_default_overrides_for_rc}; use crate::sync::{sync_para, sync_relay_only}; @@ -56,6 +57,9 @@ struct ChainArtifact { chain: String, spec_path: String, snap_path: String, + /// Size of the snapshot, measured when it is written: later steps can move + /// it (ZOMBIE_BITE_CI_PATH) and then it can no longer be stat'ed here. + snap_bytes: Option, override_wasm: Option, para_id: Option, } @@ -206,6 +210,7 @@ pub async fn doppelganger_inner( let snap_path = format!("{}/{}-snap.tgz", base_dir_str, sync_chain_name); trace!("snap_path: {snap_path}"); generate_snap(&sync_db_path, &snap_path).await.unwrap(); + let snap_bytes = fs::metadata(&snap_path).await.ok().map(|m| m.len()); let para_head_str = read_to_string(&sync_head_path) .unwrap_or_else(|_| panic!("read para_head ({sync_head_path}) file should works.")); @@ -235,6 +240,7 @@ pub async fn doppelganger_inner( }, spec_path: chain_spec_path, snap_path, + snap_bytes, override_wasm: para.wasm_overrides().map(str::to_string), para_id: Some(para.id()), }); @@ -327,6 +333,7 @@ pub async fn doppelganger_inner( // generate the data.tgz to use as snapshot let r_snap_path = format!("{}/{}-snap.tgz", base_dir_str, sync_chain); generate_snap(&sync_db_path, &r_snap_path).await.unwrap(); + let r_snap_bytes = fs::metadata(&r_snap_path).await.ok().map(|m| m.len()); let relay_artifacts = ChainArtifact { // cmd: context_relay.doppelganger_cmd(), @@ -334,13 +341,14 @@ pub async fn doppelganger_inner( chain: sync_chain, spec_path: r_chain_spec_path, snap_path: r_snap_path, + snap_bytes: r_snap_bytes, override_wasm: relay_chain.wasm_overrides().map(str::to_string), para_id: None, }; let config = generate_config( - relay_artifacts, - para_artifacts, + relay_artifacts.clone(), + para_artifacts.clone(), Some(global_base_dir.clone()), database, req_cores, @@ -451,11 +459,80 @@ pub async fn doppelganger_inner( ) .await; + let manifest = build_manifest( + &relay_chain, + ¶s_to, + &ready_content, + &relay_artifacts, + ¶_artifacts, + ); + manifest.write(&global_base_dir).await?; + clean_up_dir_for_step(global_base_dir, Step::Bite, &relay_chain, ¶s_to).await?; Ok(()) } +fn build_manifest( + relay_chain: &Relaychain, + paras_to: &[Parachain], + ready: &serde_json::Value, + relay_artifacts: &ChainArtifact, + para_artifacts: &[ChainArtifact], +) -> Manifest { + let file_name = |path: &str| { + Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + }; + + let relay = ChainEntry { + chain: relay_chain.as_chain_string(), + para_id: None, + bite_block: ready["rc_start_block"].as_u64(), + source_rpc: ready["rc_source_rpc"].as_str().map(str::to_string), + spec_file: file_name(&relay_artifacts.spec_path), + snapshot_file: file_name(&relay_artifacts.snap_path), + snapshot_bytes: relay_artifacts.snap_bytes, + upgrade_file: ready["rc_upgrade_wasm"].as_str().map(str::to_string), + upgrade_hash: ready["rc_upgrade_hash"].as_str().map(str::to_string), + }; + + // para_artifacts is built in paras_to order, so zip keeps them aligned. + let parachains = paras_to + .iter() + .zip(para_artifacts) + .map(|(para, artifact)| { + let id = para.id(); + ChainEntry { + chain: para.as_chain_string(&relay_chain.as_chain_string()), + para_id: Some(id), + bite_block: ready[format!("para_{id}_start_block")].as_u64(), + source_rpc: ready[format!("para_{id}_source_rpc")] + .as_str() + .map(str::to_string), + spec_file: file_name(&artifact.spec_path), + snapshot_file: file_name(&artifact.snap_path), + snapshot_bytes: artifact.snap_bytes, + upgrade_file: ready[format!("para_{id}_upgrade_wasm")] + .as_str() + .map(str::to_string), + upgrade_hash: ready[format!("para_{id}_upgrade_hash")] + .as_str() + .map(str::to_string), + } + }) + .collect(); + + Manifest { + version: manifest::VERSION, + bundle: Step::Bite.dir(), + created_at: manifest::now_unix(), + relay, + parachains, + } +} + async fn copy_upgrade_blob(from: &str, base_dir: &str, blob_name: &str) -> (String, String) { let wasm = fs::read(from) .await @@ -618,6 +695,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()); @@ -625,6 +708,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 { @@ -633,6 +719,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}"); @@ -1195,6 +1295,7 @@ mod test { chain: "polkadot".into(), spec_path: relay_spec_path.into(), snap_path: relay_snap_path.into(), + snap_bytes: None, override_wasm: None, para_id: None, }; @@ -1203,6 +1304,7 @@ mod test { chain: "ah-polkadot".into(), spec_path: ah_spec_path.into(), snap_path: ah_snap_path.into(), + snap_bytes: None, override_wasm: None, para_id: Some(1000), }; diff --git a/src/main.rs b/src/main.rs index caf26a6..20119e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,12 @@ use tracing::{debug, info, level_filters::LevelFilter, trace, warn}; use tracing_subscriber::EnvFilter; use zombienet_sdk::{LocalFileSystem, Network, NetworkNode}; +mod bootnodes; +mod bundle; mod cli; mod config; mod doppelganger; +mod manifest; mod metadata; mod monit; mod overrides; @@ -119,8 +122,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; @@ -129,9 +139,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 @@ -168,6 +185,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"); @@ -187,8 +205,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"); } @@ -232,8 +254,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 { @@ -242,10 +270,22 @@ async fn main() -> Result<(), anyhow::Error> { with_monitor, step, apply_upgrade, + publish_bootnodes, + bundle, } => { - 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(); + + 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())) @@ -278,7 +318,23 @@ 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::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, diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..04a78ab --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,113 @@ +//! Bundle manifest. +//! +//! A bite is often produced in CI and restored hours later on another machine, +//! so the artifacts have to describe themselves: which block each chain was +//! bitten at, where the state came from, and what the files are. + +use std::{ + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use tokio::fs; +use tracing::info; + +pub const MANIFEST_FILE: &str = "manifest.json"; +/// Bumped when the shape changes, so an older bundle is reported as such +/// instead of silently failing to parse. +pub const VERSION: u32 = 1; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ChainEntry { + pub chain: String, + pub para_id: Option, + /// Block the state was captured at. + pub bite_block: Option, + /// Network the state came from. + pub source_rpc: Option, + pub spec_file: Option, + pub snapshot_file: Option, + pub snapshot_bytes: Option, + /// Runtime carried as an authorized upgrade, if any. + pub upgrade_file: Option, + pub upgrade_hash: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Manifest { + #[serde(default)] + pub version: u32, + /// Step directory this manifest describes (the bite bundle); later steps + /// repack the snapshots under different names. + #[serde(default)] + pub bundle: String, + pub created_at: u64, + pub relay: ChainEntry, + pub parachains: Vec, +} + +pub fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() +} + +impl Manifest { + pub async fn write(&self, base_path: &Path) -> Result<(), anyhow::Error> { + let path = base_path.join(MANIFEST_FILE); + fs::write(&path, serde_json::to_string_pretty(self)?).await?; + info!("📄 manifest written to {}", path.to_string_lossy()); + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn manifest_round_trips() { + let dir = std::env::temp_dir().join("zb-manifest-round-trip"); + fs::create_dir_all(&dir).await.unwrap(); + + let manifest = Manifest { + version: VERSION, + bundle: "bite".into(), + created_at: 1, + relay: ChainEntry { + chain: "kusama".into(), + bite_block: Some(42), + source_rpc: Some("wss://example".into()), + snapshot_bytes: Some(123), + ..Default::default() + }, + parachains: vec![ChainEntry { + chain: "asset-hub-kusama".into(), + para_id: Some(1000), + bite_block: Some(7), + ..Default::default() + }], + }; + manifest.write(&dir).await.unwrap(); + + let content = fs::read_to_string(dir.join(MANIFEST_FILE)).await.unwrap(); + let read: Manifest = serde_json::from_str(&content).unwrap(); + assert_eq!(read.version, VERSION); + assert_eq!(read.bundle, "bite"); + assert_eq!(read.relay.bite_block, Some(42)); + assert_eq!(read.parachains[0].para_id, Some(1000)); + + fs::remove_dir_all(&dir).await.unwrap(); + } + + #[tokio::test] + async fn missing_manifest_reads_as_none() { + let dir = std::env::temp_dir().join("zb-manifest-absent"); + fs::create_dir_all(&dir).await.unwrap(); + let _ = fs::remove_file(dir.join(MANIFEST_FILE)).await; + + assert!(fs::read_to_string(dir.join(MANIFEST_FILE)).await.is_err()); + } +}