1use serde::{Deserialize, Serialize};
2use std::{collections::HashSet, env, net::Ipv4Addr};
3use thiserror::Error;
4
5use crate::cluster::{SpawnError, SpawnedCluster};
6
7pub const DEFAULT_BITCOIND_IMAGE: &str = "lightninglabs/bitcoin-core:30";
9pub const DEFAULT_LND_IMAGE: &str = "lightninglabs/lnd:v0.20.1-beta";
11pub const DEFAULT_NODES_PER_BITCOIND: usize = 3;
13pub const DEFAULT_NODE_ALIAS: &str = "node-0";
15pub const DEFAULT_STARTUP_RETRY_ATTEMPTS: usize = 500;
17pub const DEFAULT_STARTUP_RETRY_INTERVAL_MS: usize = 100;
19
20pub const ENV_BITCOIND_IMAGE: &str = "SPAWN_LND_BITCOIND_IMAGE";
22pub const ENV_LND_IMAGE: &str = "SPAWN_LND_LND_IMAGE";
24pub const ENV_KEEP_CONTAINERS: &str = "SPAWN_LND_KEEP_CONTAINERS";
26pub const ENV_NODES_PER_BITCOIND: &str = "SPAWN_LND_NODES_PER_BITCOIND";
28pub const ENV_STARTUP_RETRY_ATTEMPTS: &str = "SPAWN_LND_STARTUP_RETRY_ATTEMPTS";
30pub const ENV_STARTUP_RETRY_INTERVAL_MS: &str = "SPAWN_LND_STARTUP_RETRY_INTERVAL_MS";
32pub const ENV_CLUSTER_SUBNET: &str = "SPAWN_LND_CLUSTER_SUBNET";
34
35#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
37pub struct SpawnLndConfig {
38 pub nodes: Vec<NodeConfig>,
40 pub bitcoind_image: String,
42 pub lnd_image: String,
44 pub nodes_per_bitcoind: usize,
46 pub keep_containers: bool,
48 pub startup_retry: RetryPolicy,
50 pub cluster_subnet: Option<String>,
55}
56
57impl SpawnLndConfig {
58 pub fn builder() -> SpawnLndBuilder {
60 SpawnLndBuilder::default()
61 }
62
63 pub async fn spawn(self) -> Result<SpawnedCluster, SpawnError> {
65 SpawnedCluster::spawn(self).await
66 }
67
68 pub fn validate(&self) -> Result<(), ConfigError> {
70 validate_config(self)
71 }
72
73 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 pub fn node_aliases(&self) -> impl Iterator<Item = &str> {
84 self.nodes.iter().map(|node| node.alias.as_str())
85 }
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
90pub struct RetryPolicy {
91 pub attempts: usize,
93 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 pub fn new(attempts: usize, interval_ms: usize) -> Self {
109 Self {
110 attempts,
111 interval_ms,
112 }
113 }
114
115 pub fn interval(&self) -> std::time::Duration {
117 std::time::Duration::from_millis(self.interval_ms as u64)
118 }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
123pub struct NodeConfig {
124 pub alias: String,
126 pub lnd_args: Vec<String>,
128}
129
130impl NodeConfig {
131 pub fn new(alias: impl Into<String>) -> Self {
133 Self {
134 alias: alias.into(),
135 lnd_args: Vec::new(),
136 }
137 }
138
139 pub fn with_lnd_arg(mut self, arg: impl Into<String>) -> Self {
141 self.lnd_args.push(arg.into());
142 self
143 }
144
145 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#[derive(Clone, Debug, Default)]
158pub struct SpawnLnd;
159
160impl SpawnLnd {
161 pub fn builder() -> SpawnLndBuilder {
163 SpawnLndConfig::builder()
164 }
165}
166
167#[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 pub fn node(mut self, alias: impl Into<String>) -> Self {
182 self.nodes.push(NodeConfig::new(alias));
183 self
184 }
185
186 pub fn node_config(mut self, node: NodeConfig) -> Self {
188 self.nodes.push(node);
189 self
190 }
191
192 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 pub fn bitcoind_image(mut self, image: impl Into<String>) -> Self {
204 self.bitcoind_image = Some(image.into());
205 self
206 }
207
208 pub fn lnd_image(mut self, image: impl Into<String>) -> Self {
210 self.lnd_image = Some(image.into());
211 self
212 }
213
214 pub fn nodes_per_bitcoind(mut self, count: usize) -> Self {
216 self.nodes_per_bitcoind = Some(count);
217 self
218 }
219
220 pub fn keep_containers(mut self, keep: bool) -> Self {
222 self.keep_containers = Some(keep);
223 self
224 }
225
226 pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
228 self.startup_retry = Some(policy);
229 self
230 }
231
232 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 pub fn cluster_subnet(mut self, subnet: impl Into<String>) -> Self {
240 self.cluster_subnet = Some(subnet.into());
241 self
242 }
243
244 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 pub async fn spawn(self) -> Result<SpawnedCluster, SpawnError> {
296 self.build()?.spawn().await
297 }
298}
299
300#[derive(Clone, Debug, Error, Eq, PartialEq)]
302pub enum ConfigError {
303 #[error("node alias cannot be empty")]
305 EmptyAlias,
306
307 #[error("node alias contains unsupported characters: {0}")]
309 InvalidAlias(String),
310
311 #[error("duplicate node alias: {0}")]
313 DuplicateAlias(String),
314
315 #[error("at least one LND node is required")]
317 EmptyNodes,
318
319 #[error("{field} Docker image cannot be empty")]
321 EmptyImage {
322 field: &'static str,
324 },
325
326 #[error("{field} Docker image contains whitespace: {image}")]
328 ImageContainsWhitespace {
329 field: &'static str,
331 image: String,
333 },
334
335 #[error("{field} Docker image must include a tag or digest: {image}")]
337 ImageMissingTagOrDigest {
338 field: &'static str,
340 image: String,
342 },
343
344 #[error("nodes_per_bitcoind must be greater than zero")]
346 InvalidNodesPerBitcoind,
347
348 #[error("startup retry attempts must be greater than zero")]
350 InvalidStartupRetryAttempts,
351
352 #[error("startup retry interval must be greater than zero milliseconds")]
354 InvalidStartupRetryInterval,
355
356 #[error("cluster subnet must be valid IPv4 CIDR notation, got {0}")]
358 InvalidClusterSubnet(String),
359
360 #[error("environment variable {var} must be a positive integer, got {value}")]
362 InvalidEnvUsize {
363 var: String,
365 value: String,
367 },
368
369 #[error("environment variable {var} must be a boolean, got {value}")]
371 InvalidEnvBool {
372 var: String,
374 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}