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