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