Skip to main content

spawn_lnd/
config.rs

1use serde::{Deserialize, Serialize};
2use std::{collections::HashSet, env, net::Ipv4Addr};
3use thiserror::Error;
4
5use crate::cluster::{SpawnError, SpawnedCluster};
6
7/// Default Bitcoin Core Docker image used for new clusters.
8pub const DEFAULT_BITCOIND_IMAGE: &str = "lightninglabs/bitcoin-core:30";
9/// Default LND Docker image used for new clusters.
10pub const DEFAULT_LND_IMAGE: &str = "lightninglabs/lnd:v0.20.1-beta";
11/// Default number of LND nodes assigned to each Bitcoin Core regtest backend.
12pub const DEFAULT_NODES_PER_BITCOIND: usize = 3;
13/// Default node alias used when a builder is spawned without explicit nodes.
14pub const DEFAULT_NODE_ALIAS: &str = "node-0";
15/// Default retry count for Docker and daemon readiness polling.
16pub const DEFAULT_STARTUP_RETRY_ATTEMPTS: usize = 500;
17/// Default delay between readiness retry attempts.
18pub const DEFAULT_STARTUP_RETRY_INTERVAL_MS: usize = 100;
19
20/// Environment variable overriding the Bitcoin Core image.
21pub const ENV_BITCOIND_IMAGE: &str = "SPAWN_LND_BITCOIND_IMAGE";
22/// Environment variable overriding the LND image.
23pub const ENV_LND_IMAGE: &str = "SPAWN_LND_LND_IMAGE";
24/// Environment variable preserving containers after failure or shutdown.
25pub const ENV_KEEP_CONTAINERS: &str = "SPAWN_LND_KEEP_CONTAINERS";
26/// Environment variable overriding the number of LND nodes per Bitcoin Core.
27pub const ENV_NODES_PER_BITCOIND: &str = "SPAWN_LND_NODES_PER_BITCOIND";
28/// Environment variable overriding readiness retry attempts.
29pub const ENV_STARTUP_RETRY_ATTEMPTS: &str = "SPAWN_LND_STARTUP_RETRY_ATTEMPTS";
30/// Environment variable overriding readiness retry interval milliseconds.
31pub const ENV_STARTUP_RETRY_INTERVAL_MS: &str = "SPAWN_LND_STARTUP_RETRY_INTERVAL_MS";
32/// Environment variable overriding the Docker network IPv4 subnet.
33pub const ENV_CLUSTER_SUBNET: &str = "SPAWN_LND_CLUSTER_SUBNET";
34
35/// Complete cluster configuration.
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
37pub struct SpawnLndConfig {
38    /// LND node definitions, in spawn order.
39    pub nodes: Vec<NodeConfig>,
40    /// Docker image for Bitcoin Core containers.
41    pub bitcoind_image: String,
42    /// Docker image for LND containers.
43    pub lnd_image: String,
44    /// Number of LND nodes backed by each Bitcoin Core container.
45    pub nodes_per_bitcoind: usize,
46    /// Whether cluster shutdown and startup rollback should preserve containers.
47    pub keep_containers: bool,
48    /// Retry policy used for daemon startup and readiness checks.
49    pub startup_retry: RetryPolicy,
50    /// Optional IPv4 subnet used for the managed Docker bridge network.
51    ///
52    /// When omitted, this crate chooses an explicit private subnet so Docker
53    /// accepts static container IP assignment.
54    pub cluster_subnet: Option<String>,
55}
56
57impl SpawnLndConfig {
58    /// Create a builder using defaults and environment overrides.
59    pub fn builder() -> SpawnLndBuilder {
60        SpawnLndBuilder::default()
61    }
62
63    /// Spawn a Docker-backed cluster from this configuration.
64    pub async fn spawn(self) -> Result<SpawnedCluster, SpawnError> {
65        SpawnedCluster::spawn(self).await
66    }
67
68    /// Validate this configuration without spawning containers.
69    pub fn validate(&self) -> Result<(), ConfigError> {
70        validate_config(self)
71    }
72
73    /// Return the number of Bitcoin Core chain groups implied by this config.
74    pub fn chain_group_count(&self) -> usize {
75        if self.nodes_per_bitcoind == 0 {
76            return 0;
77        }
78
79        self.nodes.len().div_ceil(self.nodes_per_bitcoind)
80    }
81
82    /// Iterate configured LND aliases in spawn order.
83    pub fn node_aliases(&self) -> impl Iterator<Item = &str> {
84        self.nodes.iter().map(|node| node.alias.as_str())
85    }
86}
87
88/// Retry parameters for startup and readiness polling.
89#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
90pub struct RetryPolicy {
91    /// Number of attempts before timing out.
92    pub attempts: usize,
93    /// Delay between attempts, in milliseconds.
94    pub interval_ms: usize,
95}
96
97impl Default for RetryPolicy {
98    fn default() -> Self {
99        Self {
100            attempts: DEFAULT_STARTUP_RETRY_ATTEMPTS,
101            interval_ms: DEFAULT_STARTUP_RETRY_INTERVAL_MS,
102        }
103    }
104}
105
106impl RetryPolicy {
107    /// Create a retry policy from an attempt count and interval.
108    pub fn new(attempts: usize, interval_ms: usize) -> Self {
109        Self {
110            attempts,
111            interval_ms,
112        }
113    }
114
115    /// Return the interval as a [`std::time::Duration`].
116    pub fn interval(&self) -> std::time::Duration {
117        std::time::Duration::from_millis(self.interval_ms as u64)
118    }
119}
120
121/// Per-node LND configuration.
122#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
123pub struct NodeConfig {
124    /// Stable alias used to address the node through the cluster API.
125    pub alias: String,
126    /// Extra command-line flags appended to the LND container command.
127    pub lnd_args: Vec<String>,
128}
129
130impl NodeConfig {
131    /// Create a node configuration with the given alias.
132    pub fn new(alias: impl Into<String>) -> Self {
133        Self {
134            alias: alias.into(),
135            lnd_args: Vec::new(),
136        }
137    }
138
139    /// Append one extra LND command-line argument.
140    pub fn with_lnd_arg(mut self, arg: impl Into<String>) -> Self {
141        self.lnd_args.push(arg.into());
142        self
143    }
144
145    /// Append multiple extra LND command-line arguments.
146    pub fn with_lnd_args<I, S>(mut self, args: I) -> Self
147    where
148        I: IntoIterator<Item = S>,
149        S: Into<String>,
150    {
151        self.lnd_args.extend(args.into_iter().map(Into::into));
152        self
153    }
154}
155
156/// Entry point for building and spawning a cluster.
157#[derive(Clone, Debug, Default)]
158pub struct SpawnLnd;
159
160impl SpawnLnd {
161    /// Create a new [`SpawnLndBuilder`].
162    pub fn builder() -> SpawnLndBuilder {
163        SpawnLndConfig::builder()
164    }
165}
166
167/// Builder for [`SpawnLndConfig`].
168#[derive(Clone, Debug, Default)]
169pub struct SpawnLndBuilder {
170    nodes: Vec<NodeConfig>,
171    bitcoind_image: Option<String>,
172    lnd_image: Option<String>,
173    nodes_per_bitcoind: Option<usize>,
174    keep_containers: Option<bool>,
175    startup_retry: Option<RetryPolicy>,
176    cluster_subnet: Option<String>,
177}
178
179impl SpawnLndBuilder {
180    /// Add one LND node by alias.
181    pub fn node(mut self, alias: impl Into<String>) -> Self {
182        self.nodes.push(NodeConfig::new(alias));
183        self
184    }
185
186    /// Add one fully configured LND node.
187    pub fn node_config(mut self, node: NodeConfig) -> Self {
188        self.nodes.push(node);
189        self
190    }
191
192    /// Add multiple LND nodes by alias.
193    pub fn nodes<I, S>(mut self, aliases: I) -> Self
194    where
195        I: IntoIterator<Item = S>,
196        S: Into<String>,
197    {
198        self.nodes.extend(aliases.into_iter().map(NodeConfig::new));
199        self
200    }
201
202    /// Override the Bitcoin Core Docker image.
203    pub fn bitcoind_image(mut self, image: impl Into<String>) -> Self {
204        self.bitcoind_image = Some(image.into());
205        self
206    }
207
208    /// Override the LND Docker image.
209    pub fn lnd_image(mut self, image: impl Into<String>) -> Self {
210        self.lnd_image = Some(image.into());
211        self
212    }
213
214    /// Set how many LND nodes share each Bitcoin Core container.
215    pub fn nodes_per_bitcoind(mut self, count: usize) -> Self {
216        self.nodes_per_bitcoind = Some(count);
217        self
218    }
219
220    /// Preserve containers instead of removing them during shutdown or rollback.
221    pub fn keep_containers(mut self, keep: bool) -> Self {
222        self.keep_containers = Some(keep);
223        self
224    }
225
226    /// Set the startup retry policy.
227    pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
228        self.startup_retry = Some(policy);
229        self
230    }
231
232    /// Set the startup retry policy from attempt and interval values.
233    pub fn startup_retry(mut self, attempts: usize, interval_ms: usize) -> Self {
234        self.startup_retry = Some(RetryPolicy::new(attempts, interval_ms));
235        self
236    }
237
238    /// Set the IPv4 subnet for the managed Docker bridge network.
239    pub fn cluster_subnet(mut self, subnet: impl Into<String>) -> Self {
240        self.cluster_subnet = Some(subnet.into());
241        self
242    }
243
244    /// Build and validate a [`SpawnLndConfig`].
245    pub fn build(self) -> Result<SpawnLndConfig, ConfigError> {
246        let bitcoind_image = option_or_env(
247            self.bitcoind_image,
248            ENV_BITCOIND_IMAGE,
249            DEFAULT_BITCOIND_IMAGE,
250        );
251        let lnd_image = option_or_env(self.lnd_image, ENV_LND_IMAGE, DEFAULT_LND_IMAGE);
252        let nodes_per_bitcoind = match self.nodes_per_bitcoind {
253            Some(count) => count,
254            None => env_usize(ENV_NODES_PER_BITCOIND)?.unwrap_or(DEFAULT_NODES_PER_BITCOIND),
255        };
256        let keep_containers = match self.keep_containers {
257            Some(keep) => keep,
258            None => env_bool(ENV_KEEP_CONTAINERS)?.unwrap_or(false),
259        };
260        let startup_retry = match self.startup_retry {
261            Some(policy) => policy,
262            None => RetryPolicy {
263                attempts: env_usize(ENV_STARTUP_RETRY_ATTEMPTS)?
264                    .unwrap_or(DEFAULT_STARTUP_RETRY_ATTEMPTS),
265                interval_ms: env_usize(ENV_STARTUP_RETRY_INTERVAL_MS)?
266                    .unwrap_or(DEFAULT_STARTUP_RETRY_INTERVAL_MS),
267            },
268        };
269        let cluster_subnet = match self.cluster_subnet {
270            Some(subnet) => Some(subnet),
271            None => env_string(ENV_CLUSTER_SUBNET),
272        };
273
274        let nodes = if self.nodes.is_empty() {
275            vec![NodeConfig::new(DEFAULT_NODE_ALIAS)]
276        } else {
277            self.nodes
278        };
279
280        let config = SpawnLndConfig {
281            nodes,
282            bitcoind_image,
283            lnd_image,
284            nodes_per_bitcoind,
285            keep_containers,
286            startup_retry,
287            cluster_subnet,
288        };
289
290        validate_config(&config)?;
291        Ok(config)
292    }
293
294    /// Build a config and spawn the cluster.
295    pub async fn spawn(self) -> Result<SpawnedCluster, SpawnError> {
296        self.build()?.spawn().await
297    }
298}
299
300/// Configuration validation error.
301#[derive(Clone, Debug, Error, Eq, PartialEq)]
302pub enum ConfigError {
303    /// A node alias was empty.
304    #[error("node alias cannot be empty")]
305    EmptyAlias,
306
307    /// A node alias contained unsupported characters.
308    #[error("node alias contains unsupported characters: {0}")]
309    InvalidAlias(String),
310
311    /// The same node alias was configured more than once.
312    #[error("duplicate node alias: {0}")]
313    DuplicateAlias(String),
314
315    /// The config contained no LND nodes.
316    #[error("at least one LND node is required")]
317    EmptyNodes,
318
319    /// A Docker image field was empty.
320    #[error("{field} Docker image cannot be empty")]
321    EmptyImage {
322        /// Config field name.
323        field: &'static str,
324    },
325
326    /// A Docker image field contained whitespace.
327    #[error("{field} Docker image contains whitespace: {image}")]
328    ImageContainsWhitespace {
329        /// Config field name.
330        field: &'static str,
331        /// Invalid Docker image reference.
332        image: String,
333    },
334
335    /// A Docker image did not include a tag or digest.
336    #[error("{field} Docker image must include a tag or digest: {image}")]
337    ImageMissingTagOrDigest {
338        /// Config field name.
339        field: &'static str,
340        /// Invalid Docker image reference.
341        image: String,
342    },
343
344    /// `nodes_per_bitcoind` was zero.
345    #[error("nodes_per_bitcoind must be greater than zero")]
346    InvalidNodesPerBitcoind,
347
348    /// Retry attempts were zero.
349    #[error("startup retry attempts must be greater than zero")]
350    InvalidStartupRetryAttempts,
351
352    /// Retry interval was zero.
353    #[error("startup retry interval must be greater than zero milliseconds")]
354    InvalidStartupRetryInterval,
355
356    /// A configured Docker network subnet was not valid IPv4 CIDR notation.
357    #[error("cluster subnet must be valid IPv4 CIDR notation, got {0}")]
358    InvalidClusterSubnet(String),
359
360    /// An integer environment override could not be parsed.
361    #[error("environment variable {var} must be a positive integer, got {value}")]
362    InvalidEnvUsize {
363        /// Environment variable name.
364        var: String,
365        /// Invalid environment variable value.
366        value: String,
367    },
368
369    /// A boolean environment override could not be parsed.
370    #[error("environment variable {var} must be a boolean, got {value}")]
371    InvalidEnvBool {
372        /// Environment variable name.
373        var: String,
374        /// Invalid environment variable value.
375        value: String,
376    },
377}
378
379fn validate_config(config: &SpawnLndConfig) -> Result<(), ConfigError> {
380    validate_image("bitcoind_image", &config.bitcoind_image)?;
381    validate_image("lnd_image", &config.lnd_image)?;
382
383    if config.nodes_per_bitcoind == 0 {
384        return Err(ConfigError::InvalidNodesPerBitcoind);
385    }
386
387    if config.startup_retry.attempts == 0 {
388        return Err(ConfigError::InvalidStartupRetryAttempts);
389    }
390
391    if config.startup_retry.interval_ms == 0 {
392        return Err(ConfigError::InvalidStartupRetryInterval);
393    }
394    if let Some(subnet) = &config.cluster_subnet {
395        validate_cluster_subnet(subnet)?;
396    }
397
398    if config.nodes.is_empty() {
399        return Err(ConfigError::EmptyNodes);
400    }
401
402    let mut aliases = HashSet::with_capacity(config.nodes.len());
403    for node in &config.nodes {
404        validate_alias(&node.alias)?;
405
406        if !aliases.insert(node.alias.clone()) {
407            return Err(ConfigError::DuplicateAlias(node.alias.clone()));
408        }
409    }
410
411    Ok(())
412}
413
414fn validate_alias(alias: &str) -> Result<(), ConfigError> {
415    if alias.is_empty() {
416        return Err(ConfigError::EmptyAlias);
417    }
418
419    let is_valid = alias
420        .chars()
421        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'));
422
423    if !is_valid {
424        return Err(ConfigError::InvalidAlias(alias.to_string()));
425    }
426
427    Ok(())
428}
429
430fn validate_image(field: &'static str, image: &str) -> Result<(), ConfigError> {
431    if image.is_empty() {
432        return Err(ConfigError::EmptyImage { field });
433    }
434
435    if image.chars().any(char::is_whitespace) {
436        return Err(ConfigError::ImageContainsWhitespace {
437            field,
438            image: image.to_string(),
439        });
440    }
441
442    if !image_has_tag_or_digest(image) {
443        return Err(ConfigError::ImageMissingTagOrDigest {
444            field,
445            image: image.to_string(),
446        });
447    }
448
449    Ok(())
450}
451
452fn validate_cluster_subnet(subnet: &str) -> Result<(), ConfigError> {
453    let Some((address, prefix)) = subnet.split_once('/') else {
454        return Err(ConfigError::InvalidClusterSubnet(subnet.to_string()));
455    };
456    if address.parse::<Ipv4Addr>().is_err() {
457        return Err(ConfigError::InvalidClusterSubnet(subnet.to_string()));
458    }
459    let Ok(prefix) = prefix.parse::<u8>() else {
460        return Err(ConfigError::InvalidClusterSubnet(subnet.to_string()));
461    };
462    if prefix > 30 {
463        return Err(ConfigError::InvalidClusterSubnet(subnet.to_string()));
464    }
465
466    Ok(())
467}
468
469fn image_has_tag_or_digest(image: &str) -> bool {
470    if image.contains('@') {
471        return true;
472    }
473
474    let last_path_component = image.rsplit('/').next().unwrap_or(image);
475    last_path_component.contains(':')
476}
477
478fn option_or_env(option: Option<String>, var: &str, default: &str) -> String {
479    option
480        .or_else(|| env::var(var).ok())
481        .unwrap_or_else(|| default.to_string())
482}
483
484fn env_usize(var: &str) -> Result<Option<usize>, ConfigError> {
485    let Ok(value) = env::var(var) else {
486        return Ok(None);
487    };
488
489    let parsed = value
490        .parse::<usize>()
491        .map_err(|_| ConfigError::InvalidEnvUsize {
492            var: var.to_string(),
493            value: value.clone(),
494        })?;
495
496    if parsed == 0 {
497        return Err(ConfigError::InvalidEnvUsize {
498            var: var.to_string(),
499            value,
500        });
501    }
502
503    Ok(Some(parsed))
504}
505
506fn env_bool(var: &str) -> Result<Option<bool>, ConfigError> {
507    let Ok(value) = env::var(var) else {
508        return Ok(None);
509    };
510
511    match value.to_ascii_lowercase().as_str() {
512        "1" | "true" | "yes" | "on" => Ok(Some(true)),
513        "0" | "false" | "no" | "off" => Ok(Some(false)),
514        _ => Err(ConfigError::InvalidEnvBool {
515            var: var.to_string(),
516            value,
517        }),
518    }
519}
520
521fn env_string(var: &str) -> Option<String> {
522    env::var(var)
523        .ok()
524        .map(|value| value.trim().to_string())
525        .filter(|value| !value.is_empty())
526}
527
528#[cfg(test)]
529mod tests {
530    use super::{
531        ConfigError, DEFAULT_BITCOIND_IMAGE, DEFAULT_LND_IMAGE, DEFAULT_NODE_ALIAS,
532        DEFAULT_NODES_PER_BITCOIND, SpawnLnd, SpawnLndConfig,
533    };
534
535    #[test]
536    fn builder_uses_expected_defaults() {
537        let config = SpawnLnd::builder().build().expect("valid defaults");
538
539        assert_eq!(config.bitcoind_image, DEFAULT_BITCOIND_IMAGE);
540        assert_eq!(config.lnd_image, DEFAULT_LND_IMAGE);
541        assert_eq!(config.nodes_per_bitcoind, DEFAULT_NODES_PER_BITCOIND);
542        assert!(!config.keep_containers);
543        assert_eq!(config.startup_retry, super::RetryPolicy::default());
544        assert_eq!(config.cluster_subnet, None);
545        assert_eq!(
546            config.node_aliases().collect::<Vec<_>>(),
547            [DEFAULT_NODE_ALIAS]
548        );
549    }
550
551    #[test]
552    fn builder_accepts_custom_values() {
553        let config = SpawnLndConfig::builder()
554            .nodes(["alice", "bob", "carol", "dave"])
555            .bitcoind_image("custom/bitcoin:30")
556            .lnd_image("custom/lnd:v1")
557            .nodes_per_bitcoind(3)
558            .keep_containers(true)
559            .startup_retry(12, 250)
560            .cluster_subnet("172.28.0.0/16")
561            .build()
562            .expect("valid config");
563
564        assert_eq!(config.bitcoind_image, "custom/bitcoin:30");
565        assert_eq!(config.lnd_image, "custom/lnd:v1");
566        assert_eq!(config.nodes_per_bitcoind, 3);
567        assert_eq!(config.startup_retry, super::RetryPolicy::new(12, 250));
568        assert_eq!(config.cluster_subnet.as_deref(), Some("172.28.0.0/16"));
569        assert_eq!(config.chain_group_count(), 2);
570        assert!(config.keep_containers);
571        assert_eq!(
572            config.node_aliases().collect::<Vec<_>>(),
573            ["alice", "bob", "carol", "dave"]
574        );
575    }
576
577    #[test]
578    fn rejects_empty_alias() {
579        let error = SpawnLnd::builder()
580            .node("")
581            .build()
582            .expect_err("empty alias should fail");
583
584        assert_eq!(error, ConfigError::EmptyAlias);
585    }
586
587    #[test]
588    fn rejects_invalid_alias_characters() {
589        let error = SpawnLnd::builder()
590            .node("alice node")
591            .build()
592            .expect_err("invalid alias should fail");
593
594        assert_eq!(error, ConfigError::InvalidAlias("alice node".to_string()));
595    }
596
597    #[test]
598    fn rejects_duplicate_aliases() {
599        let error = SpawnLnd::builder()
600            .nodes(["alice", "bob", "alice"])
601            .build()
602            .expect_err("duplicate alias should fail");
603
604        assert_eq!(error, ConfigError::DuplicateAlias("alice".to_string()));
605    }
606
607    #[test]
608    fn rejects_empty_image() {
609        let error = SpawnLnd::builder()
610            .bitcoind_image("")
611            .build()
612            .expect_err("empty image should fail");
613
614        assert_eq!(
615            error,
616            ConfigError::EmptyImage {
617                field: "bitcoind_image"
618            }
619        );
620    }
621
622    #[test]
623    fn rejects_untagged_image() {
624        let error = SpawnLnd::builder()
625            .lnd_image("lightninglabs/lnd")
626            .build()
627            .expect_err("untagged image should fail");
628
629        assert_eq!(
630            error,
631            ConfigError::ImageMissingTagOrDigest {
632                field: "lnd_image",
633                image: "lightninglabs/lnd".to_string()
634            }
635        );
636    }
637
638    #[test]
639    fn accepts_digest_pinned_image() {
640        let config = SpawnLnd::builder()
641            .lnd_image("lightninglabs/lnd@sha256:abc123")
642            .build()
643            .expect("digest-pinned image should pass");
644
645        assert_eq!(config.lnd_image, "lightninglabs/lnd@sha256:abc123");
646    }
647
648    #[test]
649    fn rejects_zero_nodes_per_bitcoind() {
650        let error = SpawnLnd::builder()
651            .nodes_per_bitcoind(0)
652            .build()
653            .expect_err("zero grouping should fail");
654
655        assert_eq!(error, ConfigError::InvalidNodesPerBitcoind);
656    }
657
658    #[test]
659    fn rejects_zero_startup_retry_attempts() {
660        let error = SpawnLnd::builder()
661            .startup_retry(0, 100)
662            .build()
663            .expect_err("zero attempts should fail");
664
665        assert_eq!(error, ConfigError::InvalidStartupRetryAttempts);
666    }
667
668    #[test]
669    fn rejects_zero_startup_retry_interval() {
670        let error = SpawnLnd::builder()
671            .startup_retry(1, 0)
672            .build()
673            .expect_err("zero interval should fail");
674
675        assert_eq!(error, ConfigError::InvalidStartupRetryInterval);
676    }
677
678    #[test]
679    fn rejects_invalid_cluster_subnet() {
680        let error = SpawnLnd::builder()
681            .cluster_subnet("not-cidr")
682            .build()
683            .expect_err("invalid subnet should fail");
684
685        assert_eq!(
686            error,
687            ConfigError::InvalidClusterSubnet("not-cidr".to_string())
688        );
689    }
690
691    #[test]
692    fn validates_direct_config_inputs() {
693        let config = SpawnLndConfig {
694            nodes: Vec::new(),
695            bitcoind_image: DEFAULT_BITCOIND_IMAGE.to_string(),
696            lnd_image: DEFAULT_LND_IMAGE.to_string(),
697            nodes_per_bitcoind: DEFAULT_NODES_PER_BITCOIND,
698            keep_containers: false,
699            startup_retry: super::RetryPolicy::default(),
700            cluster_subnet: None,
701        };
702
703        assert_eq!(config.validate(), Err(ConfigError::EmptyNodes));
704    }
705
706    #[test]
707    fn invalid_direct_config_chain_group_count_does_not_panic() {
708        let config = SpawnLndConfig {
709            nodes: Vec::new(),
710            bitcoind_image: DEFAULT_BITCOIND_IMAGE.to_string(),
711            lnd_image: DEFAULT_LND_IMAGE.to_string(),
712            nodes_per_bitcoind: 0,
713            keep_containers: false,
714            startup_retry: super::RetryPolicy::default(),
715            cluster_subnet: None,
716        };
717
718        assert_eq!(config.chain_group_count(), 0);
719    }
720}