From afe736857a4c2370eeba852d32a06390e2f34603 Mon Sep 17 00:00:00 2001 From: Maksym H Date: Wed, 26 Aug 2026 16:13:54 +0100 Subject: [PATCH 1/6] Verify overrides against chain metadata, patch live HostConfiguration --- README.md | 9 + src/cli.rs | 53 +++- src/config.rs | 24 +- src/doppelganger.rs | 38 ++- src/main.rs | 9 +- src/metadata.rs | 164 ++++++++++ src/overrides.rs | 733 ++++++++++++++++++++++++++------------------ 7 files changed, 716 insertions(+), 314 deletions(-) create mode 100644 src/metadata.rs diff --git a/README.md b/README.md index 23617e5..e0c28f5 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,15 @@ zombie-bite bite -r kusama --rc-upgrade ./kusama_runtime.wasm --and-spawn --appl zombie-bite spawn -d /tmp/base_path --apply-upgrade ``` +#### Cores and messaging state + +- `--para-cores =` overrides how many cores a parachain gets (defaults mirror the live networks, e.g. asset-hub takes 3 for elastic scaling). The relay's validator count follows the total. +- `--keep-messaging-state` keeps the inherited HRMP/DMP state instead of clearing it. Only correct when the relay's parachains are exactly the ones being bitten, so both snapshots agree on channel heads; on a shared relay the mismatch makes cumulus panic with `HRMP head mismatch`. + +#### Overrides are checked against the runtime + +Storage keys are derived from pallet and item names, items the runtime does not have are skipped, and a value that does not survive a decode/encode round-trip against its real on-chain type fails the bite instead of silently landing as something else. `HostConfiguration` is patched from the live value (only `num_cores` changes) rather than replaced, so executor params and async-backing settings of the bitten chain are preserved. A parachain needs an `rpc_endpoint` for its overrides to be verified this way. + #### Spawn Spawn a new instance of the _bited_ network with the following cmd: diff --git a/src/cli.rs b/src/cli.rs index 24816a5..f5405c1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,9 @@ use std::{ }; use tracing::{trace, warn}; -use crate::config::{Parachain, Relaychain, Upgrades, ZombieBiteConfig}; +use crate::config::{ + BiteOptions, CoresOverride, Parachain, Relaychain, Upgrades, ZombieBiteConfig, +}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -47,6 +49,15 @@ pub enum Commands { /// for every carried upgrade and wait until it enacts. #[arg(long, default_value_t = false, verbatim_doc_comment)] apply_upgrade: bool, + /// Keep the inherited HRMP/DMP state instead of clearing it. Only correct + /// when the relay's parachains are exactly the ones being bitten, so the + /// two snapshots agree on channel heads. + #[arg(long, default_value_t = false, verbatim_doc_comment)] + keep_messaging_state: bool, + /// Override the cores assigned to a parachain, format: = + /// Can be set multiple times, once per para. + #[arg(long = "para-cores", verbatim_doc_comment)] + para_cores: Vec, /// 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, @@ -154,8 +165,8 @@ pub struct ResolvedBiteConfig { pub parachains: Vec, pub base_path: PathBuf, pub and_spawn: bool, - pub upgrades: Upgrades, pub apply_upgrade: bool, + pub opts: BiteOptions, } #[derive(Debug)] @@ -178,6 +189,8 @@ pub fn resolve_bite_config( relay_upgrade: Option, para_upgrade: Vec, apply_upgrade: bool, + keep_messaging_state: bool, + para_cores: Vec, ) -> Result { // Load config file if provided let config_file = if let Some(path) = config_path { @@ -315,13 +328,47 @@ pub fn resolve_bite_config( false }; + // Per-para cores: CLI entries win over the config file's `cores`. + let mut cores: CoresOverride = CoresOverride::new(); + if let Some(ref config) = config_file { + for para_cfg in config.parachains.as_deref().unwrap_or_default() { + if let (Some(c), Some(para)) = (para_cfg.cores, para_cfg.to_parachain()) { + cores.insert(para.id(), c); + } + } + } + for entry in ¶_cores { + let (id, c) = entry.split_once('=').unwrap_or_else(|| { + panic!("--para-cores format must be =, got: {entry}") + }); + let id: u32 = id + .parse() + .unwrap_or_else(|_| panic!("Invalid para_id '{id}' in --para-cores")); + let c: u32 = c + .parse() + .unwrap_or_else(|_| panic!("Invalid cores '{c}' in --para-cores")); + cores.insert(id, c); + } + + let resolved_keep_messaging = if keep_messaging_state { + true + } else if let Some(ref config) = config_file { + config.keep_messaging_state.unwrap_or(false) + } else { + false + }; + Ok(ResolvedBiteConfig { relaychain, parachains: resolved_parachains, base_path: resolved_base_path, and_spawn: resolved_and_spawn, - upgrades, apply_upgrade: resolved_apply_upgrade, + opts: BiteOptions { + upgrades, + cores, + keep_messaging_state: resolved_keep_messaging, + }, }) } diff --git a/src/config.rs b/src/config.rs index f9f84e3..0c8d154 100644 --- a/src/config.rs +++ b/src/config.rs @@ -184,7 +184,22 @@ impl Upgrades { } } -pub fn get_assigned_cores(relay: &Relaychain, para: &Parachain) -> u32 { +/// Per-parachain core counts from configuration, keyed by para id. Overrides +/// the built-in defaults below, which mirror the live networks. +pub type CoresOverride = std::collections::HashMap; + +/// Everything a bite needs beyond the chains themselves. +#[derive(Debug, Default, Clone)] +pub struct BiteOptions { + pub upgrades: Upgrades, + pub cores: CoresOverride, + pub keep_messaging_state: bool, +} + +pub fn get_assigned_cores(relay: &Relaychain, para: &Parachain, override_: &CoresOverride) -> u32 { + if let Some(cores) = override_.get(¶.id()) { + return *cores; + } match para { Parachain::AssetHub { .. } => 3, Parachain::People { .. } => match relay { @@ -680,6 +695,11 @@ pub struct ZombieBiteConfig { pub and_spawn: Option, pub with_monitor: Option, pub apply_upgrade: Option, + /// Keep inherited HRMP/DMP state instead of clearing it. Correct when the + /// relay and parachain snapshots agree on channel heads (a relay whose only + /// 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, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -1174,6 +1194,7 @@ mod test { and_spawn: None, with_monitor: None, apply_upgrade: None, + keep_messaging_state: None, }; assert_eq!(config.get_parachains().len(), 0); @@ -1228,6 +1249,7 @@ mod test { and_spawn: None, with_monitor: None, apply_upgrade: None, + keep_messaging_state: None, }; let parachains = config.get_parachains(); diff --git a/src/doppelganger.rs b/src/doppelganger.rs index 281c92b..39cc7a9 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -21,7 +21,7 @@ use flate2::Compression; use tar::Builder; use tracing::debug; -use tracing::{info, trace}; +use tracing::{info, trace, warn}; use zombienet_configuration::shared::types::AssetLocation; use zombienet_configuration::NetworkConfigBuilder; use zombienet_orchestrator::network::Network; @@ -38,8 +38,9 @@ use crate::utils::{ }; use crate::config::{ - get_assigned_cores, get_state_pruning_config, Context, Parachain, Relaychain, Step, Upgrades, + get_assigned_cores, get_state_pruning_config, BiteOptions, Context, Parachain, Relaychain, Step, }; +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}; @@ -64,7 +65,7 @@ pub async fn doppelganger_inner( relay_chain: Relaychain, paras_to: Vec, database: &str, - upgrades: &Upgrades, + opts: &BiteOptions, ) -> Result<(), anyhow::Error> { // Star the node and wait until finish (with temp dir managed by us) info!( @@ -93,13 +94,25 @@ pub async fn doppelganger_inner( // Parachain sync let mut syncs = vec![]; for para in ¶s_to { + let para_meta = match para.rpc_endpoint() { + Some(url) => ChainMetadata::fetch(&format!("para {}", para.id()), url).await, + None => { + warn!( + "para {}: no 'rpc_endpoint' configured, overrides will not be verified against the runtime", + para.id() + ); + None + } + }; let para_default_overrides_path = generate_default_overrides_for_para( &base_dir_str, para, &relay_chain, - upgrades.paras.get(¶.id()).map(String::as_str), + opts.upgrades.paras.get(¶.id()).map(String::as_str), + para_meta.as_ref(), + opts.keep_messaging_state, ) - .await; + .await?; let info_path = format!("{base_dir_str}/para-{}.txt", para.id()); let maybe_target_header_path = if let Some(at_block) = para.at_block() { @@ -222,16 +235,21 @@ pub async fn doppelganger_inner( } let req_cores: u32 = paras_to.iter().fold(0u32, |acc, para| { - acc + get_assigned_cores(&relay_chain, para) + acc + get_assigned_cores(&relay_chain, para, &opts.cores) }); + let rc_meta = + ChainMetadata::fetch(&relay_chain.as_chain_string(), &relay_chain.rpc_endpoint()).await; let rc_default_overrides_path = generate_default_overrides_for_rc( &base_dir_str, &relay_chain, ¶s_to, req_cores, - upgrades.relay.as_deref(), + opts.upgrades.relay.as_deref(), + rc_meta.as_ref(), + &opts.cores, + opts.keep_messaging_state, ) - .await; + .await?; let rc_info_path = format!("{base_dir_str}/rc_info.txt"); // RELAYCHAIN sync @@ -368,14 +386,14 @@ pub async fn doppelganger_inner( // Carried upgrade blobs live next to ready.json (outside the step dirs, so // they survive clean-up) and must match the seeded System::AuthorizedUpgrade. let global_base_dir_str = global_base_dir.to_string_lossy(); - if let Some(upgrade_wasm) = &upgrades.relay { + if let Some(upgrade_wasm) = &opts.upgrades.relay { let blob_name = format!("{}-upgrade.wasm", relay_chain.as_chain_string()); let (blob, hash) = copy_upgrade_blob(upgrade_wasm, &global_base_dir_str, &blob_name).await; ready_content["rc_upgrade_wasm"] = json!(blob); ready_content["rc_upgrade_hash"] = json!(hash); } for para in ¶s_to { - if let Some(upgrade_wasm) = upgrades.paras.get(¶.id()) { + if let Some(upgrade_wasm) = opts.upgrades.paras.get(¶.id()) { let blob_name = format!( "{}-upgrade.wasm", para.as_chain_string(&relay_chain.as_chain_string()) diff --git a/src/main.rs b/src/main.rs index c93cef1..caf26a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkNode}; mod cli; mod config; mod doppelganger; +mod metadata; mod monit; mod overrides; mod sync; @@ -165,6 +166,8 @@ async fn main() -> Result<(), anyhow::Error> { relay_upgrade, para_upgrade, apply_upgrade, + keep_messaging_state, + para_cores, } => { if with_monitor && !and_spawn { bail!("--with-monitor can only be used with --and-spawn"); @@ -182,12 +185,14 @@ async fn main() -> Result<(), anyhow::Error> { relay_upgrade, para_upgrade, apply_upgrade, + keep_messaging_state, + para_cores, )?; if resolved_config.apply_upgrade && !resolved_config.and_spawn { bail!("--apply-upgrade can only be used with --and-spawn"); } - if resolved_config.apply_upgrade && resolved_config.upgrades.is_empty() { + if resolved_config.apply_upgrade && resolved_config.opts.upgrades.is_empty() { bail!("--apply-upgrade needs an upgrade to carry (--rc-upgrade / --para-upgrade)"); } @@ -197,7 +202,7 @@ async fn main() -> Result<(), anyhow::Error> { resolved_config.relaychain, resolved_config.parachains, &database, - &resolved_config.upgrades, + &resolved_config.opts, ) .await .expect("bite should work"); diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 0000000..974f7a9 --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,164 @@ +//! Metadata-checked storage overrides. +//! +//! Storage keys are derived from pallet/item names and every candidate value is +//! decoded against the item's real on-chain type, so a runtime that renames an +//! item, changes a type or reorders a struct fails the bite loudly instead of +//! producing a network that silently never builds blocks. + +use anyhow::{anyhow, bail}; +use tracing::{debug, warn}; +use zombienet_sdk::subxt::{ + ext::{ + scale_value, + subxt_rpcs::{client::RpcParams, RpcClient}, + }, + Metadata, OnlineClient, PolkadotConfig, +}; + +pub fn storage_key(pallet: &str, item: &str) -> String { + array_bytes::bytes2hex( + "", + substorager::storage_value_key(pallet.as_bytes(), item.as_bytes()), + ) +} + +pub struct ChainMetadata { + rpc: RpcClient, + metadata: Metadata, +} + +impl ChainMetadata { + /// Fetch metadata from the chain being bitten. Returns `None` (with a + /// warning) when the endpoint can't be reached, so a bite without network + /// access to the source falls back to unverified overrides rather than + /// failing outright. + pub async fn fetch(chain: &str, url: &str) -> Option { + match OnlineClient::::from_url(url).await { + Ok(client) => { + let metadata = client.metadata(); + let rpc = match RpcClient::from_url(url).await { + Ok(rpc) => rpc, + Err(e) => { + warn!("{chain}: can't open rpc client to {url}: {e}"); + return None; + } + }; + debug!("{chain}: metadata fetched from {url}"); + Some(Self { rpc, metadata }) + } + Err(e) => { + warn!("{chain}: can't fetch metadata from {url}, overrides will not be verified against the runtime: {e}"); + None + } + } + } + + /// Type id of the item's value, or `None` when the runtime has no such + /// pallet or item. + fn value_ty(&self, pallet: &str, item: &str) -> Option { + Some( + self.metadata + .pallet_by_name(pallet)? + .storage()? + .entry_by_name(item)? + .entry_type() + .value_ty(), + ) + } + + pub fn has_item(&self, pallet: &str, item: &str) -> bool { + self.value_ty(pallet, item).is_some() + } + + /// Require `value_hex` to decode against the item's on-chain type and + /// re-encode to the same bytes. Trailing bytes are an error too - that is + /// what a wrong length prefix looks like. + pub fn verify_value( + &self, + pallet: &str, + item: &str, + value_hex: &str, + ) -> Result<(), anyhow::Error> { + let ty = self + .value_ty(pallet, item) + .ok_or_else(|| anyhow!("{pallet}::{item} not in metadata"))?; + let bytes = hex::decode(value_hex) + .map_err(|e| anyhow!("{pallet}::{item}: value is not valid hex: {e}"))?; + + let mut cursor = &bytes[..]; + let value = scale_value::scale::decode_as_type(&mut cursor, ty, self.metadata.types()) + .map_err(|e| { + anyhow!("{pallet}::{item}: value does not decode as its on-chain type: {e}") + })?; + if !cursor.is_empty() { + bail!( + "{pallet}::{item}: {} trailing byte(s) after decoding, the value is malformed (wrong length prefix?)", + cursor.len() + ); + } + + let mut re_encoded = vec![]; + scale_value::scale::encode_as_type(&value, ty, self.metadata.types(), &mut re_encoded) + .map_err(|e| anyhow!("{pallet}::{item}: value does not re-encode: {e}"))?; + if re_encoded != bytes { + bail!( + "{pallet}::{item}: value is not byte-identical after a decode/encode round-trip (got 0x{}, expected 0x{value_hex})", + hex::encode(&re_encoded) + ); + } + Ok(()) + } + + /// Read a live storage value as hex (no `0x` prefix). + pub async fn storage_value(&self, key: &str) -> Result, anyhow::Error> { + let mut params = RpcParams::new(); + params.push(format!("0x{key}"))?; + let raw: Option = self.rpc.request("state_getStorage", params).await?; + Ok(raw.map(|v| v.trim_start_matches("0x").to_string())) + } + + /// Decode a live value, hand it to `patch`, and re-encode it. Only the + /// fields `patch` touches change - everything else the live runtime + /// configured is preserved byte for byte. + pub fn patch_value( + &self, + pallet: &str, + item: &str, + value_hex: &str, + patch: impl FnOnce(&mut scale_value::Value) -> Result<(), anyhow::Error>, + ) -> Result { + let ty = self + .value_ty(pallet, item) + .ok_or_else(|| anyhow!("{pallet}::{item} not in metadata"))?; + let bytes = hex::decode(value_hex)?; + let mut value = + scale_value::scale::decode_as_type(&mut &bytes[..], ty, self.metadata.types()) + .map_err(|e| anyhow!("{pallet}::{item}: live value does not decode: {e}"))?; + + patch(&mut value)?; + + let mut out = vec![]; + scale_value::scale::encode_as_type(&value, ty, self.metadata.types(), &mut out) + .map_err(|e| anyhow!("{pallet}::{item}: patched value does not re-encode: {e}"))?; + Ok(hex::encode(out)) + } +} + +/// Set a named field on a composite `Value`, keeping every other field as the +/// live runtime had it. +pub fn set_field( + value: &mut scale_value::Value, + field: &str, + to: scale_value::Value, +) -> Result<(), anyhow::Error> { + let scale_value::ValueDef::Composite(scale_value::Composite::Named(fields)) = &mut value.value + else { + bail!("expected a struct with named fields to set '{field}' on"); + }; + let entry = fields + .iter_mut() + .find(|(name, _)| name == field) + .ok_or_else(|| anyhow!("no field '{field}' in value"))?; + entry.1 = to; + Ok(()) +} diff --git a/src/overrides.rs b/src/overrides.rs index 39ed095..21e27f7 100644 --- a/src/overrides.rs +++ b/src/overrides.rs @@ -2,9 +2,11 @@ use codec::Encode; use serde_json::{json, Value}; use std::{env, path::PathBuf}; use tokio::fs; +use tracing::{info, warn}; use crate::{ - config::{get_assigned_cores, Parachain, Relaychain}, + config::{get_assigned_cores, CoresOverride, Parachain, Relaychain}, + metadata::{set_field, storage_key, ChainMetadata}, utils::{ generate_collator_key_from_seed, generate_collator_next_keys_injects, get_validator_keys, ParaId, ValidationCode, @@ -12,48 +14,145 @@ use crate::{ }; use zombienet_sdk::generators::core_assignment; +use zombienet_sdk::subxt::ext::scale_value::Value as ScaleValue; + +/// Storage overrides and injects for one chain, keyed by pallet and item name. +/// +/// Every entry is checked against the chain's own metadata when it is +/// available: an item the runtime does not have is skipped, and a value that +/// does not survive a decode/encode round-trip against its real on-chain type +/// fails the bite. +struct OverrideSet<'a> { + meta: Option<&'a ChainMetadata>, + overrides: Value, + injects: Value, + skipped: Vec, + errors: Vec, +} + +impl<'a> OverrideSet<'a> { + fn new(meta: Option<&'a ChainMetadata>) -> Self { + Self { + meta, + overrides: json!({}), + injects: json!({}), + skipped: vec![], + errors: vec![], + } + } + + /// `None` when the entry should be dropped (item absent, or value invalid). + fn checked_key(&mut self, pallet: &str, item: &str, value: &str) -> Option { + if let Some(meta) = self.meta { + if !meta.has_item(pallet, item) { + self.skipped.push(format!("{pallet}::{item}")); + return None; + } + if let Err(e) = meta.verify_value(pallet, item, value) { + self.errors.push(e.to_string()); + return None; + } + } + Some(storage_key(pallet, item)) + } + + fn set(&mut self, pallet: &str, item: &str, value: impl AsRef) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value) { + self.overrides[key] = json!(value); + } + } + + /// Map entry; `key_suffix` is the already-hashed map key. + fn set_map(&mut self, pallet: &str, item: &str, key_suffix: &str, value: impl AsRef) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value) { + self.overrides[format!("{key}{key_suffix}")] = json!(value); + } + } + + fn inject(&mut self, pallet: &str, item: &str, value: impl AsRef) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value) { + self.injects[key] = json!(value); + } + } + + fn inject_map(&mut self, pallet: &str, item: &str, key_suffix: &str, value: impl AsRef) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value) { + self.injects[format!("{key}{key_suffix}")] = json!(value); + } + } + + /// Well-known keys that are not pallet storage items (`:code`, + /// `:UsePreviousValidators:`), so there is no type to check them against. + fn set_raw(&mut self, key: &str, value: impl AsRef) { + self.overrides[key] = json!(value.as_ref()); + } + + fn inject_raw(&mut self, key: &str, value: impl AsRef) { + self.injects[key] = json!(value.as_ref()); + } + + fn finish(self, chain: &str) -> Result<(Value, Value), anyhow::Error> { + if !self.errors.is_empty() { + anyhow::bail!( + "{chain}: {} override(s) do not match the runtime:\n - {}", + self.errors.len(), + self.errors.join("\n - ") + ); + } + if !self.skipped.is_empty() { + info!( + "{chain}: skipped {} override(s) the runtime does not have: {}", + self.skipped.len(), + self.skipped.join(", ") + ); + } + Ok((self.overrides, self.injects)) + } +} /// Seed `System::AuthorizedUpgrade` with the blob's hash, the state a passed /// `authorize_upgrade(hash)` referendum leaves behind. The permissionless /// `apply_authorized_upgrade(blob)` can then enact the upgrade through the /// production path, which needs no sudo (usable on Kusama/Polkadot forks). -async fn inject_authorized_upgrade(injects: &mut Value, upgrade_wasm: &str) { +async fn inject_authorized_upgrade(set: &mut OverrideSet<'_>, upgrade_wasm: &str) { let wasm_content = fs::read(upgrade_wasm) .await .unwrap_or_else(|_| panic!("Error reading upgrade wasm from path {}", upgrade_wasm)); - let auth_key = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"System"[..], b"AuthorizedUpgrade"), - ); // CodeUpgradeAuthorization { code_hash, check_version: true } let value = format!( "{}01", hex::encode(subhasher::blake2_256(&wasm_content[..])) ); - injects[auth_key] = Value::String(value); + set.inject("System", "AuthorizedUpgrade", value); } -/// Generate the injects for Session.NextKeys storage overrides for validators -fn generate_next_keys_injects( - validator_keys: &[&crate::utils::ValidatorKeys], -) -> serde_json::Value { - let mut next_keys_injects = serde_json::json!({}); - for keys in validator_keys { - let stash_bytes = hex::decode(keys.stash).expect("stash should be valid hex"); - let stash_hash = array_bytes::bytes2hex("", &subhasher::twox64_concat(&stash_bytes)[..8]); - let inject_key = format!( - "cec5070d609dd3497f72bde07fc96ba04c014e6bf8b8c2c011e7290b85696bb3{}{}", - stash_hash, keys.stash - ); - next_keys_injects[inject_key] = serde_json::json!(keys.session_keys_encoded()); +/// Patch `num_cores` into the live `HostConfiguration`, leaving every other +/// field the production chain configured (executor params, async backing, +/// max_pov_size) untouched. Falls back to a per-relay blob when the live value +/// is unavailable, which loses those fields - hence the warning. +async fn host_config( + relay: &Relaychain, + num_cores: u32, + meta: Option<&ChainMetadata>, +) -> Result { + if let Some(meta) = meta { + let key = storage_key("Configuration", "ActiveConfig"); + if let Some(live) = meta.storage_value(&key).await? { + let patched = patch_num_cores(meta, &live, num_cores)?; + info!( + "Configuration::ActiveConfig patched from the live value (num_cores -> {num_cores})" + ); + return Ok(patched); + } + warn!("Configuration::ActiveConfig not readable from the source, falling back to the built-in host config (executor params and async backing settings of the live chain are lost)"); } - next_keys_injects -} -// Build HostConfig per Relay -fn host_config(relay: &Relaychain, num_cores: u32) -> String { let cores = array_bytes::bytes2hex("", num_cores.encode()); - match relay { + Ok(match relay { Relaychain::Westend { .. } => { format!("00003000005000005555150000008000fbff0100000200000a000000c80000006400000006000000020000000000a00000c800000a00000000c0220fca950300000000000000000000c0220fca9503000000000000000000e8030000009001000a000000009001000c01002000000600c4090000000000000601983a0000000000008070000001c800000006000000580200000200000028000000000000000200000001000000020000000f00000002000000100a010000000a00000005000000010500000005000000{}1027000080b2e60e80c3c9018096980000000000000000000000000000000000", cores) } @@ -66,10 +165,60 @@ fn host_config(relay: &Relaychain, num_cores: u32) -> String { Relaychain::Paseo { .. } => { format!("e067350000800000aaaa020000001000fbff0000100000000a0000003c0000003c00000003000000020000000000a00000c800001e0000000000000000000000000000000000000000000000000000000000000000000000e8030000009001001e000000009001000c01002000000600c4090000000000000601983a000000000000b00400000006000000640000000200000019000000000000000200000002000000020000000500000001000000100b010000000a00000004000000010300000005000000{}6400000080b2e60e80c3c9018096980000000000000000000000000000000000", cores) } + }) +} + +/// `num_cores` lives in `scheduler_params` on current runtimes and at the top +/// level on older ones. +fn patch_num_cores( + meta: &ChainMetadata, + live: &str, + num_cores: u32, +) -> Result { + meta.patch_value("Configuration", "ActiveConfig", live, |value| { + // context is only used for decode diagnostics, encoding ignores it + let cores = ScaleValue::u128(num_cores as u128).map_context(|_| 0_u32); + if let Some(params) = nested_mut(value, "scheduler_params") { + set_field(params, "num_cores", cores) + } else { + set_field(value, "num_cores", cores) + } + }) +} + +fn nested_mut<'v>(value: &'v mut ScaleValue, field: &str) -> Option<&'v mut ScaleValue> { + use zombienet_sdk::subxt::ext::scale_value::{Composite, ValueDef}; + let ValueDef::Composite(Composite::Named(fields)) = &mut value.value else { + return None; + }; + fields + .iter_mut() + .find(|(name, _)| name == field) + .map(|(_, v)| v) +} + +/// Generate the injects for Session.NextKeys storage overrides for validators +fn generate_next_keys_injects( + set: &mut OverrideSet<'_>, + validator_keys: &[&crate::utils::ValidatorKeys], +) { + for keys in validator_keys { + let stash_bytes = hex::decode(keys.stash).expect("stash should be valid hex"); + let stash_hash = array_bytes::bytes2hex("", &subhasher::twox64_concat(&stash_bytes)[..8]); + set.inject_map( + "Session", + "NextKeys", + &format!("{stash_hash}{}", keys.stash), + keys.session_keys_encoded(), + ); } } + /// Generate the storage overrides for relay chain validators -pub fn generate_rc_overrides(validator_keys: &[&crate::utils::ValidatorKeys]) -> serde_json::Value { +fn generate_rc_overrides( + set: &mut OverrideSet<'_>, + validator_keys: &[&crate::utils::ValidatorKeys], +) { let num_validators = validator_keys.len(); // Build stash list for validators (concatenated hex) @@ -128,56 +277,72 @@ pub fn generate_rc_overrides(validator_keys: &[&crate::utils::ValidatorKeys]) -> // Format validator count as compact encoded let validator_count_hex = format!("{:02x}", num_validators * 4); // *4 because we encode each as 4 bytes - // Build base overrides object - let overrides = json!({ - // Validator Validators (dynamic list) - "7d9fe37370ac390779f35763d98106e888dcde934c658227ee1dfafcd6e16903": format!("{}{}", validator_count_hex, stash_list), - // Session Validators (dynamic list) - "cec5070d609dd3497f72bde07fc96ba088dcde934c658227ee1dfafcd6e16903": format!("{}{}", validator_count_hex, stash_list), - // Session QueuedKeys (dynamic list) - "cec5070d609dd3497f72bde07fc96ba0e0cdd062e6eaf24295ad4ccfc41d4609": format!("{}{}", validator_count_hex, queued_keys), - // Babe Authorities (dynamic list) - "1cb6f36e027abb2091cfb5110ab5087f5e0621c4869aa60c02be9adcc98a0d1d": format!("{}{}", validator_count_hex, babe_authorities), - // Babe NextAuthorities (dynamic list) - "1cb6f36e027abb2091cfb5110ab5087faacf00b9b41fda7a9268821c2a2b3e4c": format!("{}{}", validator_count_hex, babe_authorities), - // Grandpa Authorities (dynamic list) - "5f9cc45b7a00c5899361e1c6099678dc5e0621c4869aa60c02be9adcc98a0d1d": format!("{}{}", validator_count_hex, grandpa_authorities), - // Staking Invulnerables (dynamic list) - "5f3e4907f716ac89b6347d15ececedca5579297f4dfb9609e7e4c2ebab9ce40a": format!("{}{}", validator_count_hex, stash_list), - // paraScheduler validatorGroup (dynamic groups based on validator count) - "94eadf0156a8ad5156507773d0471e4a16973e1142f5bd30d9464076794007db": validator_groups, - // paraShared activeValidatorIndices (dynamic) - "b341e3a63e58a188839b242d17f8c9f82586833f834350b4d435d5fd269ecc8b": format!("{}{}", validator_count_hex, validator_indices), - // paraShared activeValidatorKeys (dynamic) - "b341e3a63e58a188839b242d17f8c9f87a50c904b368210021127f9238883a6e": format!("{}{}", validator_count_hex, para_validator_keys), - // authorityDiscovery keys (dynamic) - "2099d7f109d6e535fb000bba623fd4409f99a2ce711f3a31b2fc05604c93f179": format!("{}{}", validator_count_hex, authority_discovery_keys), - // authorityDiscovery nextKeys (dynamic) - "2099d7f109d6e535fb000bba623fd4404c014e6bf8b8c2c011e7290b85696bb3": format!("{}{}", validator_count_hex, authority_discovery_keys), - // Sudo Key (Alice) - "5c0d1176a568c1f92944340dbfed9e9c530ebca703c85910e7164cb7d1c9e47b": "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", - }); - - overrides + let stashes = format!("{validator_count_hex}{stash_list}"); + // Only present on chains using the validator-set pallet. + set.set("ValidatorSet", "Validators", &stashes); + set.set("Session", "Validators", &stashes); + set.set("Staking", "Invulnerables", &stashes); + set.set( + "Session", + "QueuedKeys", + format!("{validator_count_hex}{queued_keys}"), + ); + set.set( + "Babe", + "Authorities", + format!("{validator_count_hex}{babe_authorities}"), + ); + set.set( + "Babe", + "NextAuthorities", + format!("{validator_count_hex}{babe_authorities}"), + ); + set.set( + "Grandpa", + "Authorities", + format!("{validator_count_hex}{grandpa_authorities}"), + ); + set.set("ParaScheduler", "ValidatorGroups", &validator_groups); + set.set( + "ParasShared", + "ActiveValidatorIndices", + format!("{validator_count_hex}{validator_indices}"), + ); + set.set( + "ParasShared", + "ActiveValidatorKeys", + format!("{validator_count_hex}{para_validator_keys}"), + ); + set.set( + "AuthorityDiscovery", + "Keys", + format!("{validator_count_hex}{authority_discovery_keys}"), + ); + set.set( + "AuthorityDiscovery", + "NextKeys", + format!("{validator_count_hex}{authority_discovery_keys}"), + ); + set.set( + "Sudo", + "Key", + "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + ); } -fn augment_overrides_for_paras(relay: &Relaychain, paras: &[&Parachain], overrides: &mut Value) { +fn augment_overrides_for_paras( + set: &mut OverrideSet<'_>, + relay: &Relaychain, + paras: &[&Parachain], + cores_override: &CoresOverride, + keep_messaging_state: bool, +) { // Generate paras_parachains let para_ids: Vec = paras.iter().map(|para| para.id()).collect(); - let paras_parachains = generate_paras_parachains_value(para_ids); - - // paras parachains (dynamic based on first parachain) - overrides["cd710b30bd2eab0352ddcc26417aa1940b76934f4cc08dee01012d059e1b83ee"] = - json!(paras_parachains); - - // Add DMP and HRMP storage keys for each parachain - let dmp_dmqh_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Dmp"[..], b"DownwardMessageQueueHeads"), - ); - let hrmp_hici_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Hrmp"[..], b"HrmpIngressChannelsIndex"), + set.set( + "Paras", + "Parachains", + generate_paras_parachains_value(para_ids), ); // used to assign cores @@ -191,80 +356,96 @@ fn augment_overrides_for_paras(relay: &Relaychain, paras: &[&Parachain], overrid let para_hex = array_bytes::bytes2hex("", para_id.encode()); let para_key_part = format!("{para_twox64}{para_hex}"); - // DMP downwardMessageQueueHeads (empty for each para) - let dmp_queue_key = format!("{dmp_dmqh_prefix}{para_key_part}"); - overrides[dmp_queue_key] = - json!("0000000000000000000000000000000000000000000000000000000000000000"); - - // HRMP hrmpIngressChannelsIndex (empty for each para) - let hrmp_channels_key = format!("{hrmp_hici_prefix}{para_key_part}"); - overrides[hrmp_channels_key] = json!("00"); + if keep_messaging_state { + // The relay and parachain snapshots agree on channel heads (a + // self-owned relay), so inherited HRMP/DMP channels stay usable. + } else { + set.set_map( + "Dmp", + "DownwardMessageQueueHeads", + ¶_key_part, + "0000000000000000000000000000000000000000000000000000000000000000", + ); + set.set_map("Hrmp", "HrmpIngressChannelsIndex", ¶_key_part, "00"); + } // ParaScheduler - let para_cores = get_assigned_cores(relay, para); + let para_cores = get_assigned_cores(relay, para, cores_override); for _ in 0..para_cores { para_scheduler_value_parts.push(core_assignment::generate(core_index, para.id())); core_index += 1; } - - let count_prefix = format!("{:02x}", para_scheduler_value_parts.len() * 4); - let core_assign_value = format!("{count_prefix}{}", para_scheduler_value_parts.join("")); - // key is generated with prefix (`0x`) - let scheduler_key = core_assignment::get_parascheduler_storage_key(); - overrides[&scheduler_key[2..]] = json!(core_assign_value); } + + let count_prefix = format!("{:02x}", para_scheduler_value_parts.len() * 4); + let core_assign_value = format!("{count_prefix}{}", para_scheduler_value_parts.join("")); + // key is generated with prefix (`0x`), and the item is not in metadata on + // every runtime, so it goes in raw. + let scheduler_key = core_assignment::get_parascheduler_storage_key(); + set.set_raw(&scheduler_key[2..], core_assign_value); } +#[allow(clippy::too_many_arguments)] pub async fn generate_default_overrides_for_rc( base_dir: &str, relay: &Relaychain, paras: &Vec, req_cores: u32, maybe_upgrade: Option<&str>, -) -> PathBuf { + meta: Option<&ChainMetadata>, + cores_override: &CoresOverride, + keep_messaging_state: bool, +) -> Result { let num_validators = crate::config::num_validators_for_cores(req_cores); let validator_keys = get_validator_keys(num_validators as usize); - let next_keys_injects = generate_next_keys_injects(&validator_keys); + let mut set = OverrideSet::new(meta); - // Generate the rc overrides with parachains - let paras_refs: Vec<&Parachain> = paras.iter().collect(); - let mut overrides = generate_rc_overrides(&validator_keys); + generate_rc_overrides(&mut set, &validator_keys); - // add the paras related keys to override to _overrides_ json - augment_overrides_for_paras(relay, ¶s_refs, &mut overrides); + // add the paras related keys to override + let paras_refs: Vec<&Parachain> = paras.iter().collect(); + augment_overrides_for_paras( + &mut set, + relay, + ¶s_refs, + cores_override, + keep_messaging_state, + ); - // Override Configuration activeConfig - overrides["06de3d8a54d27e44a9d5ce189618f22db4b49d95320d9021994c850f25b8e385"] = - json!(host_config(relay, req_cores)); + set.set( + "Configuration", + "ActiveConfig", + host_config(relay, req_cores, meta).await?, + ); - // Keys to inject (mostly storage maps that are not present in the current state) - // < Item> - let mut injects = next_keys_injects; + generate_next_keys_injects(&mut set, &validator_keys); // set `UsePreviousValidators` to true to keep using the same validator set. - injects["c57d82d01f0fc18afc048ca20ac460dd"] = json!("01"); + set.inject_raw("c57d82d01f0fc18afc048ca20ac460dd", "01"); // RcMigrator Manager (set //Alice by default) - injects["2185d18cb42ae97242af0e70e6ad689012fcd13ee43ae32cc87f798eb5ed3295"] = - json!("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"); + set.inject( + "RcMigrator", + "Manager", + "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + ); // update the overrides / injects map to use IFF the key is provided if let Ok(sudo_key) = env::var("ZOMBIE_SUDO") { - // Sudo Key - overrides["5c0d1176a568c1f92944340dbfed9e9c530ebca703c85910e7164cb7d1c9e47b"] = - Value::String(sudo_key.clone()); - - // RcMigrator Manager - injects["2185d18cb42ae97242af0e70e6ad689012fcd13ee43ae32cc87f798eb5ed3295"] = - Value::String(sudo_key); + set.set("Sudo", "Key", &sudo_key); + set.inject("RcMigrator", "Manager", &sudo_key); } if let Some(override_wasm) = relay.wasm_overrides() { let wasm_content = fs::read(override_wasm) .await .unwrap_or_else(|_| panic!("Error reading override_wasm from path {}", override_wasm)); - overrides["3a636f6465"] = Value::String(hex::encode(wasm_content)); + set.set_raw("3a636f6465", hex::encode(wasm_content)); + } + + if let Some(upgrade_wasm) = maybe_upgrade { + inject_authorized_upgrade(&mut set, upgrade_wasm).await; } // also check if any parachain includes a wasm override but we can't doit in the @@ -275,42 +456,24 @@ pub async fn generate_default_overrides_for_rc( panic!("Error reading override_wasm from path {}", override_wasm) }); let code_hash = hex::encode(subhasher::blake2_256(&wasm_content[..])); - - // we should now override let para_id_map_key = crate::utils::para_id_for_map_hash(para.id()); - // Paras.CurrentCodeHash(paraId) - let current_code_hash_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Paras"[..], b"CurrentCodeHash"), - ); - overrides[&format!("{current_code_hash_prefix}{para_id_map_key}")] = - Value::String(code_hash.clone()); - // Paras.CodeByHash (should be injected since is have a reference to hash of the code itself) - let code_by_hash_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Paras"[..], b"CodeByHash"), - ); + set.set_map("Paras", "CurrentCodeHash", ¶_id_map_key, &code_hash); + + // CodeByHash / CodeByHashRefs are injected since the map key is the + // hash of the code itself, so they are never in the imported state. let validation_code: ValidationCode = ValidationCode(wasm_content); - let validation_code_encoded = validation_code.encode(); - injects[&format!("{code_by_hash_prefix}{code_hash}")] = - Value::String(hex::encode(validation_code_encoded)); - - // Paras.CodeByHashRefs (should be injected since is have a reference to hash of the code itself) - let code_by_hash_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Paras"[..], b"CodeByHashRefs"), + set.inject_map( + "Paras", + "CodeByHash", + &code_hash, + hex::encode(validation_code.encode()), ); - // hardcoded to 1 encoded - injects[&format!("{code_by_hash_prefix}{code_hash}")] = - Value::String("01000000".into()); + set.inject_map("Paras", "CodeByHashRefs", &code_hash, "01000000"); } } - if let Some(upgrade_wasm) = maybe_upgrade { - inject_authorized_upgrade(&mut injects, upgrade_wasm).await; - } - + let (overrides, injects) = set.finish(&relay.as_chain_string())?; let full_content = json!({ "overrides": overrides, "injects": injects @@ -321,7 +484,7 @@ pub async fn generate_default_overrides_for_rc( fs::write(&file_path, contents) .await .expect("write file should works."); - file_path + Ok(file_path) } pub async fn generate_default_overrides_for_para( @@ -329,7 +492,9 @@ pub async fn generate_default_overrides_for_para( para: &Parachain, relay: &Relaychain, maybe_upgrade: Option<&str>, -) -> PathBuf { + meta: Option<&ChainMetadata>, + keep_messaging_state: bool, +) -> Result { // For AH determine key type based on relay chain: ed25519 for Polkadot, sr25519 for others let key_type = match (relay, para) { (Relaychain::Polkadot { .. }, Parachain::AssetHub { .. }) => "ed", @@ -340,41 +505,59 @@ pub async fn generate_default_overrides_for_para( let seed = format!("Collator-{}", para.id()); let key_to_use = generate_collator_key_from_seed(&seed, key_type); - // Generate the injects using the helper function - let mut injects = generate_collator_next_keys_injects(&key_to_use); + let mut set = OverrideSet::new(meta); - if let Some(upgrade_wasm) = maybe_upgrade { - inject_authorized_upgrade(&mut injects, upgrade_wasm).await; + set.set("Session", "Validators", format!("04{key_to_use}")); + set.set( + "Session", + "QueuedKeys", + format!("04{key_to_use}{key_to_use}"), + ); + set.set( + "CollatorSelection", + "Invulnerables", + format!("04{key_to_use}"), + ); + set.set("Aura", "Authorities", format!("04{key_to_use}")); + set.set("AuraExt", "Authorities", format!("04{key_to_use}")); + set.set("CollatorSelection", "DesiredCandidates", "01000000"); + set.set( + "Sudo", + "Key", + "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + ); + + if !keep_messaging_state { + set.set( + "ParachainSystem", + "LastDmqMqcHead", + "0000000000000000000000000000000000000000000000000000000000000000", + ); } - // - // e.g Validator Validators - let mut overrides = json!({ - // Session Validators - "cec5070d609dd3497f72bde07fc96ba088dcde934c658227ee1dfafcd6e16903": &format!("04{key_to_use}"), - // Session QueuedKeys - "cec5070d609dd3497f72bde07fc96ba0e0cdd062e6eaf24295ad4ccfc41d4609": &format!("04{key_to_use}{key_to_use}"), - // CollatorSelection Invulnerables (collator) - "15464cac3378d46f113cd5b7a4d71c845579297f4dfb9609e7e4c2ebab9ce40a": &format!("04{key_to_use}"), - // Aura authorities - "57f8dc2f5ab09467896f47300f0424385e0621c4869aa60c02be9adcc98a0d1d": &format!("04{key_to_use}"), - // AuraExt authorities - "3c311d57d4daf52904616cf69648081e5e0621c4869aa60c02be9adcc98a0d1d": &format!("04{key_to_use}"), - // parachainSystem lastDmqMqcHead (emtpy) - "45323df7cc47150b3930e2666b0aa313911a5dd3f1155f5b7d0c5aa102a757f9": "0000000000000000000000000000000000000000000000000000000000000000", - // CollatorSelection DesiredCandidates (set to 1) - "15464cac3378d46f113cd5b7a4d71c84476f594316a7dfe49c1f352d95abdaf1": "01000000", - // Sudo Key (Alice) - "5c0d1176a568c1f92944340dbfed9e9c530ebca703c85910e7164cb7d1c9e47b": "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", - }); + // Session.NextKeys for the collator + for (key, value) in generate_collator_next_keys_injects(&key_to_use) + .as_object() + .expect("collator injects should be a map") + { + set.inject_raw( + key, + value.as_str().expect("collator inject should be a string"), + ); + } if let Some(override_wasm) = para.wasm_overrides() { let wasm_content = fs::read(override_wasm) .await .unwrap_or_else(|_| panic!("Error reading override_wasm from path {}", override_wasm)); - overrides["3a636f6465"] = Value::String(hex::encode(wasm_content)); + set.set_raw("3a636f6465", hex::encode(wasm_content)); + } + + if let Some(upgrade_wasm) = maybe_upgrade { + inject_authorized_upgrade(&mut set, upgrade_wasm).await; } + let (overrides, injects) = set.finish(&format!("para {}", para.id()))?; let full_content = json!({ "overrides": overrides, "injects": injects @@ -385,7 +568,7 @@ pub async fn generate_default_overrides_for_para( fs::write(&file_path, contents) .await .expect("write file should works."); - file_path + Ok(file_path) } fn generate_paras_parachains_value(ids: impl Into>) -> String { @@ -404,30 +587,6 @@ mod test { use super::*; - #[test] - fn genesis_slot_encode_u64() { - let prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Babe"[..], b"GenesisSlot"), - ); - let a = 295769115_u64; - let a_encoded = a.encode(); - println!("{}: {}", prefix, array_bytes::bytes2hex("", a_encoded)); - } - - #[test] - fn encode_u32() { - let a = 100_u32; - let a_encoded = a.encode(); - println!("encoded: {}", array_bytes::bytes2hex("", a_encoded)); - } - - #[test] - fn validator_groups_count_hex() { - let validator_groups_count_hex = format!("{:02x}", 3 * 4); - println!("c: {validator_groups_count_hex}"); - } - #[test] fn generate_paras_parachains_value_works() { let value = generate_paras_parachains_value([1000_u32]); @@ -444,14 +603,19 @@ mod test { ¶s, 2, None, + None, + &CoresOverride::new(), + false, ) - .await; + .await + .unwrap(); } #[test] fn test_generate_next_keys_injects() { let validator_keys = get_validator_keys(2); - let next_keys_injects = generate_next_keys_injects(&validator_keys); + let mut set = OverrideSet::new(None); + generate_next_keys_injects(&mut set, &validator_keys); let expected = json!({ // Session NextKeys (alice) @@ -460,29 +624,7 @@ mod test { "cec5070d609dd3497f72bde07fc96ba04c014e6bf8b8c2c011e7290b85696bb30e5be00fbc2e15b5fe65717dad0447d715f660a0a58411de509b42e6efb8375f562f58a554d5860e": "d17c2d7823ebf260fd138f2d7e27d114c0145d968b5ff5006125f2414fadae698eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a488eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a488eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a488eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a480390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27", }); - assert_eq!(next_keys_injects, expected); - } - - #[tokio::test] - async fn alive_and_bob_inject_keys() { - // Just 2 is alice and bob - let validator_keys = get_validator_keys(2); - let mut next_keys_injects = generate_next_keys_injects(&validator_keys); - - // RcMigrator Manager (set //Alice by default) - next_keys_injects["2185d18cb42ae97242af0e70e6ad689012fcd13ee43ae32cc87f798eb5ed3295"] = - json!("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"); - - let expected = json!({ - // Session NextKeys (alice) - "cec5070d609dd3497f72bde07fc96ba04c014e6bf8b8c2c011e7290b85696bb3e535263148daaf49be5ddb1579b72e84524fc29e78609e3caf42e85aa118ebfe0b0ad404b5bdd25f": "88dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0eed43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27dd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27dd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27dd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d020a1091341fe5664bfa1782d5e04779689068c916b04cb365ec3153755684d9a1", - // Session NextKeys (bob) - "cec5070d609dd3497f72bde07fc96ba04c014e6bf8b8c2c011e7290b85696bb30e5be00fbc2e15b5fe65717dad0447d715f660a0a58411de509b42e6efb8375f562f58a554d5860e": "d17c2d7823ebf260fd138f2d7e27d114c0145d968b5ff5006125f2414fadae698eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a488eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a488eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a488eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a480390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27", - // RcMigrator Manager (set //Alice by default) see: https://github.com/polkadot-fellows/runtimes/blob/22116f7d02c220db4f7187c6967dbd6bf89274cf/pallets/rc-migrator/src/lib.rs#L702-L707 - "2185d18cb42ae97242af0e70e6ad689012fcd13ee43ae32cc87f798eb5ed3295": "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" - }); - - assert_eq!(next_keys_injects, expected); + assert_eq!(set.injects, expected); } #[test] @@ -491,11 +633,14 @@ mod test { let validator_keys = get_validator_keys(2); let para = crate::config::Parachain::new("asset-hub"); let paras = vec![¶]; - let mut overrides = generate_rc_overrides(&validator_keys); let rc = Relaychain::new("polkadot"); - augment_overrides_for_paras(&rc, ¶s, &mut overrides); - // Validator Validators + let mut set = OverrideSet::new(None); + generate_rc_overrides(&mut set, &validator_keys); + augment_overrides_for_paras(&mut set, &rc, ¶s, &CoresOverride::new(), false); + let overrides = set.overrides; + + // ValidatorSet Validators assert_eq!( overrides["7d9fe37370ac390779f35763d98106e888dcde934c658227ee1dfafcd6e16903"], "08be5ddb1579b72e84524fc29e78609e3caf42e85aa118ebfe0b0ad404b5bdd25ffe65717dad0447d715f660a0a58411de509b42e6efb8375f562f58a554d5860e" @@ -537,10 +682,6 @@ mod test { overrides["94eadf0156a8ad5156507773d0471e4a16973e1142f5bd30d9464076794007db"], array_bytes::bytes2hex("", expected_groups.encode()) ); - assert_eq!( - overrides["94eadf0156a8ad5156507773d0471e4a16973e1142f5bd30d9464076794007db"], - "0804000000000401000000" - ); // Para Id Parachains assert_eq!( @@ -559,94 +700,90 @@ mod test { overrides["5c0d1176a568c1f92944340dbfed9e9c530ebca703c85910e7164cb7d1c9e47b"], "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" ); - } - #[tokio::test] - async fn inject_authorized_upgrade_seeds_hash_and_check_version() { - let wasm_path = "/tmp/zombie-bite-test-upgrade.wasm"; - let wasm = b"not-a-real-runtime"; - tokio::fs::write(wasm_path, wasm).await.unwrap(); - - let mut injects = json!({}); - inject_authorized_upgrade(&mut injects, wasm_path).await; - - let key = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"System"[..], b"AuthorizedUpgrade"), + // Dmp / Hrmp cleared for the para + let para_id = ParaId(1000); + let para_key = format!( + "{}{}", + array_bytes::bytes2hex("", subhasher::twox64(para_id.encode())), + array_bytes::bytes2hex("", para_id.encode()) ); - let expected = format!("{}01", hex::encode(subhasher::blake2_256(&wasm[..]))); - assert_eq!(injects[key], json!(expected)); - } - - #[test] - fn encode_dmq() { - let dmp_dmqh_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Dmp"[..], b"DownwardMessageQueueHeads"), + assert_eq!( + overrides[format!( + "{}{para_key}", + storage_key("Hrmp", "HrmpIngressChannelsIndex") + )], + "00" ); - - println!("Dmp_DownwardMessageQueueHeads {dmp_dmqh_prefix}"); - - let para_id = ParaId(1005); - - let para_twox64 = array_bytes::bytes2hex("", subhasher::twox64(para_id.encode())); - let para_hex = array_bytes::bytes2hex("", para_id.encode()); - - println!("Dmp_DownwardMessageQueueHeads_1005 {dmp_dmqh_prefix}{para_twox64}{para_hex}"); } #[test] - fn encode_two_128() { - let name = array_bytes::bytes2hex("", subhasher::twox128(b":UsePreviousValidators:")); - println!(":UsePreviousValidators: : {name}"); - } + fn keep_messaging_state_leaves_channels_alone() { + let validator_keys = get_validator_keys(2); + let para = crate::config::Parachain::new("asset-hub"); + let paras = vec![¶]; + let rc = Relaychain::new("polkadot"); - #[test] - fn booleans() { - let t = true.encode(); - let f = false.encode(); - println!("true: {}", array_bytes::bytes2hex("", t)); - println!("false: {}", array_bytes::bytes2hex("", f)); - } - #[test] - fn encode_hrmp() { - let hrmp_prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"Hrmp"[..], b"HrmpIngressChannelsIndex"), + let mut set = OverrideSet::new(None); + generate_rc_overrides(&mut set, &validator_keys); + augment_overrides_for_paras(&mut set, &rc, ¶s, &CoresOverride::new(), true); + + let keys: Vec<&String> = set + .overrides + .as_object() + .unwrap() + .keys() + .filter(|k| { + k.starts_with(&storage_key("Hrmp", "HrmpIngressChannelsIndex")) + || k.starts_with(&storage_key("Dmp", "DownwardMessageQueueHeads")) + }) + .collect(); + assert!( + keys.is_empty(), + "should not touch messaging state: {keys:?}" ); - println!("Hrmp_HrmpIngressChannelsIndex {hrmp_prefix}"); } #[test] - fn core_descriptor() { - let prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"CoretimeAssignmentProvider"[..], b"CoreDescriptors"), - ); - println!("p {prefix}"); - let core_0_descriptor_idx_key = format!( - "{prefix}{}", - array_bytes::bytes2hex("", subhasher::twox256(0_u32.to_le_bytes())) + fn cores_override_changes_assignment() { + let para = crate::config::Parachain::new("asset-hub"); + let rc = Relaychain::new("polkadot"); + let mut cores = CoresOverride::new(); + cores.insert(1000, 1); + + let scheduler_key = core_assignment::get_parascheduler_storage_key(); + let assignment = |cores: &CoresOverride| { + let mut set = OverrideSet::new(None); + augment_overrides_for_paras(&mut set, &rc, &[¶], cores, false); + set.overrides[&scheduler_key[2..]] + .as_str() + .unwrap() + .to_string() + }; + + // asset-hub defaults to three cores, the override brings it down to one + let default = assignment(&CoresOverride::new()); + let overridden = assignment(&cores); + assert!(default.starts_with("0c"), "expected 3 cores, got {default}"); + assert!( + overridden.starts_with("04"), + "expected 1 core, got {overridden}" ); - println!("core: {core_0_descriptor_idx_key}"); } - #[test] - fn create_cores_desc() { - let para_id = ParaId(1000); - let para_hex = array_bytes::bytes2hex("", para_id.encode()); - let prefix = array_bytes::bytes2hex( - "", - substorager::storage_value_key(&b"CoretimeAssignmentProvider"[..], b"CoreDescriptors"), - ); - for core in 0..3 { - let core_descriptor_idx_key = format!( - "{prefix}{}", - array_bytes::bytes2hex("", subhasher::twox256((core as u32).to_le_bytes())) - ); - let core_descriptor_value = format!("00010402{}00e100e100010000e1", para_hex); + #[tokio::test] + async fn inject_authorized_upgrade_seeds_hash_and_check_version() { + let wasm_path = "/tmp/zombie-bite-test-upgrade.wasm"; + let wasm = b"not-a-real-runtime"; + tokio::fs::write(wasm_path, wasm).await.unwrap(); - println!("\"0x{core_descriptor_idx_key}: 0x{core_descriptor_value}\""); - } + let mut set = OverrideSet::new(None); + inject_authorized_upgrade(&mut set, wasm_path).await; + + let expected = format!("{}01", hex::encode(subhasher::blake2_256(&wasm[..]))); + assert_eq!( + set.injects[storage_key("System", "AuthorizedUpgrade")], + json!(expected) + ); } } From 39059b60eae995b70af9f9c15317279fd1523a7a Mon Sep 17 00:00:00 2001 From: Maksym H Date: Wed, 26 Aug 2026 17:48:14 +0100 Subject: [PATCH 2/6] Address review: error on required items, pin reads to bite block, test metadata paths --- README.md | 4 +- src/cli.rs | 44 +++++---- src/config.rs | 22 +++++ src/doppelganger.rs | 18 +++- src/metadata.rs | 186 +++++++++++++++++++++++++++++------- src/overrides.rs | 226 ++++++++++++++++++++++++++++++++++++-------- 6 files changed, 409 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index e0c28f5..c7cd541 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,9 @@ zombie-bite spawn -d /tmp/base_path --apply-upgrade #### Overrides are checked against the runtime -Storage keys are derived from pallet and item names, items the runtime does not have are skipped, and a value that does not survive a decode/encode round-trip against its real on-chain type fails the bite instead of silently landing as something else. `HostConfiguration` is patched from the live value (only `num_cores` changes) rather than replaced, so executor params and async-backing settings of the bitten chain are preserved. A parachain needs an `rpc_endpoint` for its overrides to be verified this way. +Storage keys are derived from pallet and item names, and every value is decoded against its real on-chain type and required to re-encode byte-identically, so a renamed item or changed type fails the bite instead of silently landing as something else. Items the runtime does not have are skipped — except ones you asked for explicitly (a carried upgrade, a wasm override, `ZOMBIE_SUDO`), which are errors. `HostConfiguration` is patched from the live value (only `num_cores` changes) rather than replaced, so executor params, async backing and `max_pov_size` of the bitten chain are preserved. + +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`. #### Spawn diff --git a/src/cli.rs b/src/cli.rs index f5405c1..4920822 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use anyhow::{anyhow, bail}; use clap::{Parser, Subcommand}; use std::{ env, @@ -294,20 +295,19 @@ pub fn resolve_bite_config( }; // Resolve upgrades (CLI overrides config file) + let mut para_upgrades = std::collections::HashMap::new(); + for entry in ¶_upgrade { + let (id, path) = entry.split_once('=').ok_or_else(|| { + anyhow!("--para-upgrade must be =, got '{entry}'") + })?; + let id: u32 = id + .parse() + .map_err(|_| anyhow!("invalid para_id '{id}' in --para-upgrade"))?; + para_upgrades.insert(id, path.to_string()); + } let mut upgrades = Upgrades { relay: relay_upgrade, - paras: para_upgrade - .iter() - .map(|entry| { - let (id, path) = entry.split_once('=').unwrap_or_else(|| { - panic!("--para-upgrade format must be =, got: {entry}") - }); - let id: u32 = id - .parse() - .unwrap_or_else(|_| panic!("Invalid para_id '{id}' in --para-upgrade")); - (id, path.to_string()) - }) - .collect(), + paras: para_upgrades, }; if let Some(ref config) = config_file { if upgrades.relay.is_none() { @@ -338,17 +338,27 @@ pub fn resolve_bite_config( } } for entry in ¶_cores { - let (id, c) = entry.split_once('=').unwrap_or_else(|| { - panic!("--para-cores format must be =, got: {entry}") - }); + let (id, c) = entry + .split_once('=') + .ok_or_else(|| anyhow!("--para-cores must be =, got '{entry}'"))?; let id: u32 = id .parse() - .unwrap_or_else(|_| panic!("Invalid para_id '{id}' in --para-cores")); + .map_err(|_| anyhow!("invalid para_id '{id}' in --para-cores"))?; let c: u32 = c .parse() - .unwrap_or_else(|_| panic!("Invalid cores '{c}' in --para-cores")); + .map_err(|_| anyhow!("invalid cores '{c}' in --para-cores"))?; + if c == 0 { + bail!("--para-cores {id}=0: a parachain with no cores can't have blocks backed"); + } cores.insert(id, c); } + // A core count for a para that is not part of the bite is a typo, not a + // silently ignorable no-op. + for id in cores.keys() { + if !resolved_parachains.iter().any(|para| para.id() == *id) { + bail!("--para-cores/config sets cores for para {id}, which is not part of this bite"); + } + } let resolved_keep_messaging = if keep_messaging_state { true diff --git a/src/config.rs b/src/config.rs index 0c8d154..fdcae28 100644 --- a/src/config.rs +++ b/src/config.rs @@ -535,6 +535,28 @@ impl Parachain { } } + /// Endpoint used to read the parachain's metadata when none is configured, + /// so overrides are checked against the runtime by default. A wrong or + /// unreachable guess only costs the verification (with a warning), never the + /// bite itself. + // TODO: same as the relay endpoints, these should be configurable. + pub fn default_rpc_endpoint(&self, relay: &Relaychain) -> Option { + let prefix = match self { + Parachain::AssetHub { .. } => "asset-hub", + Parachain::Coretime { .. } => "coretime", + Parachain::People { .. } => "people", + Parachain::BridgeHub { .. } => "bridge-hub", + Parachain::Collectives { .. } => "collectives", + // A custom para is only reachable through the endpoint its config + // supplies. + Parachain::Custom { .. } => return None, + }; + Some(format!( + "wss://{prefix}-{}-rpc.n.dwellir.com", + relay.as_chain_string() + )) + } + pub fn chain_spec_path(&self) -> Option<&str> { match self { Parachain::Custom { chain_spec, .. } => Some(chain_spec.as_str()), diff --git a/src/doppelganger.rs b/src/doppelganger.rs index 39cc7a9..e6c6386 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -94,8 +94,14 @@ pub async fn doppelganger_inner( // Parachain sync let mut syncs = vec![]; for para in ¶s_to { - let para_meta = match para.rpc_endpoint() { - Some(url) => ChainMetadata::fetch(&format!("para {}", para.id()), url).await, + let para_meta = match para + .rpc_endpoint() + .map(str::to_string) + .or_else(|| para.default_rpc_endpoint(&relay_chain)) + { + Some(url) => { + ChainMetadata::fetch(&format!("para {}", para.id()), &url, para.at_block()).await + } None => { warn!( "para {}: no 'rpc_endpoint' configured, overrides will not be verified against the runtime", @@ -237,8 +243,12 @@ pub async fn doppelganger_inner( let req_cores: u32 = paras_to.iter().fold(0u32, |acc, para| { acc + get_assigned_cores(&relay_chain, para, &opts.cores) }); - let rc_meta = - ChainMetadata::fetch(&relay_chain.as_chain_string(), &relay_chain.rpc_endpoint()).await; + let rc_meta = ChainMetadata::fetch( + &relay_chain.as_chain_string(), + &relay_chain.rpc_endpoint(), + relay_chain.at_block(), + ) + .await; let rc_default_overrides_path = generate_default_overrides_for_rc( &base_dir_str, &relay_chain, diff --git a/src/metadata.rs b/src/metadata.rs index 974f7a9..c6a789b 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -12,7 +12,7 @@ use zombienet_sdk::subxt::{ scale_value, subxt_rpcs::{client::RpcParams, RpcClient}, }, - Metadata, OnlineClient, PolkadotConfig, + Metadata, }; pub fn storage_key(pallet: &str, item: &str) -> String { @@ -22,29 +22,54 @@ pub fn storage_key(pallet: &str, item: &str) -> String { ) } +/// What an override is checked against. `ChainMetadata` is the real +/// implementation; tests use a double. +pub trait RuntimeCheck { + fn has_item(&self, pallet: &str, item: &str) -> bool; + fn verify_value(&self, pallet: &str, item: &str, value_hex: &str) -> Result<(), anyhow::Error>; +} + pub struct ChainMetadata { rpc: RpcClient, metadata: Metadata, + /// Block the state is read at, so metadata and storage come from the same + /// runtime as the state being imported. + at: Option, } impl ChainMetadata { - /// Fetch metadata from the chain being bitten. Returns `None` (with a + /// Fetch metadata from the chain being bitten, at `at_block` when the bite + /// is pinned to a block (otherwise at head). Returns `None` (with a /// warning) when the endpoint can't be reached, so a bite without network /// access to the source falls back to unverified overrides rather than /// failing outright. - pub async fn fetch(chain: &str, url: &str) -> Option { - match OnlineClient::::from_url(url).await { - Ok(client) => { - let metadata = client.metadata(); - let rpc = match RpcClient::from_url(url).await { - Ok(rpc) => rpc, - Err(e) => { - warn!("{chain}: can't open rpc client to {url}: {e}"); - return None; - } - }; - debug!("{chain}: metadata fetched from {url}"); - Some(Self { rpc, metadata }) + pub async fn fetch(chain: &str, url: &str, at_block: Option) -> Option { + let rpc = match RpcClient::from_url(url).await { + Ok(rpc) => rpc, + Err(e) => { + warn!("{chain}: can't reach {url}, overrides will not be verified against the runtime: {e}"); + return None; + } + }; + + let at = match at_block { + Some(block) => match block_hash(&rpc, block).await { + Ok(Some(hash)) => Some(hash), + _ => { + warn!("{chain}: can't resolve the hash of block {block}, overrides will not be verified against the runtime"); + return None; + } + }, + None => None, + }; + + match fetch_metadata(&rpc, at.as_deref()).await { + Ok(metadata) => { + debug!( + "{chain}: metadata fetched from {url} at {}", + at.as_deref().unwrap_or("head") + ); + Some(Self { rpc, metadata, at }) } Err(e) => { warn!("{chain}: can't fetch metadata from {url}, overrides will not be verified against the runtime: {e}"); @@ -66,19 +91,7 @@ impl ChainMetadata { ) } - pub fn has_item(&self, pallet: &str, item: &str) -> bool { - self.value_ty(pallet, item).is_some() - } - - /// Require `value_hex` to decode against the item's on-chain type and - /// re-encode to the same bytes. Trailing bytes are an error too - that is - /// what a wrong length prefix looks like. - pub fn verify_value( - &self, - pallet: &str, - item: &str, - value_hex: &str, - ) -> Result<(), anyhow::Error> { + fn verify(&self, pallet: &str, item: &str, value_hex: &str) -> Result<(), anyhow::Error> { let ty = self .value_ty(pallet, item) .ok_or_else(|| anyhow!("{pallet}::{item} not in metadata"))?; @@ -109,10 +122,14 @@ impl ChainMetadata { Ok(()) } - /// Read a live storage value as hex (no `0x` prefix). + /// Read a live storage value as hex (no `0x` prefix), at the same block the + /// metadata came from. pub async fn storage_value(&self, key: &str) -> Result, anyhow::Error> { let mut params = RpcParams::new(); params.push(format!("0x{key}"))?; + if let Some(at) = &self.at { + params.push(at)?; + } let raw: Option = self.rpc.request("state_getStorage", params).await?; Ok(raw.map(|v| v.trim_start_matches("0x").to_string())) } @@ -131,9 +148,18 @@ impl ChainMetadata { .value_ty(pallet, item) .ok_or_else(|| anyhow!("{pallet}::{item} not in metadata"))?; let bytes = hex::decode(value_hex)?; - let mut value = - scale_value::scale::decode_as_type(&mut &bytes[..], ty, self.metadata.types()) - .map_err(|e| anyhow!("{pallet}::{item}: live value does not decode: {e}"))?; + let mut cursor = &bytes[..]; + let mut value = scale_value::scale::decode_as_type(&mut cursor, ty, self.metadata.types()) + .map_err(|e| anyhow!("{pallet}::{item}: live value does not decode: {e}"))?; + // Trailing bytes mean the metadata and the value disagree (e.g. the + // runtime upgraded between the two reads); patching would silently + // truncate the tail and still round-trip cleanly. + if !cursor.is_empty() { + bail!( + "{pallet}::{item}: live value has {} trailing byte(s) against this runtime's type, refusing to patch it", + cursor.len() + ); + } patch(&mut value)?; @@ -144,6 +170,62 @@ impl ChainMetadata { } } +impl RuntimeCheck for ChainMetadata { + fn has_item(&self, pallet: &str, item: &str) -> bool { + self.value_ty(pallet, item).is_some() + } + + /// Require `value_hex` to decode against the item's on-chain type and + /// re-encode to the same bytes. Trailing bytes are an error too - that is + /// what a wrong length prefix looks like. + fn verify_value(&self, pallet: &str, item: &str, value_hex: &str) -> Result<(), anyhow::Error> { + self.verify(pallet, item, value_hex) + } +} + +async fn block_hash(rpc: &RpcClient, block: u32) -> Result, anyhow::Error> { + let mut params = RpcParams::new(); + params.push(block)?; + Ok(rpc.request("chain_getBlockHash", params).await?) +} + +async fn fetch_metadata(rpc: &RpcClient, at: Option<&str>) -> Result { + use zombienet_sdk::subxt::ext::{ + codec::Decode, + frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed}, + }; + + let mut params = RpcParams::new(); + if let Some(at) = at { + params.push(at)?; + } + let raw: String = rpc.request("state_getMetadata", params).await?; + let bytes = hex::decode(raw.trim_start_matches("0x"))?; + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..])?; + if !matches!( + prefixed.1, + RuntimeMetadata::V14(_) | RuntimeMetadata::V15(_) + ) { + bail!("unsupported metadata version, expected v14 or v15"); + } + Metadata::try_from(prefixed).map_err(|e| anyhow!("can't read metadata: {e}")) +} + +/// Get a mutable handle on a named inner struct. +pub fn nested_mut<'v>( + value: &'v mut scale_value::Value, + field: &str, +) -> Option<&'v mut scale_value::Value> { + let scale_value::ValueDef::Composite(scale_value::Composite::Named(fields)) = &mut value.value + else { + return None; + }; + fields + .iter_mut() + .find(|(name, _)| name == field) + .map(|(_, v)| v) +} + /// Set a named field on a composite `Value`, keeping every other field as the /// live runtime had it. pub fn set_field( @@ -162,3 +244,43 @@ pub fn set_field( entry.1 = to; Ok(()) } + +#[cfg(test)] +mod test { + use super::*; + use zombienet_sdk::subxt::ext::scale_value::{value, Value as ScaleValue}; + + fn cores(n: u128) -> ScaleValue { + ScaleValue::u128(n).map_context(|_| 0_u32) + } + + #[test] + fn set_field_replaces_only_that_field() { + let mut v = value!({ num_cores: 18u32, max_pov_size: 5u32 }).map_context(|_| 0_u32); + set_field(&mut v, "num_cores", cores(5)).unwrap(); + + let expected = value!({ num_cores: 5u32, max_pov_size: 5u32 }).map_context(|_| 0_u32); + assert_eq!(v, expected); + } + + #[test] + fn set_field_errors_on_unknown_field_and_wrong_shape() { + let mut named = value!({ num_cores: 1u32 }).map_context(|_| 0_u32); + assert!(set_field(&mut named, "nope", cores(5)).is_err()); + + let mut unnamed = ScaleValue::u128(1).map_context(|_| 0_u32); + assert!(set_field(&mut unnamed, "num_cores", cores(5)).is_err()); + } + + #[test] + fn nested_mut_finds_the_inner_struct() { + let mut v = value!({ scheduler_params: { num_cores: 18u32 } }).map_context(|_| 0_u32); + + let params = nested_mut(&mut v, "scheduler_params").expect("nested struct"); + set_field(params, "num_cores", cores(5)).unwrap(); + + let expected = value!({ scheduler_params: { num_cores: 5u32 } }).map_context(|_| 0_u32); + assert_eq!(v, expected); + assert!(nested_mut(&mut v, "missing").is_none()); + } +} diff --git a/src/overrides.rs b/src/overrides.rs index 21e27f7..ab81eb0 100644 --- a/src/overrides.rs +++ b/src/overrides.rs @@ -6,7 +6,7 @@ use tracing::{info, warn}; use crate::{ config::{get_assigned_cores, CoresOverride, Parachain, Relaychain}, - metadata::{set_field, storage_key, ChainMetadata}, + metadata::{nested_mut, set_field, storage_key, ChainMetadata, RuntimeCheck}, utils::{ generate_collator_key_from_seed, generate_collator_next_keys_injects, get_validator_keys, ParaId, ValidationCode, @@ -23,7 +23,7 @@ use zombienet_sdk::subxt::ext::scale_value::Value as ScaleValue; /// does not survive a decode/encode round-trip against its real on-chain type /// fails the bite. struct OverrideSet<'a> { - meta: Option<&'a ChainMetadata>, + meta: Option<&'a dyn RuntimeCheck>, overrides: Value, injects: Value, skipped: Vec, @@ -31,7 +31,7 @@ struct OverrideSet<'a> { } impl<'a> OverrideSet<'a> { - fn new(meta: Option<&'a ChainMetadata>) -> Self { + fn new(meta: Option<&'a dyn RuntimeCheck>) -> Self { Self { meta, overrides: json!({}), @@ -42,10 +42,26 @@ impl<'a> OverrideSet<'a> { } /// `None` when the entry should be dropped (item absent, or value invalid). - fn checked_key(&mut self, pallet: &str, item: &str, value: &str) -> Option { + /// + /// `required` entries are ones the user explicitly asked for (a carried + /// upgrade, a wasm override, a sudo key): an item the runtime does not have + /// is an error there, not something to quietly drop. + fn checked_key( + &mut self, + pallet: &str, + item: &str, + value: &str, + required: bool, + ) -> Option { if let Some(meta) = self.meta { if !meta.has_item(pallet, item) { - self.skipped.push(format!("{pallet}::{item}")); + if required { + self.errors.push(format!( + "{pallet}::{item} is not in the runtime, so it can't be set" + )); + } else { + self.skipped.push(format!("{pallet}::{item}")); + } return None; } if let Err(e) = meta.verify_value(pallet, item, value) { @@ -58,7 +74,15 @@ impl<'a> OverrideSet<'a> { fn set(&mut self, pallet: &str, item: &str, value: impl AsRef) { let value = value.as_ref(); - if let Some(key) = self.checked_key(pallet, item, value) { + if let Some(key) = self.checked_key(pallet, item, value, false) { + self.overrides[key] = json!(value); + } + } + + /// Like `set`, for an entry the user asked for explicitly. + fn set_required(&mut self, pallet: &str, item: &str, value: impl AsRef) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value, true) { self.overrides[key] = json!(value); } } @@ -66,25 +90,80 @@ impl<'a> OverrideSet<'a> { /// Map entry; `key_suffix` is the already-hashed map key. fn set_map(&mut self, pallet: &str, item: &str, key_suffix: &str, value: impl AsRef) { let value = value.as_ref(); - if let Some(key) = self.checked_key(pallet, item, value) { + if let Some(key) = self.checked_key(pallet, item, value, false) { + self.overrides[format!("{key}{key_suffix}")] = json!(value); + } + } + + fn set_map_required( + &mut self, + pallet: &str, + item: &str, + key_suffix: &str, + value: impl AsRef, + ) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value, true) { self.overrides[format!("{key}{key_suffix}")] = json!(value); } } fn inject(&mut self, pallet: &str, item: &str, value: impl AsRef) { let value = value.as_ref(); - if let Some(key) = self.checked_key(pallet, item, value) { + if let Some(key) = self.checked_key(pallet, item, value, false) { + self.injects[key] = json!(value); + } + } + + fn inject_required(&mut self, pallet: &str, item: &str, value: impl AsRef) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value, true) { self.injects[key] = json!(value); } } fn inject_map(&mut self, pallet: &str, item: &str, key_suffix: &str, value: impl AsRef) { let value = value.as_ref(); - if let Some(key) = self.checked_key(pallet, item, value) { + if let Some(key) = self.checked_key(pallet, item, value, false) { self.injects[format!("{key}{key_suffix}")] = json!(value); } } + fn inject_map_required( + &mut self, + pallet: &str, + item: &str, + key_suffix: &str, + value: impl AsRef, + ) { + let value = value.as_ref(); + if let Some(key) = self.checked_key(pallet, item, value, true) { + self.injects[format!("{key}{key_suffix}")] = json!(value); + } + } + + /// Item the runtime must have, whose value is too large to be worth + /// verifying (a multi-MB runtime blob decodes into millions of `Value` + /// nodes and the value is built by us from `Encode` anyway). + fn inject_map_unverified( + &mut self, + pallet: &str, + item: &str, + key_suffix: &str, + value: impl AsRef, + ) { + if let Some(meta) = self.meta { + if !meta.has_item(pallet, item) { + self.errors.push(format!( + "{pallet}::{item} is not in the runtime, so it can't be set" + )); + return; + } + } + let key = storage_key(pallet, item); + self.injects[format!("{key}{key_suffix}")] = json!(value.as_ref()); + } + /// Well-known keys that are not pallet storage items (`:code`, /// `:UsePreviousValidators:`), so there is no type to check them against. fn set_raw(&mut self, key: &str, value: impl AsRef) { @@ -127,13 +206,16 @@ async fn inject_authorized_upgrade(set: &mut OverrideSet<'_>, upgrade_wasm: &str "{}01", hex::encode(subhasher::blake2_256(&wasm_content[..])) ); - set.inject("System", "AuthorizedUpgrade", value); + set.inject_required("System", "AuthorizedUpgrade", value); } /// Patch `num_cores` into the live `HostConfiguration`, leaving every other /// field the production chain configured (executor params, async backing, -/// max_pov_size) untouched. Falls back to a per-relay blob when the live value -/// is unavailable, which loses those fields - hence the warning. +/// max_pov_size) untouched. +/// +/// The built-in per-relay blob is only used when the source can't be reached at +/// all: it is a snapshot of a past runtime, so it drops whatever the live chain +/// has configured since. async fn host_config( relay: &Relaychain, num_cores: u32, @@ -141,16 +223,17 @@ async fn host_config( ) -> Result { if let Some(meta) = meta { let key = storage_key("Configuration", "ActiveConfig"); - if let Some(live) = meta.storage_value(&key).await? { - let patched = patch_num_cores(meta, &live, num_cores)?; - info!( - "Configuration::ActiveConfig patched from the live value (num_cores -> {num_cores})" - ); - return Ok(patched); - } - warn!("Configuration::ActiveConfig not readable from the source, falling back to the built-in host config (executor params and async backing settings of the live chain are lost)"); + let live = meta + .storage_value(&key) + .await? + .ok_or_else(|| anyhow::anyhow!("Configuration::ActiveConfig is empty on the source chain, refusing to replace it with a built-in blob"))?; + let patched = patch_num_cores(meta, &live, num_cores)?; + info!("Configuration::ActiveConfig patched from the live value (num_cores -> {num_cores})"); + return Ok(patched); } + warn!("using the built-in host config: it is a snapshot of a past runtime, so executor params, async backing settings and max_pov_size of the live chain are lost"); + let cores = array_bytes::bytes2hex("", num_cores.encode()); Ok(match relay { Relaychain::Westend { .. } => { @@ -186,17 +269,6 @@ fn patch_num_cores( }) } -fn nested_mut<'v>(value: &'v mut ScaleValue, field: &str) -> Option<&'v mut ScaleValue> { - use zombienet_sdk::subxt::ext::scale_value::{Composite, ValueDef}; - let ValueDef::Composite(Composite::Named(fields)) = &mut value.value else { - return None; - }; - fields - .iter_mut() - .find(|(name, _)| name == field) - .map(|(_, v)| v) -} - /// Generate the injects for Session.NextKeys storage overrides for validators fn generate_next_keys_injects( set: &mut OverrideSet<'_>, @@ -399,7 +471,7 @@ pub async fn generate_default_overrides_for_rc( let num_validators = crate::config::num_validators_for_cores(req_cores); let validator_keys = get_validator_keys(num_validators as usize); - let mut set = OverrideSet::new(meta); + let mut set = OverrideSet::new(meta.map(|m| m as &dyn RuntimeCheck)); generate_rc_overrides(&mut set, &validator_keys); @@ -433,7 +505,7 @@ pub async fn generate_default_overrides_for_rc( // update the overrides / injects map to use IFF the key is provided if let Ok(sudo_key) = env::var("ZOMBIE_SUDO") { - set.set("Sudo", "Key", &sudo_key); + set.set_required("Sudo", "Key", &sudo_key); set.inject("RcMigrator", "Manager", &sudo_key); } @@ -458,18 +530,18 @@ pub async fn generate_default_overrides_for_rc( let code_hash = hex::encode(subhasher::blake2_256(&wasm_content[..])); let para_id_map_key = crate::utils::para_id_for_map_hash(para.id()); - set.set_map("Paras", "CurrentCodeHash", ¶_id_map_key, &code_hash); + set.set_map_required("Paras", "CurrentCodeHash", ¶_id_map_key, &code_hash); // CodeByHash / CodeByHashRefs are injected since the map key is the // hash of the code itself, so they are never in the imported state. let validation_code: ValidationCode = ValidationCode(wasm_content); - set.inject_map( + set.inject_map_unverified( "Paras", "CodeByHash", &code_hash, hex::encode(validation_code.encode()), ); - set.inject_map("Paras", "CodeByHashRefs", &code_hash, "01000000"); + set.inject_map_required("Paras", "CodeByHashRefs", &code_hash, "01000000"); } } @@ -505,7 +577,7 @@ pub async fn generate_default_overrides_for_para( let seed = format!("Collator-{}", para.id()); let key_to_use = generate_collator_key_from_seed(&seed, key_type); - let mut set = OverrideSet::new(meta); + let mut set = OverrideSet::new(meta.map(|m| m as &dyn RuntimeCheck)); set.set("Session", "Validators", format!("04{key_to_use}")); set.set( @@ -682,6 +754,11 @@ mod test { overrides["94eadf0156a8ad5156507773d0471e4a16973e1142f5bd30d9464076794007db"], array_bytes::bytes2hex("", expected_groups.encode()) ); + // pin the wire bytes too: compact(2) then each group with its own compact len + assert_eq!( + overrides["94eadf0156a8ad5156507773d0471e4a16973e1142f5bd30d9464076794007db"], + "0804000000000401000000" + ); // Para Id Parachains assert_eq!( @@ -786,4 +863,79 @@ mod test { json!(expected) ); } + + /// Runtime check stub: `present` lists the items the runtime has, and any + /// value equal to `bad_value` fails verification. + struct FakeRuntime { + present: Vec<(&'static str, &'static str)>, + bad_value: &'static str, + } + + impl RuntimeCheck for FakeRuntime { + fn has_item(&self, pallet: &str, item: &str) -> bool { + self.present.iter().any(|(p, i)| *p == pallet && *i == item) + } + + fn verify_value( + &self, + pallet: &str, + item: &str, + value_hex: &str, + ) -> Result<(), anyhow::Error> { + if value_hex == self.bad_value { + anyhow::bail!("{pallet}::{item}: bad value"); + } + Ok(()) + } + } + + #[test] + fn missing_item_is_skipped_but_required_one_is_an_error() { + let runtime = FakeRuntime { + present: vec![("Session", "Validators")], + bad_value: "", + }; + + let mut set = OverrideSet::new(Some(&runtime)); + set.set("Session", "Validators", "04ff"); + // absent from the runtime: dropped quietly + set.set("ValidatorSet", "Validators", "04ff"); + // absent but explicitly requested: must fail the bite + set.inject_required("System", "AuthorizedUpgrade", "04ff"); + + assert_eq!(set.overrides[storage_key("Session", "Validators")], "04ff"); + assert_eq!(set.skipped, vec!["ValidatorSet::Validators"]); + let err = set.finish("test").unwrap_err().to_string(); + assert!(err.contains("System::AuthorizedUpgrade"), "got: {err}"); + } + + #[test] + fn value_that_fails_verification_fails_the_bite() { + let runtime = FakeRuntime { + present: vec![("Session", "Validators")], + bad_value: "deadbeef", + }; + + let mut set = OverrideSet::new(Some(&runtime)); + set.set("Session", "Validators", "deadbeef"); + + assert!(set.overrides.as_object().unwrap().is_empty()); + let err = set.finish("test").unwrap_err().to_string(); + assert!(err.contains("bad value"), "got: {err}"); + } + + #[test] + fn unverified_map_inject_still_requires_the_item() { + let runtime = FakeRuntime { + present: vec![], + bad_value: "", + }; + + let mut set = OverrideSet::new(Some(&runtime)); + // a huge wasm blob is not verified, but the item must exist + set.inject_map_unverified("Paras", "CodeByHash", "aa", "00ff"); + + let err = set.finish("test").unwrap_err().to_string(); + assert!(err.contains("Paras::CodeByHash"), "got: {err}"); + } } From 7631b00029257715e00f7d4588e26c45e6af84a3 Mon Sep 17 00:00:00 2001 From: Maksym H Date: Wed, 26 Aug 2026 17:51:21 +0100 Subject: [PATCH 3/6] Write a bundle manifest describing the bite artifacts --- README.md | 4 + src/doppelganger.rs | 85 ++++++++++++++++++- src/main.rs | 3 + src/manifest.rs | 195 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/manifest.rs diff --git a/README.md b/README.md index c7cd541..f095141 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ 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`. +#### 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. + #### Spawn Spawn a new instance of the _bited_ network with the following cmd: diff --git a/src/doppelganger.rs b/src/doppelganger.rs index e6c6386..a8be6fc 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 = manifest::file_size(&snap_path).await; 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 = manifest::file_size(&r_snap_path).await; 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,82 @@ pub async fn doppelganger_inner( ) .await; + let mut manifest = build_manifest( + &relay_chain, + ¶s_to, + &ready_content, + &relay_artifacts, + ¶_artifacts, + ); + manifest.binaries = manifest::binary_versions().await; + 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, + binaries: vec![], + } +} + async fn copy_upgrade_blob(from: &str, base_dir: &str, blob_name: &str) -> (String, String) { let wasm = fs::read(from) .await @@ -1195,6 +1274,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 +1283,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..f9b7827 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkNode}; mod cli; mod config; mod doppelganger; +mod manifest; mod metadata; mod monit; mod overrides; @@ -260,6 +261,8 @@ async fn main() -> Result<(), anyhow::Error> { resolve_if_dir_exist(&resolved_config.base_path, step).await; + manifest::warn_on_binary_mismatch(resolved_config.base_path.as_path()).await; + let network = doppelganger::spawn(step, resolved_config.base_path.as_path(), None, None) .await diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..757b4be --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,195 @@ +//! 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 which binaries produced it. The +//! last one matters because a snapshot written by a newer node fails to restore +//! in ways that look like corruption. + +use std::{ + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use tokio::{fs, process::Command}; +use tracing::{info, warn}; + +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; + +/// Binaries that produce the snapshots, and whose versions therefore have to +/// match on restore. +const SNAPSHOT_BINARIES: [&str; 2] = ["doppelganger", "doppelganger-parachain"]; + +#[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, + /// `--version` of the binaries that produced the snapshots. + #[serde(default)] + pub binaries: Vec<(String, String)>, +} + +async fn binary_version(cmd: &str) -> Option { + let out = Command::new(cmd).arg("--version").output().await.ok()?; + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!version.is_empty()).then_some(version) +} + +pub async fn binary_versions() -> Vec<(String, String)> { + let mut versions = vec![]; + for cmd in SNAPSHOT_BINARIES { + if let Some(version) = binary_version(cmd).await { + versions.push((cmd.to_string(), version)); + } + } + versions +} + +pub async fn file_size(path: &str) -> Option { + fs::metadata(path).await.ok().map(|m| m.len()) +} + +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(()) + } + + /// `None` when there is no manifest; a manifest that exists but can't be + /// parsed warns, since that means a shape change rather than an old bundle. + pub async fn read(base_path: &Path) -> Option { + let path = base_path.join(MANIFEST_FILE); + let content = fs::read_to_string(&path).await.ok()?; + match serde_json::from_str::(&content) { + Ok(manifest) => Some(manifest), + Err(e) => { + warn!("{}: can't read manifest: {e}", path.to_string_lossy()); + None + } + } + } +} + +/// Compare the binaries that produced the bundle with the ones on this machine. +/// A mismatch is a warning, not an error: it usually still restores, and when it +/// does not the failure otherwise looks like a corrupt snapshot. +/// +/// Both sides are the *doppelganger* binaries: the bite writes what produced the +/// snapshots, and a restore needs the same ones to import that state. +pub async fn warn_on_binary_mismatch(base_path: &Path) { + let Some(manifest) = Manifest::read(base_path).await else { + info!("no bundle manifest found, skipping the binary version check"); + return; + }; + if manifest.version != VERSION { + warn!( + "bundle manifest is version {} but this build writes {VERSION}; some fields may be missing", + manifest.version + ); + } + if manifest.binaries.is_empty() { + info!("bundle manifest records no binary versions, skipping the check"); + return; + } + + let local = binary_versions().await; + for (cmd, bundled) in &manifest.binaries { + match local.iter().find(|(name, _)| name == cmd) { + Some((_, current)) if current == bundled => {} + Some((_, current)) => warn!( + "{cmd}: bundle was produced with '{bundled}' but this machine has '{current}'; a snapshot from a newer node can fail to restore in ways that look like corruption" + ), + None => warn!("{cmd}: not found locally, can't compare with the bundle's '{bundled}'"), + } + } +} + +#[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() + }], + binaries: vec![("doppelganger".into(), "1.2.3".into())], + }; + manifest.write(&dir).await.unwrap(); + + let read = Manifest::read(&dir) + .await + .expect("manifest should be there"); + 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)); + assert_eq!(read.binaries[0].1, "1.2.3"); + + 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!(Manifest::read(&dir).await.is_none()); + // must not panic when there is nothing to compare + warn_on_binary_mismatch(&dir).await; + } +} From 727cba8a897961bbe8e32a279fdc1bc6c1fe1806 Mon Sep 17 00:00:00 2001 From: Mak <1177472+mordamax@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:04:10 +0100 Subject: [PATCH 4/6] Publish the fork's own nodes as bootNodes, optionally under a public host (#132) --- 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 361883a65ccf596ef83eb0a1f517010ff4366f7a Mon Sep 17 00:00:00 2001 From: Mak <1177472+mordamax@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:43:36 +0100 Subject: [PATCH 5/6] Pack a step's artifacts into one restorable bundle (#136) * Publish the fork's own nodes as bootNodes, optionally under a public host * Pack a step's artifacts into one restorable bundle --------- Co-authored-by: Javier Viola <363911+pepoviola@users.noreply.github.com> --- 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, From f777046e897247f43d0774e0a6c60dd45e7be4d7 Mon Sep 17 00:00:00 2001 From: Maksym H Date: Sun, 30 Aug 2026 17:59:30 +0100 Subject: [PATCH 6/6] Address review: drop binary-version tracking from the manifest --- README.md | 2 +- src/doppelganger.rs | 8 ++-- src/main.rs | 2 - src/manifest.rs | 94 +++------------------------------------------ 4 files changed, 10 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 6fa1327..51ce5c2 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Only the address host is rewritten — port, transport and peer id stay as spawn #### 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. +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 diff --git a/src/doppelganger.rs b/src/doppelganger.rs index f450c1e..87b745e 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -210,7 +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 = manifest::file_size(&snap_path).await; + 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.")); @@ -333,7 +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 = manifest::file_size(&r_snap_path).await; + let r_snap_bytes = fs::metadata(&r_snap_path).await.ok().map(|m| m.len()); let relay_artifacts = ChainArtifact { // cmd: context_relay.doppelganger_cmd(), @@ -459,14 +459,13 @@ pub async fn doppelganger_inner( ) .await; - let mut manifest = build_manifest( + let manifest = build_manifest( &relay_chain, ¶s_to, &ready_content, &relay_artifacts, ¶_artifacts, ); - manifest.binaries = manifest::binary_versions().await; manifest.write(&global_base_dir).await?; clean_up_dir_for_step(global_base_dir, Step::Bite, &relay_chain, ¶s_to).await?; @@ -531,7 +530,6 @@ fn build_manifest( created_at: manifest::now_unix(), relay, parachains, - binaries: vec![], } } diff --git a/src/main.rs b/src/main.rs index 1e09c0b..20119e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -300,8 +300,6 @@ async fn main() -> Result<(), anyhow::Error> { resolve_if_dir_exist(&resolved_config.base_path, step).await; - manifest::warn_on_binary_mismatch(resolved_config.base_path.as_path()).await; - let network = doppelganger::spawn(step, resolved_config.base_path.as_path(), None, None) .await diff --git a/src/manifest.rs b/src/manifest.rs index 757b4be..04a78ab 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -2,9 +2,7 @@ //! //! 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 which binaries produced it. The -//! last one matters because a snapshot written by a newer node fails to restore -//! in ways that look like corruption. +//! bitten at, where the state came from, and what the files are. use std::{ path::Path, @@ -12,18 +10,14 @@ use std::{ }; use serde::{Deserialize, Serialize}; -use tokio::{fs, process::Command}; -use tracing::{info, warn}; +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; -/// Binaries that produce the snapshots, and whose versions therefore have to -/// match on restore. -const SNAPSHOT_BINARIES: [&str; 2] = ["doppelganger", "doppelganger-parachain"]; - #[derive(Debug, Default, Serialize, Deserialize)] pub struct ChainEntry { pub chain: String, @@ -51,29 +45,6 @@ pub struct Manifest { pub created_at: u64, pub relay: ChainEntry, pub parachains: Vec, - /// `--version` of the binaries that produced the snapshots. - #[serde(default)] - pub binaries: Vec<(String, String)>, -} - -async fn binary_version(cmd: &str) -> Option { - let out = Command::new(cmd).arg("--version").output().await.ok()?; - let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); - (!version.is_empty()).then_some(version) -} - -pub async fn binary_versions() -> Vec<(String, String)> { - let mut versions = vec![]; - for cmd in SNAPSHOT_BINARIES { - if let Some(version) = binary_version(cmd).await { - versions.push((cmd.to_string(), version)); - } - } - versions -} - -pub async fn file_size(path: &str) -> Option { - fs::metadata(path).await.ok().map(|m| m.len()) } pub fn now_unix() -> u64 { @@ -90,54 +61,6 @@ impl Manifest { info!("📄 manifest written to {}", path.to_string_lossy()); Ok(()) } - - /// `None` when there is no manifest; a manifest that exists but can't be - /// parsed warns, since that means a shape change rather than an old bundle. - pub async fn read(base_path: &Path) -> Option { - let path = base_path.join(MANIFEST_FILE); - let content = fs::read_to_string(&path).await.ok()?; - match serde_json::from_str::(&content) { - Ok(manifest) => Some(manifest), - Err(e) => { - warn!("{}: can't read manifest: {e}", path.to_string_lossy()); - None - } - } - } -} - -/// Compare the binaries that produced the bundle with the ones on this machine. -/// A mismatch is a warning, not an error: it usually still restores, and when it -/// does not the failure otherwise looks like a corrupt snapshot. -/// -/// Both sides are the *doppelganger* binaries: the bite writes what produced the -/// snapshots, and a restore needs the same ones to import that state. -pub async fn warn_on_binary_mismatch(base_path: &Path) { - let Some(manifest) = Manifest::read(base_path).await else { - info!("no bundle manifest found, skipping the binary version check"); - return; - }; - if manifest.version != VERSION { - warn!( - "bundle manifest is version {} but this build writes {VERSION}; some fields may be missing", - manifest.version - ); - } - if manifest.binaries.is_empty() { - info!("bundle manifest records no binary versions, skipping the check"); - return; - } - - let local = binary_versions().await; - for (cmd, bundled) in &manifest.binaries { - match local.iter().find(|(name, _)| name == cmd) { - Some((_, current)) if current == bundled => {} - Some((_, current)) => warn!( - "{cmd}: bundle was produced with '{bundled}' but this machine has '{current}'; a snapshot from a newer node can fail to restore in ways that look like corruption" - ), - None => warn!("{cmd}: not found locally, can't compare with the bundle's '{bundled}'"), - } - } } #[cfg(test)] @@ -166,18 +89,15 @@ mod test { bite_block: Some(7), ..Default::default() }], - binaries: vec![("doppelganger".into(), "1.2.3".into())], }; manifest.write(&dir).await.unwrap(); - let read = Manifest::read(&dir) - .await - .expect("manifest should be there"); + 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)); - assert_eq!(read.binaries[0].1, "1.2.3"); fs::remove_dir_all(&dir).await.unwrap(); } @@ -188,8 +108,6 @@ mod test { fs::create_dir_all(&dir).await.unwrap(); let _ = fs::remove_file(dir.join(MANIFEST_FILE)).await; - assert!(Manifest::read(&dir).await.is_none()); - // must not panic when there is nothing to compare - warn_on_binary_mismatch(&dir).await; + assert!(fs::read_to_string(dir.join(MANIFEST_FILE)).await.is_err()); } }