From e5f20d4477f425630c85d646cfef6a0da8873ff2 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 27 Jan 2026 07:26:18 +0100 Subject: [PATCH] Add node config overrides --- examples/tests/dynamic_join.rs | 1 + examples/tests/manual_cluster.rs | 2 + examples/tests/node_config_override.rs | 113 ++++++++++++++++++ testing-framework/core/src/nodes/node.rs | 19 +++ .../core/src/scenario/capabilities.rs | 19 ++- .../core/src/scenario/definition.rs | 36 ++++-- testing-framework/core/src/topology/config.rs | 42 ++++++- .../core/src/topology/deployment.rs | 51 ++++---- .../core/src/topology/generation.rs | 10 +- .../local/src/node_control/config.rs | 8 +- .../deployers/local/src/node_control/mod.rs | 38 +++++- 11 files changed, 288 insertions(+), 51 deletions(-) create mode 100644 examples/tests/node_config_override.rs diff --git a/examples/tests/dynamic_join.rs b/examples/tests/dynamic_join.rs index d892b5a..6c9f316 100644 --- a/examples/tests/dynamic_join.rs +++ b/examples/tests/dynamic_join.rs @@ -85,6 +85,7 @@ impl Workload for JoinNodeWithPeersWorkload { let options = StartNodeOptions { peers: PeerSelection::Named(self.peers.clone()), + config_patch: None, }; let node = handle.start_node_with(&self.name, options).await?; let client = node.api; diff --git a/examples/tests/manual_cluster.rs b/examples/tests/manual_cluster.rs index 4af827e..805238c 100644 --- a/examples/tests/manual_cluster.rs +++ b/examples/tests/manual_cluster.rs @@ -32,6 +32,7 @@ async fn manual_cluster_two_clusters_merge() -> Result<()> { "a", StartNodeOptions { peers: PeerSelection::None, + config_patch: None, }, ) .await? @@ -46,6 +47,7 @@ async fn manual_cluster_two_clusters_merge() -> Result<()> { "c", StartNodeOptions { peers: PeerSelection::Named(vec!["node-a".to_owned()]), + config_patch: None, }, ) .await? diff --git a/examples/tests/node_config_override.rs b/examples/tests/node_config_override.rs new file mode 100644 index 0000000..805e65a --- /dev/null +++ b/examples/tests/node_config_override.rs @@ -0,0 +1,113 @@ +use std::{ + net::{SocketAddr, TcpListener}, + time::Duration, +}; + +use anyhow::Result; +use testing_framework_core::{ + nodes::ApiClient, + scenario::{Deployer, PeerSelection, ScenarioBuilder, StartNodeOptions}, + topology::config::TopologyConfig, +}; +use testing_framework_runner_local::LocalDeployer; +use tracing_subscriber::fmt::try_init; + +#[tokio::test] +#[ignore = "run manually with `cargo test -p runner-examples -- --ignored manual_cluster_api_port_override`"] +async fn manual_cluster_api_port_override() -> Result<()> { + let _ = try_init(); + // Required env vars (set on the command line when running this test): + // - `POL_PROOF_DEV_MODE=true` + // - `LOGOS_BLOCKCHAIN_NODE_BIN=...` + // - `LOGOS_BLOCKCHAIN_CIRCUITS=...` + // - `RUST_LOG=info` (optional) + + let api_port = random_api_port(); + + let deployer = LocalDeployer::new(); + let cluster = deployer.manual_cluster(TopologyConfig::with_node_numbers(1))?; + + let node = cluster + .start_node_with( + "override-api", + StartNodeOptions { + peers: PeerSelection::None, + config_patch: None, + } + .create_patch(move |mut config| { + println!("overriding API port to {api_port}"); + + let current_addr = config.http.backend_settings.address; + + config.http.backend_settings.address = SocketAddr::new(current_addr.ip(), api_port); + + Ok(config) + }), + ) + .await? + .api; + + node.consensus_info() + .await + .expect("consensus_info should succeed"); + + assert_eq!(resolved_port(&node), api_port); + + Ok(()) +} + +#[tokio::test] +#[ignore = "run manually with `cargo test -p runner-examples -- --ignored scenario_builder_api_port_override`"] +async fn scenario_builder_api_port_override() -> Result<()> { + let _ = try_init(); + // Required env vars (set on the command line when running this test): + // - `POL_PROOF_DEV_MODE=true` + // - `LOGOS_BLOCKCHAIN_NODE_BIN=...` + // - `LOGOS_BLOCKCHAIN_CIRCUITS=...` + // - `RUST_LOG=info` (optional) + let api_port = random_api_port(); + + let mut scenario = ScenarioBuilder::topology_with(|t| { + t.network_star() + .nodes(1) + .node_config_patch_with(0, move |mut config| { + println!("overriding API port to {api_port}"); + + let current_addr = config.http.backend_settings.address; + + config.http.backend_settings.address = SocketAddr::new(current_addr.ip(), api_port); + + Ok(config) + }) + }) + .with_run_duration(Duration::from_secs(1)) + .build()?; + + let deployer = LocalDeployer::default(); + let runner = deployer.deploy(&scenario).await?; + let handle = runner.run(&mut scenario).await?; + + let client = handle + .context() + .node_clients() + .any_client() + .ok_or_else(|| anyhow::anyhow!("scenario did not expose any node clients"))?; + + client + .consensus_info() + .await + .expect("consensus_info should succeed"); + + assert_eq!(resolved_port(&client), api_port); + + Ok(()) +} + +fn random_api_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind random API port"); + listener.local_addr().expect("read API port").port() +} + +fn resolved_port(client: &ApiClient) -> u16 { + client.base_url().port().unwrap_or_default() +} diff --git a/testing-framework/core/src/nodes/node.rs b/testing-framework/core/src/nodes/node.rs index 581ff14..ac49c68 100644 --- a/testing-framework/core/src/nodes/node.rs +++ b/testing-framework/core/src/nodes/node.rs @@ -16,6 +16,8 @@ use crate::{ node::{NodeAddresses, NodeConfigCommon, NodeHandle, SpawnNodeError, spawn_node}, }, }, + scenario::DynError, + topology::config::NodeConfigPatch, }; const BIN_PATH: &str = "target/debug/logos-blockchain-node"; @@ -34,6 +36,23 @@ pub struct Node { handle: NodeHandle, } +pub fn apply_node_config_patches<'a>( + mut config: Config, + patches: impl IntoIterator, +) -> Result { + for patch in patches { + config = patch(config)?; + } + Ok(config) +} + +pub fn apply_node_config_patch( + config: Config, + patch: &NodeConfigPatch, +) -> Result { + apply_node_config_patches(config, [patch]) +} + impl Deref for Node { type Target = NodeHandle; diff --git a/testing-framework/core/src/scenario/capabilities.rs b/testing-framework/core/src/scenario/capabilities.rs index 5695d19..1c05590 100644 --- a/testing-framework/core/src/scenario/capabilities.rs +++ b/testing-framework/core/src/scenario/capabilities.rs @@ -1,8 +1,10 @@ +use std::sync::Arc; + use async_trait::async_trait; use reqwest::Url; use super::DynError; -use crate::nodes::ApiClient; +use crate::{nodes::ApiClient, topology::config::NodeConfigPatch}; /// Marker type used by scenario builders to request node control support. #[derive(Clone, Copy, Debug, Default)] @@ -34,20 +36,33 @@ pub enum PeerSelection { } /// Options for dynamically starting a node. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct StartNodeOptions { /// How to select initial peers on startup. pub peers: PeerSelection, + /// Optional node config patch applied before spawn. + pub config_patch: Option, } impl Default for StartNodeOptions { fn default() -> Self { Self { peers: PeerSelection::DefaultLayout, + config_patch: None, } } } +impl StartNodeOptions { + pub fn create_patch(mut self, f: F) -> Self + where + F: Fn(nomos_node::Config) -> Result + Send + Sync + 'static, + { + self.config_patch = Some(Arc::new(f)); + self + } +} + /// Trait implemented by scenario capability markers to signal whether node /// control is required. pub trait RequiresNodeControl { diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 522d0ab..dceb3c9 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -1,5 +1,6 @@ use std::{num::NonZeroUsize, sync::Arc, time::Duration}; +use nomos_node::Config as NodeConfig; use thiserror::Error; use tracing::{debug, info}; @@ -8,7 +9,7 @@ use super::{ workload::Workload, }; use crate::topology::{ - config::{TopologyBuildError, TopologyBuilder, TopologyConfig}, + config::{NodeConfigPatch, TopologyBuildError, TopologyBuilder, TopologyConfig}, configs::{network::Libp2pNetworkLayout, wallet::WalletConfig}, generation::GeneratedTopology, }; @@ -302,16 +303,37 @@ impl TopologyConfigurator { self } + /// Apply a config patch for a specific node index. + #[must_use] + pub fn node_config_patch(mut self, index: usize, patch: NodeConfigPatch) -> Self { + self.builder.topology = self.builder.topology.with_node_config_patch(index, patch); + self + } + + /// Apply a config patch for a specific node index. + #[must_use] + pub fn node_config_patch_with(mut self, index: usize, f: F) -> Self + where + F: Fn(NodeConfig) -> Result + Send + Sync + 'static, + { + self.builder.topology = self + .builder + .topology + .with_node_config_patch(index, Arc::new(f)); + self + } + /// Finalize and return the underlying scenario builder. #[must_use] pub fn apply(self) -> Builder { - let mut config = TopologyConfig::with_node_numbers(self.nodes); - if self.network_star { - config.network_params.libp2p_network_layout = Libp2pNetworkLayout::Star; - } - let mut builder = self.builder; - builder.topology = TopologyBuilder::new(config); + builder.topology = builder.topology.with_node_count(self.nodes); + + if self.network_star { + builder.topology = builder + .topology + .with_network_layout(Libp2pNetworkLayout::Star); + } builder } } diff --git a/testing-framework/core/src/topology/config.rs b/testing-framework/core/src/topology/config.rs index ebcd3e1..69e9bc3 100644 --- a/testing-framework/core/src/topology/config.rs +++ b/testing-framework/core/src/topology/config.rs @@ -1,7 +1,10 @@ +use std::{collections::HashMap, sync::Arc}; + use nomos_core::{ mantle::GenesisTx as _, sdp::{Locator, ServiceType}, }; +use nomos_node::Config as NodeConfig; use testing_framework_config::topology::{ configs::{ api::{ApiConfigError, create_api_configs}, @@ -18,12 +21,18 @@ use testing_framework_config::topology::{ }; use thiserror::Error; -use crate::topology::{ - configs::{GeneralConfig, time::default_time_config}, - generation::{GeneratedNodeConfig, GeneratedTopology}, - utils::{TopologyResolveError, create_kms_configs, resolve_ids, resolve_ports}, +use crate::{ + scenario::DynError, + topology::{ + configs::{GeneralConfig, time::default_time_config}, + generation::{GeneratedNodeConfig, GeneratedTopology}, + utils::{TopologyResolveError, create_kms_configs, resolve_ids, resolve_ports}, + }, }; +/// Per-node config patch applied after the default node config is generated. +pub type NodeConfigPatch = Arc Result + Send + Sync>; + #[derive(Debug, Error)] pub enum TopologyBuildError { #[error("topology must include at least one node")] @@ -55,6 +64,7 @@ pub struct TopologyConfig { pub consensus_params: ConsensusParams, pub network_params: NetworkParams, pub wallet_config: WalletConfig, + pub node_config_patches: HashMap, } impl TopologyConfig { @@ -66,6 +76,7 @@ impl TopologyConfig { consensus_params: ConsensusParams::default_for_participants(1), network_params: NetworkParams::default(), wallet_config: WalletConfig::default(), + node_config_patches: HashMap::new(), } } @@ -77,6 +88,7 @@ impl TopologyConfig { consensus_params: ConsensusParams::default_for_participants(2), network_params: NetworkParams::default(), wallet_config: WalletConfig::default(), + node_config_patches: HashMap::new(), } } @@ -90,6 +102,7 @@ impl TopologyConfig { consensus_params: ConsensusParams::default_for_participants(participants), network_params: NetworkParams::default(), wallet_config: WalletConfig::default(), + node_config_patches: HashMap::new(), } } @@ -97,6 +110,17 @@ impl TopologyConfig { pub const fn wallet(&self) -> &WalletConfig { &self.wallet_config } + + #[must_use] + pub fn node_config_patch(&self, index: usize) -> Option<&NodeConfigPatch> { + self.node_config_patches.get(&index) + } + + #[must_use] + pub fn with_node_config_patch(mut self, index: usize, patch: NodeConfigPatch) -> Self { + self.node_config_patches.insert(index, patch); + self + } } /// Builder that produces `GeneratedTopology` instances from a `TopologyConfig`. @@ -132,6 +156,13 @@ impl TopologyBuilder { self } + #[must_use] + /// Apply a config patch for a specific node index. + pub fn with_node_config_patch(mut self, index: usize, patch: NodeConfigPatch) -> Self { + self.config.node_config_patches.insert(index, patch); + self + } + #[must_use] /// Set node counts. pub const fn with_node_count(mut self, nodes: usize) -> Self { @@ -204,6 +235,7 @@ impl TopologyBuilder { &tracing_configs, &kms_configs, &time_config, + &config.node_config_patches, )?; Ok(GeneratedTopology { config, nodes }) @@ -291,6 +323,7 @@ fn build_node_descriptors( tracing_configs: &[testing_framework_config::topology::configs::tracing::GeneralTracingConfig], kms_configs: &[key_management_system_service::backend::preload::PreloadKMSBackendSettings], time_config: &testing_framework_config::topology::configs::time::GeneralTimeConfig, + node_config_patches: &HashMap, ) -> Result, TopologyBuildError> { let mut nodes = Vec::with_capacity(config.n_nodes); @@ -324,6 +357,7 @@ fn build_node_descriptors( id, general, blend_port, + config_patch: node_config_patches.get(&i).cloned(), }; nodes.push(descriptor); diff --git a/testing-framework/core/src/topology/deployment.rs b/testing-framework/core/src/topology/deployment.rs index 7cb2fc2..95d952b 100644 --- a/testing-framework/core/src/topology/deployment.rs +++ b/testing-framework/core/src/topology/deployment.rs @@ -5,12 +5,12 @@ use thiserror::Error; use crate::{ nodes::{ common::node::SpawnNodeError, - node::{Node, create_node_config}, + node::{Node, apply_node_config_patch, create_node_config}, }, + scenario, topology::{ config::{TopologyBuildError, TopologyBuilder, TopologyConfig}, - configs::GeneralConfig, - generation::find_expected_peer_counts, + generation::{GeneratedNodeConfig, find_expected_peer_counts}, readiness::{NetworkReadiness, ReadinessCheck, ReadinessError}, utils::multiaddr_port, }, @@ -29,18 +29,17 @@ pub enum SpawnTopologyError { Build(#[from] TopologyBuildError), #[error(transparent)] Node(#[from] SpawnNodeError), + #[error("node config patch failed for node-{index}: {source}")] + ConfigPatch { + index: usize, + source: scenario::DynError, + }, } impl Topology { pub async fn spawn(config: TopologyConfig) -> Result { let generated = TopologyBuilder::new(config.clone()).build()?; - let n_nodes = config.n_nodes; - let node_configs = generated - .iter() - .map(|node| node.general.clone()) - .collect::>(); - - let nodes = Self::spawn_nodes(node_configs, n_nodes).await?; + let nodes = Self::spawn_nodes(generated.nodes()).await?; Ok(Self { nodes }) } @@ -55,28 +54,32 @@ impl Topology { .with_blend_ports(blend_ports.to_vec()) .build()?; - let node_configs = generated - .iter() - .map(|node| node.general.clone()) - .collect::>(); - - let nodes = Self::spawn_nodes(node_configs, config.n_nodes).await?; + let nodes = Self::spawn_nodes(generated.nodes()).await?; Ok(Self { nodes }) } pub(crate) async fn spawn_nodes( - config: Vec, - n_nodes: usize, + nodes: &[GeneratedNodeConfig], ) -> Result { - let mut nodes = Vec::new(); - for i in 0..n_nodes { - let config = create_node_config(config[i].clone()); - let label = format!("node-{i}"); - nodes.push(Node::spawn(config, &label).await?); + let mut spawned = Vec::new(); + for node in nodes { + let mut config = create_node_config(node.general.clone()); + + if let Some(patch) = node.config_patch.as_ref() { + config = apply_node_config_patch(config, patch).map_err(|source| { + SpawnTopologyError::ConfigPatch { + index: node.index, + source, + } + })?; + } + + let label = format!("node-{}", node.index); + spawned.push(Node::spawn(config, &label).await?); } - Ok(nodes) + Ok(spawned) } #[must_use] diff --git a/testing-framework/core/src/topology/generation.rs b/testing-framework/core/src/topology/generation.rs index e691742..270e4f3 100644 --- a/testing-framework/core/src/topology/generation.rs +++ b/testing-framework/core/src/topology/generation.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, time::Duration}; use reqwest::{Client, Url}; use crate::topology::{ - config::TopologyConfig, + config::{NodeConfigPatch, TopologyConfig}, configs::{GeneralConfig, wallet::WalletAccount}, deployment::{SpawnTopologyError, Topology}, readiness::{HttpNetworkReadiness, ReadinessCheck, ReadinessError}, @@ -16,6 +16,7 @@ pub struct GeneratedNodeConfig { pub id: [u8; 32], pub general: GeneralConfig, pub blend_port: u16, + pub config_patch: Option, } impl GeneratedNodeConfig { @@ -82,12 +83,7 @@ impl GeneratedTopology { } pub async fn spawn_local(&self) -> Result { - let configs = self - .iter() - .map(|node| node.general.clone()) - .collect::>(); - - let nodes = Topology::spawn_nodes(configs, self.config.n_nodes).await?; + let nodes = Topology::spawn_nodes(self.nodes()).await?; Ok(Topology { nodes }) } diff --git a/testing-framework/deployers/local/src/node_control/config.rs b/testing-framework/deployers/local/src/node_control/config.rs index 26062ad..28673f2 100644 --- a/testing-framework/deployers/local/src/node_control/config.rs +++ b/testing-framework/deployers/local/src/node_control/config.rs @@ -11,7 +11,7 @@ use testing_framework_config::topology::configs::{ use testing_framework_core::{ scenario::{PeerSelection, StartNodeOptions}, topology::{ - config::TopologyConfig, + config::{NodeConfigPatch, TopologyConfig}, configs::GeneralConfig, generation::{GeneratedNodeConfig, GeneratedTopology}, }, @@ -27,7 +27,7 @@ pub(super) fn build_general_config_for( peer_ports_by_name: &HashMap, options: &StartNodeOptions, peer_ports: &[u16], -) -> Result<(GeneralConfig, u16), LocalDynamicError> { +) -> Result<(GeneralConfig, u16, Option), LocalDynamicError> { if let Some(node) = descriptor_for(descriptors, index) { let mut config = node.general.clone(); let initial_peers = resolve_initial_peers( @@ -40,7 +40,7 @@ pub(super) fn build_general_config_for( config.network_config.backend.initial_peers = initial_peers; - return Ok((config, node.network_port())); + return Ok((config, node.network_port(), node.config_patch.clone())); } let id = random_node_id(); @@ -61,7 +61,7 @@ pub(super) fn build_general_config_for( ) .map_err(|source| LocalDynamicError::Config { source })?; - Ok((general_config, network_port)) + Ok((general_config, network_port, None)) } fn descriptor_for(descriptors: &GeneratedTopology, index: usize) -> Option<&GeneratedNodeConfig> { diff --git a/testing-framework/deployers/local/src/node_control/mod.rs b/testing-framework/deployers/local/src/node_control/mod.rs index 7bb0acc..60827a3 100644 --- a/testing-framework/deployers/local/src/node_control/mod.rs +++ b/testing-framework/deployers/local/src/node_control/mod.rs @@ -8,7 +8,7 @@ use testing_framework_config::topology::configs::{consensus, time}; use testing_framework_core::{ nodes::{ ApiClient, - node::{Node, create_node_config}, + node::{Node, apply_node_config_patch, create_node_config}, }, scenario::{DynError, NodeControlHandle, StartNodeOptions, StartedNode}, topology::{ @@ -41,6 +41,8 @@ pub enum LocalDynamicError { InvalidArgument { message: String }, #[error("{message}")] PortAllocation { message: String }, + #[error("node config patch failed: {message}")] + ConfigPatch { message: String }, } pub struct LocalDynamicNodes { @@ -230,7 +232,7 @@ impl LocalDynamicNodes { ) }; - let (general_config, network_port) = build_general_config_for( + let (general_config, network_port, descriptor_patch) = build_general_config_for( &self.descriptors, &self.base_consensus, &self.base_time, @@ -240,7 +242,12 @@ impl LocalDynamicNodes { &peer_ports, )?; - let config = create_node_config(general_config); + let config = build_node_config( + general_config, + descriptor_patch.as_ref(), + options.config_patch.as_ref(), + )?; + let api_client = self .spawn_and_register_node(&node_name, network_port, config) .await?; @@ -275,6 +282,31 @@ impl LocalDynamicNodes { } } +fn build_node_config( + general_config: testing_framework_config::topology::configs::GeneralConfig, + descriptor_patch: Option<&testing_framework_core::topology::config::NodeConfigPatch>, + options_patch: Option<&testing_framework_core::topology::config::NodeConfigPatch>, +) -> Result { + let mut config = create_node_config(general_config); + config = apply_patch_if_needed(config, descriptor_patch)?; + config = apply_patch_if_needed(config, options_patch)?; + + Ok(config) +} + +fn apply_patch_if_needed( + config: NodeConfig, + patch: Option<&testing_framework_core::topology::config::NodeConfigPatch>, +) -> Result { + let Some(patch) = patch else { + return Ok(config); + }; + + apply_node_config_patch(config, patch).map_err(|err| LocalDynamicError::ConfigPatch { + message: err.to_string(), + }) +} + #[async_trait::async_trait] impl NodeControlHandle for LocalDynamicNodes { async fn restart_node(&self, _index: usize) -> Result<(), DynError> {