Skip to main content

spawn_lnd/
config.rs

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