zombienet_configuration/shared/
node.rs

1use std::{cell::RefCell, error::Error, fmt::Display, marker::PhantomData, path::PathBuf, rc::Rc};
2
3use multiaddr::Multiaddr;
4use serde::{ser::SerializeStruct, Deserialize, Serialize};
5
6use super::{
7    errors::FieldError,
8    helpers::{
9        ensure_port_unique, ensure_value_is_not_empty, generate_unique_node_name,
10        generate_unique_node_name_from_names, merge_errors, merge_errors_vecs,
11    },
12    macros::states,
13    resources::ResourcesBuilder,
14    types::{AssetLocation, ChainDefaultContext, Command, Image, ValidationContext, U128},
15};
16use crate::{
17    shared::{
18        resources::Resources,
19        types::{Arg, Port},
20    },
21    utils::{default_as_true, default_initial_balance},
22};
23
24states! {
25    Buildable,
26    Initial
27}
28
29/// An environment variable with a name and a value.
30/// It can be constructed from a `(&str, &str)`.
31///
32/// # Examples:
33///
34/// ```
35/// use zombienet_configuration::shared::node::EnvVar;
36///
37/// let simple_var: EnvVar = ("FOO", "BAR").into();
38///
39/// assert_eq!(
40///     simple_var,
41///     EnvVar {
42///         name: "FOO".into(),
43///         value: "BAR".into()
44///     }
45/// )
46/// ```
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct EnvVar {
49    /// The name of the environment variable.
50    pub name: String,
51
52    /// The value of the environment variable.
53    pub value: String,
54}
55
56impl From<(&str, &str)> for EnvVar {
57    fn from((name, value): (&str, &str)) -> Self {
58        Self {
59            name: name.to_owned(),
60            value: value.to_owned(),
61        }
62    }
63}
64
65/// A node configuration, with fine-grained configuration options.
66#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
67pub struct NodeConfig {
68    pub(crate) name: String,
69    pub(crate) image: Option<Image>,
70    pub(crate) command: Option<Command>,
71    pub(crate) subcommand: Option<Command>,
72    #[serde(default)]
73    args: Vec<Arg>,
74    #[serde(alias = "validator", default = "default_as_true")]
75    pub(crate) is_validator: bool,
76    #[serde(alias = "invulnerable", default = "default_as_true")]
77    pub(crate) is_invulnerable: bool,
78    #[serde(alias = "bootnode", default)]
79    pub(crate) is_bootnode: bool,
80    #[serde(alias = "balance")]
81    #[serde(default = "default_initial_balance")]
82    initial_balance: U128,
83    #[serde(default)]
84    env: Vec<EnvVar>,
85    #[serde(default)]
86    bootnodes_addresses: Vec<Multiaddr>,
87    pub(crate) resources: Option<Resources>,
88    ws_port: Option<Port>,
89    rpc_port: Option<Port>,
90    prometheus_port: Option<Port>,
91    p2p_port: Option<Port>,
92    p2p_cert_hash: Option<String>,
93    pub(crate) db_snapshot: Option<AssetLocation>,
94    /// Optional override for the automatically generated EVM (eth) session key.
95    /// When set, override the auto-generated key so the seed will not be part of the resulting zombie.json
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    override_eth_key: Option<String>,
98    #[serde(default)]
99    // used to skip serialization of fields with defaults to avoid duplication
100    pub(crate) chain_context: ChainDefaultContext,
101    pub(crate) node_log_path: Option<PathBuf>,
102    // optional node keystore path override
103    keystore_path: Option<PathBuf>,
104    /// Keystore key types to generate.
105    /// Supports short form (e.g., "audi") using predefined schemas,
106    /// or long form (e.g., "audi_sr") with explicit schema (sr, ed, ec).
107    #[serde(default)]
108    keystore_key_types: Vec<String>,
109    /// Chain spec session key types to inject.
110    /// Supports short form (e.g., "aura") using predefined schemas,
111    /// or long form (e.g., "aura_sr") with explicit schema (sr, ed, ec).
112    /// When empty, uses the default session keys from the chain spec.
113    #[serde(default)]
114    chain_spec_key_types: Vec<String>,
115}
116
117impl Serialize for NodeConfig {
118    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
119    where
120        S: serde::Serializer,
121    {
122        let mut state = serializer.serialize_struct("NodeConfig", 19)?;
123        state.serialize_field("name", &self.name)?;
124
125        if self.image == self.chain_context.default_image {
126            state.skip_field("image")?;
127        } else {
128            state.serialize_field("image", &self.image)?;
129        }
130
131        if self.command == self.chain_context.default_command {
132            state.skip_field("command")?;
133        } else {
134            state.serialize_field("command", &self.command)?;
135        }
136
137        if self.subcommand.is_none() {
138            state.skip_field("subcommand")?;
139        } else {
140            state.serialize_field("subcommand", &self.subcommand)?;
141        }
142
143        if self.args.is_empty() || self.args == self.chain_context.default_args {
144            state.skip_field("args")?;
145        } else {
146            state.serialize_field("args", &self.args)?;
147        }
148
149        state.serialize_field("validator", &self.is_validator)?;
150        state.serialize_field("invulnerable", &self.is_invulnerable)?;
151        state.serialize_field("bootnode", &self.is_bootnode)?;
152        state.serialize_field("balance", &self.initial_balance)?;
153
154        if self.env.is_empty() {
155            state.skip_field("env")?;
156        } else {
157            state.serialize_field("env", &self.env)?;
158        }
159
160        if self.bootnodes_addresses.is_empty() {
161            state.skip_field("bootnodes_addresses")?;
162        } else {
163            state.serialize_field("bootnodes_addresses", &self.bootnodes_addresses)?;
164        }
165
166        if self.resources == self.chain_context.default_resources {
167            state.skip_field("resources")?;
168        } else {
169            state.serialize_field("resources", &self.resources)?;
170        }
171
172        state.serialize_field("ws_port", &self.ws_port)?;
173        state.serialize_field("rpc_port", &self.rpc_port)?;
174        state.serialize_field("prometheus_port", &self.prometheus_port)?;
175        state.serialize_field("p2p_port", &self.p2p_port)?;
176        state.serialize_field("p2p_cert_hash", &self.p2p_cert_hash)?;
177        state.serialize_field("override_eth_key", &self.override_eth_key)?;
178
179        if self.db_snapshot == self.chain_context.default_db_snapshot {
180            state.skip_field("db_snapshot")?;
181        } else {
182            state.serialize_field("db_snapshot", &self.db_snapshot)?;
183        }
184
185        if self.node_log_path.is_none() {
186            state.skip_field("node_log_path")?;
187        } else {
188            state.serialize_field("node_log_path", &self.node_log_path)?;
189        }
190
191        if self.keystore_path.is_none() {
192            state.skip_field("keystore_path")?;
193        } else {
194            state.serialize_field("keystore_path", &self.keystore_path)?;
195        }
196
197        if self.keystore_key_types.is_empty() {
198            state.skip_field("keystore_key_types")?;
199        } else {
200            state.serialize_field("keystore_key_types", &self.keystore_key_types)?;
201        }
202
203        if self.chain_spec_key_types.is_empty() {
204            state.skip_field("chain_spec_key_typese")?;
205        } else {
206            state.serialize_field("chain_spec_key_types", &self.chain_spec_key_types)?;
207        }
208
209        state.skip_field("chain_context")?;
210        state.end()
211    }
212}
213
214/// A group of nodes configuration
215#[derive(Debug, Clone, PartialEq, Deserialize)]
216pub struct GroupNodeConfig {
217    #[serde(flatten)]
218    pub(crate) base_config: NodeConfig,
219    pub(crate) count: usize,
220}
221
222impl GroupNodeConfig {
223    /// Expands the group into individual node configs.
224    /// Each node will have the same base configuration, but with unique names and log paths.
225    pub fn expand_group_configs(&self) -> Vec<NodeConfig> {
226        let mut used_names = std::collections::HashSet::new();
227
228        (0..self.count)
229            .map(|i| {
230                let mut node = self.base_config.clone();
231                // append count sufix
232                let node_name = format!("{}-{i}", node.name);
233
234                let unique_name = generate_unique_node_name_from_names(node_name, &mut used_names);
235                node.name = unique_name;
236
237                // If base config has a log path, generate unique log path for each node
238                if let Some(ref base_log_path) = node.node_log_path {
239                    let unique_log_path = if let Some(parent) = base_log_path.parent() {
240                        parent.join(format!("{}.log", node.name))
241                    } else {
242                        PathBuf::from(format!("{}.log", node.name))
243                    };
244                    node.node_log_path = Some(unique_log_path);
245                }
246
247                node
248            })
249            .collect()
250    }
251}
252
253impl Serialize for GroupNodeConfig {
254    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
255    where
256        S: serde::Serializer,
257    {
258        let mut state = serializer.serialize_struct("GroupNodeConfig", 18)?;
259        state.serialize_field("NodeConfig", &self.base_config)?;
260        state.serialize_field("count", &self.count)?;
261        state.end()
262    }
263}
264
265impl NodeConfig {
266    /// Node name (should be unique).
267    pub fn name(&self) -> &str {
268        &self.name
269    }
270
271    /// Image to run (only podman/k8s).
272    pub fn image(&self) -> Option<&Image> {
273        self.image.as_ref()
274    }
275
276    /// Command to run the node.
277    pub fn command(&self) -> Option<&Command> {
278        self.command.as_ref()
279    }
280
281    /// Subcommand to run the node.
282    pub fn subcommand(&self) -> Option<&Command> {
283        self.subcommand.as_ref()
284    }
285
286    /// Arguments to use for node.
287    pub fn args(&self) -> Vec<&Arg> {
288        self.args.iter().collect()
289    }
290
291    /// Arguments to use for node.
292    pub(crate) fn set_args(&mut self, args: Vec<Arg>) {
293        self.args = args;
294    }
295
296    /// Whether the node is a validator.
297    pub fn is_validator(&self) -> bool {
298        self.is_validator
299    }
300
301    /// Whether the node keys must be added to invulnerables.
302    pub fn is_invulnerable(&self) -> bool {
303        self.is_invulnerable
304    }
305
306    /// Whether the node is a bootnode.
307    pub fn is_bootnode(&self) -> bool {
308        self.is_bootnode
309    }
310
311    /// Node initial balance present in genesis.
312    pub fn initial_balance(&self) -> u128 {
313        self.initial_balance.0
314    }
315
316    /// Environment variables to set (inside pod for podman/k8s, inside shell for native).
317    pub fn env(&self) -> Vec<&EnvVar> {
318        self.env.iter().collect()
319    }
320
321    /// List of node's bootnodes addresses to use.
322    pub fn bootnodes_addresses(&self) -> Vec<&Multiaddr> {
323        self.bootnodes_addresses.iter().collect()
324    }
325
326    /// Default resources.
327    pub fn resources(&self) -> Option<&Resources> {
328        self.resources.as_ref()
329    }
330
331    /// Websocket port to use.
332    pub fn ws_port(&self) -> Option<u16> {
333        self.ws_port
334    }
335
336    /// RPC port to use.
337    pub fn rpc_port(&self) -> Option<u16> {
338        self.rpc_port
339    }
340
341    /// Prometheus port to use.
342    pub fn prometheus_port(&self) -> Option<u16> {
343        self.prometheus_port
344    }
345
346    /// P2P port to use.
347    pub fn p2p_port(&self) -> Option<u16> {
348        self.p2p_port
349    }
350
351    /// `libp2p` cert hash to use with `WebRTC` transport.
352    pub fn p2p_cert_hash(&self) -> Option<&str> {
353        self.p2p_cert_hash.as_deref()
354    }
355
356    /// Database snapshot.
357    pub fn db_snapshot(&self) -> Option<&AssetLocation> {
358        self.db_snapshot.as_ref()
359    }
360
361    /// Node log path
362    pub fn node_log_path(&self) -> Option<&PathBuf> {
363        self.node_log_path.as_ref()
364    }
365
366    /// Keystore path
367    pub fn keystore_path(&self) -> Option<&PathBuf> {
368        self.keystore_path.as_ref()
369    }
370
371    /// Override EVM session key to use for the node
372    pub fn override_eth_key(&self) -> Option<&str> {
373        self.override_eth_key.as_deref()
374    }
375
376    /// Keystore key types to generate.
377    /// Returns the list of key type specifications (short form like "audi" or long form like "audi_sr").
378    pub fn keystore_key_types(&self) -> Vec<&str> {
379        self.keystore_key_types.iter().map(String::as_str).collect()
380    }
381
382    /// Chain spec session key types to inject.
383    /// Returns the list of key type specifications (short form like "aura" or long form like "aura_sr").
384    pub fn chain_spec_key_types(&self) -> Vec<&str> {
385        self.chain_spec_key_types
386            .iter()
387            .map(String::as_str)
388            .collect()
389    }
390}
391
392/// A node configuration builder, used to build a [`NodeConfig`] declaratively with fields validation.
393pub struct NodeConfigBuilder<S> {
394    config: NodeConfig,
395    validation_context: Rc<RefCell<ValidationContext>>,
396    errors: Vec<anyhow::Error>,
397    _state: PhantomData<S>,
398}
399
400impl Default for NodeConfigBuilder<Initial> {
401    fn default() -> Self {
402        Self {
403            config: NodeConfig {
404                name: "".into(),
405                image: None,
406                command: None,
407                subcommand: None,
408                args: vec![],
409                is_validator: true,
410                is_invulnerable: true,
411                is_bootnode: false,
412                initial_balance: 2_000_000_000_000.into(),
413                env: vec![],
414                bootnodes_addresses: vec![],
415                resources: None,
416                ws_port: None,
417                rpc_port: None,
418                prometheus_port: None,
419                p2p_port: None,
420                p2p_cert_hash: None,
421                db_snapshot: None,
422                override_eth_key: None,
423                chain_context: Default::default(),
424                node_log_path: None,
425                keystore_path: None,
426                keystore_key_types: vec![],
427                chain_spec_key_types: vec![],
428            },
429            validation_context: Default::default(),
430            errors: vec![],
431            _state: PhantomData,
432        }
433    }
434}
435
436impl<A> NodeConfigBuilder<A> {
437    fn transition<B>(
438        config: NodeConfig,
439        validation_context: Rc<RefCell<ValidationContext>>,
440        errors: Vec<anyhow::Error>,
441    ) -> NodeConfigBuilder<B> {
442        NodeConfigBuilder {
443            config,
444            validation_context,
445            errors,
446            _state: PhantomData,
447        }
448    }
449}
450
451impl NodeConfigBuilder<Initial> {
452    pub fn new(
453        chain_context: ChainDefaultContext,
454        validation_context: Rc<RefCell<ValidationContext>>,
455    ) -> Self {
456        Self::transition(
457            NodeConfig {
458                command: chain_context.default_command.clone(),
459                image: chain_context.default_image.clone(),
460                resources: chain_context.default_resources.clone(),
461                db_snapshot: chain_context.default_db_snapshot.clone(),
462                args: chain_context.default_args.clone(),
463                chain_context,
464                ..Self::default().config
465            },
466            validation_context,
467            vec![],
468        )
469    }
470
471    /// Set the name of the node.
472    pub fn with_name<T: Into<String> + Copy>(self, name: T) -> NodeConfigBuilder<Buildable> {
473        let name: String = generate_unique_node_name(name, self.validation_context.clone());
474
475        match ensure_value_is_not_empty(&name) {
476            Ok(_) => Self::transition(
477                NodeConfig {
478                    name,
479                    ..self.config
480                },
481                self.validation_context,
482                self.errors,
483            ),
484            Err(e) => Self::transition(
485                NodeConfig {
486                    // we still set the name in error case to display error path
487                    name,
488                    ..self.config
489                },
490                self.validation_context,
491                merge_errors(self.errors, FieldError::Name(e).into()),
492            ),
493        }
494    }
495}
496
497impl NodeConfigBuilder<Buildable> {
498    /// Set the command that will be executed to launch the node. Override the default.
499    pub fn with_command<T>(self, command: T) -> Self
500    where
501        T: TryInto<Command>,
502        T::Error: Error + Send + Sync + 'static,
503    {
504        match command.try_into() {
505            Ok(command) => Self::transition(
506                NodeConfig {
507                    command: Some(command),
508                    ..self.config
509                },
510                self.validation_context,
511                self.errors,
512            ),
513            Err(error) => Self::transition(
514                self.config,
515                self.validation_context,
516                merge_errors(self.errors, FieldError::Command(error.into()).into()),
517            ),
518        }
519    }
520
521    /// Set the subcommand that will be executed to launch the node.
522    pub fn with_subcommand<T>(self, subcommand: T) -> Self
523    where
524        T: TryInto<Command>,
525        T::Error: Error + Send + Sync + 'static,
526    {
527        match subcommand.try_into() {
528            Ok(subcommand) => Self::transition(
529                NodeConfig {
530                    subcommand: Some(subcommand),
531                    ..self.config
532                },
533                self.validation_context,
534                self.errors,
535            ),
536            Err(error) => Self::transition(
537                self.config,
538                self.validation_context,
539                merge_errors(self.errors, FieldError::Command(error.into()).into()),
540            ),
541        }
542    }
543
544    /// Set the image that will be used for the node (only podman/k8s). Override the default.
545    pub fn with_image<T>(self, image: T) -> Self
546    where
547        T: TryInto<Image>,
548        T::Error: Error + Send + Sync + 'static,
549    {
550        match image.try_into() {
551            Ok(image) => Self::transition(
552                NodeConfig {
553                    image: Some(image),
554                    ..self.config
555                },
556                self.validation_context,
557                self.errors,
558            ),
559            Err(error) => Self::transition(
560                self.config,
561                self.validation_context,
562                merge_errors(self.errors, FieldError::Image(error.into()).into()),
563            ),
564        }
565    }
566
567    /// Set the arguments that will be used when launching the node. Override the default_args of the chain context.
568    pub fn with_args(self, args: Vec<Arg>) -> Self {
569        Self::transition(
570            NodeConfig {
571                args,
572                ..self.config
573            },
574            self.validation_context,
575            self.errors,
576        )
577    }
578
579    /// Set whether the node is a validator.
580    pub fn validator(self, choice: bool) -> Self {
581        Self::transition(
582            NodeConfig {
583                is_validator: choice,
584                ..self.config
585            },
586            self.validation_context,
587            self.errors,
588        )
589    }
590
591    /// Set whether the node is invulnerable.
592    pub fn invulnerable(self, choice: bool) -> Self {
593        Self::transition(
594            NodeConfig {
595                is_invulnerable: choice,
596                ..self.config
597            },
598            self.validation_context,
599            self.errors,
600        )
601    }
602
603    /// Set whether the node is a bootnode.
604    pub fn bootnode(self, choice: bool) -> Self {
605        Self::transition(
606            NodeConfig {
607                is_bootnode: choice,
608                ..self.config
609            },
610            self.validation_context,
611            self.errors,
612        )
613    }
614
615    /// Override the EVM session key to use for the node
616    pub fn with_override_eth_key(self, session_key: impl Into<String>) -> Self {
617        Self::transition(
618            NodeConfig {
619                override_eth_key: Some(session_key.into()),
620                ..self.config
621            },
622            self.validation_context,
623            self.errors,
624        )
625    }
626
627    /// Set the node initial balance.
628    pub fn with_initial_balance(self, initial_balance: u128) -> Self {
629        Self::transition(
630            NodeConfig {
631                initial_balance: initial_balance.into(),
632                ..self.config
633            },
634            self.validation_context,
635            self.errors,
636        )
637    }
638
639    /// Set the node environment variables that will be used when launched. Override the default.
640    pub fn with_env(self, env: Vec<impl Into<EnvVar>>) -> Self {
641        let env = env.into_iter().map(|var| var.into()).collect::<Vec<_>>();
642
643        Self::transition(
644            NodeConfig { env, ..self.config },
645            self.validation_context,
646            self.errors,
647        )
648    }
649
650    /// Set the bootnodes addresses that the node will try to connect to. Override the default.
651    ///
652    /// Note: Bootnode address replacements are NOT supported here.
653    /// Only arguments (`args`) support dynamic replacements. Bootnode addresses must be a valid address.
654    pub fn with_raw_bootnodes_addresses<T>(self, bootnodes_addresses: Vec<T>) -> Self
655    where
656        T: TryInto<Multiaddr> + Display + Copy,
657        T::Error: Error + Send + Sync + 'static,
658    {
659        let mut addrs = vec![];
660        let mut errors = vec![];
661
662        for (index, addr) in bootnodes_addresses.into_iter().enumerate() {
663            match addr.try_into() {
664                Ok(addr) => addrs.push(addr),
665                Err(error) => errors.push(
666                    FieldError::BootnodesAddress(index, addr.to_string(), error.into()).into(),
667                ),
668            }
669        }
670
671        Self::transition(
672            NodeConfig {
673                bootnodes_addresses: addrs,
674                ..self.config
675            },
676            self.validation_context,
677            merge_errors_vecs(self.errors, errors),
678        )
679    }
680
681    /// Set the resources limits what will be used for the node (only podman/k8s). Override the default.
682    pub fn with_resources(self, f: impl FnOnce(ResourcesBuilder) -> ResourcesBuilder) -> Self {
683        match f(ResourcesBuilder::new()).build() {
684            Ok(resources) => Self::transition(
685                NodeConfig {
686                    resources: Some(resources),
687                    ..self.config
688                },
689                self.validation_context,
690                self.errors,
691            ),
692            Err(errors) => Self::transition(
693                self.config,
694                self.validation_context,
695                merge_errors_vecs(
696                    self.errors,
697                    errors
698                        .into_iter()
699                        .map(|error| FieldError::Resources(error).into())
700                        .collect::<Vec<_>>(),
701                ),
702            ),
703        }
704    }
705
706    /// Set the websocket port that will be exposed. Uniqueness across config will be checked.
707    pub fn with_ws_port(self, ws_port: Port) -> Self {
708        match ensure_port_unique(ws_port, self.validation_context.clone()) {
709            Ok(_) => Self::transition(
710                NodeConfig {
711                    ws_port: Some(ws_port),
712                    ..self.config
713                },
714                self.validation_context,
715                self.errors,
716            ),
717            Err(error) => Self::transition(
718                self.config,
719                self.validation_context,
720                merge_errors(self.errors, FieldError::WsPort(error).into()),
721            ),
722        }
723    }
724
725    /// Set the RPC port that will be exposed. Uniqueness across config will be checked.
726    pub fn with_rpc_port(self, rpc_port: Port) -> Self {
727        match ensure_port_unique(rpc_port, self.validation_context.clone()) {
728            Ok(_) => Self::transition(
729                NodeConfig {
730                    rpc_port: Some(rpc_port),
731                    ..self.config
732                },
733                self.validation_context,
734                self.errors,
735            ),
736            Err(error) => Self::transition(
737                self.config,
738                self.validation_context,
739                merge_errors(self.errors, FieldError::RpcPort(error).into()),
740            ),
741        }
742    }
743
744    /// Set the Prometheus port that will be exposed for metrics. Uniqueness across config will be checked.
745    pub fn with_prometheus_port(self, prometheus_port: Port) -> Self {
746        match ensure_port_unique(prometheus_port, self.validation_context.clone()) {
747            Ok(_) => Self::transition(
748                NodeConfig {
749                    prometheus_port: Some(prometheus_port),
750                    ..self.config
751                },
752                self.validation_context,
753                self.errors,
754            ),
755            Err(error) => Self::transition(
756                self.config,
757                self.validation_context,
758                merge_errors(self.errors, FieldError::PrometheusPort(error).into()),
759            ),
760        }
761    }
762
763    /// Set the P2P port that will be exposed. Uniqueness across config will be checked.
764    pub fn with_p2p_port(self, p2p_port: Port) -> Self {
765        match ensure_port_unique(p2p_port, self.validation_context.clone()) {
766            Ok(_) => Self::transition(
767                NodeConfig {
768                    p2p_port: Some(p2p_port),
769                    ..self.config
770                },
771                self.validation_context,
772                self.errors,
773            ),
774            Err(error) => Self::transition(
775                self.config,
776                self.validation_context,
777                merge_errors(self.errors, FieldError::P2pPort(error).into()),
778            ),
779        }
780    }
781
782    /// Set the P2P cert hash that will be used as part of the multiaddress
783    /// if and only if the multiaddress is set to use `webrtc`.
784    pub fn with_p2p_cert_hash(self, p2p_cert_hash: impl Into<String>) -> Self {
785        Self::transition(
786            NodeConfig {
787                p2p_cert_hash: Some(p2p_cert_hash.into()),
788                ..self.config
789            },
790            self.validation_context,
791            self.errors,
792        )
793    }
794
795    /// Set the database snapshot that will be used to launch the node. Override the default.
796    pub fn with_db_snapshot(self, location: impl Into<AssetLocation>) -> Self {
797        Self::transition(
798            NodeConfig {
799                db_snapshot: Some(location.into()),
800                ..self.config
801            },
802            self.validation_context,
803            self.errors,
804        )
805    }
806
807    /// Set the node log path that will be used to launch the node.
808    pub fn with_log_path(self, log_path: impl Into<PathBuf>) -> Self {
809        Self::transition(
810            NodeConfig {
811                node_log_path: Some(log_path.into()),
812                ..self.config
813            },
814            self.validation_context,
815            self.errors,
816        )
817    }
818
819    /// Set the keystore path override.
820    pub fn with_keystore_path(self, keystore_path: impl Into<PathBuf>) -> Self {
821        Self::transition(
822            NodeConfig {
823                keystore_path: Some(keystore_path.into()),
824                ..self.config
825            },
826            self.validation_context,
827            self.errors,
828        )
829    }
830
831    /// Set the keystore key types to generate.
832    ///
833    /// Each key type can be specified in short form (e.g., "audi") using predefined schemas
834    /// (defaults to `sr` if no predefined schema exists for the key type),
835    /// or in long form (e.g., "audi_sr") with an explicit schema (sr, ed, ec).
836    ///
837    /// # Examples
838    ///
839    /// ```
840    /// use zombienet_configuration::shared::{node::NodeConfigBuilder, types::ChainDefaultContext};
841    ///
842    /// let config = NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
843    ///     .with_name("node")
844    ///     .with_keystore_key_types(vec!["audi", "gran", "cust_sr"])
845    ///     .build()
846    ///     .unwrap();
847    ///
848    /// assert_eq!(config.keystore_key_types(), &["audi", "gran", "cust_sr"]);
849    /// ```
850    pub fn with_keystore_key_types(self, key_types: Vec<impl Into<String>>) -> Self {
851        Self::transition(
852            NodeConfig {
853                keystore_key_types: key_types.into_iter().map(|k| k.into()).collect(),
854                ..self.config
855            },
856            self.validation_context,
857            self.errors,
858        )
859    }
860
861    /// Set the chain spec session key types to inject.
862    ///
863    /// Each key type can be specified in short form (e.g., "aura") using predefined schemas
864    /// (defaults to `sr` if no predefined schema exists for the key type),
865    /// or in long form (e.g., "aura_sr") with an explicit schema (sr, ed, ec).
866    ///
867    /// When specified, only these keys will be injected into the chain spec session keys.
868    /// When empty, uses the default session keys from the chain spec.
869    ///
870    /// # Examples
871    ///
872    /// ```
873    /// use zombienet_configuration::shared::{node::NodeConfigBuilder, types::ChainDefaultContext};
874    ///
875    /// let config = NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
876    ///     .with_name("node")
877    ///     .with_chain_spec_key_types(vec!["aura", "grandpa", "babe_sr"])
878    ///     .build()
879    ///     .unwrap();
880    ///
881    /// assert_eq!(
882    ///     config.chain_spec_key_types(),
883    ///     &["aura", "grandpa", "babe_sr"]
884    /// );
885    /// ```
886    pub fn with_chain_spec_key_types(self, key_types: Vec<impl Into<String>>) -> Self {
887        Self::transition(
888            NodeConfig {
889                chain_spec_key_types: key_types.into_iter().map(|k| k.into()).collect(),
890                ..self.config
891            },
892            self.validation_context,
893            self.errors,
894        )
895    }
896
897    /// Seals the builder and returns a [`NodeConfig`] if there are no validation errors, else returns errors.
898    pub fn build(self) -> Result<NodeConfig, (String, Vec<anyhow::Error>)> {
899        if !self.errors.is_empty() {
900            return Err((self.config.name.clone(), self.errors));
901        }
902
903        Ok(self.config)
904    }
905}
906
907/// A group node configuration builder, used to build a [`GroupNodeConfig`] declaratively with fields validation.
908pub struct GroupNodeConfigBuilder<S> {
909    base_config: NodeConfig,
910    count: usize,
911    validation_context: Rc<RefCell<ValidationContext>>,
912    errors: Vec<anyhow::Error>,
913    _state: PhantomData<S>,
914}
915
916impl GroupNodeConfigBuilder<Initial> {
917    pub fn new(
918        chain_context: ChainDefaultContext,
919        validation_context: Rc<RefCell<ValidationContext>>,
920    ) -> Self {
921        let (errors, base_config) = match NodeConfigBuilder::new(
922            chain_context.clone(),
923            validation_context.clone(),
924        )
925        .with_name(" ") // placeholder
926        .build()
927        {
928            Ok(base_config) => (vec![], base_config),
929            Err((_name, errors)) => (errors, NodeConfig::default()),
930        };
931
932        Self {
933            base_config,
934            count: 1,
935            validation_context,
936            errors,
937            _state: PhantomData,
938        }
939    }
940
941    /// Set the base node config using a closure.
942    pub fn with_base_node(
943        mut self,
944        f: impl FnOnce(NodeConfigBuilder<Initial>) -> NodeConfigBuilder<Buildable>,
945    ) -> GroupNodeConfigBuilder<Buildable> {
946        match f(NodeConfigBuilder::new(
947            ChainDefaultContext::default(),
948            self.validation_context.clone(),
949        ))
950        .build()
951        {
952            Ok(node) => {
953                self.base_config = node;
954                GroupNodeConfigBuilder {
955                    base_config: self.base_config,
956                    count: self.count,
957                    validation_context: self.validation_context,
958                    errors: self.errors,
959                    _state: PhantomData,
960                }
961            },
962            Err((_name, errors)) => {
963                self.errors.extend(errors);
964                GroupNodeConfigBuilder {
965                    base_config: self.base_config,
966                    count: self.count,
967                    validation_context: self.validation_context,
968                    errors: self.errors,
969                    _state: PhantomData,
970                }
971            },
972        }
973    }
974
975    /// Set the number of nodes in the group.
976    pub fn with_count(mut self, count: usize) -> Self {
977        self.count = count;
978        self
979    }
980}
981
982impl GroupNodeConfigBuilder<Buildable> {
983    pub fn build(self) -> Result<GroupNodeConfig, (String, Vec<anyhow::Error>)> {
984        if self.count == 0 {
985            return Err((
986                self.base_config.name().to_string(),
987                vec![anyhow::anyhow!("Count cannot be zero")],
988            ));
989        }
990
991        if !self.errors.is_empty() {
992            return Err((self.base_config.name().to_string(), self.errors));
993        }
994
995        Ok(GroupNodeConfig {
996            base_config: self.base_config,
997            count: self.count,
998        })
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use std::collections::HashSet;
1005
1006    use super::*;
1007
1008    #[test]
1009    fn node_config_builder_should_succeeds_and_returns_a_node_config() {
1010        let node_config =
1011            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1012                .with_name("node")
1013                .with_command("mycommand")
1014                .with_image("myrepo:myimage")
1015                .with_args(vec![("--arg1", "value1").into(), "--option2".into()])
1016                .validator(true)
1017                .invulnerable(true)
1018                .bootnode(true)
1019                .with_override_eth_key("0x0123456789abcdef0123456789abcdef01234567")
1020                .with_initial_balance(100_000_042)
1021                .with_env(vec![("VAR1", "VALUE1"), ("VAR2", "VALUE2")])
1022                .with_raw_bootnodes_addresses(vec![
1023                    "/ip4/10.41.122.55/tcp/45421",
1024                    "/ip4/51.144.222.10/tcp/2333",
1025                ])
1026                .with_resources(|resources| {
1027                    resources
1028                        .with_request_cpu("200M")
1029                        .with_request_memory("500M")
1030                        .with_limit_cpu("1G")
1031                        .with_limit_memory("2G")
1032                })
1033                .with_ws_port(5000)
1034                .with_rpc_port(6000)
1035                .with_prometheus_port(7000)
1036                .with_p2p_port(8000)
1037                .with_p2p_cert_hash(
1038                    "ec8d6467180a4b72a52b24c53aa1e53b76c05602fa96f5d0961bf720edda267f",
1039                )
1040                .with_db_snapshot("/tmp/mysnapshot")
1041                .with_keystore_path("/tmp/mykeystore")
1042                .build()
1043                .unwrap();
1044
1045        assert_eq!(node_config.name(), "node");
1046        assert_eq!(node_config.command().unwrap().as_str(), "mycommand");
1047        assert_eq!(node_config.image().unwrap().as_str(), "myrepo:myimage");
1048        let args: Vec<Arg> = vec![("--arg1", "value1").into(), "--option2".into()];
1049        assert_eq!(node_config.args(), args.iter().collect::<Vec<_>>());
1050        assert!(node_config.is_validator());
1051        assert!(node_config.is_invulnerable());
1052        assert!(node_config.is_bootnode());
1053        assert_eq!(
1054            node_config.override_eth_key(),
1055            Some("0x0123456789abcdef0123456789abcdef01234567")
1056        );
1057        assert_eq!(node_config.initial_balance(), 100_000_042);
1058        let env: Vec<EnvVar> = vec![("VAR1", "VALUE1").into(), ("VAR2", "VALUE2").into()];
1059        assert_eq!(node_config.env(), env.iter().collect::<Vec<_>>());
1060        let bootnodes_addresses: Vec<Multiaddr> = vec![
1061            "/ip4/10.41.122.55/tcp/45421".try_into().unwrap(),
1062            "/ip4/51.144.222.10/tcp/2333".try_into().unwrap(),
1063        ];
1064        assert_eq!(
1065            node_config.bootnodes_addresses(),
1066            bootnodes_addresses.iter().collect::<Vec<_>>()
1067        );
1068        let resources = node_config.resources().unwrap();
1069        assert_eq!(resources.request_cpu().unwrap().as_str(), "200M");
1070        assert_eq!(resources.request_memory().unwrap().as_str(), "500M");
1071        assert_eq!(resources.limit_cpu().unwrap().as_str(), "1G");
1072        assert_eq!(resources.limit_memory().unwrap().as_str(), "2G");
1073        assert_eq!(node_config.ws_port().unwrap(), 5000);
1074        assert_eq!(node_config.rpc_port().unwrap(), 6000);
1075        assert_eq!(node_config.prometheus_port().unwrap(), 7000);
1076        assert_eq!(node_config.p2p_port().unwrap(), 8000);
1077        assert_eq!(
1078            node_config.p2p_cert_hash().unwrap(),
1079            "ec8d6467180a4b72a52b24c53aa1e53b76c05602fa96f5d0961bf720edda267f"
1080        );
1081        assert!(matches!(
1082            node_config.db_snapshot().unwrap(), AssetLocation::FilePath(value) if value.to_str().unwrap() == "/tmp/mysnapshot"
1083        ));
1084        assert!(matches!(
1085            node_config.keystore_path().unwrap().to_str().unwrap(),
1086            "/tmp/mykeystore"
1087        ));
1088    }
1089
1090    #[test]
1091    fn node_config_builder_should_use_unique_name_if_node_name_already_used() {
1092        let mut used_nodes_names = HashSet::new();
1093        used_nodes_names.insert("mynode".into());
1094        let validation_context = Rc::new(RefCell::new(ValidationContext {
1095            used_nodes_names,
1096            ..Default::default()
1097        }));
1098        let node_config =
1099            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context)
1100                .with_name("mynode")
1101                .build()
1102                .unwrap();
1103
1104        assert_eq!(node_config.name, "mynode-1");
1105    }
1106
1107    #[test]
1108    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_command_is_invalid() {
1109        let (node_name, errors) =
1110            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1111                .with_name("node")
1112                .with_command("invalid command")
1113                .build()
1114                .unwrap_err();
1115
1116        assert_eq!(node_name, "node");
1117        assert_eq!(errors.len(), 1);
1118        assert_eq!(
1119            errors.first().unwrap().to_string(),
1120            "command: 'invalid command' shouldn't contains whitespace"
1121        );
1122    }
1123
1124    #[test]
1125    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_image_is_invalid() {
1126        let (node_name, errors) =
1127            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1128                .with_name("node")
1129                .with_image("myinvalid.image")
1130                .build()
1131                .unwrap_err();
1132
1133        assert_eq!(node_name, "node");
1134        assert_eq!(errors.len(), 1);
1135        assert_eq!(
1136            errors.first().unwrap().to_string(),
1137            "image: 'myinvalid.image' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'"
1138        );
1139    }
1140
1141    #[test]
1142    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_one_bootnode_address_is_invalid(
1143    ) {
1144        let (node_name, errors) =
1145            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1146                .with_name("node")
1147                .with_raw_bootnodes_addresses(vec!["/ip4//tcp/45421"])
1148                .build()
1149                .unwrap_err();
1150
1151        assert_eq!(node_name, "node");
1152        assert_eq!(errors.len(), 1);
1153        assert_eq!(
1154            errors.first().unwrap().to_string(),
1155            "bootnodes_addresses[0]: '/ip4//tcp/45421' failed to parse: invalid IPv4 address syntax"
1156        );
1157    }
1158
1159    #[test]
1160    fn node_config_builder_should_fails_and_returns_mulitle_errors_and_node_name_if_multiple_bootnode_address_are_invalid(
1161    ) {
1162        let (node_name, errors) =
1163            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1164                .with_name("node")
1165                .with_raw_bootnodes_addresses(vec!["/ip4//tcp/45421", "//10.42.153.10/tcp/43111"])
1166                .build()
1167                .unwrap_err();
1168
1169        assert_eq!(node_name, "node");
1170        assert_eq!(errors.len(), 2);
1171        assert_eq!(
1172            errors.first().unwrap().to_string(),
1173            "bootnodes_addresses[0]: '/ip4//tcp/45421' failed to parse: invalid IPv4 address syntax"
1174        );
1175        assert_eq!(
1176            errors.get(1).unwrap().to_string(),
1177            "bootnodes_addresses[1]: '//10.42.153.10/tcp/43111' unknown protocol string: "
1178        );
1179    }
1180
1181    #[test]
1182    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_resources_has_an_error(
1183    ) {
1184        let (node_name, errors) =
1185            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1186                .with_name("node")
1187                .with_resources(|resources| resources.with_limit_cpu("invalid"))
1188                .build()
1189                .unwrap_err();
1190
1191        assert_eq!(node_name, "node");
1192        assert_eq!(errors.len(), 1);
1193        assert_eq!(
1194            errors.first().unwrap().to_string(),
1195            r"resources.limit_cpu: 'invalid' doesn't match regex '^\d+(.\d+)?(m|K|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$'"
1196        );
1197    }
1198
1199    #[test]
1200    fn node_config_builder_should_fails_and_returns_multiple_errors_and_node_name_if_resources_has_multiple_errors(
1201    ) {
1202        let (node_name, errors) =
1203            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1204                .with_name("node")
1205                .with_resources(|resources| {
1206                    resources
1207                        .with_limit_cpu("invalid")
1208                        .with_request_memory("invalid")
1209                })
1210                .build()
1211                .unwrap_err();
1212
1213        assert_eq!(node_name, "node");
1214        assert_eq!(errors.len(), 2);
1215        assert_eq!(
1216            errors.first().unwrap().to_string(),
1217            r"resources.limit_cpu: 'invalid' doesn't match regex '^\d+(.\d+)?(m|K|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$'"
1218        );
1219        assert_eq!(
1220            errors.get(1).unwrap().to_string(),
1221            r"resources.request_memory: 'invalid' doesn't match regex '^\d+(.\d+)?(m|K|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$'"
1222        );
1223    }
1224
1225    #[test]
1226    fn node_config_builder_should_fails_and_returns_multiple_errors_and_node_name_if_multiple_fields_have_errors(
1227    ) {
1228        let (node_name, errors) =
1229            NodeConfigBuilder::new(ChainDefaultContext::default(), Default::default())
1230                .with_name("node")
1231                .with_command("invalid command")
1232                .with_image("myinvalid.image")
1233                .with_resources(|resources| {
1234                    resources
1235                        .with_limit_cpu("invalid")
1236                        .with_request_memory("invalid")
1237                })
1238                .build()
1239                .unwrap_err();
1240
1241        assert_eq!(node_name, "node");
1242        assert_eq!(errors.len(), 4);
1243        assert_eq!(
1244            errors.first().unwrap().to_string(),
1245            "command: 'invalid command' shouldn't contains whitespace"
1246        );
1247        assert_eq!(
1248            errors.get(1).unwrap().to_string(),
1249            "image: 'myinvalid.image' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'"
1250        );
1251        assert_eq!(
1252            errors.get(2).unwrap().to_string(),
1253            r"resources.limit_cpu: 'invalid' doesn't match regex '^\d+(.\d+)?(m|K|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$'"
1254        );
1255        assert_eq!(
1256            errors.get(3).unwrap().to_string(),
1257            r"resources.request_memory: 'invalid' doesn't match regex '^\d+(.\d+)?(m|K|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$'"
1258        );
1259    }
1260
1261    #[test]
1262    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_ws_port_is_already_used(
1263    ) {
1264        let validation_context = Rc::new(RefCell::new(ValidationContext {
1265            used_ports: vec![30333],
1266            ..Default::default()
1267        }));
1268        let (node_name, errors) =
1269            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context)
1270                .with_name("node")
1271                .with_ws_port(30333)
1272                .build()
1273                .unwrap_err();
1274
1275        assert_eq!(node_name, "node");
1276        assert_eq!(errors.len(), 1);
1277        assert_eq!(
1278            errors.first().unwrap().to_string(),
1279            "ws_port: '30333' is already used across config"
1280        );
1281    }
1282
1283    #[test]
1284    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_rpc_port_is_already_used(
1285    ) {
1286        let validation_context = Rc::new(RefCell::new(ValidationContext {
1287            used_ports: vec![4444],
1288            ..Default::default()
1289        }));
1290        let (node_name, errors) =
1291            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context)
1292                .with_name("node")
1293                .with_rpc_port(4444)
1294                .build()
1295                .unwrap_err();
1296
1297        assert_eq!(node_name, "node");
1298        assert_eq!(errors.len(), 1);
1299        assert_eq!(
1300            errors.first().unwrap().to_string(),
1301            "rpc_port: '4444' is already used across config"
1302        );
1303    }
1304
1305    #[test]
1306    fn node_config_builder_should_fails_and_returns_an_error_and_node_name_if_prometheus_port_is_already_used(
1307    ) {
1308        let validation_context = Rc::new(RefCell::new(ValidationContext {
1309            used_ports: vec![9089],
1310            ..Default::default()
1311        }));
1312        let (node_name, errors) =
1313            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context)
1314                .with_name("node")
1315                .with_prometheus_port(9089)
1316                .build()
1317                .unwrap_err();
1318
1319        assert_eq!(node_name, "node");
1320        assert_eq!(errors.len(), 1);
1321        assert_eq!(
1322            errors.first().unwrap().to_string(),
1323            "prometheus_port: '9089' is already used across config"
1324        );
1325    }
1326
1327    #[test]
1328    fn node_config_builder_should_fails_and_returns_and_error_and_node_name_if_p2p_port_is_already_used(
1329    ) {
1330        let validation_context = Rc::new(RefCell::new(ValidationContext {
1331            used_ports: vec![45093],
1332            ..Default::default()
1333        }));
1334        let (node_name, errors) =
1335            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context)
1336                .with_name("node")
1337                .with_p2p_port(45093)
1338                .build()
1339                .unwrap_err();
1340
1341        assert_eq!(node_name, "node");
1342        assert_eq!(errors.len(), 1);
1343        assert_eq!(
1344            errors.first().unwrap().to_string(),
1345            "p2p_port: '45093' is already used across config"
1346        );
1347    }
1348
1349    #[test]
1350    fn node_config_builder_should_fails_if_node_name_is_empty() {
1351        let validation_context = Rc::new(RefCell::new(ValidationContext {
1352            ..Default::default()
1353        }));
1354
1355        let (_, errors) =
1356            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context)
1357                .with_name("")
1358                .build()
1359                .unwrap_err();
1360
1361        assert_eq!(errors.len(), 1);
1362        assert_eq!(errors.first().unwrap().to_string(), "name: can't be empty");
1363    }
1364
1365    #[test]
1366    fn group_default_base_node() {
1367        let validation_context = Rc::new(RefCell::new(ValidationContext::default()));
1368
1369        let group_config =
1370            GroupNodeConfigBuilder::new(ChainDefaultContext::default(), validation_context.clone())
1371                .with_base_node(|node| node.with_name("validator"))
1372                .build()
1373                .unwrap();
1374
1375        // Check group config
1376        assert_eq!(group_config.count, 1);
1377        assert_eq!(group_config.base_config.name(), "validator");
1378    }
1379
1380    #[test]
1381    fn group_custom_base_node() {
1382        let validation_context = Rc::new(RefCell::new(ValidationContext::default()));
1383        let node_config =
1384            NodeConfigBuilder::new(ChainDefaultContext::default(), validation_context.clone())
1385                .with_name("node")
1386                .with_command("some_command")
1387                .with_image("repo:image")
1388                .validator(true)
1389                .invulnerable(true)
1390                .bootnode(true);
1391
1392        let group_config =
1393            GroupNodeConfigBuilder::new(ChainDefaultContext::default(), validation_context.clone())
1394                .with_count(5)
1395                .with_base_node(|_node| node_config)
1396                .build()
1397                .unwrap();
1398
1399        // Check group config
1400        assert_eq!(group_config.count, 5);
1401
1402        assert_eq!(group_config.base_config.name(), "node");
1403        assert_eq!(
1404            group_config.base_config.command().unwrap().as_str(),
1405            "some_command"
1406        );
1407        assert_eq!(
1408            group_config.base_config.image().unwrap().as_str(),
1409            "repo:image"
1410        );
1411        assert!(group_config.base_config.is_validator());
1412        assert!(group_config.base_config.is_invulnerable());
1413        assert!(group_config.base_config.is_bootnode());
1414    }
1415
1416    #[test]
1417    fn ensure_default_args_are_overrided() {
1418        let validation_context = Rc::new(RefCell::new(ValidationContext::default()));
1419        let chain_context = ChainDefaultContext {
1420            default_args: vec!["-lruntime=trace".into()],
1421            ..Default::default()
1422        };
1423        let node_config = NodeConfigBuilder::new(chain_context, validation_context)
1424            .with_name("node")
1425            .with_args(vec!["-lruntime=info".into()])
1426            .build()
1427            .unwrap();
1428
1429        assert_eq!(node_config.args, vec!["-lruntime=info".into()]);
1430    }
1431}