1use serde::{Deserialize, Serialize};
2use std::{collections::HashSet, env};
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";
32
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35pub struct SpawnLndConfig {
36 pub nodes: Vec<NodeConfig>,
38 pub bitcoind_image: String,
40 pub lnd_image: String,
42 pub nodes_per_bitcoind: usize,
44 pub keep_containers: bool,
46 pub startup_retry: RetryPolicy,
48}
49
50impl SpawnLndConfig {
51 pub fn builder() -> SpawnLndBuilder {
53 SpawnLndBuilder::default()
54 }
55
56 pub async fn spawn(self) -> Result<SpawnedCluster, SpawnError> {
58 SpawnedCluster::spawn(self).await
59 }
60
61 pub fn validate(&self) -> Result<(), ConfigError> {
63 validate_config(self)
64 }
65
66 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 pub fn node_aliases(&self) -> impl Iterator<Item = &str> {
77 self.nodes.iter().map(|node| node.alias.as_str())
78 }
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
83pub struct RetryPolicy {
84 pub attempts: usize,
86 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 pub fn new(attempts: usize, interval_ms: usize) -> Self {
102 Self {
103 attempts,
104 interval_ms,
105 }
106 }
107
108 pub fn interval(&self) -> std::time::Duration {
110 std::time::Duration::from_millis(self.interval_ms as u64)
111 }
112}
113
114#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
116pub struct NodeConfig {
117 pub alias: String,
119 pub lnd_args: Vec<String>,
121}
122
123impl NodeConfig {
124 pub fn new(alias: impl Into<String>) -> Self {
126 Self {
127 alias: alias.into(),
128 lnd_args: Vec::new(),
129 }
130 }
131
132 pub fn with_lnd_arg(mut self, arg: impl Into<String>) -> Self {
134 self.lnd_args.push(arg.into());
135 self
136 }
137
138 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#[derive(Clone, Debug, Default)]
151pub struct SpawnLnd;
152
153impl SpawnLnd {
154 pub fn builder() -> SpawnLndBuilder {
156 SpawnLndConfig::builder()
157 }
158}
159
160#[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 pub fn node(mut self, alias: impl Into<String>) -> Self {
174 self.nodes.push(NodeConfig::new(alias));
175 self
176 }
177
178 pub fn node_config(mut self, node: NodeConfig) -> Self {
180 self.nodes.push(node);
181 self
182 }
183
184 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 pub fn bitcoind_image(mut self, image: impl Into<String>) -> Self {
196 self.bitcoind_image = Some(image.into());
197 self
198 }
199
200 pub fn lnd_image(mut self, image: impl Into<String>) -> Self {
202 self.lnd_image = Some(image.into());
203 self
204 }
205
206 pub fn nodes_per_bitcoind(mut self, count: usize) -> Self {
208 self.nodes_per_bitcoind = Some(count);
209 self
210 }
211
212 pub fn keep_containers(mut self, keep: bool) -> Self {
214 self.keep_containers = Some(keep);
215 self
216 }
217
218 pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
220 self.startup_retry = Some(policy);
221 self
222 }
223
224 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 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 pub async fn spawn(self) -> Result<SpawnedCluster, SpawnError> {
277 self.build()?.spawn().await
278 }
279}
280
281#[derive(Clone, Debug, Error, Eq, PartialEq)]
283pub enum ConfigError {
284 #[error("node alias cannot be empty")]
286 EmptyAlias,
287
288 #[error("node alias contains unsupported characters: {0}")]
290 InvalidAlias(String),
291
292 #[error("duplicate node alias: {0}")]
294 DuplicateAlias(String),
295
296 #[error("at least one LND node is required")]
298 EmptyNodes,
299
300 #[error("{field} Docker image cannot be empty")]
302 EmptyImage {
303 field: &'static str,
305 },
306
307 #[error("{field} Docker image contains whitespace: {image}")]
309 ImageContainsWhitespace {
310 field: &'static str,
312 image: String,
314 },
315
316 #[error("{field} Docker image must include a tag or digest: {image}")]
318 ImageMissingTagOrDigest {
319 field: &'static str,
321 image: String,
323 },
324
325 #[error("nodes_per_bitcoind must be greater than zero")]
327 InvalidNodesPerBitcoind,
328
329 #[error("startup retry attempts must be greater than zero")]
331 InvalidStartupRetryAttempts,
332
333 #[error("startup retry interval must be greater than zero milliseconds")]
335 InvalidStartupRetryInterval,
336
337 #[error("environment variable {var} must be a positive integer, got {value}")]
339 InvalidEnvUsize {
340 var: String,
342 value: String,
344 },
345
346 #[error("environment variable {var} must be a boolean, got {value}")]
348 InvalidEnvBool {
349 var: String,
351 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}