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