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