zombienet_configuration/shared/
node.rs

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