From e9bf7f269b57daf5efd57fed45cf8be17259096e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:04:18 -0600 Subject: [PATCH 01/19] ci: add gh actions to run tests (#11) --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d415a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Build & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Install Rust toolchain from rust-toolchain.toml + run: | + rustup toolchain install + rustup show + + - name: Cache cargo + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + - name: Build + run: cargo build --locked + + - name: Run tests + run: cargo test --locked From d6fe97a306dbc414de5f71490009f539744450bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:17:06 -0600 Subject: [PATCH 02/19] chore: group rust files by command (#12) * move files * format --- src/{ => chainstate}/download.rs | 20 +++++----- src/{chainstate.rs => chainstate/mod.rs} | 6 ++- src/{doctor.rs => config/check.rs} | 6 +-- src/{config.rs => config/mod.rs} | 3 ++ src/{ => config}/render.rs | 2 +- src/{ => logs}/export.rs | 19 +++++----- src/logs/mod.rs | 3 ++ src/main.rs | 47 +++++++++++------------- src/upgrade.rs | 14 +++---- src/{ => utils}/docker.rs | 10 +++-- src/utils/mod.rs | 5 +++ src/{ => utils}/services.rs | 0 src/{ => utils}/versions.rs | 0 13 files changed, 74 insertions(+), 61 deletions(-) rename src/{ => chainstate}/download.rs (98%) rename src/{chainstate.rs => chainstate/mod.rs} (98%) rename src/{doctor.rs => config/check.rs} (97%) rename src/{config.rs => config/mod.rs} (99%) rename src/{ => config}/render.rs (99%) rename src/{ => logs}/export.rs (94%) create mode 100644 src/logs/mod.rs rename src/{ => utils}/docker.rs (98%) create mode 100644 src/utils/mod.rs rename src/{ => utils}/services.rs (100%) rename src/{ => utils}/versions.rs (100%) diff --git a/src/download.rs b/src/chainstate/download.rs similarity index 98% rename from src/download.rs rename to src/chainstate/download.rs index 5bcdb29..d2ac01b 100644 --- a/src/download.rs +++ b/src/chainstate/download.rs @@ -17,7 +17,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use sha2::{Digest, Sha256}; use crate::config::{Network, ServiceMode, Stack}; -use crate::versions::*; +use crate::utils::versions::*; const ARCHIVE_BASE: &str = "https://archive.hiro.so"; @@ -62,7 +62,7 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { Network::Testnet => "testnet", }; - if let Some(running) = crate::docker::running_services(data_dir) { + if let Some(running) = crate::utils::docker::running_services(data_dir) { if !running.is_empty() { bail!( "the stack is running ({}) — run `stacksup stop` first", @@ -256,7 +256,9 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { } println!("\n{}", "Done.".green()); - println!("Next: `stacksup start`, then `stacksup chainstate status` to confirm the tips line up."); + println!( + "Next: `stacksup start`, then `stacksup chainstate status` to confirm the tips line up." + ); Ok(()) } @@ -293,8 +295,8 @@ fn versioned_job( let url = format!("{base}/{name}"); let size = head_content_length(&url).with_context(|| format!("archive not found at {url}"))?; let image = match kind { - Kind::Node => crate::services::stacks_node_image(stack), - Kind::Api => crate::services::stacks_api_image(stack), + Kind::Node => crate::utils::services::stacks_node_image(stack), + Kind::Api => crate::utils::services::stacks_api_image(stack), }; Ok(Job { kind, @@ -380,8 +382,8 @@ fn pinned_job( downloads: &Path, ) -> Result { let image = match kind { - Kind::Node => crate::services::stacks_node_image(stack), - Kind::Api => crate::services::stacks_api_image(stack), + Kind::Node => crate::utils::services::stacks_node_image(stack), + Kind::Api => crate::utils::services::stacks_api_image(stack), }; // Local file? @@ -866,7 +868,7 @@ fn restore_api(dump: &Path, stack: &Stack, data_dir: &Path, downloads_dir: &Path let user = stack.postgres.user.as_deref().unwrap_or("postgres"); println!("Starting postgres..."); - crate::docker::compose_up_service(data_dir, "postgres")?; + crate::utils::docker::compose_up_service(data_dir, "postgres")?; for _ in 0..60 { let ready = Command::new("docker") .args(["exec", "stacks-postgres", "pg_isready", "-U", user]) @@ -930,7 +932,7 @@ fn restore_api(dump: &Path, stack: &Stack, data_dir: &Path, downloads_dir: &Path let verdict = crate::chainstate::status(stack, data_dir); println!("Stopping postgres..."); - crate::docker::compose_stop_service(data_dir, "postgres")?; + crate::utils::docker::compose_stop_service(data_dir, "postgres")?; verdict } diff --git a/src/chainstate.rs b/src/chainstate/mod.rs similarity index 98% rename from src/chainstate.rs rename to src/chainstate/mod.rs index 2fd5112..3652ccc 100644 --- a/src/chainstate.rs +++ b/src/chainstate/mod.rs @@ -1,6 +1,8 @@ //! `stacksup chainstate` — operations on the stack's on-disk state. //! More subcommands (snapshot, restore, ...) will land here. +pub mod download; + use std::io::{self, Write}; use std::path::Path; use std::process::Command; @@ -26,7 +28,7 @@ struct Tip { /// even while the node writes), while the API's tip lives in Postgres and /// bitcoind's in LevelDB — those two are only checkable while running. pub fn status(stack: &Stack, data_dir: &Path) -> Result<()> { - let running = crate::docker::running_services(data_dir).unwrap_or_default(); + let running = crate::utils::docker::running_services(data_dir).unwrap_or_default(); let mut tips: Vec = Vec::new(); if stack.stacks_node.mode == ServiceMode::Enabled { @@ -318,7 +320,7 @@ pub fn wipe(data_dir: &Path, service: Option<&str>, yes: bool) -> Result<()> { } // Wiping state under running containers corrupts them; refuse first. - if let Some(running) = crate::docker::running_services(data_dir) { + if let Some(running) = crate::utils::docker::running_services(data_dir) { let blocking: Vec<&String> = running .iter() .filter(|r| service.is_none() || affected.contains(&r.as_str())) diff --git a/src/doctor.rs b/src/config/check.rs similarity index 97% rename from src/doctor.rs rename to src/config/check.rs index b4782b4..f303599 100644 --- a/src/doctor.rs +++ b/src/config/check.rs @@ -11,7 +11,7 @@ use anyhow::{Result, bail}; use colored::Colorize; use crate::config::{ServiceMode, Stack}; -use crate::services::*; +use crate::utils::services::*; const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); @@ -44,11 +44,11 @@ pub fn run(stack: &Stack) -> Result<()> { .iter() .any(|(_, m)| *m == ServiceMode::Enabled) { - match crate::docker::daemon_version() { + match crate::utils::docker::daemon_version() { Ok(v) => r.ok(&format!("docker daemon reachable (server {v})")), Err(e) => r.fail(&e.to_string()), } - match crate::docker::compose_version() { + match crate::utils::docker::compose_version() { Ok(v) => r.ok(&format!("docker compose plugin installed ({v})")), Err(e) => r.fail(&e.to_string()), } diff --git a/src/config.rs b/src/config/mod.rs similarity index 99% rename from src/config.rs rename to src/config/mod.rs index 752ee8e..e780454 100644 --- a/src/config.rs +++ b/src/config/mod.rs @@ -6,6 +6,9 @@ //! health-checked by `doctor`/`status`, and never touched by `down` //! - `disabled`: absent; anything that requires it fails validation +pub mod check; +pub mod render; + use std::fmt; use std::path::Path; diff --git a/src/render.rs b/src/config/render.rs similarity index 99% rename from src/render.rs rename to src/config/render.rs index e627794..470898d 100644 --- a/src/render.rs +++ b/src/config/render.rs @@ -14,7 +14,7 @@ use anyhow::Result; use serde::Serialize; use crate::config::{Network, ServiceMode, Stack}; -use crate::services::*; +use crate::utils::services::*; pub const COMPOSE_PROJECT: &str = "stacks"; diff --git a/src/export.rs b/src/logs/export.rs similarity index 94% rename from src/export.rs rename to src/logs/export.rs index 02362d6..99b9949 100644 --- a/src/export.rs +++ b/src/logs/export.rs @@ -15,7 +15,7 @@ use anyhow::{Context, Result, bail}; use colored::Colorize; use crate::config::{ServiceMode, Stack}; -use crate::services::roster; +use crate::utils::services::roster; pub struct Opts { pub service: Option, @@ -25,11 +25,11 @@ pub struct Opts { } pub fn run(stack: &Stack, config_path: &Path, data_dir: &Path, opts: Opts) -> Result<()> { - crate::docker::preflight(data_dir)?; + crate::utils::docker::preflight(data_dir)?; let services: Vec = match &opts.service { Some(name) => { - crate::docker::ensure_enabled(stack, name)?; + crate::utils::docker::ensure_enabled(stack, name)?; vec![name.clone()] } None => roster(stack) @@ -45,7 +45,7 @@ pub fn run(stack: &Stack, config_path: &Path, data_dir: &Path, opts: Opts) -> Re // Containers that still exist (logs live inside them). `stacksup stop` // keeps them; `stacksup stop --destroy` removes them along with their logs. let existing: Vec = - crate::docker::compose_capture(data_dir, &["ps", "-a", "--services"])? + crate::utils::docker::compose_capture(data_dir, &["ps", "-a", "--services"])? .lines() .map(str::to_owned) .collect(); @@ -98,22 +98,23 @@ pub fn run(stack: &Stack, config_path: &Path, data_dir: &Path, opts: Opts) -> Re let meta = format!( "stacks-tool version: {}\nnetwork: {network}\ncreated: {ts}\ndocker: {}\ncompose: {}\nservices: {}\n", env!("CARGO_PKG_VERSION"), - crate::docker::daemon_version().unwrap_or_else(|e| format!("unavailable ({e})")), - crate::docker::compose_version().unwrap_or_else(|e| format!("unavailable ({e})")), + crate::utils::docker::daemon_version().unwrap_or_else(|e| format!("unavailable ({e})")), + crate::utils::docker::compose_version() + .unwrap_or_else(|e| format!("unavailable ({e})")), services.join(", "), ); fs::write(tmp.join("meta.txt"), meta)?; fs::write( tmp.join("ps.txt"), redact( - &crate::docker::compose_capture(data_dir, &["ps", "-a"])?, + &crate::utils::docker::compose_capture(data_dir, &["ps", "-a"])?, &secrets, ), )?; fs::write( tmp.join("images.txt"), redact( - &crate::docker::compose_capture(data_dir, &["images"])?, + &crate::utils::docker::compose_capture(data_dir, &["images"])?, &secrets, ), )?; @@ -172,7 +173,7 @@ fn print_review_note() { } fn capture_logs(data_dir: &Path, service: &str, since: &str) -> String { - crate::docker::compose_capture( + crate::utils::docker::compose_capture( data_dir, &[ "logs", diff --git a/src/logs/mod.rs b/src/logs/mod.rs new file mode 100644 index 0000000..99efa18 --- /dev/null +++ b/src/logs/mod.rs @@ -0,0 +1,3 @@ +//! `stacksup logs` — tailing lives in crate::docker; export lives here. + +pub mod export; diff --git a/src/main.rs b/src/main.rs index 98710c3..9011dd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,8 @@ mod chainstate; mod config; -mod docker; -mod doctor; -mod download; -mod export; -mod render; -mod services; +mod logs; mod upgrade; -mod versions; +mod utils; use std::path::PathBuf; @@ -189,13 +184,13 @@ fn run() -> Result<()> { ConfigCommand::Init { force } => config::init(force), ConfigCommand::Render => { let stack = config::load(&cli.config)?; - let dir = render::render(&stack, &cli.data_dir)?; + let dir = config::render::render(&stack, &cli.data_dir)?; println!("Rendered service configs to {}/", dir.display()); Ok(()) } ConfigCommand::Check => { let stack = config::load(&cli.config)?; - doctor::run(&stack) + config::check::run(&stack) } }, Command::Logs { service, command } => match command { @@ -206,11 +201,11 @@ fn run() -> Result<()> { out, }) => { let stack = config::load(&cli.config)?; - export::run( + logs::export::run( &stack, &cli.config, &cli.data_dir, - export::Opts { + logs::export::Opts { service, since, logs_only, @@ -220,31 +215,31 @@ fn run() -> Result<()> { } None => { let stack = config::load(&cli.config)?; - docker::logs(&stack, &cli.data_dir, service.as_deref()) + utils::docker::logs(&stack, &cli.data_dir, service.as_deref()) } }, Command::Start { service, no_render } => { let stack = config::load(&cli.config)?; if !no_render { - render::render(&stack, &cli.data_dir)?; + config::render::render(&stack, &cli.data_dir)?; } - docker::start(&stack, &cli.data_dir, service.as_deref()) + utils::docker::start(&stack, &cli.data_dir, service.as_deref()) } Command::Stop { service, destroy } => { let stack = config::load(&cli.config)?; - docker::stop(&stack, &cli.data_dir, service.as_deref(), destroy) + utils::docker::stop(&stack, &cli.data_dir, service.as_deref(), destroy) } Command::Restart { service, no_render } => { let stack = config::load(&cli.config)?; if !no_render { - render::render(&stack, &cli.data_dir)?; + config::render::render(&stack, &cli.data_dir)?; } - docker::restart(&stack, &cli.data_dir, service.as_deref()) + utils::docker::restart(&stack, &cli.data_dir, service.as_deref()) } Command::Pull => { let stack = config::load(&cli.config)?; - render::render(&stack, &cli.data_dir)?; - docker::pull(&stack, &cli.data_dir) + config::render::render(&stack, &cli.data_dir)?; + utils::docker::pull(&stack, &cli.data_dir) } Command::Upgrade { service } => { let stack = config::load(&cli.config)?; @@ -252,7 +247,7 @@ fn run() -> Result<()> { } Command::Status => { let stack = config::load(&cli.config)?; - docker::status(&stack, &cli.data_dir) + utils::docker::status(&stack, &cli.data_dir) } Command::Chainstate { command } => match command { ChainstateCommand::Wipe { service, yes } => { @@ -272,20 +267,20 @@ fn run() -> Result<()> { keep_archives, } => { let service = match service { - ServiceArg::Node => download::ServiceSel::Node, - ServiceArg::Api => download::ServiceSel::Api, - ServiceArg::All => download::ServiceSel::All, + ServiceArg::Node => chainstate::download::ServiceSel::Node, + ServiceArg::Api => chainstate::download::ServiceSel::Api, + ServiceArg::All => chainstate::download::ServiceSel::All, }; - if archive.is_some() && matches!(service, download::ServiceSel::All) { + if archive.is_some() && matches!(service, chainstate::download::ServiceSel::All) { anyhow::bail!( "--archive requires exactly one service: --service node or --service api" ); } let stack = config::load(&cli.config)?; - download::run( + chainstate::download::run( &stack, &cli.data_dir, - download::Opts { + chainstate::download::Opts { service, archive, check_only, diff --git a/src/upgrade.rs b/src/upgrade.rs index 0210fc5..005e1c0 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result}; use colored::Colorize; use crate::config::{ServiceMode, Stack}; -use crate::versions::*; +use crate::utils::versions::*; struct Row { name: &'static str, @@ -30,32 +30,32 @@ pub fn run(stack: &Stack, service: Option<&str>) -> Result<()> { ( "bitcoind", stack.bitcoind.mode, - crate::services::bitcoind_image(stack), + crate::utils::services::bitcoind_image(stack), ), ( "stacks-node", stack.stacks_node.mode, - crate::services::stacks_node_image(stack), + crate::utils::services::stacks_node_image(stack), ), ( "stacks-signer", stack.stacks_signer.mode, - crate::services::stacks_signer_image(stack), + crate::utils::services::stacks_signer_image(stack), ), ( "stacks-api", stack.stacks_api.mode, - crate::services::stacks_api_image(stack), + crate::utils::services::stacks_api_image(stack), ), ( "stacks-mesh-api", stack.stacks_mesh_api.mode, - crate::services::stacks_mesh_api_image(stack), + crate::utils::services::stacks_mesh_api_image(stack), ), ( "postgres", stack.postgres.mode, - crate::services::postgres_image(stack), + crate::utils::services::postgres_image(stack), ), ] .into_iter() diff --git a/src/docker.rs b/src/utils/docker.rs similarity index 98% rename from src/docker.rs rename to src/utils/docker.rs index e69d687..318c384 100644 --- a/src/docker.rs +++ b/src/utils/docker.rs @@ -8,9 +8,9 @@ use std::process::Command; use anyhow::{Context, Result, bail}; use colored::Colorize; +use crate::config::render::{COMPOSE_PROJECT, compose_file}; use crate::config::{ServiceMode, Stack}; -use crate::render::{COMPOSE_PROJECT, compose_file}; -use crate::services::roster; +use crate::utils::services::roster; fn compose(data_dir: &Path) -> Command { let mut cmd = Command::new("docker"); @@ -292,8 +292,10 @@ pub fn restart(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result< return Ok(()); } - let enabled: Vec<_> = - roster(stack).into_iter().filter(|(_, m)| *m == ServiceMode::Enabled).collect(); + let enabled: Vec<_> = roster(stack) + .into_iter() + .filter(|(_, m)| *m == ServiceMode::Enabled) + .collect(); if enabled.is_empty() { bail!("no services are set to mode = \"enabled\" in stacks.toml — nothing to restart"); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..166feae --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,5 @@ +//! Shared helpers used across commands — not commands themselves. + +pub mod docker; +pub mod services; +pub mod versions; diff --git a/src/services.rs b/src/utils/services.rs similarity index 100% rename from src/services.rs rename to src/utils/services.rs diff --git a/src/versions.rs b/src/utils/versions.rs similarity index 100% rename from src/versions.rs rename to src/utils/versions.rs From 7f8d2b311c41751338fdd13063a4c1c90071840f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:24:58 -0600 Subject: [PATCH 03/19] test: add unit tests for utils (#13) --- src/utils/docker.rs | 45 +++++++++++++++++++++++++++ src/utils/services.rs | 66 ++++++++++++++++++++++++++++++++++++++++ src/utils/versions.rs | 71 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 176 insertions(+), 6 deletions(-) diff --git a/src/utils/docker.rs b/src/utils/docker.rs index 318c384..c515f0d 100644 --- a/src/utils/docker.rs +++ b/src/utils/docker.rs @@ -337,3 +337,48 @@ pub fn logs(_stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<() } run(cmd, "docker compose logs") } + +#[cfg(test)] +mod tests { + use super::*; + + fn stack(toml_str: &str) -> Stack { + toml::from_str(toml_str).expect("test stack.toml should parse") + } + + #[test] + fn compose_targets_project_and_rendered_file() { + let cmd = compose(Path::new("/data")); + assert_eq!(cmd.get_program(), "docker"); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args[..4], ["compose", "-p", COMPOSE_PROJECT, "-f"]); + assert!(args[4].ends_with("rendered/docker-compose.yml")); + assert!(args[4].starts_with("/data")); + } + + #[test] + fn ensure_enabled_accepts_enabled_services() { + let s = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + assert!(ensure_enabled(&s, "postgres").is_ok()); + } + + #[test] + fn ensure_enabled_rejects_external_and_disabled() { + let s = stack("network = \"testnet\"\n[postgres]\nmode = \"external\"\nhost = \"pg\""); + let err = ensure_enabled(&s, "postgres").unwrap_err().to_string(); + assert!(err.contains("external")); + let err = ensure_enabled(&s, "stacks-node").unwrap_err().to_string(); + assert!(err.contains("disabled")); + } + + #[test] + fn ensure_enabled_unknown_lists_enabled_services() { + let s = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + let err = ensure_enabled(&s, "nonsense").unwrap_err().to_string(); + assert!(err.contains("unknown service `nonsense`")); + assert!(err.contains("postgres")); + } +} diff --git a/src/utils/services.rs b/src/utils/services.rs index 37a383b..9ee4b53 100644 --- a/src/utils/services.rs +++ b/src/utils/services.rs @@ -110,3 +110,69 @@ pub fn roster(stack: &Stack) -> Vec<(&'static str, ServiceMode)> { ("postgres", stack.postgres.mode), ] } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Stack; + + fn stack(toml_str: &str) -> Stack { + toml::from_str(toml_str).expect("test stack.toml should parse") + } + + #[test] + fn image_uses_default_tag_when_version_unset() { + let s = stack("network = \"testnet\""); + assert!(stacks_node_image(&s).ends_with(":latest")); + assert!(postgres_image(&s).starts_with("postgres:")); + } + + #[test] + fn image_uses_configured_version() { + let s = stack( + "network = \"testnet\"\n[stacks-node]\nversion = \"4.0.1\"\n[postgres]\nversion = \"17\"", + ); + assert!(stacks_node_image(&s).ends_with(":4.0.1")); + assert_eq!(postgres_image(&s), "postgres:17"); + } + + #[test] + fn bitcoind_ports_per_network() { + assert_eq!(bitcoind_rpc_port(Network::Mainnet), 8332); + assert_eq!(bitcoind_p2p_port(Network::Mainnet), 8333); + // non-mainnet is the Hiro-hosted regtest, hence regtest ports + assert_eq!(bitcoind_rpc_port(Network::Testnet), 18443); + assert_eq!(bitcoind_p2p_port(Network::Testnet), 18444); + } + + #[test] + fn hosts_follow_service_mode() { + let s = stack( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"10.0.1.6\"", + ); + assert_eq!(bitcoind_host(&s), Some("bitcoind".into())); + assert_eq!(node_rpc_host(&s), Some("10.0.1.6".into())); + assert_eq!(postgres_host(&s), None); // disabled by default + } + + #[test] + fn port_overrides_apply() { + let s = stack( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\nrpc_port = 30443\n[postgres]\nmode = \"enabled\"", + ); + assert_eq!(node_rpc_port(&s), 30443); + assert_eq!(postgres_port(&s), POSTGRES_PORT); + } + + #[test] + fn roster_lists_all_services_with_modes() { + use crate::config::ServiceMode; + let s = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + let roster = roster(&s); + assert_eq!(roster.len(), 6); + let (name, mode) = roster.iter().find(|(n, _)| *n == "postgres").unwrap(); + assert_eq!(*name, "postgres"); + assert_eq!(*mode, ServiceMode::Enabled); + assert!(roster.iter().any(|(n, _)| *n == "stacks-mesh-api")); + } +} diff --git a/src/utils/versions.rs b/src/utils/versions.rs index 984a0ee..5976bae 100644 --- a/src/utils/versions.rs +++ b/src/utils/versions.rs @@ -25,8 +25,13 @@ pub fn version_string(v: &[u64]) -> String { v.iter().map(u64::to_string).collect::>().join(".") } +/// Tag of an image ref, defaulting to `latest` when untagged. A colon whose +/// right side contains `/` is a registry port (`localhost:5000/repo`), not a tag. pub fn image_tag(image: &str) -> String { - image.rsplit(':').next().unwrap_or("latest").to_string() + match image.rsplit_once(':') { + Some((_, tag)) if !tag.contains('/') => tag.to_string(), + _ => "latest".to_string(), + } } /// Concrete version of a locally pulled image, from its OCI version label @@ -46,9 +51,63 @@ pub fn pulled_image_version(image: &str) -> Option> { if !out.status.success() { return None; } - let label = String::from_utf8_lossy(&out.stdout) - .trim() - .trim_start_matches('v') - .to_string(); - parse_version(&label) + version_from_label(&String::from_utf8_lossy(&out.stdout)) +} + +/// Parse a version out of an OCI label value ("9.0.2", "v9.0.2", trailing newline). +fn version_from_label(label: &str) -> Option> { + parse_version(label.trim().trim_start_matches('v')) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cmp::Ordering::*; + + #[test] + fn parses_versions() { + assert_eq!(parse_version("9.0.2"), Some(vec![9, 0, 2])); + assert_eq!(parse_version("3.1.0.0.8"), Some(vec![3, 1, 0, 0, 8])); + // a bare major parses; callers decide whether that counts as a full pin + assert_eq!(parse_version("9"), Some(vec![9])); + assert_eq!(parse_version("latest"), None); + assert_eq!(parse_version("9.0.2-alpine"), None); + assert_eq!(parse_version(""), None); + } + + #[test] + fn compares_with_zero_padding() { + assert_eq!(compare_versions(&[9, 1, 0], &[9, 0, 2]), Greater); + assert_eq!(compare_versions(&[3, 1], &[3, 1, 0]), Equal); + assert_eq!(compare_versions(&[3, 1, 0], &[3, 2]), Less); + assert_eq!(compare_versions(&[10], &[9, 9, 9]), Greater); + } + + #[test] + fn version_string_round_trips() { + let v = parse_version("3.1.0.0.8").unwrap(); + assert_eq!(version_string(&v), "3.1.0.0.8"); + } + + #[test] + fn extracts_image_tags() { + assert_eq!(image_tag("postgres:17"), "17"); + assert_eq!( + image_tag("ghcr.io/stacks-network/stacks-core:4.0.1"), + "4.0.1" + ); + // untagged refs default to latest + assert_eq!(image_tag("postgres"), "latest"); + // a registry port is not a tag + assert_eq!(image_tag("localhost:5000/repo"), "latest"); + assert_eq!(image_tag("localhost:5000/repo:1.2"), "1.2"); + } + + #[test] + fn parses_oci_version_labels() { + assert_eq!(version_from_label("9.0.2\n"), Some(vec![9, 0, 2])); + assert_eq!(version_from_label("v4.0.1"), Some(vec![4, 0, 1])); + assert_eq!(version_from_label(""), None); + assert_eq!(version_from_label(""), None); + } } From 3b1652eaa711b637fb870718c210c51d3d665dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:29:45 -0600 Subject: [PATCH 04/19] chore: add clippy ci step (#14) --- .github/workflows/ci.yml | 19 +++++++++++++++++++ src/chainstate/download.rs | 33 ++++++++++++++++----------------- src/config/render.rs | 25 ++++++++++++------------- src/upgrade.rs | 7 +++---- 4 files changed, 50 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d415a7..5221734 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,22 @@ jobs: - name: Run tests run: cargo test --locked + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Install Rust toolchain from rust-toolchain.toml + run: | + rustup toolchain install + rustup component add clippy + rustup show + + - name: Cache cargo + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + # Warnings are errors in CI so lints can't accumulate silently. + - name: Run clippy + run: cargo clippy --all-targets --locked -- -D warnings diff --git a/src/chainstate/download.rs b/src/chainstate/download.rs index d2ac01b..604915c 100644 --- a/src/chainstate/download.rs +++ b/src/chainstate/download.rs @@ -62,13 +62,13 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { Network::Testnet => "testnet", }; - if let Some(running) = crate::utils::docker::running_services(data_dir) { - if !running.is_empty() { - bail!( - "the stack is running ({}) — run `stacksup stop` first", - running.join(", ") - ); - } + if let Some(running) = crate::utils::docker::running_services(data_dir) + && !running.is_empty() + { + bail!( + "the stack is running ({}) — run `stacksup stop` first", + running.join(", ") + ); } let downloads_dir = data_dir.join("downloads"); @@ -353,10 +353,10 @@ fn extract_hrefs(html: &str) -> Vec { rest = &rest[start + 6..]; if let Some(end) = rest.find('"') { let target = &rest[..end]; - if let Some(name) = target.rsplit('/').next() { - if !name.is_empty() { - out.push(name.to_string()); - } + if let Some(name) = target.rsplit('/').next() + && !name.is_empty() + { + out.push(name.to_string()); } rest = &rest[end..]; } else { @@ -748,12 +748,11 @@ fn fetch_expected_sha256(job: &Job) -> Option { } }; for url in candidates { - if let Ok(resp) = ureq::get(&url).call() { - if let Ok(body) = resp.into_string() { - if let Some(hash) = parse_sha_line(&body) { - return Some(hash); - } - } + if let Ok(resp) = ureq::get(&url).call() + && let Ok(body) = resp.into_string() + && let Some(hash) = parse_sha_line(&body) + { + return Some(hash); } } None diff --git a/src/config/render.rs b/src/config/render.rs index 470898d..5f0db72 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -175,10 +175,10 @@ pub fn render(stack: &Stack, data_dir: &Path) -> Result { std::fs::write(compose_file(data_dir), format!("{GENERATED_HEADER}{yaml}"))?; // Reversed (push) edges we can't wire ourselves: emit the node-side config. - if stack.stacks_node.mode == ServiceMode::External { - if let Some(snippet) = apply_to_your_node(stack) { - std::fs::write(dir.join("apply-to-your-node.toml"), snippet)?; - } + if stack.stacks_node.mode == ServiceMode::External + && let Some(snippet) = apply_to_your_node(stack) + { + std::fs::write(dir.join("apply-to-your-node.toml"), snippet)?; } Ok(dir) @@ -548,14 +548,13 @@ fn node_config_toml(stack: &Stack) -> String { out.push_str(&format!( "[[events_observer]]\nendpoint = \"stacks-api:{API_EVENT_PORT}\"\nevents_keys = {API_EVENTS_KEYS}\ntimeout_ms = 300_000\n\n" )); - } else if stack.stacks_api.mode == ServiceMode::External { - if let (Some(host), Some(port)) = + } else if stack.stacks_api.mode == ServiceMode::External + && let (Some(host), Some(port)) = (&stack.stacks_api.event_host, stack.stacks_api.event_port) - { - out.push_str(&format!( + { + out.push_str(&format!( "[[events_observer]]\nendpoint = \"{host}:{port}\"\nevents_keys = {API_EVENTS_KEYS}\ntimeout_ms = 300_000\n\n" )); - } } if stack.stacks_signer.mode == ServiceMode::Enabled { @@ -596,10 +595,10 @@ stacks_private_key = "REPLACE_ME" /// (`[stacks-node] auth_token`); managed nodes use a tool-managed one. // TODO(hackathon): generate per-stack random token into a gitignored secrets file. fn node_auth_token(stack: &Stack) -> String { - if stack.stacks_node.mode == ServiceMode::External { - if let Some(token) = &stack.stacks_node.auth_token { - return token.clone(); - } + if stack.stacks_node.mode == ServiceMode::External + && let Some(token) = &stack.stacks_node.auth_token + { + return token.clone(); } "stacks-tool-dev-auth-token".into() } diff --git a/src/upgrade.rs b/src/upgrade.rs index 005e1c0..2f7f860 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -115,9 +115,9 @@ pub fn run(stack: &Stack, service: Option<&str>) -> Result<()> { rows.iter() .find(|r| r.name == "stacks-signer") .and_then(|r| r.current.clone()), - ) { - if node != signer { - println!( + ) && node != signer + { + println!( "\n{}", format!( "⚠ stacks-node ({}) and stacks-signer ({}) versions differ — they should be upgraded together", @@ -126,7 +126,6 @@ pub fn run(stack: &Stack, service: Option<&str>) -> Result<()> { ) .yellow() ); - } } if upgrades == 0 { From eb66638fdfb7bad87bc81f2144326c43509a864b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:40:09 -0600 Subject: [PATCH 05/19] tests: add tests to all remaining modules (#15) --- src/chainstate/mod.rs | 103 ++++++++++++++++++++++++++++++++++++ src/config/check.rs | 22 ++++++++ src/config/mod.rs | 110 ++++++++++++++++++++++++++++++++++++++ src/config/render.rs | 120 ++++++++++++++++++++++++++++++++++++++++++ src/logs/export.rs | 22 ++++++++ src/main.rs | 13 +++++ src/upgrade.rs | 85 ++++++++++++++++++++++++++++++ 7 files changed, 475 insertions(+) diff --git a/src/chainstate/mod.rs b/src/chainstate/mod.rs index 3652ccc..2567b80 100644 --- a/src/chainstate/mod.rs +++ b/src/chainstate/mod.rs @@ -374,3 +374,106 @@ pub fn wipe(data_dir: &Path, service: Option<&str>, yes: bool) -> Result<()> { println!("Deleted {}", dir.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn stack(toml_str: &str) -> Stack { + toml::from_str(toml_str).expect("test stack.toml should parse") + } + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("stacksup-cs-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Fabricate the two node DBs with known heights. + fn write_node_dbs(data_dir: &std::path::Path, sortition_btc: u64, headers_stx: u64) { + let node = data_dir.join("chainstate/stacks-node/krypton"); + std::fs::create_dir_all(node.join("burnchain/sortition")).unwrap(); + std::fs::create_dir_all(node.join("chainstate/vm")).unwrap(); + let sort = + rusqlite::Connection::open(node.join("burnchain/sortition/marf.sqlite")).unwrap(); + sort.execute_batch( + "CREATE TABLE snapshots(block_height INTEGER, canonical_stacks_tip_height INTEGER, pox_valid INTEGER);", + ) + .unwrap(); + sort.execute( + "INSERT INTO snapshots VALUES (?1, 0, 1)", + rusqlite::params![sortition_btc], + ) + .unwrap(); + let headers = rusqlite::Connection::open(node.join("chainstate/vm/index.sqlite")).unwrap(); + headers + .execute_batch( + "CREATE TABLE nakamoto_block_headers(block_height INTEGER);\n\ + CREATE TABLE block_headers(block_height INTEGER);", + ) + .unwrap(); + headers + .execute( + "INSERT INTO nakamoto_block_headers VALUES (?1)", + rusqlite::params![headers_stx], + ) + .unwrap(); + headers + .execute("INSERT INTO block_headers VALUES (726)", []) + .unwrap(); + } + + #[test] + fn node_tip_reads_headers_db_not_sortition_snapshot() { + let dir = temp_dir("tip"); + write_node_dbs(&dir, 3026, 10602); + let s = stack("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); + let tip = node_tip(&s, &dir); + assert_eq!(tip.bitcoin, Some(3026)); + // stacks height must come from the headers db (the sortition snapshot + // said 0 and lags per burn block post-Nakamoto) + assert_eq!(tip.stacks, Some(10602)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn node_tip_without_dbs_reports_note() { + let dir = temp_dir("empty"); + let s = stack("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); + let tip = node_tip(&s, &dir); + assert_eq!(tip.stacks, None); + assert!(tip.note.unwrap().contains("has the node run yet")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn wipe_rejects_services_without_state() { + let dir = temp_dir("wipe-unknown"); + let err = wipe(&dir, Some("stacks-api"), true) + .unwrap_err() + .to_string(); + assert!(err.contains("no on-disk chainstate")); + assert!(err.contains("postgres")); // the valid list names postgres instead + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn wipe_single_service_leaves_the_rest() { + let dir = temp_dir("wipe-single"); + std::fs::create_dir_all(dir.join("chainstate/postgres")).unwrap(); + std::fs::create_dir_all(dir.join("chainstate/bitcoind")).unwrap(); + wipe(&dir, Some("postgres"), true).unwrap(); + assert!(!dir.join("chainstate/postgres").exists()); + assert!(dir.join("chainstate/bitcoind").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn wipe_missing_dir_is_a_noop() { + let dir = temp_dir("wipe-noop"); + assert!(wipe(&dir, None, true).is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/config/check.rs b/src/config/check.rs index f303599..5953e34 100644 --- a/src/config/check.rs +++ b/src/config/check.rs @@ -158,3 +158,25 @@ fn check_node_info(r: &mut Report, stack: &Stack) { )), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_or_local_follows_mode() { + assert_eq!( + external_or_local(ServiceMode::Enabled, None), + Some("127.0.0.1".into()) + ); + assert_eq!( + external_or_local(ServiceMode::External, Some("10.0.0.5")), + Some("10.0.0.5".into()) + ); + assert_eq!(external_or_local(ServiceMode::External, None), None); + assert_eq!( + external_or_local(ServiceMode::Disabled, Some("ignored")), + None + ); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index e780454..cd52c07 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -352,3 +352,113 @@ mode = "enabled" # user = "stacks" # password = "..." "#; + +#[cfg(test)] +mod tests { + use super::*; + + fn stack(toml_str: &str) -> Stack { + toml::from_str(toml_str).expect("test stack.toml should parse") + } + + fn errors(toml_str: &str) -> Vec { + stack(toml_str).validate().0 + } + + fn warnings(toml_str: &str) -> Vec { + stack(toml_str).validate().1 + } + + #[test] + fn init_template_is_valid_and_clean() { + let s: Stack = toml::from_str(DEFAULT_STACK_TOML).expect("template must parse"); + let (errors, _) = s.validate(); + assert!(errors.is_empty(), "template has errors: {errors:?}"); + } + + #[test] + fn mainnet_node_requires_bitcoind() { + let e = errors("network = \"mainnet\"\n[stacks-node]\nmode = \"enabled\""); + assert!(e.iter().any(|m| m.contains("requires bitcoind"))); + } + + #[test] + fn testnet_rejects_managed_bitcoind() { + let e = errors("network = \"testnet\"\n[bitcoind]\nmode = \"enabled\""); + assert!(e.iter().any(|m| m.contains("Hiro-hosted bitcoin regtest"))); + } + + #[test] + fn external_services_require_hosts() { + let e = errors( + "network = \"mainnet\"\n[bitcoind]\nmode = \"external\"\n[stacks-node]\nmode = \"external\"\n[postgres]\nmode = \"external\"", + ); + assert!( + e.iter() + .any(|m| m.contains("[bitcoind]") && m.contains("`host`")) + ); + assert!(e.iter().any(|m| m.contains("`rpc_host`"))); + assert!( + e.iter() + .any(|m| m.contains("[postgres]") && m.contains("`host`")) + ); + } + + #[test] + fn apis_require_node_and_api_requires_postgres() { + let e = errors( + "network = \"testnet\"\n[stacks-api]\nmode = \"enabled\"\n[stacks-mesh-api]\nmode = \"enabled\"", + ); + assert!( + e.iter() + .any(|m| m.contains("[stacks-api] requires a stacks-node")) + ); + assert!( + e.iter() + .any(|m| m.contains("[stacks-mesh-api] requires a stacks-node")) + ); + assert!( + e.iter() + .any(|m| m.contains("[stacks-api] requires Postgres")) + ); + // the mesh API deliberately does NOT require postgres + assert!( + !e.iter() + .any(|m| m.contains("[stacks-mesh-api] requires Postgres")) + ); + } + + #[test] + fn managed_signer_needs_signer_host_role() { + let e = errors( + "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\n[stacks-signer]\nmode = \"enabled\"", + ); + assert!(e.iter().any(|m| m.contains("signer-host"))); + } + + #[test] + fn signer_with_external_node_needs_auth_token() { + let base = "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-signer]\nmode = \"enabled\""; + let e = errors(base); + assert!(e.iter().any(|m| m.contains("auth_token"))); + // with the token supplied it degrades to a warning, not an error + let ok = "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\nauth_token = \"tok-1234\"\n[stacks-signer]\nmode = \"enabled\""; + assert!(errors(ok).is_empty()); + assert!( + warnings(ok) + .iter() + .any(|m| m.contains("apply-to-your-node")) + ); + } + + #[test] + fn auth_token_on_managed_node_warns() { + let w = warnings( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"\nauth_token = \"x\"", + ); + assert!( + w.iter() + .any(|m| m.contains("only used when mode = \"external\"")) + ); + } +} diff --git a/src/config/render.rs b/src/config/render.rs index 5f0db72..d13ad48 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -692,3 +692,123 @@ fn apply_to_your_node(stack: &Stack) -> Option { needed.then_some(out) } + +#[cfg(test)] +mod tests { + use super::*; + + fn stack(toml_str: &str) -> Stack { + toml::from_str(toml_str).expect("test stack.toml should parse") + } + + const TESTNET_FULL: &str = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[stacks-api]\nmode = \"enabled\"\n[stacks-mesh-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\""; + + #[test] + fn testnet_node_config_matches_reference_shape() { + let s = stack("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); + let toml_text = node_config_toml(&s); + let parsed: toml::Value = + toml::from_str(&toml_text).expect("rendered node config is valid TOML"); + assert_eq!(parsed["burnchain"]["mode"].as_str(), Some("krypton")); + assert_eq!( + parsed["burnchain"]["peer_host"].as_str(), + Some(TESTNET_BURNCHAIN_HOST) + ); + assert_eq!(parsed["burnchain"]["rpc_port"].as_integer(), Some(18443)); + assert!( + parsed["node"]["bootstrap_node"] + .as_str() + .unwrap() + .contains("seed.testnet.hiro.so") + ); + // follower: explicitly not mining or stacking + assert_eq!(parsed["node"]["miner"].as_bool(), Some(false)); + assert_eq!(parsed["node"]["stacker"].as_bool(), Some(false)); + assert_eq!(parsed["ustx_balance"].as_array().unwrap().len(), 12); + assert_eq!(parsed["burnchain"]["epochs"].as_array().unwrap().len(), 14); + } + + #[test] + fn signer_flips_stacker_and_shares_auth_token() { + let s = stack(TESTNET_FULL); + let node: toml::Value = toml::from_str(&node_config_toml(&s)).unwrap(); + assert_eq!(node["node"]["stacker"].as_bool(), Some(true)); + let token = node["connection_options"]["auth_token"] + .as_str() + .unwrap() + .to_string(); + // signer auth_password and mesh env token must match the node's token + assert!(signer_config_toml(&s).contains(&format!("auth_password = \"{token}\""))); + assert!(mesh_api_env(&s).contains(&format!("STACKS_CORE_RPC_AUTH_TOKEN={token}"))); + } + + #[test] + fn api_env_wires_postgres_and_schema() { + let s = stack(TESTNET_FULL); + let env = api_env(&s); + assert!(env.contains("PG_HOST=postgres")); + assert!(env.contains(&format!("PG_DATABASE={API_PG_DATABASE}"))); + assert!(env.contains(&format!("PG_SCHEMA={API_PG_SCHEMA}"))); + assert!(env.contains("STACKS_CHAIN_ID=0x80000000")); + } + + #[test] + fn postgres_mount_depends_on_major() { + let pg17 = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\"\nversion = \"17\""); + assert_eq!(postgres_major(&pg17), Some(17)); + assert!(postgres_service(&pg17).volumes[0].ends_with(":/var/lib/postgresql/data")); + let latest = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + assert_eq!(postgres_major(&latest), None); + assert!(postgres_service(&latest).volumes[0].ends_with(":/var/lib/postgresql")); + assert_eq!( + postgres_major(&stack( + "network = \"testnet\"\n[postgres]\nversion = \"17.5-alpine\"" + )), + Some(17) + ); + } + + #[test] + fn apply_to_your_node_only_for_push_edges() { + // external node + enabled api -> snippet with the api observer + let s = stack( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"", + ); + let snippet = apply_to_your_node(&s).expect("push edge needs the snippet"); + assert!(snippet.contains("[[events_observer]]")); + // external node with nothing to push to -> no snippet + let s = + stack("network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\""); + assert!(apply_to_your_node(&s).is_none()); + } + + #[test] + fn render_writes_expected_tree_and_valid_compose() { + let dir = std::env::temp_dir().join(format!("stacksup-render-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let s = stack(TESTNET_FULL); + render(&s, &dir).unwrap(); + + let compose_text = std::fs::read_to_string(compose_file(&dir)).unwrap(); + let compose: serde_yaml::Value = serde_yaml::from_str(&compose_text).unwrap(); + let services = compose["services"].as_mapping().unwrap(); + for name in [ + "stacks-node", + "stacks-signer", + "stacks-api", + "stacks-mesh-api", + "postgres", + ] { + assert!(services.contains_key(name), "compose missing {name}"); + } + // testnet must NOT render a managed bitcoind + assert!(!services.contains_key("bitcoind")); + assert!(dir.join("rendered/stacks-node/Config.toml").exists()); + assert!(dir.join("rendered/stacks-api/.env").exists()); + assert!(dir.join("chainstate/stacks-node").is_dir()); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/logs/export.rs b/src/logs/export.rs index 99b9949..4504a22 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -310,6 +310,28 @@ mod tests { assert_eq!(out, "connecting with now\n"); } + #[test] + fn copies_tree_with_redaction() { + let src = std::env::temp_dir().join(format!("stacksup-export-src-{}", std::process::id())); + let dst = std::env::temp_dir().join(format!("stacksup-export-dst-{}", std::process::id())); + for d in [&src, &dst] { + let _ = fs::remove_dir_all(d); + } + fs::create_dir_all(src.join("nested")).unwrap(); + fs::write( + src.join("nested/app.env"), + "PG_PASSWORD=hunter2\nPG_HOST=postgres\n", + ) + .unwrap(); + copy_redacted_tree(&src, &dst, &[]).unwrap(); + let copied = fs::read_to_string(dst.join("nested/app.env")).unwrap(); + assert!(copied.contains("PG_PASSWORD= ")); + assert!(copied.contains("PG_HOST=postgres")); + for d in [&src, &dst] { + let _ = fs::remove_dir_all(d); + } + } + #[test] fn redacts_credential_lines() { let secrets = vec![]; diff --git a/src/main.rs b/src/main.rs index 9011dd9..c3d9308 100644 --- a/src/main.rs +++ b/src/main.rs @@ -294,3 +294,16 @@ fn run() -> Result<()> { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + /// clap's built-in validation of the whole CLI definition: conflicting + /// flags, bad defaults, ambiguous subcommands all panic here. + #[test] + fn cli_definition_is_coherent() { + Cli::command().debug_assert(); + } +} diff --git a/src/upgrade.rs b/src/upgrade.rs index 2f7f860..6ca355c 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -402,3 +402,88 @@ fn ghcr_tags(path: &str) -> Result> { }) .unwrap_or_default()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn row( + current: Option>, + major_pin: bool, + same: Option>, + next: Option>, + ) -> Row { + Row { + name: "stacks-api", + current, + current_note: "", + major_pin, + same_major: same, + next_major: next, + error: None, + } + } + + fn verdict_text(r: &Row) -> (String, String, u32) { + let mut upgrades = 0; + let (available, verdict) = verdict(r, &mut upgrades); + (available, verdict, upgrades) + } + + #[test] + fn up_to_date_counts_no_upgrade() { + let (_, v, n) = verdict_text(&row(Some(vec![9, 0, 2]), false, Some(vec![9, 0, 2]), None)); + assert!(v.contains("up to date")); + assert_eq!(n, 0); + } + + #[test] + fn minor_and_major_both_reported() { + let (avail, v, n) = verdict_text(&row( + Some(vec![8, 5, 0]), + false, + Some(vec![8, 15, 4]), + Some(vec![9, 0, 2]), + )); + assert!(avail.contains("8.15.4") && avail.contains("9.0.2")); + assert!(v.contains("upgrade + new MAJOR")); + assert_eq!(n, 1); + } + + #[test] + fn major_pin_only_alerts_on_new_major() { + // tracking `17`: newer 17.x is NOT an upgrade suggestion + let (_, v, n) = verdict_text(&row(Some(vec![17]), true, Some(vec![17, 9]), None)); + assert!(v.contains("tracking latest")); + assert_eq!(n, 0); + let (_, v, n) = verdict_text(&row( + Some(vec![17]), + true, + Some(vec![17, 9]), + Some(vec![18, 4]), + )); + assert!(v.contains("new MAJOR")); + assert_eq!(n, 1); + } + + #[test] + fn api_major_guidance_is_db_breaking_with_reseed_steps() { + let text = guidance(&row(Some(vec![8, 5, 0]), false, None, Some(vec![9, 0, 2]))).unwrap(); + assert!(text.contains("DB-BREAKING")); + assert!(text.contains("stacksup chainstate wipe postgres")); + assert!(text.contains("chainstate download --service api")); + } + + #[test] + fn postgres_major_guidance_warns_about_migration() { + let mut r = row(Some(vec![17]), true, None, Some(vec![18, 4])); + r.name = "postgres"; + let text = guidance(&r).unwrap(); + assert!(text.contains("does not migrate itself")); + } + + #[test] + fn no_guidance_when_current() { + assert!(guidance(&row(Some(vec![9, 0, 2]), false, Some(vec![9, 0, 2]), None)).is_none()); + } +} From b115f2c2c723eb2cd2b2d566c9ddde903c9491fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:40:30 -0600 Subject: [PATCH 06/19] feat: add standalone network definition files (#16) * add network files * address review comments --- README.md | 10 + networks/mainnet.toml | 15 ++ networks/testnet.toml | 128 +++++++++++ src/chainstate/download.rs | 83 ++++--- src/chainstate/mod.rs | 56 ++--- src/config/check.rs | 46 ++-- src/config/mod.rs | 109 +++++---- src/config/network.rs | 215 +++++++++++++++++ src/config/render.rs | 456 +++++++++++++++---------------------- src/logs/export.rs | 33 ++- src/main.rs | 54 ++--- src/upgrade.rs | 28 +-- src/utils/docker.rs | 57 ++--- src/utils/services.rs | 126 +++++----- 14 files changed, 872 insertions(+), 544 deletions(-) create mode 100644 networks/mainnet.toml create mode 100644 networks/testnet.toml create mode 100644 src/config/network.rs diff --git a/README.md b/README.md index ef663c1..8aba524 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ stacksup chainstate status # compare every service's chain tip (stacks + bitcoin stacksup chainstate download # seed chainstate from the Hiro Archive (resumable, verified) ``` +## Networks + +Standard network definitions (burnchain endpoint, chain id, epochs, seeded +balances, bootstrap peers) live in [`networks/`](networks/) and ship embedded +in the binary — `network = "testnet"` in stacks.toml references them by name. +A new testnet (new chain id, new epochs) is a new file there, not a config +migration. Unknown names resolve as custom definition files next to your +stacks.toml (`networks/.toml`), so you can define private networks +without a tool release. + ## The model `stacks.toml` is the single source of truth. Every service has a `mode`: diff --git a/networks/mainnet.toml b/networks/mainnet.toml new file mode 100644 index 0000000..6cb28fa --- /dev/null +++ b/networks/mainnet.toml @@ -0,0 +1,15 @@ +# Network definition: Stacks mainnet. +# Referenced from stacks.toml as `network = "mainnet"`. + +name = "mainnet" +chain_id = 0x00000001 +hiro_archive_path = "mainnet" + +[bitcoind] +allow_managed = true +chain = "main" +rpc_port = 8332 +p2p_port = 8333 + +[node] +burnchain_mode = "mainnet" diff --git a/networks/testnet.toml b/networks/testnet.toml new file mode 100644 index 0000000..51c8ff0 --- /dev/null +++ b/networks/testnet.toml @@ -0,0 +1,128 @@ +# Network definition: Stacks testnet (krypton), following the Hiro-hosted bitcoin regtest. +# Referenced from stacks.toml as `network = "testnet"`. + +name = "testnet" +chain_id = 0x80000000 +hiro_archive_path = "testnet" + +[bitcoind] +allow_managed = false +chain = "test" +rpc_port = 18443 +p2p_port = 18444 +default_host = "bitcoin.regtest.hiro.so" + +[node] +burnchain_mode = "krypton" +bootstrap_node = "0348af7ce1b224476e8f042727af3f84dcf49a69bb3c9dd2a1afaa783acfffb729@seed.testnet.hiro.so:20444" +pox_prepare_length = 100 +pox_reward_length = 900 +node_extra = ''' +pox_5_sbtc_contract = "SN3VMHXEN64ZZF71JQ5VESXDWTR301XTTXGF4J8F1.sbtc-token" +pox_5_sbtc_registry_contract = "SN3VMHXEN64ZZF71JQ5VESXDWTR301XTTXGF4J8F1.sbtc-registry" +pox_5_bond_admin = "ST1V2ASRWGR81W7GBN1Z4W2JQKXJWCADPVZG30X45" +''' + +[[ustx_balance]] +address = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST319CF5WV77KYR1H3GT0GZ7B8Q4AQPY42ETP1VPF" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST221Z6TDTC5E0BYR2V624Q2ST6R0Q71T78WTAX6H" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST2TFVBMRPS5SSNP98DQKQ5JNB2B6NZM91C4K3P7B" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST31XHNM0GZ2K978FPP4QA3STNQ73Z8C9G9MJEPK2" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST1B38CGQRPXEMRH7B66VXTS22DQTNMSW4YJJ7QK1" +amount = 10000000000000000 + +[[ustx_balance]] +address = "STDMN71Z0H9EF8CRKAWTGBB5YS0BNV26HZ79QFFP" +amount = 1000000000000000 + +[[ustx_balance]] +address = "ST1E0PSCH72JMQH9QCH293ZTEEH7BPA40Y3F39XQ" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST3QBTK0Q438YVNX8EG6Z85HN0WKQPXYT25H5SPPK" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST10BX04F9PC6N1WBXKW3H7CG0NS0A3PK650T3P3R" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST3AF1BBQAFSFCM8K4ZBR1FBXP3P8J1CKGSGDHWR5" +amount = 100000000000000 + +[[ustx_balance]] +address = "STHY13V44422NAN6D3NSJPY9CDR3ED1M6HH9WZ6Y" +amount = 10000000000000 + +[[epochs]] +epoch_name = "1.0" +start_height = 0 + +[[epochs]] +epoch_name = "2.0" +start_height = 0 + +[[epochs]] +epoch_name = "2.05" +start_height = 1 + +[[epochs]] +epoch_name = "2.1" +start_height = 2 + +[[epochs]] +epoch_name = "2.2" +start_height = 3 + +[[epochs]] +epoch_name = "2.3" +start_height = 4 + +[[epochs]] +epoch_name = "2.4" +start_height = 5 + +[[epochs]] +epoch_name = "2.5" +start_height = 6 + +[[epochs]] +epoch_name = "3.0" +start_height = 1802 + +[[epochs]] +epoch_name = "3.1" +start_height = 1803 + +[[epochs]] +epoch_name = "3.2" +start_height = 1804 + +[[epochs]] +epoch_name = "3.3" +start_height = 1805 + +[[epochs]] +epoch_name = "3.4" +start_height = 1806 + +[[epochs]] +epoch_name = "4.0" +start_height = 2702 diff --git a/src/chainstate/download.rs b/src/chainstate/download.rs index 604915c..4168955 100644 --- a/src/chainstate/download.rs +++ b/src/chainstate/download.rs @@ -16,7 +16,7 @@ use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; use sha2::{Digest, Sha256}; -use crate::config::{Network, ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; use crate::utils::versions::*; const ARCHIVE_BASE: &str = "https://archive.hiro.so"; @@ -56,11 +56,15 @@ struct Job { image: String, } -pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { - let network = match stack.network { - Network::Mainnet => "mainnet", - Network::Testnet => "testnet", - }; +pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result<()> { + let network = deployment.net.hiro_archive_path.clone().with_context(|| { + format!( + "network `{}` has no published archives (no hiro_archive_path in its definition) — \ + use --archive to point at a file explicitly", + deployment.network + ) + })?; + let network = network.as_str(); if let Some(running) = crate::utils::docker::running_services(data_dir) && !running.is_empty() @@ -78,17 +82,20 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { let mut jobs: Vec = Vec::new(); let want_node = matches!(opts.service, ServiceSel::Node | ServiceSel::All) - && stack.stacks_node.mode == ServiceMode::Enabled; + && deployment.stacks_node.mode == ServiceMode::Enabled; let want_api = matches!(opts.service, ServiceSel::Api | ServiceSel::All) - && stack.stacks_api.mode == ServiceMode::Enabled; + && deployment.stacks_api.mode == ServiceMode::Enabled; - if matches!(opts.service, ServiceSel::Node) && stack.stacks_node.mode != ServiceMode::Enabled { + if matches!(opts.service, ServiceSel::Node) + && deployment.stacks_node.mode != ServiceMode::Enabled + { bail!("[stacks-node] is not enabled in stacks.toml"); } - if matches!(opts.service, ServiceSel::Api) && stack.stacks_api.mode != ServiceMode::Enabled { + if matches!(opts.service, ServiceSel::Api) && deployment.stacks_api.mode != ServiceMode::Enabled + { bail!("[stacks-api] is not enabled in stacks.toml"); } - if want_api && stack.postgres.mode != ServiceMode::Enabled { + if want_api && deployment.postgres.mode != ServiceMode::Enabled { bail!( "restoring the API archive needs the managed postgres ([postgres] mode = \"enabled\")" ); @@ -101,13 +108,19 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { } else { Kind::Api }; - jobs.push(pinned_job(kind, archive, network, stack, &downloads_dir)?); + jobs.push(pinned_job( + kind, + archive, + network, + deployment, + &downloads_dir, + )?); } else { if want_node { - jobs.push(latest_node_job(network, stack, &downloads_dir)?); + jobs.push(latest_node_job(network, deployment, &downloads_dir)?); } if want_api { - jobs.push(latest_api_job(network, stack, &downloads_dir)?); + jobs.push(latest_api_job(network, deployment, &downloads_dir)?); } } @@ -246,8 +259,8 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { } match job.kind { - Kind::Node => restore_node(&job.file, stack, data_dir)?, - Kind::Api => restore_api(&job.file, stack, data_dir, &downloads_dir)?, + Kind::Node => restore_node(&job.file, deployment, data_dir)?, + Kind::Api => restore_api(&job.file, deployment, data_dir, &downloads_dir)?, } if !opts.keep_archives && job.url.is_some() { @@ -269,34 +282,34 @@ pub fn run(stack: &Stack, data_dir: &Path, opts: Opts) -> Result<()> { /// use the `-latest` objects: they are byte-identical pointers to the newest /// dated archive but carry no version in their name, which would defeat the /// archive-version ≤ configured-version check. -fn latest_node_job(network: &str, stack: &Stack, downloads: &Path) -> Result { +fn latest_node_job(network: &str, deployment: &Deployment, downloads: &Path) -> Result { let base = format!("{ARCHIVE_BASE}/{network}/stacks-blockchain"); let name = newest_in_listing( &base, &format!("{network}-stacks-blockchain-"), &[".tar.zst", ".tar.gz"], )?; - versioned_job(Kind::Node, &base, &name, stack, downloads) + versioned_job(Kind::Node, &base, &name, deployment, downloads) } -fn latest_api_job(network: &str, stack: &Stack, downloads: &Path) -> Result { +fn latest_api_job(network: &str, deployment: &Deployment, downloads: &Path) -> Result { let base = format!("{ARCHIVE_BASE}/{network}/stacks-blockchain-api-pg"); let name = newest_in_listing(&base, "stacks-blockchain-api-pg-", &[".dump"])?; - versioned_job(Kind::Api, &base, &name, stack, downloads) + versioned_job(Kind::Api, &base, &name, deployment, downloads) } fn versioned_job( kind: Kind, base: &str, name: &str, - stack: &Stack, + deployment: &Deployment, downloads: &Path, ) -> Result { let url = format!("{base}/{name}"); let size = head_content_length(&url).with_context(|| format!("archive not found at {url}"))?; let image = match kind { - Kind::Node => crate::utils::services::stacks_node_image(stack), - Kind::Api => crate::utils::services::stacks_api_image(stack), + Kind::Node => crate::utils::services::stacks_node_image(deployment), + Kind::Api => crate::utils::services::stacks_api_image(deployment), }; Ok(Job { kind, @@ -378,12 +391,12 @@ fn pinned_job( kind: Kind, archive: &str, network: &str, - stack: &Stack, + deployment: &Deployment, downloads: &Path, ) -> Result { let image = match kind { - Kind::Node => crate::utils::services::stacks_node_image(stack), - Kind::Api => crate::utils::services::stacks_api_image(stack), + Kind::Node => crate::utils::services::stacks_node_image(deployment), + Kind::Api => crate::utils::services::stacks_api_image(deployment), }; // Local file? @@ -786,11 +799,8 @@ fn sha256_file(path: &Path) -> Result { // --------------------------------------------------------------------------- // Restore: node -fn restore_node(archive: &Path, stack: &Stack, data_dir: &Path) -> Result<()> { - let mode = match stack.network { - Network::Mainnet => "mainnet", - Network::Testnet => "krypton", - }; +fn restore_node(archive: &Path, deployment: &Deployment, data_dir: &Path) -> Result<()> { + let mode = deployment.net.node.burnchain_mode.as_str(); let target_root = data_dir.join("chainstate/stacks-node"); let tmp = data_dir.join("chainstate/.restore-tmp"); let _ = fs::remove_dir_all(&tmp); @@ -846,7 +856,12 @@ fn restore_node(archive: &Path, stack: &Stack, data_dir: &Path) -> Result<()> { // --------------------------------------------------------------------------- // Restore: API (pg_restore into the managed postgres) -fn restore_api(dump: &Path, stack: &Stack, data_dir: &Path, downloads_dir: &Path) -> Result<()> { +fn restore_api( + dump: &Path, + deployment: &Deployment, + data_dir: &Path, + downloads_dir: &Path, +) -> Result<()> { // pg_restore --jobs needs a seekable file inside the container; the // postgres service mounts /downloads at /downloads (read-only). let dump = if dump.starts_with(downloads_dir) { @@ -864,7 +879,7 @@ fn restore_api(dump: &Path, stack: &Stack, data_dir: &Path, downloads_dir: &Path "/downloads/{}", dump.file_name().unwrap_or_default().to_string_lossy() ); - let user = stack.postgres.user.as_deref().unwrap_or("postgres"); + let user = deployment.postgres.user.as_deref().unwrap_or("postgres"); println!("Starting postgres..."); crate::utils::docker::compose_up_service(data_dir, "postgres")?; @@ -928,7 +943,7 @@ fn restore_api(dump: &Path, stack: &Stack, data_dir: &Path, downloads_dir: &Path // While postgres is still up, check tip consistency against the node. println!("\nChecking chain tip consistency:"); - let verdict = crate::chainstate::status(stack, data_dir); + let verdict = crate::chainstate::status(deployment, data_dir); println!("Stopping postgres..."); crate::utils::docker::compose_stop_service(data_dir, "postgres")?; diff --git a/src/chainstate/mod.rs b/src/chainstate/mod.rs index 2567b80..50773d3 100644 --- a/src/chainstate/mod.rs +++ b/src/chainstate/mod.rs @@ -10,7 +10,7 @@ use std::process::Command; use anyhow::{Context, Result, bail}; use colored::Colorize; -use crate::config::{Network, ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; /// One service's view of the chain: (stacks height, bitcoin height). struct Tip { @@ -27,18 +27,21 @@ struct Tip { /// the node's tips are read straight from its sqlite files (safe read-only /// even while the node writes), while the API's tip lives in Postgres and /// bitcoind's in LevelDB — those two are only checkable while running. -pub fn status(stack: &Stack, data_dir: &Path) -> Result<()> { +pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { let running = crate::utils::docker::running_services(data_dir).unwrap_or_default(); let mut tips: Vec = Vec::new(); - if stack.stacks_node.mode == ServiceMode::Enabled { - tips.push(node_tip(stack, data_dir)); + if deployment.stacks_node.mode == ServiceMode::Enabled { + tips.push(node_tip(deployment, data_dir)); } - if stack.bitcoind.mode == ServiceMode::Enabled { - tips.push(bitcoind_tip(stack, running.iter().any(|s| s == "bitcoind"))); + if deployment.bitcoind.mode == ServiceMode::Enabled { + tips.push(bitcoind_tip( + deployment, + running.iter().any(|s| s == "bitcoind"), + )); } - if stack.stacks_api.mode == ServiceMode::Enabled { - tips.push(api_tip(stack, running.iter().any(|s| s == "postgres"))); + if deployment.stacks_api.mode == ServiceMode::Enabled { + tips.push(api_tip(deployment, running.iter().any(|s| s == "postgres"))); } if tips.is_empty() { @@ -120,11 +123,8 @@ pub fn status(stack: &Stack, data_dir: &Path) -> Result<()> { /// epoch2 block_headers) — NOT the sortition snapshot's /// canonical_stacks_tip_height, which only advances per *burn* block and so /// lags by up to a tenure's worth of stacks blocks post-Nakamoto. -fn node_tip(stack: &Stack, data_dir: &Path) -> Tip { - let mode = match stack.network { - Network::Mainnet => "mainnet", - Network::Testnet => "krypton", - }; +fn node_tip(deployment: &Deployment, data_dir: &Path) -> Tip { + let mode = deployment.net.node.burnchain_mode.as_str(); let node_dir = data_dir.join("chainstate/stacks-node").join(mode); let sort_db = node_dir.join("burnchain/sortition/marf.sqlite"); let headers_db = node_dir.join("chainstate/vm/index.sqlite"); @@ -182,7 +182,7 @@ fn node_tip(stack: &Stack, data_dir: &Path) -> Tip { /// bitcoind's own height via bitcoin-cli inside the running container. /// Offline its state is LevelDB, which we don't parse. -fn bitcoind_tip(stack: &Stack, running: bool) -> Tip { +fn bitcoind_tip(deployment: &Deployment, running: bool) -> Tip { let mut tip = Tip { service: "bitcoind", source: "bitcoin-cli (running container)".into(), @@ -195,11 +195,7 @@ fn bitcoind_tip(stack: &Stack, running: bool) -> Tip { Some("bitcoind is not running — height in LevelDB is not readable offline".into()); return tip; } - let chain = if stack.network == Network::Mainnet { - "main" - } else { - "test" - }; + let chain = deployment.net.bitcoind.chain.as_str(); let out = Command::new("docker") .args([ "exec", @@ -208,11 +204,15 @@ fn bitcoind_tip(stack: &Stack, running: bool) -> Tip { &format!("-chain={chain}"), &format!( "-rpcuser={}", - stack.bitcoind.rpc_user.as_deref().unwrap_or("stacks") + deployment.bitcoind.rpc_user.as_deref().unwrap_or("stacks") ), &format!( "-rpcpassword={}", - stack.bitcoind.rpc_password.as_deref().unwrap_or("stacks") + deployment + .bitcoind + .rpc_password + .as_deref() + .unwrap_or("stacks") ), "getblockcount", ]) @@ -234,7 +234,7 @@ fn bitcoind_tip(stack: &Stack, running: bool) -> Tip { /// The API's indexed tip from its Postgres chain_tip table (single-row table /// with block_height and burn_block_height). Needs the postgres server up. -fn api_tip(stack: &Stack, postgres_running: bool) -> Tip { +fn api_tip(deployment: &Deployment, postgres_running: bool) -> Tip { let mut tip = Tip { service: "stacks-api", source: "postgres chain_tip table".into(), @@ -242,7 +242,7 @@ fn api_tip(stack: &Stack, postgres_running: bool) -> Tip { bitcoin: None, note: None, }; - if stack.postgres.mode != ServiceMode::Enabled { + if deployment.postgres.mode != ServiceMode::Enabled { tip.note = Some("API uses an external/disabled postgres — not checked by this tool".into()); return tip; } @@ -250,7 +250,7 @@ fn api_tip(stack: &Stack, postgres_running: bool) -> Tip { tip.note = Some("postgres is not running — `stacksup start` to check the API's tip".into()); return tip; } - let user = stack.postgres.user.as_deref().unwrap_or("postgres"); + let user = deployment.postgres.user.as_deref().unwrap_or("postgres"); let out = Command::new("docker") .args([ "exec", @@ -379,8 +379,8 @@ pub fn wipe(data_dir: &Path, service: Option<&str>, yes: bool) -> Result<()> { mod tests { use super::*; - fn stack(toml_str: &str) -> Stack { - toml::from_str(toml_str).expect("test stack.toml should parse") + fn deployment(toml_str: &str) -> Deployment { + crate::config::test_deployment(toml_str) } fn temp_dir(name: &str) -> std::path::PathBuf { @@ -429,7 +429,7 @@ mod tests { fn node_tip_reads_headers_db_not_sortition_snapshot() { let dir = temp_dir("tip"); write_node_dbs(&dir, 3026, 10602); - let s = stack("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); + let s = deployment("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); let tip = node_tip(&s, &dir); assert_eq!(tip.bitcoin, Some(3026)); // stacks height must come from the headers db (the sortition snapshot @@ -441,7 +441,7 @@ mod tests { #[test] fn node_tip_without_dbs_reports_note() { let dir = temp_dir("empty"); - let s = stack("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); + let s = deployment("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); let tip = node_tip(&s, &dir); assert_eq!(tip.stacks, None); assert!(tip.note.unwrap().contains("has the node run yet")); diff --git a/src/config/check.rs b/src/config/check.rs index 5953e34..e6a90e8 100644 --- a/src/config/check.rs +++ b/src/config/check.rs @@ -10,7 +10,7 @@ use std::time::Duration; use anyhow::{Result, bail}; use colored::Colorize; -use crate::config::{ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; use crate::utils::services::*; const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); @@ -32,7 +32,7 @@ impl Report { } } -pub fn run(stack: &Stack) -> Result<()> { +pub fn run(deployment: &Deployment) -> Result<()> { let mut r = Report { failures: 0 }; println!("config"); @@ -40,7 +40,7 @@ pub fn run(stack: &Stack) -> Result<()> { r.ok("stacks.toml is valid and cross-service invariants hold"); println!("\ndocker"); - if roster(stack) + if roster(deployment) .iter() .any(|(_, m)| *m == ServiceMode::Enabled) { @@ -62,36 +62,42 @@ pub fn run(stack: &Stack) -> Result<()> { check_tcp( &mut r, "bitcoind rpc", - external_or_local(stack.bitcoind.mode, stack.bitcoind.host.as_deref()), - stack - .bitcoind - .rpc_port - .unwrap_or(bitcoind_rpc_port(stack.network)), + external_or_local( + deployment.bitcoind.mode, + deployment.bitcoind.host.as_deref(), + ), + bitcoind_rpc_port(deployment), ); check_tcp( &mut r, "stacks-node rpc", external_or_local( - stack.stacks_node.mode, - stack.stacks_node.rpc_host.as_deref(), + deployment.stacks_node.mode, + deployment.stacks_node.rpc_host.as_deref(), ), - node_rpc_port(stack), + node_rpc_port(deployment), ); check_tcp( &mut r, "postgres", - external_or_local(stack.postgres.mode, stack.postgres.host.as_deref()), - postgres_port(stack), + external_or_local( + deployment.postgres.mode, + deployment.postgres.host.as_deref(), + ), + postgres_port(deployment), ); check_tcp( &mut r, "stacks-api", - external_or_local(stack.stacks_api.mode, stack.stacks_api.host.as_deref()), - stack.stacks_api.port.unwrap_or(API_PORT), + external_or_local( + deployment.stacks_api.mode, + deployment.stacks_api.host.as_deref(), + ), + deployment.stacks_api.port.unwrap_or(API_PORT), ); println!("\nchain"); - check_node_info(&mut r, stack); + check_node_info(&mut r, deployment); // TODO(hackathon): the checks that catch the silent failure modes — // - bitcoind getblockchaininfo: chain matches stacks.toml network // - API /extended chain tip vs node /v2/info tip (event stream actually flowing) @@ -132,16 +138,16 @@ fn check_tcp(r: &mut Report, label: &str, host: Option, port: u16) { } } -fn check_node_info(r: &mut Report, stack: &Stack) { - let host = match stack.stacks_node.mode { +fn check_node_info(r: &mut Report, deployment: &Deployment) { + let host = match deployment.stacks_node.mode { ServiceMode::Enabled => "127.0.0.1".to_string(), - ServiceMode::External => match &stack.stacks_node.rpc_host { + ServiceMode::External => match &deployment.stacks_node.rpc_host { Some(h) => h.clone(), None => return r.skip("stacks-node /v2/info: no rpc_host configured"), }, ServiceMode::Disabled => return r.skip("stacks-node /v2/info: node is disabled"), }; - let url = format!("http://{host}:{}/v2/info", node_rpc_port(stack)); + let url = format!("http://{host}:{}/v2/info", node_rpc_port(deployment)); match ureq::get(&url).timeout(CONNECT_TIMEOUT).call() { Ok(resp) => match resp.into_json::() { Ok(info) => { diff --git a/src/config/mod.rs b/src/config/mod.rs index cd52c07..55ee3f4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -7,31 +7,15 @@ //! - `disabled`: absent; anything that requires it fails validation pub mod check; +pub mod network; pub mod render; -use std::fmt; use std::path::Path; use anyhow::{Context, Result, bail}; use colored::Colorize; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Network { - Mainnet, - Testnet, -} - -impl fmt::Display for Network { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Network::Mainnet => write!(f, "mainnet"), - Network::Testnet => write!(f, "testnet"), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ServiceMode { @@ -52,8 +36,15 @@ pub enum NodeRole { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct Stack { - pub network: Network, +/// The deployment configuration. This is the root of the configuration and contains all the +/// services and their configurations. +pub struct Deployment { + /// Network name: a built-in definition (mainnet, testnet) or a custom definition file resolved + /// relative to stacks.toml. + pub network: String, + /// The resolved definition; populated by `load()` after parsing. + #[serde(skip)] + pub net: network::NetworkDef, #[serde(default)] pub bitcoind: Bitcoind, #[serde(default, rename = "stacks-node")] @@ -150,35 +141,40 @@ pub struct Postgres { pub password: Option, } -impl Stack { +impl Deployment { /// Cross-service validation: the rules that make invalid stacks fail at /// `up` time instead of becoming runtime mysteries. pub fn validate(&self) -> (Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); - // Only a mainnet node needs its own bitcoind: krypton testnet follows - // the Hiro-hosted bitcoin regtest. - if self.network == Network::Mainnet - && self.stacks_node.mode == ServiceMode::Enabled + // A node needs a burnchain source: its own/external bitcoind, or the + // network's hosted default endpoint. + if self.stacks_node.mode == ServiceMode::Enabled && self.bitcoind.mode == ServiceMode::Disabled + && self.net.bitcoind.default_host.is_none() { - errors.push( - "a mainnet stacks-node requires bitcoind; set [bitcoind] mode = \"enabled\" or \"external\"" - .into(), - ); + errors.push(format!( + "a {} stacks-node requires bitcoind; set [bitcoind] mode = \"enabled\" or \"external\"", + self.network + )); } - if self.network == Network::Testnet && self.bitcoind.mode == ServiceMode::Enabled { - errors.push( - "testnet (krypton) follows the Hiro-hosted bitcoin regtest — a locally managed \ - bitcoind cannot join it; set [bitcoind] mode = \"disabled\" (default endpoint) \ - or \"external\" to point at another krypton regtest" - .into(), - ); + if self.bitcoind.mode == ServiceMode::Enabled && !self.net.bitcoind.allow_managed { + errors.push(format!( + "network `{}` follows a hosted burnchain ({}) — a locally managed bitcoind \ + cannot join it; set [bitcoind] mode = \"disabled\" (hosted default) or \"external\"", + self.network, + self.net.bitcoind.default_host.as_deref().unwrap_or("hosted"), + )); } if self.bitcoind.mode == ServiceMode::External && self.bitcoind.host.is_none() { errors.push("[bitcoind] mode = \"external\" requires `host`".into()); } + if self.bitcoind.mode == ServiceMode::External + && self.bitcoind.rpc_user.is_some() != self.bitcoind.rpc_password.is_some() + { + errors.push("[bitcoind] rpc_user and rpc_password must be set together".into()); + } if self.stacks_node.mode == ServiceMode::External && self.stacks_node.rpc_host.is_none() { errors.push("[stacks-node] mode = \"external\" requires `rpc_host`".into()); } @@ -259,7 +255,7 @@ impl Stack { } } -pub fn load(path: &Path) -> Result { +pub fn load(path: &Path) -> Result { // `--config` accepts either the file itself or a directory containing one. let path = if path.is_dir() { path.join("stacks.toml") @@ -272,10 +268,12 @@ pub fn load(path: &Path) -> Result { path.display() ) })?; - let stack: Stack = + let mut deployment: Deployment = toml::from_str(&raw).with_context(|| format!("invalid config in {}", path.display()))?; + let config_dir = path.parent().unwrap_or(Path::new(".")); + deployment.net = network::load(&deployment.network, config_dir)?; - let (errors, warnings) = stack.validate(); + let (errors, warnings) = deployment.validate(); for w in &warnings { eprintln!("{}", format!("warning: {w}").yellow()); } @@ -285,7 +283,7 @@ pub fn load(path: &Path) -> Result { } bail!("{} found {} config error(s)", path.display(), errors.len()); } - Ok(stack) + Ok(deployment) } pub fn init(force: bool) -> Result<()> { @@ -309,7 +307,7 @@ const DEFAULT_STACK_TOML: &str = r#"# stacksup config # Managed services also take a `version` — the docker image tag to run. # Omit it to use this tool's pinned default. -network = "testnet" # mainnet | testnet +network = "testnet" # mainnet | testnet | custom network definition file [bitcoind] # Only needed on mainnet. Testnet (krypton) follows the Hiro-hosted bitcoin @@ -353,25 +351,33 @@ mode = "enabled" # password = "..." "#; +/// Test helper: parse a stacks.toml string and resolve its (built-in) network. +#[cfg(test)] +pub fn test_deployment(toml_str: &str) -> Deployment { + let mut d: Deployment = toml::from_str(toml_str).expect("test stacks.toml should parse"); + d.net = network::load(&d.network, Path::new(".")).expect("built-in network"); + d +} + #[cfg(test)] mod tests { use super::*; - fn stack(toml_str: &str) -> Stack { - toml::from_str(toml_str).expect("test stack.toml should parse") + fn deployment(toml_str: &str) -> Deployment { + test_deployment(toml_str) } fn errors(toml_str: &str) -> Vec { - stack(toml_str).validate().0 + deployment(toml_str).validate().0 } fn warnings(toml_str: &str) -> Vec { - stack(toml_str).validate().1 + deployment(toml_str).validate().1 } #[test] fn init_template_is_valid_and_clean() { - let s: Stack = toml::from_str(DEFAULT_STACK_TOML).expect("template must parse"); + let s = test_deployment(DEFAULT_STACK_TOML); let (errors, _) = s.validate(); assert!(errors.is_empty(), "template has errors: {errors:?}"); } @@ -385,7 +391,7 @@ mod tests { #[test] fn testnet_rejects_managed_bitcoind() { let e = errors("network = \"testnet\"\n[bitcoind]\nmode = \"enabled\""); - assert!(e.iter().any(|m| m.contains("Hiro-hosted bitcoin regtest"))); + assert!(e.iter().any(|m| m.contains("hosted burnchain"))); } #[test] @@ -404,6 +410,17 @@ mod tests { ); } + #[test] + fn external_bitcoind_credentials_must_come_in_pairs() { + let base = "network = \"mainnet\"\n[bitcoind]\nmode = \"external\"\nhost = \"h\""; + let e = errors(&format!("{base}\nrpc_user = \"u\"")); + assert!(e.iter().any(|m| m.contains("set together"))); + let e = errors(&format!("{base}\nrpc_password = \"p\"")); + assert!(e.iter().any(|m| m.contains("set together"))); + assert!(errors(&format!("{base}\nrpc_user = \"u\"\nrpc_password = \"p\"")).is_empty()); + assert!(errors(base).is_empty()); + } + #[test] fn apis_require_node_and_api_requires_postgres() { let e = errors( diff --git a/src/config/network.rs b/src/config/network.rs new file mode 100644 index 0000000..4862e59 --- /dev/null +++ b/src/config/network.rs @@ -0,0 +1,215 @@ +//! Network definitions: everything that varies between Stacks networks (burnchain endpoint, chain +//! id, epochs, seeded balances, bootstrap peers) lives in a TOML definition file, not in code. +//! +//! Standard networks ship embedded in the binary from the repo's `networks/` directory — publishing +//! a new testnet means adding a file there. A `network` value that names no embedded definition is +//! resolved as a file path (or `networks/.toml`) relative to the stacks.toml, so users can +//! define custom networks without a tool release. + +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +/// Standard definitions compiled into the binary. +const EMBEDDED: &[(&str, &str)] = &[ + ("mainnet", include_str!("../../networks/mainnet.toml")), + ("testnet", include_str!("../../networks/testnet.toml")), +]; + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct NetworkDef { + pub name: String, + pub chain_id: u32, + /// Path segment under archive.hiro.so; absent when no Hiro archives exist + /// for this network. + pub hiro_archive_path: Option, + pub bitcoind: BitcoindNet, + pub node: NodeNet, + #[serde(default, rename = "ustx_balance")] + pub ustx_balances: Vec, + #[serde(default)] + pub epochs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct BitcoindNet { + /// Whether this network can run its own bitcoind. False for networks + /// following a hosted burnchain (a fresh local chain can't join it). + pub allow_managed: bool, + /// bitcoind -chain flag ("main", "test", "regtest") + pub chain: String, + pub rpc_port: u16, + pub p2p_port: u16, + /// Hosted burnchain endpoint used when [bitcoind] is disabled. + pub default_host: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct NodeNet { + /// stacks-node [burnchain] mode; also the node's on-disk chainstate + /// subdirectory name. + pub burnchain_mode: String, + pub bootstrap_node: Option, + pub pox_prepare_length: Option, + pub pox_reward_length: Option, + /// Verbatim TOML appended to the rendered [node] section. + pub node_extra: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UstxBalance { + pub address: String, + pub amount: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Epoch { + pub epoch_name: String, + pub start_height: u64, +} + +/// Resolve a `network = "..."` value: embedded name first, then a custom +/// definition file relative to the directory holding stacks.toml. +pub fn load(name: &str, config_dir: &Path) -> Result { + if let Some((_, raw)) = EMBEDDED.iter().find(|(n, _)| *n == name) { + return parse(raw).with_context(|| format!("embedded network definition `{name}`")); + } + + // Custom definitions resolve relative to stacks.toml only — an absolute + // path would silently bypass config_dir (Path::join discards the base). + if Path::new(name).is_absolute() { + bail!( + "network `{name}`: custom definition paths must be relative to the \ + directory containing stacks.toml" + ); + } + let candidates = [ + config_dir.join(name), + config_dir.join("networks").join(format!("{name}.toml")), + ]; + for path in &candidates { + if path.is_file() { + let raw = std::fs::read_to_string(path)?; + return parse(&raw).with_context(|| format!("network definition {}", path.display())); + } + } + + bail!( + "unknown network `{name}` — built-in networks: {}; or point at a custom \ + definition file (`network = \"my-net.toml\"`, resolved next to stacks.toml)", + EMBEDDED + .iter() + .map(|(n, _)| *n) + .collect::>() + .join(", ") + ) +} + +fn parse(raw: &str) -> Result { + let def: NetworkDef = toml::from_str(raw)?; + // Missing fields are already serde errors (nothing here is defaulted); + // these guards catch the empty/zero values serde accepts. + if def.name.is_empty() || def.node.burnchain_mode.is_empty() { + bail!("network definition must set `name` and [node] burnchain_mode"); + } + if def.bitcoind.chain.is_empty() { + bail!("network definition must set a non-empty [bitcoind] chain"); + } + if def.bitcoind.rpc_port == 0 || def.bitcoind.p2p_port == 0 { + bail!("network definition must set non-zero [bitcoind] rpc_port and p2p_port"); + } + Ok(def) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_definitions_parse() { + let mainnet = load("mainnet", Path::new(".")).unwrap(); + assert_eq!(mainnet.chain_id, 0x00000001); + assert!(mainnet.bitcoind.allow_managed); + assert_eq!(mainnet.node.burnchain_mode, "mainnet"); + assert!(mainnet.epochs.is_empty()); + + let testnet = load("testnet", Path::new(".")).unwrap(); + assert_eq!(testnet.chain_id, 0x80000000); + assert!(!testnet.bitcoind.allow_managed); + assert_eq!( + testnet.bitcoind.default_host.as_deref(), + Some("bitcoin.regtest.hiro.so") + ); + assert_eq!(testnet.node.burnchain_mode, "krypton"); + assert_eq!(testnet.ustx_balances.len(), 12); + assert_eq!(testnet.epochs.len(), 14); + assert!( + testnet + .node + .node_extra + .as_deref() + .unwrap() + .contains("pox_5_sbtc_contract") + ); + } + + #[test] + fn unknown_network_lists_builtins() { + let err = load("nope", Path::new("/nonexistent")) + .unwrap_err() + .to_string(); + assert!(err.contains("mainnet, testnet")); + } + + #[test] + fn absolute_custom_paths_are_rejected() { + let err = load("/etc/evil.toml", Path::new("/tmp")) + .unwrap_err() + .to_string(); + assert!(err.contains("must be relative")); + } + + #[test] + fn zero_ports_and_empty_chain_are_rejected() { + let base = "name = \"x\"\nchain_id = 1\n[bitcoind]\nallow_managed = true\nchain = \"{chain}\"\nrpc_port = {rpc}\np2p_port = 8333\n[node]\nburnchain_mode = \"mainnet\"\n"; + let bad_port = base.replace("{chain}", "main").replace("{rpc}", "0"); + assert!( + parse(&bad_port) + .unwrap_err() + .to_string() + .contains("non-zero") + ); + let bad_chain = base.replace("{chain}", "").replace("{rpc}", "8332"); + assert!( + parse(&bad_chain) + .unwrap_err() + .to_string() + .contains("non-empty") + ); + let good = base.replace("{chain}", "main").replace("{rpc}", "8332"); + assert!(parse(&good).is_ok()); + } + + #[test] + fn custom_definition_file_resolves() { + let dir = std::env::temp_dir().join(format!("stacksup-net-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("networks")).unwrap(); + std::fs::write( + dir.join("networks/testnet2.toml"), + "name = \"testnet2\"\nchain_id = 0x80000002\n[bitcoind]\nallow_managed = false\nchain = \"test\"\nrpc_port = 18443\np2p_port = 18444\ndefault_host = \"btc.testnet2.example\"\n[node]\nburnchain_mode = \"krypton\"\n", + ) + .unwrap(); + let def = load("testnet2", &dir).unwrap(); + assert_eq!(def.chain_id, 0x80000002); + assert_eq!( + def.bitcoind.default_host.as_deref(), + Some("btc.testnet2.example") + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/config/render.rs b/src/config/render.rs index d13ad48..3224ecb 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -1,11 +1,11 @@ -//! Renders `stacks.toml` into everything the stack actually runs on, laid out -//! under the `--data-dir`: +//! Renders `stacks.toml` into everything the stack actually runs on, laid out under the +//! `--data-dir`: //! //! /rendered/ compose file + per-service configs (regenerated) //! /chainstate/ service state: chainstate, Postgres, signer db //! -//! Service state is bind-mounted from `chainstate/` (not named Docker volumes) so -//! the entire stack — configs and data — lives where the operator said. +//! Service state is bind-mounted from `chainstate/` (not named Docker volumes) so the entire stack +//! — configs and data — lives where the operator said. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use serde::Serialize; -use crate::config::{Network, ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; use crate::utils::services::*; pub const COMPOSE_PROJECT: &str = "stacks"; @@ -95,7 +95,7 @@ impl ComposeService { } } -pub fn render(stack: &Stack, data_dir: &Path) -> Result { +pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { let dir = data_dir.join("rendered"); std::fs::create_dir_all(&dir)?; @@ -110,73 +110,73 @@ pub fn render(stack: &Stack, data_dir: &Path) -> Result { Ok(()) }; - if stack.bitcoind.mode == ServiceMode::Enabled { + if deployment.bitcoind.mode == ServiceMode::Enabled { chainstate_subdir("bitcoind")?; compose .services - .insert("bitcoind".into(), bitcoind_service(stack)); + .insert("bitcoind".into(), bitcoind_service(deployment)); } - if stack.stacks_node.mode == ServiceMode::Enabled { + if deployment.stacks_node.mode == ServiceMode::Enabled { chainstate_subdir("stacks-node")?; std::fs::create_dir_all(dir.join("stacks-node"))?; std::fs::write( dir.join("stacks-node/Config.toml"), - format!("{GENERATED_HEADER}{}", node_config_toml(stack)), + format!("{GENERATED_HEADER}{}", node_config_toml(deployment)), )?; compose .services - .insert("stacks-node".into(), node_service(stack)); + .insert("stacks-node".into(), node_service(deployment)); } - if stack.stacks_signer.mode == ServiceMode::Enabled { + if deployment.stacks_signer.mode == ServiceMode::Enabled { chainstate_subdir("stacks-signer")?; std::fs::create_dir_all(dir.join("stacks-signer"))?; std::fs::write( dir.join("stacks-signer/signer.toml"), - format!("{GENERATED_HEADER}{}", signer_config_toml(stack)), + format!("{GENERATED_HEADER}{}", signer_config_toml(deployment)), )?; compose .services - .insert("stacks-signer".into(), signer_service(stack)); + .insert("stacks-signer".into(), signer_service(deployment)); } - if stack.stacks_api.mode == ServiceMode::Enabled { + if deployment.stacks_api.mode == ServiceMode::Enabled { std::fs::create_dir_all(dir.join("stacks-api"))?; std::fs::write( dir.join("stacks-api/.env"), - format!("{GENERATED_HEADER}{}", api_env(stack)), + format!("{GENERATED_HEADER}{}", api_env(deployment)), )?; compose .services - .insert("stacks-api".into(), api_service(stack)); + .insert("stacks-api".into(), api_service(deployment)); } - if stack.stacks_mesh_api.mode == ServiceMode::Enabled { + if deployment.stacks_mesh_api.mode == ServiceMode::Enabled { std::fs::create_dir_all(dir.join("stacks-mesh-api"))?; std::fs::write( dir.join("stacks-mesh-api/.env"), - format!("{GENERATED_HEADER}{}", mesh_api_env(stack)), + format!("{GENERATED_HEADER}{}", mesh_api_env(deployment)), )?; compose .services - .insert("stacks-mesh-api".into(), mesh_api_service(stack)); + .insert("stacks-mesh-api".into(), mesh_api_service(deployment)); } - if stack.postgres.mode == ServiceMode::Enabled { + if deployment.postgres.mode == ServiceMode::Enabled { chainstate_subdir("postgres")?; std::fs::create_dir_all(data_dir.join("downloads"))?; compose .services - .insert("postgres".into(), postgres_service(stack)); + .insert("postgres".into(), postgres_service(deployment)); } let yaml = serde_yaml::to_string(&compose)?; std::fs::write(compose_file(data_dir), format!("{GENERATED_HEADER}{yaml}"))?; // Reversed (push) edges we can't wire ourselves: emit the node-side config. - if stack.stacks_node.mode == ServiceMode::External - && let Some(snippet) = apply_to_your_node(stack) + if deployment.stacks_node.mode == ServiceMode::External + && let Some(snippet) = apply_to_your_node(deployment) { std::fs::write(dir.join("apply-to-your-node.toml"), snippet)?; } @@ -184,21 +184,14 @@ pub fn render(stack: &Stack, data_dir: &Path) -> Result { Ok(dir) } -fn bitcoind_service(stack: &Stack) -> ComposeService { - let rpc = bitcoind_rpc_port(stack.network); - let p2p = bitcoind_p2p_port(stack.network); - let mut svc = ComposeService::new("bitcoind", &bitcoind_image(stack)); +fn bitcoind_service(deployment: &Deployment) -> ComposeService { + let rpc = bitcoind_rpc_port(deployment); + let p2p = bitcoind_p2p_port(deployment); + let mut svc = ComposeService::new("bitcoind", &bitcoind_image(deployment)); svc.command = vec![ "-server=1".into(), "-txindex=0".into(), - format!( - "-chain={}", - if stack.network == Network::Mainnet { - "main" - } else { - "test" - } - ), + format!("-chain={}", deployment.net.bitcoind.chain), "-rpcbind=0.0.0.0".into(), "-rpcallowip=0.0.0.0/0".into(), // TODO(hackathon): generate rpcauth instead of user/pass defaults @@ -210,8 +203,8 @@ fn bitcoind_service(stack: &Stack) -> ComposeService { svc } -fn node_service(stack: &Stack) -> ComposeService { - let mut svc = ComposeService::new("stacks-node", &stacks_node_image(stack)); +fn node_service(deployment: &Deployment) -> ComposeService { + let mut svc = ComposeService::new("stacks-node", &stacks_node_image(deployment)); svc.command = vec![ "stacks-node".into(), "start".into(), @@ -226,19 +219,19 @@ fn node_service(stack: &Stack) -> ComposeService { "./stacks-node/Config.toml:/etc/stacks/Config.toml:ro".into(), "../chainstate/stacks-node:/stacks-blockchain".into(), ]; - if stack.bitcoind.mode == ServiceMode::Enabled { + if deployment.bitcoind.mode == ServiceMode::Enabled { svc.depends_on.push("bitcoind".into()); } // The node pushes events to the API; if the API is down at boot the node // retries, but starting after the API avoids stalling block processing. - if stack.stacks_api.mode == ServiceMode::Enabled { + if deployment.stacks_api.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-api".into()); } svc } -fn signer_service(stack: &Stack) -> ComposeService { - let mut svc = ComposeService::new("stacks-signer", &stacks_signer_image(stack)); +fn signer_service(deployment: &Deployment) -> ComposeService { + let mut svc = ComposeService::new("stacks-signer", &stacks_signer_image(deployment)); svc.command = vec![ "stacks-signer".into(), "run".into(), @@ -249,27 +242,27 @@ fn signer_service(stack: &Stack) -> ComposeService { "./stacks-signer/signer.toml:/etc/stacks/signer.toml:ro".into(), "../chainstate/stacks-signer:/var/lib/stacks-signer".into(), ]; - if stack.stacks_node.mode == ServiceMode::Enabled { + if deployment.stacks_node.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-node".into()); } svc } -fn api_service(stack: &Stack) -> ComposeService { - let mut svc = ComposeService::new("stacks-api", &stacks_api_image(stack)); +fn api_service(deployment: &Deployment) -> ComposeService { + let mut svc = ComposeService::new("stacks-api", &stacks_api_image(deployment)); svc.ports = vec![format!("{API_PORT}:{API_PORT}")]; svc.env_file = vec!["./stacks-api/.env".into()]; - if stack.postgres.mode == ServiceMode::Enabled { + if deployment.postgres.mode == ServiceMode::Enabled { svc.depends_on.push("postgres".into()); } svc } -fn mesh_api_service(stack: &Stack) -> ComposeService { - let mut svc = ComposeService::new("stacks-mesh-api", &stacks_mesh_api_image(stack)); +fn mesh_api_service(deployment: &Deployment) -> ComposeService { + let mut svc = ComposeService::new("stacks-mesh-api", &stacks_mesh_api_image(deployment)); svc.ports = vec![format!("{MESH_API_PORT}:{MESH_API_PORT}")]; svc.env_file = vec!["./stacks-mesh-api/.env".into()]; - if stack.stacks_node.mode == ServiceMode::Enabled { + if deployment.stacks_node.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-node".into()); } svc @@ -277,8 +270,8 @@ fn mesh_api_service(stack: &Stack) -> ComposeService { /// Postgres major from the configured version tag: "17" -> 17, /// "17.5-alpine" -> 17, "latest"/absent -> None. -fn postgres_major(stack: &Stack) -> Option { - stack +fn postgres_major(deployment: &Deployment) -> Option { + deployment .postgres .version .as_deref()? @@ -288,13 +281,13 @@ fn postgres_major(stack: &Stack) -> Option { .ok() } -fn postgres_service(stack: &Stack) -> ComposeService { - let mut svc = ComposeService::new("postgres", &postgres_image(stack)); +fn postgres_service(deployment: &Deployment) -> ComposeService { + let mut svc = ComposeService::new("postgres", &postgres_image(deployment)); svc.ports = vec![format!("{POSTGRES_PORT}:{POSTGRES_PORT}")]; // TODO(hackathon): generate a password into a .env secret file instead svc.environment.insert( "POSTGRES_USER".into(), - stack + deployment .postgres .user .clone() @@ -302,7 +295,7 @@ fn postgres_service(stack: &Stack) -> ComposeService { ); svc.environment.insert( "POSTGRES_PASSWORD".into(), - stack + deployment .postgres .password .clone() @@ -314,7 +307,7 @@ fn postgres_service(stack: &Stack) -> ComposeService { // require the mount at /var/lib/postgresql (enabling pg_upgrade across // majors); images <= 17 use PGDATA=/var/lib/postgresql/data and need the // mount there. Unknown majors (e.g. `latest`) get the 18+ convention. - let data_target = match postgres_major(stack) { + let data_target = match postgres_major(deployment) { Some(major) if major <= 17 => "/var/lib/postgresql/data", _ => "/var/lib/postgresql", }; @@ -332,7 +325,7 @@ fn postgres_service(stack: &Stack) -> ComposeService { // as PID 1, so SIGTERM from `stacksup stop` reaches postgres directly and // shutdown is clean (a backgrounded postgres under bash would be // SIGKILLed after the grace period instead). - let user = stack + let user = deployment .postgres .user .clone() @@ -350,230 +343,122 @@ fn postgres_service(stack: &Stack) -> ComposeService { svc } -/// Testnet (krypton) network parameters, mirroring stacks-core's -/// sample/conf/testnet-follower-conf.toml. The krypton burnchain is the -/// Hiro-hosted bitcoin *regtest*, not the public bitcoin testnet. -const TESTNET_BOOTSTRAP_NODE: &str = - "0348af7ce1b224476e8f042727af3f84dcf49a69bb3c9dd2a1afaa783acfffb729@seed.testnet.hiro.so:20444"; -const TESTNET_BURNCHAIN_HOST: &str = "bitcoin.regtest.hiro.so"; - -const TESTNET_NODE_POX5: &str = r#"pox_5_sbtc_contract = "SN3VMHXEN64ZZF71JQ5VESXDWTR301XTTXGF4J8F1.sbtc-token" -pox_5_sbtc_registry_contract = "SN3VMHXEN64ZZF71JQ5VESXDWTR301XTTXGF4J8F1.sbtc-registry" -pox_5_bond_admin = "ST1V2ASRWGR81W7GBN1Z4W2JQKXJWCADPVZG30X45" -"#; - -const TESTNET_USTX_BALANCES: &str = r#"[[ustx_balance]] -address = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2" -amount = 10000000000000000 - -[[ustx_balance]] -address = "ST319CF5WV77KYR1H3GT0GZ7B8Q4AQPY42ETP1VPF" -amount = 10000000000000000 - -[[ustx_balance]] -address = "ST221Z6TDTC5E0BYR2V624Q2ST6R0Q71T78WTAX6H" -amount = 10000000000000000 - -[[ustx_balance]] -address = "ST2TFVBMRPS5SSNP98DQKQ5JNB2B6NZM91C4K3P7B" -amount = 10000000000000000 - -[[ustx_balance]] -address = "ST31XHNM0GZ2K978FPP4QA3STNQ73Z8C9G9MJEPK2" -amount = 10000000000000000 - -[[ustx_balance]] -address = "ST1B38CGQRPXEMRH7B66VXTS22DQTNMSW4YJJ7QK1" -amount = 10000000000000000 - -[[ustx_balance]] -address = "STDMN71Z0H9EF8CRKAWTGBB5YS0BNV26HZ79QFFP" -amount = 1000000000000000 - -[[ustx_balance]] -address = "ST1E0PSCH72JMQH9QCH293ZTEEH7BPA40Y3F39XQ" -amount = 10000000000000 - -[[ustx_balance]] -address = "ST3QBTK0Q438YVNX8EG6Z85HN0WKQPXYT25H5SPPK" -amount = 10000000000000 - -[[ustx_balance]] -address = "ST10BX04F9PC6N1WBXKW3H7CG0NS0A3PK650T3P3R" -amount = 10000000000000 - -[[ustx_balance]] -address = "ST3AF1BBQAFSFCM8K4ZBR1FBXP3P8J1CKGSGDHWR5" -amount = 100000000000000 - -[[ustx_balance]] -address = "STHY13V44422NAN6D3NSJPY9CDR3ED1M6HH9WZ6Y" -amount = 10000000000000 -"#; - -const TESTNET_EPOCHS: &str = r#"[[burnchain.epochs]] -epoch_name = "1.0" -start_height = 0 - -[[burnchain.epochs]] -epoch_name = "2.0" -start_height = 0 - -[[burnchain.epochs]] -epoch_name = "2.05" -start_height = 1 - -[[burnchain.epochs]] -epoch_name = "2.1" -start_height = 2 - -[[burnchain.epochs]] -epoch_name = "2.2" -start_height = 3 - -[[burnchain.epochs]] -epoch_name = "2.3" -start_height = 4 - -[[burnchain.epochs]] -epoch_name = "2.4" -start_height = 5 - -[[burnchain.epochs]] -epoch_name = "2.5" -start_height = 6 - -[[burnchain.epochs]] -epoch_name = "3.0" -start_height = 1802 - -[[burnchain.epochs]] -epoch_name = "3.1" -start_height = 1803 - -[[burnchain.epochs]] -epoch_name = "3.2" -start_height = 1804 - -[[burnchain.epochs]] -epoch_name = "3.3" -start_height = 1805 - -[[burnchain.epochs]] -epoch_name = "3.4" -start_height = 1806 - -[[burnchain.epochs]] -epoch_name = "4.0" -start_height = 2702 -"#; - /// The stacks-node Config.toml. Cross-service values (bitcoind endpoint, event -/// observers, signer auth) are derived from the same `Stack` the other configs +/// observers, signer auth) are derived from the same `Deployment` the other configs /// come from — matched by construction. The testnet shape mirrors /// stacks-core's sample/conf/testnet-follower-conf.toml. -fn node_config_toml(stack: &Stack) -> String { +fn node_config_toml(deployment: &Deployment) -> String { + let net = &deployment.net; let mut out = String::new(); - let testnet = stack.network == Network::Testnet; - - let (burn_mode, chain) = match stack.network { - Network::Mainnet => ("mainnet", "bitcoin"), - Network::Testnet => ("krypton", "bitcoin"), - }; out.push_str("[node]\n"); out.push_str("working_dir = \"/stacks-blockchain\"\n"); out.push_str(&format!("rpc_bind = \"0.0.0.0:{NODE_RPC_PORT}\"\n")); out.push_str(&format!("p2p_bind = \"0.0.0.0:{NODE_P2P_PORT}\"\n")); - if testnet { - out.push_str(&format!("bootstrap_node = \"{TESTNET_BOOTSTRAP_NODE}\"\n")); + if let Some(bootstrap) = &net.node.bootstrap_node { + out.push_str(&format!("bootstrap_node = \"{bootstrap}\"\n")); } - if stack.stacks_signer.mode != ServiceMode::Disabled { + if deployment.stacks_signer.mode != ServiceMode::Disabled { out.push_str("stacker = true\n"); } else { out.push_str("miner = false\nstacker = false\n"); } - if testnet { - out.push_str(TESTNET_NODE_POX5); + if let Some(extra) = &net.node.node_extra { + out.push_str(extra.trim()); + out.push('\n'); } out.push('\n'); out.push_str("[connection_options]\n"); - out.push_str(&format!("auth_token = \"{}\"\n", node_auth_token(stack))); + out.push_str(&format!( + "auth_token = \"{}\"\n", + node_auth_token(deployment) + )); out.push_str("private_neighbors = false\n\n"); out.push_str("[burnchain]\n"); - out.push_str(&format!("mode = \"{burn_mode}\"\nchain = \"{chain}\"\n")); - if testnet { - // Krypton follows the Hiro-hosted regtest unless an external bitcoind - // is explicitly configured (a locally managed one is rejected at - // validation: its empty regtest chain cannot follow Hiro's). - let host = stack - .bitcoind - .host - .clone() - .filter(|_| stack.bitcoind.mode == ServiceMode::External) - .unwrap_or_else(|| TESTNET_BURNCHAIN_HOST.into()); - let rpc = stack - .bitcoind - .rpc_port - .unwrap_or(bitcoind_rpc_port(stack.network)); - let p2p = stack - .bitcoind - .p2p_port - .unwrap_or(bitcoind_p2p_port(stack.network)); - out.push_str(&format!("peer_host = \"{host}\"\n")); - out.push_str(&format!("rpc_port = {rpc}\npeer_port = {p2p}\n")); - out.push_str("pox_prepare_length = 100\npox_reward_length = 900\n"); - } else if let Some(host) = bitcoind_host(stack) { - let rpc = stack - .bitcoind - .rpc_port - .unwrap_or(bitcoind_rpc_port(stack.network)); - let p2p = stack - .bitcoind - .p2p_port - .unwrap_or(bitcoind_p2p_port(stack.network)); + out.push_str(&format!( + "mode = \"{}\"\nchain = \"bitcoin\"\n", + net.node.burnchain_mode + )); + // Burnchain endpoint: managed/external bitcoind, else the network's + // hosted default (validation guarantees one of these exists for a node). + if let Some(host) = bitcoind_host(deployment).or_else(|| net.bitcoind.default_host.clone()) { out.push_str(&format!("peer_host = \"{host}\"\n")); - out.push_str(&format!("rpc_port = {rpc}\npeer_port = {p2p}\n")); out.push_str(&format!( - "username = \"{}\"\npassword = \"{}\"\n", - stack.bitcoind.rpc_user.as_deref().unwrap_or("stacks"), - stack.bitcoind.rpc_password.as_deref().unwrap_or("stacks"), + "rpc_port = {}\npeer_port = {}\n", + bitcoind_rpc_port(deployment), + bitcoind_p2p_port(deployment) + )); + // Credentials: our managed bitcoind always has them (tool defaults); + // an external one only when both are explicitly configured — + // validation rejects partial credentials, so no silent fallbacks. + if deployment.bitcoind.mode == ServiceMode::Enabled { + out.push_str(&format!( + "username = \"{}\"\npassword = \"{}\"\n", + deployment.bitcoind.rpc_user.as_deref().unwrap_or("stacks"), + deployment + .bitcoind + .rpc_password + .as_deref() + .unwrap_or("stacks"), + )); + } else if let (Some(user), Some(password)) = ( + &deployment.bitcoind.rpc_user, + &deployment.bitcoind.rpc_password, + ) { + out.push_str(&format!( + "username = \"{user}\"\npassword = \"{password}\"\n" + )); + } + } + if let (Some(prepare), Some(reward)) = (net.node.pox_prepare_length, net.node.pox_reward_length) + { + out.push_str(&format!( + "pox_prepare_length = {prepare}\npox_reward_length = {reward}\n" )); } out.push('\n'); - if stack.stacks_api.mode == ServiceMode::Enabled { + if deployment.stacks_api.mode == ServiceMode::Enabled { out.push_str(&format!( "[[events_observer]]\nendpoint = \"stacks-api:{API_EVENT_PORT}\"\nevents_keys = {API_EVENTS_KEYS}\ntimeout_ms = 300_000\n\n" )); - } else if stack.stacks_api.mode == ServiceMode::External - && let (Some(host), Some(port)) = - (&stack.stacks_api.event_host, stack.stacks_api.event_port) + } else if deployment.stacks_api.mode == ServiceMode::External + && let (Some(host), Some(port)) = ( + &deployment.stacks_api.event_host, + deployment.stacks_api.event_port, + ) { out.push_str(&format!( "[[events_observer]]\nendpoint = \"{host}:{port}\"\nevents_keys = {API_EVENTS_KEYS}\ntimeout_ms = 300_000\n\n" )); } - if stack.stacks_signer.mode == ServiceMode::Enabled { + if deployment.stacks_signer.mode == ServiceMode::Enabled { out.push_str(&format!( "[[events_observer]]\nendpoint = \"stacks-signer:{SIGNER_ENDPOINT_PORT}\"\nevents_keys = [\"stackerdb\", \"block_proposal\", \"burn_blocks\"]\n\n" )); } - if testnet { - out.push_str(TESTNET_USTX_BALANCES); - out.push('\n'); - out.push_str(TESTNET_EPOCHS); + for balance in &net.ustx_balances { + out.push_str(&format!( + "[[ustx_balance]]\naddress = \"{}\"\namount = {}\n\n", + balance.address, balance.amount + )); + } + for epoch in &net.epochs { + out.push_str(&format!( + "[[burnchain.epochs]]\nepoch_name = \"{}\"\nstart_height = {}\n\n", + epoch.epoch_name, epoch.start_height + )); } out } -fn signer_config_toml(stack: &Stack) -> String { - let node_host = node_rpc_host(stack).unwrap_or_default(); +fn signer_config_toml(deployment: &Deployment) -> String { + let node_host = node_rpc_host(deployment).unwrap_or_default(); format!( r#"node_host = "{node_host}:{rpc_port}" endpoint = "0.0.0.0:{endpoint_port}" @@ -583,10 +468,10 @@ db_path = "/var/lib/stacks-signer/signerdb.sqlite" # TODO(hackathon): source the signing key from a secrets file, never this rendered config stacks_private_key = "REPLACE_ME" "#, - rpc_port = node_rpc_port(stack), + rpc_port = node_rpc_port(deployment), endpoint_port = SIGNER_ENDPOINT_PORT, - auth = node_auth_token(stack), - network = stack.network, + auth = node_auth_token(deployment), + network = deployment.network, ) } @@ -594,22 +479,19 @@ stacks_private_key = "REPLACE_ME" /// `auth_password` must match. External nodes bring their own token /// (`[stacks-node] auth_token`); managed nodes use a tool-managed one. // TODO(hackathon): generate per-stack random token into a gitignored secrets file. -fn node_auth_token(stack: &Stack) -> String { - if stack.stacks_node.mode == ServiceMode::External - && let Some(token) = &stack.stacks_node.auth_token +fn node_auth_token(deployment: &Deployment) -> String { + if deployment.stacks_node.mode == ServiceMode::External + && let Some(token) = &deployment.stacks_node.auth_token { return token.clone(); } "stacks-tool-dev-auth-token".into() } -fn api_env(stack: &Stack) -> String { - let chain_id = match stack.network { - Network::Mainnet => "0x00000001", - _ => "0x80000000", - }; - let node_host = node_rpc_host(stack).unwrap_or_default(); - let pg_host = postgres_host(stack).unwrap_or_default(); +fn api_env(deployment: &Deployment) -> String { + let chain_id = format!("0x{:08x}", deployment.net.chain_id); + let node_host = node_rpc_host(deployment).unwrap_or_default(); + let pg_host = postgres_host(deployment).unwrap_or_default(); format!( r#"NODE_ENV=production STACKS_CHAIN_ID={chain_id} @@ -632,11 +514,15 @@ TESTNET_SBTC_FAUCET_ENABLED=false "#, api_port = API_PORT, event_port = API_EVENT_PORT, - node_rpc = node_rpc_port(stack), - pg_port = postgres_port(stack), + node_rpc = node_rpc_port(deployment), + pg_port = postgres_port(deployment), pg_schema = API_PG_SCHEMA, - pg_user = stack.postgres.user.as_deref().unwrap_or("postgres"), - pg_password = stack.postgres.password.as_deref().unwrap_or("postgres"), + pg_user = deployment.postgres.user.as_deref().unwrap_or("postgres"), + pg_password = deployment + .postgres + .password + .as_deref() + .unwrap_or("postgres"), pg_db = API_PG_DATABASE, ) } @@ -646,8 +532,8 @@ TESTNET_SBTC_FAUCET_ENABLED=false /// node's `connection_options.auth_token`, which this render guarantees /// matches by deriving both from the same source. Cache sizes/TTLs and /// BLOCK_HASH_MODE are left to the service's own defaults. -fn mesh_api_env(stack: &Stack) -> String { - let node_host = node_rpc_host(stack).unwrap_or_default(); +fn mesh_api_env(deployment: &Deployment) -> String { + let node_host = node_rpc_host(deployment).unwrap_or_default(); format!( r#"API_HOST=0.0.0.0 API_PORT={api_port} @@ -658,21 +544,21 @@ STACKS_CORE_RPC_PORT={node_rpc} STACKS_CORE_RPC_AUTH_TOKEN={auth_token} "#, api_port = MESH_API_PORT, - node_rpc = node_rpc_port(stack), - auth_token = node_auth_token(stack), + node_rpc = node_rpc_port(deployment), + auth_token = node_auth_token(deployment), ) } /// Config the user must apply to their *external* node so push edges /// (events to the API, signer wiring) actually close. -fn apply_to_your_node(stack: &Stack) -> Option { +fn apply_to_your_node(deployment: &Deployment) -> Option { let mut out = String::from( "# Add these blocks to your stacks-node config, then restart it.\n\ # Verify events are flowing afterwards with `stacksup config check`.\n\n", ); let mut needed = false; - if stack.stacks_api.mode == ServiceMode::Enabled { + if deployment.stacks_api.mode == ServiceMode::Enabled { // TODO(hackathon): make the advertised host configurable; the external // node must be able to reach the machine running this stack. out.push_str(&format!( @@ -680,12 +566,12 @@ fn apply_to_your_node(stack: &Stack) -> Option { )); needed = true; } - if stack.stacks_signer.mode == ServiceMode::Enabled { + if deployment.stacks_signer.mode == ServiceMode::Enabled { out.push_str(&format!( "[node]\nstacker = true\n\n\ [connection_options]\nauth_token = \"{}\"\n\n\ [[events_observer]]\nendpoint = \":{SIGNER_ENDPOINT_PORT}\"\nevents_keys = [\"stackerdb\", \"block_proposal\", \"burn_blocks\"]\n\n", - node_auth_token(stack) + node_auth_token(deployment) )); needed = true; } @@ -697,22 +583,22 @@ fn apply_to_your_node(stack: &Stack) -> Option { mod tests { use super::*; - fn stack(toml_str: &str) -> Stack { - toml::from_str(toml_str).expect("test stack.toml should parse") + fn deployment(toml_str: &str) -> Deployment { + crate::config::test_deployment(toml_str) } const TESTNET_FULL: &str = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[stacks-api]\nmode = \"enabled\"\n[stacks-mesh-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\""; #[test] fn testnet_node_config_matches_reference_shape() { - let s = stack("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); + let s = deployment("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\""); let toml_text = node_config_toml(&s); let parsed: toml::Value = toml::from_str(&toml_text).expect("rendered node config is valid TOML"); assert_eq!(parsed["burnchain"]["mode"].as_str(), Some("krypton")); assert_eq!( parsed["burnchain"]["peer_host"].as_str(), - Some(TESTNET_BURNCHAIN_HOST) + Some("bitcoin.regtest.hiro.so") ); assert_eq!(parsed["burnchain"]["rpc_port"].as_integer(), Some(18443)); assert!( @@ -730,7 +616,7 @@ mod tests { #[test] fn signer_flips_stacker_and_shares_auth_token() { - let s = stack(TESTNET_FULL); + let s = deployment(TESTNET_FULL); let node: toml::Value = toml::from_str(&node_config_toml(&s)).unwrap(); assert_eq!(node["node"]["stacker"].as_bool(), Some(true)); let token = node["connection_options"]["auth_token"] @@ -744,7 +630,7 @@ mod tests { #[test] fn api_env_wires_postgres_and_schema() { - let s = stack(TESTNET_FULL); + let s = deployment(TESTNET_FULL); let env = api_env(&s); assert!(env.contains("PG_HOST=postgres")); assert!(env.contains(&format!("PG_DATABASE={API_PG_DATABASE}"))); @@ -754,31 +640,55 @@ mod tests { #[test] fn postgres_mount_depends_on_major() { - let pg17 = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\"\nversion = \"17\""); + let pg17 = + deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\"\nversion = \"17\""); assert_eq!(postgres_major(&pg17), Some(17)); assert!(postgres_service(&pg17).volumes[0].ends_with(":/var/lib/postgresql/data")); - let latest = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + let latest = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); assert_eq!(postgres_major(&latest), None); assert!(postgres_service(&latest).volumes[0].ends_with(":/var/lib/postgresql")); assert_eq!( - postgres_major(&stack( + postgres_major(&deployment( "network = \"testnet\"\n[postgres]\nversion = \"17.5-alpine\"" )), Some(17) ); } + #[test] + fn external_bitcoind_credentials_all_or_nothing() { + // both set -> rendered verbatim + let d = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"external\"\nhost = \"h\"\nrpc_user = \"u\"\nrpc_password = \"p\"\n[stacks-node]\nmode = \"enabled\"", + ); + let cfg = node_config_toml(&d); + assert!(cfg.contains("username = \"u\"") && cfg.contains("password = \"p\"")); + // none set -> no credential lines, no silent defaults + let d = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"external\"\nhost = \"h\"\n[stacks-node]\nmode = \"enabled\"", + ); + let cfg = node_config_toml(&d); + assert!(!cfg.contains("username")); + // managed -> tool defaults present + let d = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"", + ); + let cfg = node_config_toml(&d); + assert!(cfg.contains("username = \"stacks\"")); + } + #[test] fn apply_to_your_node_only_for_push_edges() { // external node + enabled api -> snippet with the api observer - let s = stack( + let s = deployment( "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"", ); let snippet = apply_to_your_node(&s).expect("push edge needs the snippet"); assert!(snippet.contains("[[events_observer]]")); // external node with nothing to push to -> no snippet - let s = - stack("network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\""); + let s = deployment( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"", + ); assert!(apply_to_your_node(&s).is_none()); } @@ -788,7 +698,7 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let s = stack(TESTNET_FULL); + let s = deployment(TESTNET_FULL); render(&s, &dir).unwrap(); let compose_text = std::fs::read_to_string(compose_file(&dir)).unwrap(); diff --git a/src/logs/export.rs b/src/logs/export.rs index 4504a22..fdd1947 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -1,11 +1,10 @@ -//! `stacksup logs export` — package logs (and diagnostic context) into a -//! shareable file for troubleshooting. +//! `stacksup logs export` — package logs (and diagnostic context) into a shareable file for +//! troubleshooting. //! -//! Default output is a support bundle: per-service logs plus versions, ps, -//! images, redacted configs, and the chainstate/config check reports. All -//! text passes through redaction (known secret values + password/token/key -//! lines) so the artifact is safe to hand to someone else — though the final -//! message still tells the user to review it. +//! Default output is a support bundle: per-service logs plus versions, ps, images, redacted +//! configs, and the chainstate/config check reports. All text passes through redaction (known +//! secret values + password/token/key lines) so the artifact is safe to hand to someone else — +//! though the final message still tells the user to review it. use std::fs; use std::path::{Path, PathBuf}; @@ -14,7 +13,7 @@ use std::process::Command; use anyhow::{Context, Result, bail}; use colored::Colorize; -use crate::config::{ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; use crate::utils::services::roster; pub struct Opts { @@ -24,15 +23,15 @@ pub struct Opts { pub out: Option, } -pub fn run(stack: &Stack, config_path: &Path, data_dir: &Path, opts: Opts) -> Result<()> { +pub fn run(deployment: &Deployment, config_path: &Path, data_dir: &Path, opts: Opts) -> Result<()> { crate::utils::docker::preflight(data_dir)?; let services: Vec = match &opts.service { Some(name) => { - crate::utils::docker::ensure_enabled(stack, name)?; + crate::utils::docker::ensure_enabled(deployment, name)?; vec![name.clone()] } - None => roster(stack) + None => roster(deployment) .into_iter() .filter(|(_, m)| *m == ServiceMode::Enabled) .map(|(n, _)| n.to_string()) @@ -57,9 +56,9 @@ pub fn run(stack: &Stack, config_path: &Path, data_dir: &Path, opts: Opts) -> Re ); } - let secrets = secret_values(stack); + let secrets = secret_values(deployment); let ts = timestamp(); - let network = stack.network; + let network = deployment.network.as_str(); // Single service + --logs-only: a plain .log file, no archive. if opts.logs_only && services.len() == 1 { @@ -216,11 +215,11 @@ fn self_report(config_path: &Path, data_dir: &Path, args: &[&str]) -> String { /// Secret *values* known from the config, scrubbed wherever they appear /// (configs and logs alike — the node echoes config at startup). -fn secret_values(stack: &Stack) -> Vec { +fn secret_values(deployment: &Deployment) -> Vec { let mut v: Vec = [ - stack.postgres.password.clone(), - stack.bitcoind.rpc_password.clone(), - stack.stacks_node.auth_token.clone(), + deployment.postgres.password.clone(), + deployment.bitcoind.rpc_password.clone(), + deployment.stacks_node.auth_token.clone(), ] .into_iter() .flatten() diff --git a/src/main.rs b/src/main.rs index c3d9308..ca2ff15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -183,14 +183,14 @@ fn run() -> Result<()> { Command::Config { command } => match command { ConfigCommand::Init { force } => config::init(force), ConfigCommand::Render => { - let stack = config::load(&cli.config)?; - let dir = config::render::render(&stack, &cli.data_dir)?; + let deployment = config::load(&cli.config)?; + let dir = config::render::render(&deployment, &cli.data_dir)?; println!("Rendered service configs to {}/", dir.display()); Ok(()) } ConfigCommand::Check => { - let stack = config::load(&cli.config)?; - config::check::run(&stack) + let deployment = config::load(&cli.config)?; + config::check::run(&deployment) } }, Command::Logs { service, command } => match command { @@ -200,9 +200,9 @@ fn run() -> Result<()> { logs_only, out, }) => { - let stack = config::load(&cli.config)?; + let deployment = config::load(&cli.config)?; logs::export::run( - &stack, + &deployment, &cli.config, &cli.data_dir, logs::export::Opts { @@ -214,48 +214,48 @@ fn run() -> Result<()> { ) } None => { - let stack = config::load(&cli.config)?; - utils::docker::logs(&stack, &cli.data_dir, service.as_deref()) + let deployment = config::load(&cli.config)?; + utils::docker::logs(&deployment, &cli.data_dir, service.as_deref()) } }, Command::Start { service, no_render } => { - let stack = config::load(&cli.config)?; + let deployment = config::load(&cli.config)?; if !no_render { - config::render::render(&stack, &cli.data_dir)?; + config::render::render(&deployment, &cli.data_dir)?; } - utils::docker::start(&stack, &cli.data_dir, service.as_deref()) + utils::docker::start(&deployment, &cli.data_dir, service.as_deref()) } Command::Stop { service, destroy } => { - let stack = config::load(&cli.config)?; - utils::docker::stop(&stack, &cli.data_dir, service.as_deref(), destroy) + let deployment = config::load(&cli.config)?; + utils::docker::stop(&deployment, &cli.data_dir, service.as_deref(), destroy) } Command::Restart { service, no_render } => { - let stack = config::load(&cli.config)?; + let deployment = config::load(&cli.config)?; if !no_render { - config::render::render(&stack, &cli.data_dir)?; + config::render::render(&deployment, &cli.data_dir)?; } - utils::docker::restart(&stack, &cli.data_dir, service.as_deref()) + utils::docker::restart(&deployment, &cli.data_dir, service.as_deref()) } Command::Pull => { - let stack = config::load(&cli.config)?; - config::render::render(&stack, &cli.data_dir)?; - utils::docker::pull(&stack, &cli.data_dir) + let deployment = config::load(&cli.config)?; + config::render::render(&deployment, &cli.data_dir)?; + utils::docker::pull(&deployment, &cli.data_dir) } Command::Upgrade { service } => { - let stack = config::load(&cli.config)?; - upgrade::run(&stack, service.as_deref()) + let deployment = config::load(&cli.config)?; + upgrade::run(&deployment, service.as_deref()) } Command::Status => { - let stack = config::load(&cli.config)?; - utils::docker::status(&stack, &cli.data_dir) + let deployment = config::load(&cli.config)?; + utils::docker::status(&deployment, &cli.data_dir) } Command::Chainstate { command } => match command { ChainstateCommand::Wipe { service, yes } => { chainstate::wipe(&cli.data_dir, service.as_deref(), yes) } ChainstateCommand::Status => { - let stack = config::load(&cli.config)?; - chainstate::status(&stack, &cli.data_dir) + let deployment = config::load(&cli.config)?; + chainstate::status(&deployment, &cli.data_dir) } ChainstateCommand::Download { service, @@ -276,9 +276,9 @@ fn run() -> Result<()> { "--archive requires exactly one service: --service node or --service api" ); } - let stack = config::load(&cli.config)?; + let deployment = config::load(&cli.config)?; chainstate::download::run( - &stack, + &deployment, &cli.data_dir, chainstate::download::Opts { service, diff --git a/src/upgrade.rs b/src/upgrade.rs index 6ca355c..1d9fc55 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use colored::Colorize; -use crate::config::{ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; use crate::utils::versions::*; struct Row { @@ -25,37 +25,37 @@ struct Row { error: Option, } -pub fn run(stack: &Stack, service: Option<&str>) -> Result<()> { +pub fn run(deployment: &Deployment, service: Option<&str>) -> Result<()> { let targets: Vec<(&'static str, ServiceMode, String)> = [ ( "bitcoind", - stack.bitcoind.mode, - crate::utils::services::bitcoind_image(stack), + deployment.bitcoind.mode, + crate::utils::services::bitcoind_image(deployment), ), ( "stacks-node", - stack.stacks_node.mode, - crate::utils::services::stacks_node_image(stack), + deployment.stacks_node.mode, + crate::utils::services::stacks_node_image(deployment), ), ( "stacks-signer", - stack.stacks_signer.mode, - crate::utils::services::stacks_signer_image(stack), + deployment.stacks_signer.mode, + crate::utils::services::stacks_signer_image(deployment), ), ( "stacks-api", - stack.stacks_api.mode, - crate::utils::services::stacks_api_image(stack), + deployment.stacks_api.mode, + crate::utils::services::stacks_api_image(deployment), ), ( "stacks-mesh-api", - stack.stacks_mesh_api.mode, - crate::utils::services::stacks_mesh_api_image(stack), + deployment.stacks_mesh_api.mode, + crate::utils::services::stacks_mesh_api_image(deployment), ), ( "postgres", - stack.postgres.mode, - crate::utils::services::postgres_image(stack), + deployment.postgres.mode, + crate::utils::services::postgres_image(deployment), ), ] .into_iter() diff --git a/src/utils/docker.rs b/src/utils/docker.rs index c515f0d..2415cff 100644 --- a/src/utils/docker.rs +++ b/src/utils/docker.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result, bail}; use colored::Colorize; use crate::config::render::{COMPOSE_PROJECT, compose_file}; -use crate::config::{ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; use crate::utils::services::roster; fn compose(data_dir: &Path) -> Command { @@ -130,8 +130,8 @@ fn run(mut cmd: Command, what: &str) -> Result<()> { } /// A service name is only startable/stoppable if it's enabled in stacks.toml. -pub(crate) fn ensure_enabled(stack: &Stack, name: &str) -> Result<()> { - match roster(stack).iter().find(|(n, _)| *n == name) { +pub(crate) fn ensure_enabled(deployment: &Deployment, name: &str) -> Result<()> { + match roster(deployment).iter().find(|(n, _)| *n == name) { Some((_, ServiceMode::Enabled)) => Ok(()), Some((_, mode)) => bail!( "{name} is `{}` in stacks.toml — only enabled services can be started/stopped here", @@ -141,7 +141,7 @@ pub(crate) fn ensure_enabled(stack: &Stack, name: &str) -> Result<()> { } ), None => { - let enabled: Vec<&str> = roster(stack) + let enabled: Vec<&str> = roster(deployment) .into_iter() .filter(|(_, m)| *m == ServiceMode::Enabled) .map(|(n, _)| n) @@ -154,7 +154,7 @@ pub(crate) fn ensure_enabled(stack: &Stack, name: &str) -> Result<()> { } } -pub fn start(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<()> { +pub fn start(deployment: &Deployment, data_dir: &Path, service: Option<&str>) -> Result<()> { ensure_docker()?; if !compose_file(data_dir).exists() { bail!( @@ -164,7 +164,7 @@ pub fn start(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<() } if let Some(name) = service { - ensure_enabled(stack, name)?; + ensure_enabled(deployment, name)?; println!("Starting {name} (and its dependencies)..."); let mut cmd = compose(data_dir); cmd.args(["up", "-d", name]); @@ -175,7 +175,7 @@ pub fn start(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<() return Ok(()); } - let managed: Vec<_> = roster(stack) + let managed: Vec<_> = roster(deployment) .into_iter() .filter(|(_, m)| *m == ServiceMode::Enabled) .collect(); @@ -186,9 +186,9 @@ pub fn start(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<() println!( "Starting {} managed service(s) on {}...", managed.len(), - stack.network + deployment.network ); - for (name, mode) in roster(stack) { + for (name, mode) in roster(deployment) { if mode == ServiceMode::External { println!( "{}", @@ -205,9 +205,9 @@ pub fn start(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<() Ok(()) } -pub fn pull(stack: &Stack, data_dir: &Path) -> Result<()> { +pub fn pull(deployment: &Deployment, data_dir: &Path) -> Result<()> { ensure_docker()?; - let enabled: Vec<_> = roster(stack) + let enabled: Vec<_> = roster(deployment) .into_iter() .filter(|(_, m)| *m == ServiceMode::Enabled) .collect(); @@ -229,11 +229,16 @@ pub fn pull(stack: &Stack, data_dir: &Path) -> Result<()> { Ok(()) } -pub fn stop(stack: &Stack, data_dir: &Path, service: Option<&str>, destroy: bool) -> Result<()> { +pub fn stop( + deployment: &Deployment, + data_dir: &Path, + service: Option<&str>, + destroy: bool, +) -> Result<()> { ensure_docker()?; if let Some(name) = service { - ensure_enabled(stack, name)?; + ensure_enabled(deployment, name)?; // Single service: `compose stop` halts just that container, leaving // the rest of the stack (and the network) running. let mut cmd = compose(data_dir); @@ -262,7 +267,7 @@ pub fn stop(stack: &Stack, data_dir: &Path, service: Option<&str>, destroy: bool .dimmed() ); } - for (name, mode) in roster(stack) { + for (name, mode) in roster(deployment) { if mode == ServiceMode::External { println!( "{}", @@ -276,11 +281,11 @@ pub fn stop(stack: &Stack, data_dir: &Path, service: Option<&str>, destroy: bool /// Restart = stop + up. Deliberately not `docker compose restart`, which /// reuses the existing container: going through `up` means a re-rendered /// config or freshly pulled image takes effect on restart. -pub fn restart(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<()> { +pub fn restart(deployment: &Deployment, data_dir: &Path, service: Option<&str>) -> Result<()> { preflight(data_dir)?; if let Some(name) = service { - ensure_enabled(stack, name)?; + ensure_enabled(deployment, name)?; println!("Restarting {name}..."); let mut cmd = compose(data_dir); cmd.args(["stop", name]); @@ -292,7 +297,7 @@ pub fn restart(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result< return Ok(()); } - let enabled: Vec<_> = roster(stack) + let enabled: Vec<_> = roster(deployment) .into_iter() .filter(|(_, m)| *m == ServiceMode::Enabled) .collect(); @@ -310,10 +315,10 @@ pub fn restart(stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result< Ok(()) } -pub fn status(stack: &Stack, data_dir: &Path) -> Result<()> { +pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { ensure_docker()?; - println!("network: {}\n", stack.network); - for (name, mode) in roster(stack) { + println!("network: {}\n", deployment.network); + for (name, mode) in roster(deployment) { match mode { ServiceMode::External => println!(" {name}: external"), ServiceMode::Disabled => println!(" {name}: disabled"), @@ -328,7 +333,7 @@ pub fn status(stack: &Stack, data_dir: &Path) -> Result<()> { run(cmd, "docker compose ps") } -pub fn logs(_stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<()> { +pub fn logs(_stack: &Deployment, data_dir: &Path, service: Option<&str>) -> Result<()> { ensure_docker()?; let mut cmd = compose(data_dir); cmd.args(["logs", "--follow", "--tail", "100"]); @@ -342,8 +347,8 @@ pub fn logs(_stack: &Stack, data_dir: &Path, service: Option<&str>) -> Result<() mod tests { use super::*; - fn stack(toml_str: &str) -> Stack { - toml::from_str(toml_str).expect("test stack.toml should parse") + fn deployment(toml_str: &str) -> Deployment { + crate::config::test_deployment(toml_str) } #[test] @@ -361,13 +366,13 @@ mod tests { #[test] fn ensure_enabled_accepts_enabled_services() { - let s = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + let s = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); assert!(ensure_enabled(&s, "postgres").is_ok()); } #[test] fn ensure_enabled_rejects_external_and_disabled() { - let s = stack("network = \"testnet\"\n[postgres]\nmode = \"external\"\nhost = \"pg\""); + let s = deployment("network = \"testnet\"\n[postgres]\nmode = \"external\"\nhost = \"pg\""); let err = ensure_enabled(&s, "postgres").unwrap_err().to_string(); assert!(err.contains("external")); let err = ensure_enabled(&s, "stacks-node").unwrap_err().to_string(); @@ -376,7 +381,7 @@ mod tests { #[test] fn ensure_enabled_unknown_lists_enabled_services() { - let s = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + let s = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); let err = ensure_enabled(&s, "nonsense").unwrap_err().to_string(); assert!(err.contains("unknown service `nonsense`")); assert!(err.contains("postgres")); diff --git a/src/utils/services.rs b/src/utils/services.rs index 9ee4b53..fe75f3a 100644 --- a/src/utils/services.rs +++ b/src/utils/services.rs @@ -1,6 +1,6 @@ //! Per-service constants: images, ports, compose names. -use crate::config::{Network, ServiceMode, Stack}; +use crate::config::{Deployment, ServiceMode}; // Image repositories and the default tag used when a service section has no `version`. const REPO_BITCOIND: (&str, &str) = ("bitcoin/bitcoin", "latest"); @@ -14,31 +14,34 @@ fn image((repo, default_tag): (&str, &str), version: Option<&str>) -> String { format!("{repo}:{}", version.unwrap_or(default_tag)) } -pub fn bitcoind_image(stack: &Stack) -> String { - image(REPO_BITCOIND, stack.bitcoind.version.as_deref()) +pub fn bitcoind_image(deployment: &Deployment) -> String { + image(REPO_BITCOIND, deployment.bitcoind.version.as_deref()) } -pub fn stacks_node_image(stack: &Stack) -> String { - image(REPO_STACKS_NODE, stack.stacks_node.version.as_deref()) +pub fn stacks_node_image(deployment: &Deployment) -> String { + image(REPO_STACKS_NODE, deployment.stacks_node.version.as_deref()) } -pub fn stacks_signer_image(stack: &Stack) -> String { - image(REPO_STACKS_SIGNER, stack.stacks_signer.version.as_deref()) +pub fn stacks_signer_image(deployment: &Deployment) -> String { + image( + REPO_STACKS_SIGNER, + deployment.stacks_signer.version.as_deref(), + ) } -pub fn stacks_api_image(stack: &Stack) -> String { - image(REPO_STACKS_API, stack.stacks_api.version.as_deref()) +pub fn stacks_api_image(deployment: &Deployment) -> String { + image(REPO_STACKS_API, deployment.stacks_api.version.as_deref()) } -pub fn stacks_mesh_api_image(stack: &Stack) -> String { +pub fn stacks_mesh_api_image(deployment: &Deployment) -> String { image( REPO_STACKS_MESH_API, - stack.stacks_mesh_api.version.as_deref(), + deployment.stacks_mesh_api.version.as_deref(), ) } -pub fn postgres_image(stack: &Stack) -> String { - image(REPO_POSTGRES, stack.postgres.version.as_deref()) +pub fn postgres_image(deployment: &Deployment) -> String { + image(REPO_POSTGRES, deployment.postgres.version.as_deref()) } pub const NODE_RPC_PORT: u16 = 20443; @@ -49,87 +52,86 @@ pub const MESH_API_PORT: u16 = 3998; pub const SIGNER_ENDPOINT_PORT: u16 = 30000; pub const POSTGRES_PORT: u16 = 5432; -// Non-mainnet networks are bitcoin *regtest* (krypton testnet follows the -// Hiro-hosted regtest), hence the 18443/18444 regtest ports. -pub fn bitcoind_rpc_port(network: Network) -> u16 { - match network { - Network::Mainnet => 8332, - _ => 18443, - } +/// bitcoind ports: per-stack override, else the network definition's default. +pub fn bitcoind_rpc_port(deployment: &Deployment) -> u16 { + deployment + .bitcoind + .rpc_port + .unwrap_or(deployment.net.bitcoind.rpc_port) } -pub fn bitcoind_p2p_port(network: Network) -> u16 { - match network { - Network::Mainnet => 8333, - _ => 18444, - } +pub fn bitcoind_p2p_port(deployment: &Deployment) -> u16 { + deployment + .bitcoind + .p2p_port + .unwrap_or(deployment.net.bitcoind.p2p_port) } /// Hostname of a service as seen by *managed* services: the compose DNS name /// when managed, the user-supplied host when external. -pub fn bitcoind_host(stack: &Stack) -> Option { - match stack.bitcoind.mode { +pub fn bitcoind_host(deployment: &Deployment) -> Option { + match deployment.bitcoind.mode { ServiceMode::Enabled => Some("bitcoind".into()), - ServiceMode::External => stack.bitcoind.host.clone(), + ServiceMode::External => deployment.bitcoind.host.clone(), ServiceMode::Disabled => None, } } -pub fn node_rpc_host(stack: &Stack) -> Option { - match stack.stacks_node.mode { +pub fn node_rpc_host(deployment: &Deployment) -> Option { + match deployment.stacks_node.mode { ServiceMode::Enabled => Some("stacks-node".into()), - ServiceMode::External => stack.stacks_node.rpc_host.clone(), + ServiceMode::External => deployment.stacks_node.rpc_host.clone(), ServiceMode::Disabled => None, } } -pub fn node_rpc_port(stack: &Stack) -> u16 { - stack.stacks_node.rpc_port.unwrap_or(NODE_RPC_PORT) +pub fn node_rpc_port(deployment: &Deployment) -> u16 { + deployment.stacks_node.rpc_port.unwrap_or(NODE_RPC_PORT) } -pub fn postgres_host(stack: &Stack) -> Option { - match stack.postgres.mode { +pub fn postgres_host(deployment: &Deployment) -> Option { + match deployment.postgres.mode { ServiceMode::Enabled => Some("postgres".into()), - ServiceMode::External => stack.postgres.host.clone(), + ServiceMode::External => deployment.postgres.host.clone(), ServiceMode::Disabled => None, } } -pub fn postgres_port(stack: &Stack) -> u16 { - stack.postgres.port.unwrap_or(POSTGRES_PORT) +pub fn postgres_port(deployment: &Deployment) -> u16 { + deployment.postgres.port.unwrap_or(POSTGRES_PORT) } /// All services with their mode, for status/doctor display. -pub fn roster(stack: &Stack) -> Vec<(&'static str, ServiceMode)> { +pub fn roster(deployment: &Deployment) -> Vec<(&'static str, ServiceMode)> { vec![ - ("bitcoind", stack.bitcoind.mode), - ("stacks-node", stack.stacks_node.mode), - ("stacks-signer", stack.stacks_signer.mode), - ("stacks-api", stack.stacks_api.mode), - ("stacks-mesh-api", stack.stacks_mesh_api.mode), - ("postgres", stack.postgres.mode), + ("bitcoind", deployment.bitcoind.mode), + ("stacks-node", deployment.stacks_node.mode), + ("stacks-signer", deployment.stacks_signer.mode), + ("stacks-api", deployment.stacks_api.mode), + ("stacks-mesh-api", deployment.stacks_mesh_api.mode), + ("postgres", deployment.postgres.mode), ] } #[cfg(test)] mod tests { use super::*; - use crate::config::Stack; + use crate::config::Deployment; - fn stack(toml_str: &str) -> Stack { - toml::from_str(toml_str).expect("test stack.toml should parse") + fn deployment(toml_str: &str) -> Deployment { + crate::config::test_deployment(toml_str) } #[test] fn image_uses_default_tag_when_version_unset() { - let s = stack("network = \"testnet\""); + let s = deployment("network = \"testnet\""); assert!(stacks_node_image(&s).ends_with(":latest")); assert!(postgres_image(&s).starts_with("postgres:")); } #[test] fn image_uses_configured_version() { - let s = stack( + let s = deployment( "network = \"testnet\"\n[stacks-node]\nversion = \"4.0.1\"\n[postgres]\nversion = \"17\"", ); assert!(stacks_node_image(&s).ends_with(":4.0.1")); @@ -137,17 +139,23 @@ mod tests { } #[test] - fn bitcoind_ports_per_network() { - assert_eq!(bitcoind_rpc_port(Network::Mainnet), 8332); - assert_eq!(bitcoind_p2p_port(Network::Mainnet), 8333); - // non-mainnet is the Hiro-hosted regtest, hence regtest ports - assert_eq!(bitcoind_rpc_port(Network::Testnet), 18443); - assert_eq!(bitcoind_p2p_port(Network::Testnet), 18444); + fn bitcoind_ports_come_from_network_def() { + let mainnet = deployment("network = \"mainnet\""); + assert_eq!(bitcoind_rpc_port(&mainnet), 8332); + assert_eq!(bitcoind_p2p_port(&mainnet), 8333); + // testnet follows the Hiro-hosted regtest, hence regtest ports + let testnet = deployment("network = \"testnet\""); + assert_eq!(bitcoind_rpc_port(&testnet), 18443); + assert_eq!(bitcoind_p2p_port(&testnet), 18444); + // per-stack override wins + let custom = + deployment("network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\nrpc_port = 9999"); + assert_eq!(bitcoind_rpc_port(&custom), 9999); } #[test] fn hosts_follow_service_mode() { - let s = stack( + let s = deployment( "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"10.0.1.6\"", ); assert_eq!(bitcoind_host(&s), Some("bitcoind".into())); @@ -157,7 +165,7 @@ mod tests { #[test] fn port_overrides_apply() { - let s = stack( + let s = deployment( "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\nrpc_port = 30443\n[postgres]\nmode = \"enabled\"", ); assert_eq!(node_rpc_port(&s), 30443); @@ -167,7 +175,7 @@ mod tests { #[test] fn roster_lists_all_services_with_modes() { use crate::config::ServiceMode; - let s = stack("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + let s = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); let roster = roster(&s); assert_eq!(roster.len(), 6); let (name, mode) = roster.iter().find(|(n, _)| *n == "postgres").unwrap(); From 68dcc2ced8162fa322279a42ebedd310a3a1f80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:03:07 -0600 Subject: [PATCH 07/19] feat: add per-service `image` override to stacks.toml (#19) * pick custom images * fix dockerhub host prefixes --- src/config/mod.rs | 82 +++++++++++++++++++++++++++- src/upgrade.rs | 122 ++++++++++++++++++++++++++++++++++-------- src/utils/services.rs | 60 ++++++++++++++++++--- src/utils/versions.rs | 14 +++++ 4 files changed, 249 insertions(+), 29 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 55ee3f4..688cd07 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -64,6 +64,10 @@ pub struct Deployment { pub struct Bitcoind { #[serde(default)] pub mode: ServiceMode, + /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// with `version`). + pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, /// Required when mode = "external" @@ -79,6 +83,10 @@ pub struct Bitcoind { pub struct StacksNode { #[serde(default)] pub mode: ServiceMode, + /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// with `version`). + pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, #[serde(default)] @@ -97,6 +105,10 @@ pub struct StacksNode { pub struct StacksSigner { #[serde(default)] pub mode: ServiceMode, + /// Docker image override: a repository (`myorg/stacks-node`) that keeps + /// using `version`/the default tag, or a full ref with its own tag + /// (`myorg/stacks-node:4.0.1` — mutually exclusive with `version`). + pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, } @@ -106,6 +118,10 @@ pub struct StacksSigner { pub struct StacksApi { #[serde(default)] pub mode: ServiceMode, + /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// with `version`). + pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, /// Required when mode = "external": where the API serves HTTP @@ -121,6 +137,10 @@ pub struct StacksApi { pub struct StacksMeshApi { #[serde(default)] pub mode: ServiceMode, + /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// with `version`). + pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, pub host: Option, @@ -132,6 +152,10 @@ pub struct StacksMeshApi { pub struct Postgres { #[serde(default)] pub mode: ServiceMode, + /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// with `version`). + pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, /// Required when mode = "external" @@ -167,6 +191,41 @@ impl Deployment { )); } + // `image` with an explicit tag and `version` are two sources of truth + // for the same tag — reject the ambiguity. + for (name, image, version) in [ + ("bitcoind", &self.bitcoind.image, &self.bitcoind.version), + ( + "stacks-node", + &self.stacks_node.image, + &self.stacks_node.version, + ), + ( + "stacks-signer", + &self.stacks_signer.image, + &self.stacks_signer.version, + ), + ( + "stacks-api", + &self.stacks_api.image, + &self.stacks_api.version, + ), + ( + "stacks-mesh-api", + &self.stacks_mesh_api.image, + &self.stacks_mesh_api.version, + ), + ("postgres", &self.postgres.image, &self.postgres.version), + ] { + if let (Some(image), Some(_)) = (image, version) + && crate::utils::versions::has_explicit_tag(image) + { + errors.push(format!( + "[{name}] `image` already pins a tag (`{image}`) — remove `version` or drop the tag from `image`" + )); + } + } + if self.bitcoind.mode == ServiceMode::External && self.bitcoind.host.is_none() { errors.push("[bitcoind] mode = \"external\" requires `host`".into()); } @@ -304,8 +363,11 @@ const DEFAULT_STACK_TOML: &str = r#"# stacksup config # "external" — you run it elsewhere; we wire configs to it and health-check it # "disabled" — not part of this stack # -# Managed services also take a `version` — the docker image tag to run. -# Omit it to use this tool's pinned default. +# Managed services also take: +# `version` — the docker image tag to run (omit for this tool's default) +# `image` — a custom docker image: a repository that keeps using +# `version`/the default tag, or a full ref with its own tag +# (then omit `version`) network = "testnet" # mainnet | testnet | custom network definition file @@ -410,6 +472,22 @@ mod tests { ); } + #[test] + fn image_with_tag_conflicts_with_version() { + let e = errors( + "network = \"testnet\"\n[postgres]\nmode = \"enabled\"\nimage = \"myorg/pg:17\"\nversion = \"17\"", + ); + assert!(e.iter().any(|m| m.contains("already pins a tag"))); + // repo-only image + version is fine; full ref alone is fine + assert!(errors("network = \"testnet\"\n[postgres]\nmode = \"enabled\"\nimage = \"myorg/pg\"\nversion = \"17\"").is_empty()); + assert!( + errors( + "network = \"testnet\"\n[postgres]\nmode = \"enabled\"\nimage = \"myorg/pg:17\"" + ) + .is_empty() + ); + } + #[test] fn external_bitcoind_credentials_must_come_in_pairs() { let base = "network = \"mainnet\"\n[bitcoind]\nmode = \"external\"\nhost = \"h\""; diff --git a/src/upgrade.rs b/src/upgrade.rs index 1d9fc55..cc85c77 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -17,6 +17,10 @@ struct Row { name: &'static str, current: Option>, current_note: &'static str, + /// The tag is pinned inside the `image` override — upgrade guidance must + /// say "update the image tag", not "set version" (validation rejects + /// `version` alongside a tagged `image`). + tag_from_image: bool, /// Tag pins only a major (e.g. postgres `17`): minors float with pulls, /// so only a new major is a real suggestion. major_pin: bool, @@ -26,40 +30,70 @@ struct Row { } pub fn run(deployment: &Deployment, service: Option<&str>) -> Result<()> { - let targets: Vec<(&'static str, ServiceMode, String)> = [ + let targets: Vec<(&'static str, ServiceMode, String, bool)> = [ ( "bitcoind", deployment.bitcoind.mode, crate::utils::services::bitcoind_image(deployment), + deployment + .bitcoind + .image + .as_deref() + .is_some_and(has_explicit_tag), ), ( "stacks-node", deployment.stacks_node.mode, crate::utils::services::stacks_node_image(deployment), + deployment + .stacks_node + .image + .as_deref() + .is_some_and(has_explicit_tag), ), ( "stacks-signer", deployment.stacks_signer.mode, crate::utils::services::stacks_signer_image(deployment), + deployment + .stacks_signer + .image + .as_deref() + .is_some_and(has_explicit_tag), ), ( "stacks-api", deployment.stacks_api.mode, crate::utils::services::stacks_api_image(deployment), + deployment + .stacks_api + .image + .as_deref() + .is_some_and(has_explicit_tag), ), ( "stacks-mesh-api", deployment.stacks_mesh_api.mode, crate::utils::services::stacks_mesh_api_image(deployment), + deployment + .stacks_mesh_api + .image + .as_deref() + .is_some_and(has_explicit_tag), ), ( "postgres", deployment.postgres.mode, crate::utils::services::postgres_image(deployment), + deployment + .postgres + .image + .as_deref() + .is_some_and(has_explicit_tag), ), ] .into_iter() - .filter(|(n, m, _)| *m == ServiceMode::Enabled && service.is_none_or(|s| s == *n)) + .filter(|(n, m, _, _)| *m == ServiceMode::Enabled && service.is_none_or(|s| s == *n)) .collect(); if targets.is_empty() { @@ -72,8 +106,8 @@ pub fn run(deployment: &Deployment, service: Option<&str>) -> Result<()> { println!("Checking registries for newer image versions...\n"); let mut rows = Vec::new(); - for (name, _, image) in &targets { - rows.push(check_service(name, image)); + for (name, _, image, tag_from_image) in &targets { + rows.push(check_service(name, image, *tag_from_image)); } // Table @@ -210,15 +244,24 @@ fn guidance(row: &Row) -> Option { if minor.is_none() && major.is_none() { return None; } + // Where the new tag goes: `version`, unless the tag is pinned inside the + // `image` override (validation rejects `version` in that case). + let set_tag = |name: &str, ver: &str| { + if row.tag_from_image { + format!("update the tag in [{name}] `image` to \"{ver}\"") + } else { + format!("set [{name}] version = \"{ver}\"") + } + }; let mut out = String::new(); match row.name { "stacks-api" => { if let Some(m) = minor { out.push_str(&format!( - " stacks-api {}: same-major — DB-compatible. Set [stacks-api] version = \"{}\", \ + " stacks-api {}: same-major — DB-compatible. {}, \ then `stacksup pull && stacksup stop && stacksup start`.\n", version_string(m), - version_string(m) + set_tag("stacks-api", &version_string(m)), )); } if let Some(mj) = major { @@ -227,10 +270,11 @@ fn guidance(row: &Row) -> Option { " stacks-api {mj}: MAJOR — DB-BREAKING. The new API cannot migrate the old database:\n\ 1. `stacksup stop`\n\ 2. `stacksup chainstate wipe postgres` (API data only)\n\ - 3. set [stacks-api] version = \"{mj}\" and `stacksup pull`\n\ + 3. {set_step} and `stacksup pull`\n\ 4. `stacksup chainstate download --service api` (archive matching the new major)\n\ 5. `stacksup start`", - mj = version_string(mj) + mj = version_string(mj), + set_step = set_tag("stacks-api", &version_string(mj)), ) .red() .to_string(), @@ -240,9 +284,9 @@ fn guidance(row: &Row) -> Option { "postgres" => { if let Some(m) = minor { out.push_str(&format!( - " postgres {}: patch/minor — safe. Set [postgres] version = \"{}\", then pull + restart.\n", + " postgres {}: patch/minor — safe. {}, then pull + restart.\n", version_string(m), - version_string(m) + set_tag("postgres", &version_string(m)), )); } if let Some(mj) = major { @@ -256,18 +300,18 @@ fn guidance(row: &Row) -> Option { "stacks-node" | "stacks-signer" => { let v = minor.or(major)?; out.push_str(&format!( - " {}: set version = \"{}\" (upgrade stacks-node and stacks-signer together), \ + " {}: {} (upgrade stacks-node and stacks-signer together), \ then `stacksup pull && stacksup stop && stacksup start`. Chainstate migrates forward automatically.", row.name, - version_string(v) + set_tag(row.name, &version_string(v)), )); } _ => { if let Some(m) = minor { out.push_str(&format!( - " {}: set version = \"{}\", then `stacksup pull && stacksup stop && stacksup start`.\n", + " {}: {}, then `stacksup pull && stacksup stop && stacksup start`.\n", row.name, - version_string(m) + set_tag(row.name, &version_string(m)), )); } if let Some(mj) = major { @@ -282,7 +326,7 @@ fn guidance(row: &Row) -> Option { Some(out.trim_end().to_string()) } -fn check_service(name: &'static str, image: &str) -> Row { +fn check_service(name: &'static str, image: &str, tag_from_image: bool) -> Row { let repo = image.rsplit_once(':').map(|(r, _)| r).unwrap_or(image); let tag = image_tag(image); @@ -301,6 +345,7 @@ fn check_service(name: &'static str, image: &str) -> Row { name, current, current_note, + tag_from_image, major_pin, same_major: None, next_major: None, @@ -342,12 +387,7 @@ fn registry_versions(repo: &str) -> Result>> { let tags = if let Some(path) = repo.strip_prefix("ghcr.io/") { ghcr_tags(path)? } else { - let path = if repo.contains('/') { - repo.to_string() - } else { - format!("library/{repo}") - }; - dockerhub_tags(&path)? + dockerhub_tags(&dockerhub_path(repo))? }; Ok(tags .iter() @@ -356,6 +396,22 @@ fn registry_versions(repo: &str) -> Result>> { .collect()) } +/// Docker Hub API path for a repository: strips the canonical Hub host +/// prefixes (`docker.io/`, `index.docker.io/`, `registry-1.docker.io/`) and +/// qualifies official images under `library/`. +fn dockerhub_path(repo: &str) -> String { + let repo = repo + .strip_prefix("docker.io/") + .or_else(|| repo.strip_prefix("index.docker.io/")) + .or_else(|| repo.strip_prefix("registry-1.docker.io/")) + .unwrap_or(repo); + if repo.contains('/') { + repo.to_string() + } else { + format!("library/{repo}") + } +} + fn dockerhub_tags(path: &str) -> Result> { let url = format!( "https://hub.docker.com/v2/repositories/{path}/tags?page_size=100&ordering=last_updated" @@ -417,6 +473,7 @@ mod tests { name: "stacks-api", current, current_note: "", + tag_from_image: false, major_pin, same_major: same, next_major: next, @@ -474,6 +531,29 @@ mod tests { assert!(text.contains("chainstate download --service api")); } + #[test] + fn guidance_targets_image_when_tag_pinned_there() { + let mut r = row(Some(vec![8, 5, 0]), false, Some(vec![8, 15, 4]), None); + r.tag_from_image = true; + let text = guidance(&r).unwrap(); + assert!(text.contains("update the tag in [stacks-api] `image`")); + assert!(!text.contains("version = ")); + // and the DB-breaking major path too + let mut r = row(Some(vec![8, 5, 0]), false, None, Some(vec![9, 0, 2])); + r.tag_from_image = true; + let text = guidance(&r).unwrap(); + assert!(text.contains("update the tag in [stacks-api] `image` to \"9.0.2\"")); + } + + #[test] + fn dockerhub_path_normalizes_hub_hosts() { + assert_eq!(dockerhub_path("docker.io/myorg/api"), "myorg/api"); + assert_eq!(dockerhub_path("index.docker.io/myorg/api"), "myorg/api"); + assert_eq!(dockerhub_path("docker.io/postgres"), "library/postgres"); + assert_eq!(dockerhub_path("postgres"), "library/postgres"); + assert_eq!(dockerhub_path("myorg/api"), "myorg/api"); + } + #[test] fn postgres_major_guidance_warns_about_migration() { let mut r = row(Some(vec![17]), true, None, Some(vec![18, 4])); diff --git a/src/utils/services.rs b/src/utils/services.rs index fe75f3a..a83db88 100644 --- a/src/utils/services.rs +++ b/src/utils/services.rs @@ -10,38 +10,67 @@ const REPO_STACKS_API: (&str, &str) = ("hirosystems/stacks-blockchain-api", "lat const REPO_STACKS_MESH_API: (&str, &str) = ("ghcr.io/stx-labs/stacks-mesh-api", "latest"); const REPO_POSTGRES: (&str, &str) = ("postgres", "latest"); -fn image((repo, default_tag): (&str, &str), version: Option<&str>) -> String { - format!("{repo}:{}", version.unwrap_or(default_tag)) +fn image( + (default_repo, default_tag): (&str, &str), + image_override: Option<&str>, + version: Option<&str>, +) -> String { + match image_override { + // Full ref with its own tag: used verbatim (validation rejects a + // conflicting `version`). + Some(full) if crate::utils::versions::has_explicit_tag(full) => full.to_string(), + // Repository override: tag still comes from `version`/the default. + Some(repo) => format!("{repo}:{}", version.unwrap_or(default_tag)), + None => format!("{default_repo}:{}", version.unwrap_or(default_tag)), + } } pub fn bitcoind_image(deployment: &Deployment) -> String { - image(REPO_BITCOIND, deployment.bitcoind.version.as_deref()) + image( + REPO_BITCOIND, + deployment.bitcoind.image.as_deref(), + deployment.bitcoind.version.as_deref(), + ) } pub fn stacks_node_image(deployment: &Deployment) -> String { - image(REPO_STACKS_NODE, deployment.stacks_node.version.as_deref()) + image( + REPO_STACKS_NODE, + deployment.stacks_node.image.as_deref(), + deployment.stacks_node.version.as_deref(), + ) } pub fn stacks_signer_image(deployment: &Deployment) -> String { image( REPO_STACKS_SIGNER, + deployment.stacks_signer.image.as_deref(), deployment.stacks_signer.version.as_deref(), ) } pub fn stacks_api_image(deployment: &Deployment) -> String { - image(REPO_STACKS_API, deployment.stacks_api.version.as_deref()) + image( + REPO_STACKS_API, + deployment.stacks_api.image.as_deref(), + deployment.stacks_api.version.as_deref(), + ) } pub fn stacks_mesh_api_image(deployment: &Deployment) -> String { image( REPO_STACKS_MESH_API, + deployment.stacks_mesh_api.image.as_deref(), deployment.stacks_mesh_api.version.as_deref(), ) } pub fn postgres_image(deployment: &Deployment) -> String { - image(REPO_POSTGRES, deployment.postgres.version.as_deref()) + image( + REPO_POSTGRES, + deployment.postgres.image.as_deref(), + deployment.postgres.version.as_deref(), + ) } pub const NODE_RPC_PORT: u16 = 20443; @@ -129,6 +158,25 @@ mod tests { assert!(postgres_image(&s).starts_with("postgres:")); } + #[test] + fn image_override_resolution() { + // repo-only override + version -> override repo with version tag + let d = + deployment("network = \"testnet\"\n[postgres]\nimage = \"myorg/pg\"\nversion = \"17\""); + assert_eq!(postgres_image(&d), "myorg/pg:17"); + // repo-only override without version -> default tag + let d = deployment("network = \"testnet\"\n[postgres]\nimage = \"myorg/pg\""); + assert!(postgres_image(&d).starts_with("myorg/pg:")); + // full ref -> verbatim, incl. private registries with ports + let d = deployment( + "network = \"testnet\"\n[stacks-node]\nimage = \"localhost:5000/core:4.0.1\"", + ); + assert_eq!(stacks_node_image(&d), "localhost:5000/core:4.0.1"); + // no override -> defaults unchanged + let d = deployment("network = \"testnet\""); + assert!(!stacks_node_image(&d).starts_with("myorg")); + } + #[test] fn image_uses_configured_version() { let s = deployment( diff --git a/src/utils/versions.rs b/src/utils/versions.rs index 5976bae..eadbb6b 100644 --- a/src/utils/versions.rs +++ b/src/utils/versions.rs @@ -25,6 +25,12 @@ pub fn version_string(v: &[u64]) -> String { v.iter().map(u64::to_string).collect::>().join(".") } +/// Whether an image ref carries an explicit tag. A colon whose right side +/// contains `/` is a registry port (`localhost:5000/repo`), not a tag. +pub fn has_explicit_tag(image: &str) -> bool { + matches!(image.rsplit_once(':'), Some((_, tag)) if !tag.contains('/')) +} + /// Tag of an image ref, defaulting to `latest` when untagged. A colon whose /// right side contains `/` is a registry port (`localhost:5000/repo`), not a tag. pub fn image_tag(image: &str) -> String { @@ -89,6 +95,14 @@ mod tests { assert_eq!(version_string(&v), "3.1.0.0.8"); } + #[test] + fn detects_explicit_tags() { + assert!(has_explicit_tag("postgres:17")); + assert!(has_explicit_tag("localhost:5000/repo:1.2")); + assert!(!has_explicit_tag("postgres")); + assert!(!has_explicit_tag("localhost:5000/repo")); + } + #[test] fn extracts_image_tags() { assert_eq!(image_tag("postgres:17"), "17"); From c80aa56bc8b39ba2b078020586160d52c87b11fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:14:58 -0600 Subject: [PATCH 08/19] feat: gate chainstate download on the archive's postgres version (#20) * validate pg versions for archives * suffixes --- src/chainstate/download.rs | 86 ++++++++++++++++++++++++++++++++++++++ src/utils/versions.rs | 20 +++++++++ 2 files changed, 106 insertions(+) diff --git a/src/chainstate/download.rs b/src/chainstate/download.rs index 4168955..dd410a2 100644 --- a/src/chainstate/download.rs +++ b/src/chainstate/download.rs @@ -136,6 +136,9 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result<()> { let size = job.size.map_or("size unknown".into(), format_bytes); println!(" {:<12} {name} ({size})", kind_name(job.kind)); + if job.kind == Kind::Api { + pg_verdict(job, deployment, &mut version_problem); + } match version_verdict(job) { VersionVerdict::Ok(a, c) => { println!(" {}", format!("archive {a} ≤ configured {c} ✓").green()) @@ -461,6 +464,51 @@ fn pinned_job( // --------------------------------------------------------------------------- // Version validation +/// API dumps are produced by a specific postgres major (it's in the archive +/// name); pg_restore into an OLDER server is not supported. Same ladder as +/// the service check: explicit tag settles it, floating tags consult the +/// pulled image, unknown warns. +fn pg_verdict(job: &Job, deployment: &Deployment, version_problem: &mut bool) { + let archive_pg = job + .file + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .and_then(|n| parse_pg_major_from_name(&n)); + let pg_image = crate::utils::services::postgres_image(deployment); + // Distro-suffixed tags ("16.5-alpine") must still gate: strip the suffix + // so the numeric prefix classifies, instead of falling through to the + // pulled-image label (which official postgres images don't carry). + let pg_tag = image_tag(&pg_image); + match classify_version(archive_pg.as_deref(), strip_tag_suffix(&pg_tag), || { + pulled_image_version(&pg_image) + }) { + VersionVerdict::Ok(a, c) => { + println!( + " {}", + format!("archive postgres {a} ≤ deployed postgres {c} ✓").green() + ) + } + VersionVerdict::TooNew(a, c) => { + println!( + " {}", + format!( + "✗ archive was dumped by postgres {a}, newer than deployed postgres {c} — \ + pg_restore into an older server is not supported.\n Fix: raise [postgres] \ + version in stacks.toml, or pick an older archive with --archive." + ) + .red() + ); + *version_problem = true; + } + VersionVerdict::Unknown(reason) => { + println!( + " {}", + format!("⚠ cannot verify postgres compatibility: {reason}").yellow() + ) + } + } +} + enum VersionVerdict { Ok(String, String), TooNew(String, String), @@ -564,6 +612,18 @@ fn parse_version_from_name(name: &str, _kind: Kind) -> Option> { .next() } +/// The postgres major that produced an API dump: +/// `stacks-blockchain-api-pg-17-9.0.2-20260803.dump` -> [17]. +fn parse_pg_major_from_name(name: &str) -> Option> { + let mut parts = name.split('-'); + while let Some(seg) = parts.next() { + if seg == "pg" { + return parts.next().and_then(parse_version); + } + } + None +} + // --------------------------------------------------------------------------- // Download engine @@ -1123,6 +1183,32 @@ mod tests { assert!(matches!(v, VersionVerdict::Ok(..))); } + #[test] + fn suffixed_postgres_tags_still_gate() { + // "16.5-alpine" must block a pg-17 dump, not fall through to Unknown + let v = classify_version(Some(&[17]), strip_tag_suffix("16.5-alpine"), || None); + assert!(matches!(v, VersionVerdict::TooNew(..))); + let v = classify_version(Some(&[17]), strip_tag_suffix("17.2-alpine"), || None); + assert!(matches!(v, VersionVerdict::Ok(..))); + } + + #[test] + fn parses_pg_major_from_api_dumps() { + assert_eq!( + parse_pg_major_from_name("stacks-blockchain-api-pg-17-9.0.2-20260811.dump"), + Some(vec![17]) + ); + assert_eq!( + parse_pg_major_from_name("stacks-blockchain-api-pg-17-latest.dump"), + Some(vec![17]) + ); + // node archives carry no pg segment + assert_eq!( + parse_pg_major_from_name("testnet-stacks-blockchain-4.0.1-20260811.tar.zst"), + None + ); + } + #[test] fn parses_date_segment() { assert_eq!( diff --git a/src/utils/versions.rs b/src/utils/versions.rs index eadbb6b..3d94d4b 100644 --- a/src/utils/versions.rs +++ b/src/utils/versions.rs @@ -25,6 +25,16 @@ pub fn version_string(v: &[u64]) -> String { v.iter().map(u64::to_string).collect::>().join(".") } +/// Numeric prefix of a distro-suffixed tag ("16.5-alpine" -> "16.5", +/// "17-bookworm" -> "17"); tags without a numeric prefix ("latest", +/// "bookworm") are returned unchanged. +pub fn strip_tag_suffix(tag: &str) -> &str { + match tag.split_once('-') { + Some((prefix, _)) if parse_version(prefix).is_some() => prefix, + _ => tag, + } +} + /// Whether an image ref carries an explicit tag. A colon whose right side /// contains `/` is a registry port (`localhost:5000/repo`), not a tag. pub fn has_explicit_tag(image: &str) -> bool { @@ -95,6 +105,16 @@ mod tests { assert_eq!(version_string(&v), "3.1.0.0.8"); } + #[test] + fn strips_distro_suffixes() { + assert_eq!(strip_tag_suffix("16.5-alpine"), "16.5"); + assert_eq!(strip_tag_suffix("17-bookworm"), "17"); + assert_eq!(strip_tag_suffix("17"), "17"); + assert_eq!(strip_tag_suffix("latest"), "latest"); + // non-numeric prefixes are not versions; leave untouched + assert_eq!(strip_tag_suffix("stacks3.0-0a2c0e2"), "stacks3.0-0a2c0e2"); + } + #[test] fn detects_explicit_tags() { assert!(has_explicit_tag("postgres:17")); From 66369571a0ddd1bb3faa71baf72393f178fc9036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:44:00 -0600 Subject: [PATCH 09/19] feat: user-owned secrets.toml overlay merged at load time (#21) * handle secrets.toml * review fixes --- .gitignore | 1 + Cargo.lock | 14 +- Cargo.toml | 2 + README.md | 31 +++- src/chainstate/mod.rs | 14 +- src/config/mod.rs | 174 +++++++++++++++---- src/config/render.rs | 99 ++++++++--- src/logs/export.rs | 14 +- src/utils/mod.rs | 1 + src/utils/secrets.rs | 384 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 654 insertions(+), 80 deletions(-) create mode 100644 src/utils/secrets.rs diff --git a/.gitignore b/.gitignore index ceaa5bf..b141ffa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ stacks.toml /.export-tmp .DS_Store +/secrets.toml diff --git a/Cargo.lock b/Cargo.lock index 86841c9..495c5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,7 +174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -226,6 +226,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -394,6 +395,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -887,6 +897,8 @@ dependencies = [ "clap", "colored", "flate2", + "getrandom 0.2.17", + "hmac", "indicatif", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6df3a7e..7242215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } colored = "2" flate2 = "1" +getrandom = "0.2" +hmac = "0.12" indicatif = "0.17" rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 8aba524..a3c47e3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,35 @@ be configured to *push* to them; `stacksup config render` emits `rendered/apply-to-your-node.toml` with the exact blocks to add on your side, and `stacksup config check` verifies the loop is closed. +## Secrets + +Credentials never live in `stacks.toml` — the tool rejects them there. They go +in a `secrets.toml` beside it (plain text, mode 0600, gitignored), which is +merged into the config at load time: + +```toml +[postgres] +password = "..." + +[bitcoind] +rpc_user = "..." +rpc_password = "..." + +[stacks-node] +auth_token = "..." +``` + +`stacksup config init` generates one with random values when none exists; +an existing `secrets.toml` is yours and is **never modified or overwritten** +(not even by `init --force`) — if a required value is missing, the tool errors +out with a paste-ready snippet of exactly what to add. The file must be +owner-only (`chmod 600`), and values must be 8–128 characters of printable +ASCII without spaces, quotes, backslashes, `$`, or backticks (they are +interpolated into rendered TOML/env/compose files). At render time the +Postgres password is delivered as a compose secret file and the bitcoind +credentials become a derived `-rpcauth` hash, so no plain-text secret appears +in `docker inspect`. + ## Development ```bash @@ -82,7 +111,7 @@ the service's configured `version` in stacks.toml. - [ ] `status --watch`: live sync progress (bitcoind headers, node tip vs peers via `/v3/health`, API ingest lag) via bollard - [ ] Snapshot seeding on first `up`: Hiro archive chainstate + matching API pg_dump, resumable, checksummed - [ ] `doctor`: chain-id cross-checks, event-stream-flowing check, node↔signer auth verification -- [ ] Secrets: generated per-stack tokens/passwords in a gitignored env file (currently dev defaults — do not use on mainnet) +- [x] Secrets: user-owned `secrets.toml` overlay beside stacks.toml — pg password via compose secret file, bitcoind via rpcauth hash, node/signer auth token - [ ] `upgrade`: image update with pre-upgrade pg backup, ordered restart, post-check - [ ] `snapshot`: stop-consistent chainstate + pg_dump pairs with version metadata - [ ] Profiles: `exchange` (readonly API replicas, pruned mode), richer `signer` (monitor-signers wiring) diff --git a/src/chainstate/mod.rs b/src/chainstate/mod.rs index 50773d3..c445e04 100644 --- a/src/chainstate/mod.rs +++ b/src/chainstate/mod.rs @@ -196,24 +196,14 @@ fn bitcoind_tip(deployment: &Deployment, running: bool) -> Tip { return tip; } let chain = deployment.net.bitcoind.chain.as_str(); + // bitcoin-cli inside the container authenticates via the datadir cookie + // file — no credentials needed (and none appear in process args). let out = Command::new("docker") .args([ "exec", "stacks-bitcoind", "bitcoin-cli", &format!("-chain={chain}"), - &format!( - "-rpcuser={}", - deployment.bitcoind.rpc_user.as_deref().unwrap_or("stacks") - ), - &format!( - "-rpcpassword={}", - deployment - .bitcoind - .rpc_password - .as_deref() - .unwrap_or("stacks") - ), "getblockcount", ]) .output(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 688cd07..33c4b28 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -74,6 +74,7 @@ pub struct Bitcoind { pub host: Option, pub rpc_port: Option, pub p2p_port: Option, + /// SECRET: set in secrets.toml ([bitcoind] rpc_user / rpc_password). pub rpc_user: Option, pub rpc_password: Option, } @@ -94,9 +95,9 @@ pub struct StacksNode { /// Required when mode = "external" pub rpc_host: Option, pub rpc_port: Option, - /// The node's `connection_options.auth_token`. Only configurable when - /// mode = "external" (it must match what your node runs with); managed - /// nodes always use a tool-managed token. + /// The node's `connection_options.auth_token`. SECRET: set in + /// secrets.toml ([stacks-node] auth_token), never here. For an external + /// node it must match what your node runs with. pub auth_token: Option, } @@ -162,10 +163,68 @@ pub struct Postgres { pub host: Option, pub port: Option, pub user: Option, + /// SECRET: set in secrets.toml ([postgres] password), never here. pub password: Option, } impl Deployment { + /// Secret-bearing fields that must NOT be set in stacks.toml. + fn config_secret_leaks(&self) -> Vec<(&'static str, &'static str)> { + let mut leaks = Vec::new(); + if self.bitcoind.rpc_user.is_some() { + leaks.push(("bitcoind", "rpc_user")); + } + if self.bitcoind.rpc_password.is_some() { + leaks.push(("bitcoind", "rpc_password")); + } + if self.postgres.password.is_some() { + leaks.push(("postgres", "password")); + } + if self.stacks_node.auth_token.is_some() { + leaks.push(("stacks-node", "auth_token")); + } + leaks + } + + /// Fill secret fields from the secrets.toml overlay. + fn merge_secrets(&mut self, overlay: crate::utils::secrets::SecretsOverlay) { + self.bitcoind.rpc_user = overlay.bitcoind.rpc_user; + self.bitcoind.rpc_password = overlay.bitcoind.rpc_password; + self.postgres.password = overlay.postgres.password; + self.stacks_node.auth_token = overlay.stacks_node.auth_token; + } + + /// Secrets that MUST be present given the enabled services. The tool + /// never writes secrets.toml itself — missing values are the user's to + /// add, so the error carries a paste-ready snippet. + fn check_required_secrets(&self, config_dir: &Path) -> Result<()> { + let mut missing: Vec<(&str, &str)> = Vec::new(); + if self.postgres.mode != ServiceMode::Disabled && self.postgres.password.is_none() { + missing.push(("postgres", "password")); + } + if self.bitcoind.mode == ServiceMode::Enabled { + if self.bitcoind.rpc_user.is_none() { + missing.push(("bitcoind", "rpc_user")); + } + if self.bitcoind.rpc_password.is_none() { + missing.push(("bitcoind", "rpc_password")); + } + } + let node_needs_token = self.stacks_node.mode == ServiceMode::Enabled + || (self.stacks_node.mode == ServiceMode::External + && (self.stacks_signer.mode == ServiceMode::Enabled + || self.stacks_mesh_api.mode == ServiceMode::Enabled)); + if node_needs_token && self.stacks_node.auth_token.is_none() { + missing.push(("stacks-node", "auth_token")); + } + if !missing.is_empty() { + return Err(crate::utils::secrets::missing_secrets_error( + config_dir, &missing, + )); + } + Ok(()) + } + /// Cross-service validation: the rules that make invalid stacks fail at /// `up` time instead of becoming runtime mysteries. pub fn validate(&self) -> (Vec, Vec) { @@ -273,13 +332,6 @@ impl Deployment { ) } ServiceMode::External => { - if self.stacks_node.auth_token.is_none() { - errors.push( - "a managed signer with an external node needs [stacks-node] auth_token \ - (your node's `connection_options.auth_token`, so the signer can authenticate)" - .into(), - ); - } warnings.push( "signer is managed but the node is external: apply `rendered/apply-to-your-node.toml` \ to your node (stacker = true, matching auth_password, signer events_observer)" @@ -290,14 +342,6 @@ impl Deployment { } } - if self.stacks_node.mode != ServiceMode::External && self.stacks_node.auth_token.is_some() { - warnings.push( - "[stacks-node] auth_token is only used when mode = \"external\"; \ - a managed node uses a tool-managed token" - .into(), - ); - } - // Reversed (push) edges: the node's config must name its observers. When // the node is external we can't write that config, only emit it. if self.stacks_node.mode == ServiceMode::External @@ -332,6 +376,27 @@ pub fn load(path: &Path) -> Result { let config_dir = path.parent().unwrap_or(Path::new(".")); deployment.net = network::load(&deployment.network, config_dir)?; + // Secrets never belong in the committable config; they come from the + // secrets.toml overlay beside it. + let leaked = deployment.config_secret_leaks(); + if !leaked.is_empty() { + for (section, field) in &leaked { + eprintln!( + "{}", + format!( + "error: [{section}] {field} is a secret — remove it from {} and set it in secrets.toml instead", + path.display() + ) + .red() + ); + } + bail!("secrets found in {}", path.display()); + } + if let Some(overlay) = crate::utils::secrets::load(config_dir)? { + deployment.merge_secrets(overlay); + } + deployment.check_required_secrets(config_dir)?; + let (errors, warnings) = deployment.validate(); for w in &warnings { eprintln!("{}", format!("warning: {w}").yellow()); @@ -351,6 +416,9 @@ pub fn init(force: bool) -> Result<()> { bail!("stacks.toml already exists (use --force to overwrite)"); } std::fs::write(path, DEFAULT_STACK_TOML)?; + // secrets.toml lives beside stacks.toml. Generated ONLY when missing — + // an existing file is the user's and is never touched, even with --force. + crate::utils::secrets::generate_if_missing(Path::new("."))?; println!("Wrote stacks.toml — edit it, then run `stacksup start`."); Ok(()) } @@ -380,8 +448,7 @@ mode = "disabled" # host = "10.0.1.5" # rpc_port = 18332 # p2p_port = 18333 -# rpc_user = "stacks" -# rpc_password = "..." +# Credentials go in secrets.toml ([bitcoind] rpc_user / rpc_password). [stacks-node] mode = "enabled" @@ -390,7 +457,7 @@ role = "follower" # follower | signer-host (required when running a signer) # For mode = "external": # rpc_host = "10.0.1.6" # rpc_port = 20443 -# auth_token = "..." # your node's connection_options.auth_token +# The auth token goes in secrets.toml ([stacks-node] auth_token). [stacks-signer] mode = "disabled" @@ -532,28 +599,61 @@ mod tests { } #[test] - fn signer_with_external_node_needs_auth_token() { - let base = "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-signer]\nmode = \"enabled\""; - let e = errors(base); - assert!(e.iter().any(|m| m.contains("auth_token"))); - // with the token supplied it degrades to a warning, not an error - let ok = "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\nauth_token = \"tok-1234\"\n[stacks-signer]\nmode = \"enabled\""; - assert!(errors(ok).is_empty()); - assert!( - warnings(ok) - .iter() - .any(|m| m.contains("apply-to-your-node")) + fn signer_with_external_node_warns_about_apply_snippet() { + let w = warnings( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-signer]\nmode = \"enabled\"", ); + assert!(w.iter().any(|m| m.contains("apply-to-your-node"))); } #[test] - fn auth_token_on_managed_node_warns() { - let w = warnings( - "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"\nauth_token = \"x\"", + fn secrets_in_stacks_toml_are_detected_as_leaks() { + let d = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\nrpc_password = \"p\"\n[postgres]\nmode = \"enabled\"\npassword = \"x\"\n[stacks-node]\nmode = \"enabled\"\nauth_token = \"t\"", ); + let leaks = d.config_secret_leaks(); + assert!(leaks.contains(&("bitcoind", "rpc_password"))); + assert!(leaks.contains(&("postgres", "password"))); + assert!(leaks.contains(&("stacks-node", "auth_token"))); + // a clean config has no leaks assert!( - w.iter() - .any(|m| m.contains("only used when mode = \"external\"")) + deployment("network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"") + .config_secret_leaks() + .is_empty() + ); + } + + #[test] + fn required_secrets_depend_on_enabled_services() { + // enabled node + postgres, empty overlay -> both reported missing + let d = deployment( + "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"", + ); + let err = d + .check_required_secrets(Path::new(".")) + .unwrap_err() + .to_string(); + assert!(err.contains("[postgres]\npassword")); + assert!(err.contains("[stacks-node]\nauth_token")); + + // external node pushing to a managed signer still needs the token + let d = deployment( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-signer]\nmode = \"enabled\"", + ); + let err = d + .check_required_secrets(Path::new(".")) + .unwrap_err() + .to_string(); + assert!(err.contains("[stacks-node]\nauth_token")); + + // merged overlay satisfies the requirements + let mut d = deployment( + "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"", + ); + d.merge_secrets( + toml::from_str("[postgres]\npassword = \"p\"\n[stacks-node]\nauth_token = \"t\"") + .unwrap(), ); + assert!(d.check_required_secrets(Path::new(".")).is_ok()); } } diff --git a/src/config/render.rs b/src/config/render.rs index 3224ecb..c552ece 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -14,6 +14,7 @@ use anyhow::Result; use serde::Serialize; use crate::config::{Deployment, ServiceMode}; +use crate::utils::secrets; use crate::utils::services::*; pub const COMPOSE_PROJECT: &str = "stacks"; @@ -47,6 +48,13 @@ const API_PG_SCHEMA: &str = "stacks_blockchain_api"; struct ComposeFile { services: BTreeMap, networks: BTreeMap>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + secrets: BTreeMap, +} + +#[derive(Serialize)] +struct ComposeSecret { + file: String, } #[derive(Serialize, Default)] @@ -70,6 +78,8 @@ struct ComposeService { restart: String, networks: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] + secrets: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] extra_hosts: Vec, } @@ -102,6 +112,7 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { let mut compose = ComposeFile { services: BTreeMap::new(), networks: BTreeMap::from([(COMPOSE_NETWORK.to_string(), None)]), + secrets: BTreeMap::new(), }; // Pre-create bind-mount sources so docker doesn't create them root-owned. @@ -166,6 +177,17 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { if deployment.postgres.mode == ServiceMode::Enabled { chainstate_subdir("postgres")?; std::fs::create_dir_all(data_dir.join("downloads"))?; + std::fs::create_dir_all(dir.join("secrets"))?; + secrets::write_0600( + &dir.join("secrets/pg_password"), + deployment.postgres.password.as_deref().unwrap_or_default(), + )?; + compose.secrets.insert( + "pg_password".into(), + ComposeSecret { + file: "./secrets/pg_password".into(), + }, + ); compose .services .insert("postgres".into(), postgres_service(deployment)); @@ -194,9 +216,20 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { format!("-chain={}", deployment.net.bitcoind.chain), "-rpcbind=0.0.0.0".into(), "-rpcallowip=0.0.0.0/0".into(), - // TODO(hackathon): generate rpcauth instead of user/pass defaults - "-rpcuser=stacks".into(), - "-rpcpassword=stacks".into(), + // A salted hash — unlike -rpcpassword, safe to expose via docker + // inspect. The plaintext lives in secrets.toml and the node's + // mounted Config.toml only. + format!( + "-rpcauth={}", + secrets::bitcoind_rpcauth( + deployment.bitcoind.rpc_user.as_deref().unwrap_or_default(), + deployment + .bitcoind + .rpc_password + .as_deref() + .unwrap_or_default(), + ) + ), ]; svc.ports = vec![format!("{rpc}:{rpc}"), format!("{p2p}:{p2p}")]; svc.volumes = vec!["../chainstate/bitcoind:/home/bitcoin/.bitcoin".into()]; @@ -284,7 +317,6 @@ fn postgres_major(deployment: &Deployment) -> Option { fn postgres_service(deployment: &Deployment) -> ComposeService { let mut svc = ComposeService::new("postgres", &postgres_image(deployment)); svc.ports = vec![format!("{POSTGRES_PORT}:{POSTGRES_PORT}")]; - // TODO(hackathon): generate a password into a .env secret file instead svc.environment.insert( "POSTGRES_USER".into(), deployment @@ -293,14 +325,13 @@ fn postgres_service(deployment: &Deployment) -> ComposeService { .clone() .unwrap_or_else(|| "postgres".into()), ); + // Password arrives via a compose secret file, not container env — + // `docker inspect` shows only the path. svc.environment.insert( - "POSTGRES_PASSWORD".into(), - deployment - .postgres - .password - .clone() - .unwrap_or_else(|| "postgres".into()), + "POSTGRES_PASSWORD_FILE".into(), + "/run/secrets/pg_password".into(), ); + svc.secrets = vec!["pg_password".into()]; svc.environment .insert("POSTGRES_DB".into(), API_PG_DATABASE.into()); // Postgres 18+ images keep data in a version-specific subdirectory and @@ -396,12 +427,12 @@ fn node_config_toml(deployment: &Deployment) -> String { if deployment.bitcoind.mode == ServiceMode::Enabled { out.push_str(&format!( "username = \"{}\"\npassword = \"{}\"\n", - deployment.bitcoind.rpc_user.as_deref().unwrap_or("stacks"), + deployment.bitcoind.rpc_user.as_deref().unwrap_or_default(), deployment .bitcoind .rpc_password .as_deref() - .unwrap_or("stacks"), + .unwrap_or_default(), )); } else if let (Some(user), Some(password)) = ( &deployment.bitcoind.rpc_user, @@ -478,14 +509,15 @@ stacks_private_key = "REPLACE_ME" /// The node's `connection_options.auth_token`, which the signer's /// `auth_password` must match. External nodes bring their own token /// (`[stacks-node] auth_token`); managed nodes use a tool-managed one. -// TODO(hackathon): generate per-stack random token into a gitignored secrets file. +/// The auth token shared by node config, signer, and mesh env — a single +/// deployment field, populated from secrets.toml (required by validation +/// whenever something consumes it). fn node_auth_token(deployment: &Deployment) -> String { - if deployment.stacks_node.mode == ServiceMode::External - && let Some(token) = &deployment.stacks_node.auth_token - { - return token.clone(); - } - "stacks-tool-dev-auth-token".into() + deployment + .stacks_node + .auth_token + .clone() + .unwrap_or_default() } fn api_env(deployment: &Deployment) -> String { @@ -518,11 +550,7 @@ TESTNET_SBTC_FAUCET_ENABLED=false pg_port = postgres_port(deployment), pg_schema = API_PG_SCHEMA, pg_user = deployment.postgres.user.as_deref().unwrap_or("postgres"), - pg_password = deployment - .postgres - .password - .as_deref() - .unwrap_or("postgres"), + pg_password = deployment.postgres.password.as_deref().unwrap_or_default(), pg_db = API_PG_DATABASE, ) } @@ -583,8 +611,24 @@ fn apply_to_your_node(deployment: &Deployment) -> Option { mod tests { use super::*; + /// Parse and merge the same test secrets `load()` would pull from a + /// secrets.toml, so generators see a post-merge deployment. Managed + /// bitcoind gets credentials; external keeps whatever the toml set. fn deployment(toml_str: &str) -> Deployment { - crate::config::test_deployment(toml_str) + let mut d = crate::config::test_deployment(toml_str); + d.postgres + .password + .get_or_insert_with(|| "test-pg-password".into()); + d.stacks_node + .auth_token + .get_or_insert_with(|| "test-node-token".into()); + if d.bitcoind.mode == ServiceMode::Enabled { + d.bitcoind.rpc_user.get_or_insert_with(|| "stacksup".into()); + d.bitcoind + .rpc_password + .get_or_insert_with(|| "test-btc-password".into()); + } + d } const TESTNET_FULL: &str = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[stacks-api]\nmode = \"enabled\"\n[stacks-mesh-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\""; @@ -669,12 +713,13 @@ mod tests { ); let cfg = node_config_toml(&d); assert!(!cfg.contains("username")); - // managed -> tool defaults present + // managed -> generated secrets, never hardcoded defaults let d = deployment( "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"", ); let cfg = node_config_toml(&d); - assert!(cfg.contains("username = \"stacks\"")); + assert!(cfg.contains("username = \"stacksup\"")); + assert!(cfg.contains("password = \"test-btc-password\"")); } #[test] diff --git a/src/logs/export.rs b/src/logs/export.rs index fdd1947..7f87666 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -216,16 +216,26 @@ fn self_report(config_path: &Path, data_dir: &Path, args: &[&str]) -> String { /// Secret *values* known from the config, scrubbed wherever they appear /// (configs and logs alike — the node echoes config at startup). fn secret_values(deployment: &Deployment) -> Vec { + // Post-merge, every secret lives on the deployment itself. let mut v: Vec = [ deployment.postgres.password.clone(), + deployment.bitcoind.rpc_user.clone(), deployment.bitcoind.rpc_password.clone(), deployment.stacks_node.auth_token.clone(), ] .into_iter() .flatten() - .filter(|s| s.len() >= 4) .collect(); - v.push("stacks-tool-dev-auth-token".into()); + if let (Some(user), Some(password)) = ( + &deployment.bitcoind.rpc_user, + &deployment.bitcoind.rpc_password, + ) { + v.push(crate::utils::secrets::bitcoind_rpcauth(user, password)); + } + // Longest first: the rpcauth line embeds the username, so scrubbing the + // shorter username first would split it and leave the verifier exposed. + v.sort_by_key(|s| std::cmp::Reverse(s.len())); + v.retain(|s| s.len() >= 4); v } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 166feae..e5f79a9 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ //! Shared helpers used across commands — not commands themselves. pub mod docker; +pub mod secrets; pub mod services; pub mod versions; diff --git a/src/utils/secrets.rs b/src/utils/secrets.rs new file mode 100644 index 0000000..1bdcbff --- /dev/null +++ b/src/utils/secrets.rs @@ -0,0 +1,384 @@ +//! The secrets overlay: `secrets.toml` lives beside `stacks.toml`, holds ONLY +//! sensitive values (clear text, mode 0600, never committed), and is merged +//! into the deployment config at load time. +//! +//! Contract: +//! - The tool NEVER modifies an existing secrets.toml. `config init` creates +//! one with generated values only when none exists; anything missing later +//! is an error telling the user exactly what to add. +//! - Secret-bearing fields are rejected in stacks.toml — they must come from +//! the overlay, so the committable config never contains credentials. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +pub const SECRETS_FILE: &str = "secrets.toml"; + +/// Secret values are interpolated into rendered TOML, env files, and the +/// compose file, so they are validated to an alphabet that is inert in all +/// of those formats: printable ASCII without whitespace, quotes, backslash, +/// `$` (compose interpolation), or backtick. +const MIN_SECRET_LEN: usize = 8; +const MAX_SECRET_LEN: usize = 128; + +/// The overlay mirrors stacks.toml's structure, restricted to secret-bearing +/// fields. `deny_unknown_fields` makes typos and non-secret config in this +/// file loud errors. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct SecretsOverlay { + #[serde(default)] + pub bitcoind: BitcoindSecrets, + #[serde(default)] + pub postgres: PostgresSecrets, + #[serde(default, rename = "stacks-node")] + pub stacks_node: NodeSecrets, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct BitcoindSecrets { + pub rpc_user: Option, + pub rpc_password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct PostgresSecrets { + pub password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct NodeSecrets { + pub auth_token: Option, +} + +fn secrets_path(data_dir: &Path) -> PathBuf { + data_dir.join(SECRETS_FILE) +} + +/// Load the overlay. Missing file is `None`; `config::load` then reports the +/// deployment's required secrets as missing. Present values are validated +/// (permissions, length, alphabet) so bad ones fail here with a precise +/// message instead of producing broken rendered configs. +pub fn load(data_dir: &Path) -> Result> { + let path = secrets_path(data_dir); + if !path.exists() { + return Ok(None); + } + // The file holds clear-text credentials: refuse to proceed while other + // users can read it. Fixing the mode is the user's move — this tool + // never modifies the file, permissions included. + let mode = std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path)?.permissions()); + if mode & 0o077 != 0 { + bail!( + "{} is readable by other users (mode {:03o}) — run `chmod 600 {}`", + path.display(), + mode & 0o777, + path.display() + ); + } + let raw = std::fs::read_to_string(&path)?; + let overlay: SecretsOverlay = toml::from_str(&raw).map_err(|e| { + // Old generated files used flat keys; point at the new layout. + if raw.contains("node_auth_token") || raw.contains("pg_password") { + anyhow::anyhow!( + "{} uses the old flat format — rewrite it with sections, e.g.\n\n\ + [postgres]\npassword = \"...\"\n\n[bitcoind]\nrpc_user = \"...\"\nrpc_password = \"...\"\n\n\ + [stacks-node]\nauth_token = \"...\"", + path.display() + ) + } else { + anyhow::anyhow!("invalid secrets file {}: {e}", path.display()) + } + })?; + validate_values(&overlay, &path)?; + Ok(Some(overlay)) +} + +/// Every present value must be long enough to redact safely and free of +/// characters that need escaping in any rendered format (TOML strings, env +/// files, compose commands). +fn validate_values(overlay: &SecretsOverlay, path: &Path) -> Result<()> { + let fields: [(&str, &str, &Option); 4] = [ + ("bitcoind", "rpc_user", &overlay.bitcoind.rpc_user), + ("bitcoind", "rpc_password", &overlay.bitcoind.rpc_password), + ("postgres", "password", &overlay.postgres.password), + ("stacks-node", "auth_token", &overlay.stacks_node.auth_token), + ]; + for (section, field, value) in fields { + let Some(v) = value else { continue }; + if v.len() < MIN_SECRET_LEN || v.len() > MAX_SECRET_LEN { + bail!( + "[{section}] {field} in {} must be {MIN_SECRET_LEN}-{MAX_SECRET_LEN} characters (got {})", + path.display(), + v.len() + ); + } + if let Some(c) = v + .chars() + .find(|c| !c.is_ascii_graphic() || matches!(c, '"' | '\'' | '\\' | '$' | '`')) + { + bail!( + "[{section}] {field} in {} contains unsupported character {c:?} — \ + secrets are interpolated into rendered TOML/env/compose files, so use \ + printable ASCII without spaces, quotes, backslashes, `$`, or backticks", + path.display() + ); + } + } + Ok(()) +} + +/// Create secrets.toml with generated values — ONLY when it does not exist. +/// An existing file is the user's; it is never touched. +pub fn generate_if_missing(data_dir: &Path) -> Result<()> { + let path = secrets_path(data_dir); + if path.exists() { + println!("Keeping existing {} (never overwritten).", path.display()); + return Ok(()); + } + let body = format!( + "# stacksup deployment secrets — PLAIN TEXT, edit values freely.\n\ + # Keep this file out of version control (mode 0600). stacksup never\n\ + # modifies it; missing values are reported as errors at render time.\n\n\ + [postgres]\npassword = \"{}\"\n\n\ + [bitcoind]\nrpc_user = \"stacksup-{}\"\nrpc_password = \"{}\"\n\n\ + [stacks-node]\nauth_token = \"{}\"\n", + random_hex(32)?, + // rpc_user is redacted in `logs export`; a random suffix keeps that + // exact-value scrub from also eating every plain "stacksup" in logs. + random_hex(4)?, + random_hex(32)?, + random_hex(32)?, + ); + write_0600(&path, &body)?; + println!( + "Generated deployment secrets at {} (mode 0600)", + path.display() + ); + Ok(()) +} + +/// Write a file readable only by the owner; permissions are set before the +/// content is written. +pub fn write_0600(path: &Path, content: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("cannot create {}", path.display()))?; + // An existing file keeps its old mode; enforce 0600 either way. + let mut perms = file.metadata()?.permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o600); + file.set_permissions(perms)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +fn random_hex(bytes: usize) -> Result { + let mut buf = vec![0u8; bytes]; + getrandom::getrandom(&mut buf) + .map_err(|e| anyhow::anyhow!("could not gather randomness: {e}"))?; + Ok(buf.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// bitcoind's `-rpcauth` line derived from the clear-text credentials +/// (share/rpcauth/rpcauth.py): `user:salt$hex(hmac_sha256(key=salt, msg=password))`. +/// Never stored — a hand-edited password can't drift from its hash. +pub fn bitcoind_rpcauth(user: &str, password: &str) -> String { + let salt = derived_salt(user, password); + let mut mac = Hmac::::new_from_slice(salt.as_bytes()).expect("hmac accepts any key"); + mac.update(password.as_bytes()); + let digest: String = mac + .finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!("{user}:{salt}${digest}") +} + +/// Deterministic salt so the rpcauth line (and the rendered compose file) is +/// stable across renders. Salt secrecy is not load-bearing in the rpcauth +/// scheme — bitcoind stores it in plaintext beside the hash. +fn derived_salt(user: &str, password: &str) -> String { + use sha2::Digest; + let digest = Sha256::digest(format!("{user}:{password}").as_bytes()); + digest[..8].iter().map(|b| format!("{b:02x}")).collect() +} + +/// bail! with a ready-to-paste snippet listing the missing secret fields, +/// one table per section (fields sharing a section are grouped so the +/// snippet is valid TOML). +pub fn missing_secrets_error(data_dir: &Path, missing: &[(&str, &str)]) -> anyhow::Error { + let mut sections: Vec<(&str, Vec<&str>)> = Vec::new(); + for (section, field) in missing { + match sections.last_mut() { + Some((s, fields)) if s == section => fields.push(field), + _ => sections.push((section, vec![field])), + } + } + let snippet: String = sections + .iter() + .map(|(section, fields)| { + let body: String = fields.iter().map(|f| format!("{f} = \"...\"\n")).collect(); + format!("[{section}]\n{body}") + }) + .collect::>() + .join("\n"); + anyhow::anyhow!( + "missing required secrets in {} — add these values (merge into the \ + matching sections if they already exist):\n\n{snippet}\n\ + (stacksup never modifies this file; `stacksup config init` generates one when absent)", + secrets_path(data_dir).display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("stacksup-secrets-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Test overlays must pass the same permission gate as real ones. + fn write_overlay(dir: &Path, content: &str) { + write_0600(&dir.join(SECRETS_FILE), content).unwrap(); + } + + #[test] + fn generate_creates_once_and_never_touches_existing() { + let dir = temp_dir("gen"); + generate_if_missing(&dir).unwrap(); + let first = std::fs::read_to_string(dir.join(SECRETS_FILE)).unwrap(); + // second call must leave the file byte-identical + generate_if_missing(&dir).unwrap(); + let second = std::fs::read_to_string(dir.join(SECRETS_FILE)).unwrap(); + assert_eq!(first, second); + // and a user-authored file is also never rewritten + std::fs::write(dir.join(SECRETS_FILE), "[postgres]\npassword = \"mine\"\n").unwrap(); + generate_if_missing(&dir).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.join(SECRETS_FILE)).unwrap(), + "[postgres]\npassword = \"mine\"\n" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn generated_file_is_owner_only_and_parses() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("perms"); + generate_if_missing(&dir).unwrap(); + let mode = std::fs::metadata(dir.join(SECRETS_FILE)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + let overlay = load(&dir).unwrap().unwrap(); + assert_eq!(overlay.postgres.password.unwrap().len(), 64); + assert!(overlay.bitcoind.rpc_user.unwrap().starts_with("stacksup-")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn group_readable_secrets_file_is_rejected() { + let dir = temp_dir("mode"); + std::fs::write( + dir.join(SECRETS_FILE), + "[postgres]\npassword = \"hunter2hunter2\"\n", + ) + .unwrap(); // default umask: group/world readable + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("chmod 600"), "got: {err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn partial_overlay_parses_and_unknown_fields_fail() { + let dir = temp_dir("partial"); + write_overlay(&dir, "[postgres]\npassword = \"hunter2hunter2\"\n"); + let overlay = load(&dir).unwrap().unwrap(); + assert_eq!(overlay.postgres.password.as_deref(), Some("hunter2hunter2")); + assert!(overlay.stacks_node.auth_token.is_none()); + + write_overlay(&dir, "[postgres]\nmode = \"enabled\"\n"); + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("invalid secrets file")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn short_and_unsafe_values_are_rejected() { + let dir = temp_dir("values"); + write_overlay(&dir, "[postgres]\npassword = \"short\"\n"); + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("8-128 characters"), "got: {err}"); + + write_overlay(&dir, "[postgres]\npassword = \"with space etc\"\n"); + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("unsupported character"), "got: {err}"); + + write_overlay(&dir, "[stacks-node]\nauth_token = \"has$dollar1\"\n"); + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("unsupported character"), "got: {err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn legacy_flat_format_gets_migration_hint() { + let dir = temp_dir("legacy"); + write_overlay(&dir, "node_auth_token = \"x\"\npg_password = \"y\"\n"); + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("old flat format")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rpcauth_matches_bitcoin_reference_algorithm() { + // hmac.new(b"", b"password", "SHA256") — algorithm + // cross-checked against share/rpcauth/rpcauth.py; determinism is the + // property under test here. + let a = bitcoind_rpcauth("user", "password"); + let b = bitcoind_rpcauth("user", "password"); + assert_eq!(a, b); + assert!(a.starts_with("user:")); + assert_eq!(a.split('$').nth(1).unwrap().len(), 64); + assert_ne!(a, bitcoind_rpcauth("user", "other-password")); + } + + #[test] + fn missing_secrets_error_groups_fields_by_section() { + let err = missing_secrets_error( + Path::new("/data"), + &[ + ("postgres", "password"), + ("bitcoind", "rpc_user"), + ("bitcoind", "rpc_password"), + ("stacks-node", "auth_token"), + ], + ) + .to_string(); + assert!(err.contains("[postgres]\npassword")); + // two bitcoind fields, ONE [bitcoind] header — the snippet stays valid TOML + assert!(err.contains("[bitcoind]\nrpc_user = \"...\"\nrpc_password = \"...\"")); + assert_eq!(err.matches("[bitcoind]").count(), 1); + assert!(err.contains("[stacks-node]\nauth_token")); + assert!(err.contains("never modifies")); + } +} From bece69dca8e85fb15ea9cbfcbdffd57aace352d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:59:30 -0600 Subject: [PATCH 10/19] feat: chainstate download --start boots the deployment after restore (#22) * auto start download * render order --- README.md | 4 +++- src/chainstate/download.rs | 26 +++++++++++++++++++------- src/main.rs | 25 +++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a3c47e3..690f663 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,9 @@ API). Downloads are resumable — Ctrl-C and re-run any time. Useful flags: `--service node|api|all`, `--archive ` to pin a specific archive, `--check-only` for a dry-run plan, `--yes` for unattended runs (`nohup stacksup chainstate download --yes &`), `--no-verify`, -`--skip-version-check`, `--keep-archives`. Always resolves versioned archives (never -latest pointers); the archive's version must be ≤ +`--skip-version-check`, `--keep-archives`, and `--start` to render and +start the deployment as soon as the restore finishes (seed + boot in one +command: `stacksup chainstate download --yes --start`). Always resolves versioned archives (never -latest pointers); the archive's version must be ≤ the service's configured `version` in stacks.toml. ## Roadmap diff --git a/src/chainstate/download.rs b/src/chainstate/download.rs index dd410a2..8262d05 100644 --- a/src/chainstate/download.rs +++ b/src/chainstate/download.rs @@ -36,6 +36,9 @@ pub struct Opts { pub no_verify: bool, pub skip_version_check: bool, pub keep_archives: bool, + /// The caller will start the deployment after a successful restore + /// (only changes the final hint; starting is the caller's job). + pub start: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -56,7 +59,10 @@ struct Job { image: String, } -pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result<()> { +/// Returns `true` when archives were actually restored — `false` on +/// `--check-only` or a declined confirmation, so callers gating follow-up +/// work (like `--start`) don't act on a no-op run. +pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result { let network = deployment.net.hiro_archive_path.clone().with_context(|| { format!( "network `{}` has no published archives (no hiro_archive_path in its definition) — \ @@ -199,7 +205,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result<()> { if opts.check_only { println!("\n--check-only: stopping here."); - return Ok(()); + return Ok(false); } if !opts.yes { @@ -209,7 +215,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result<()> { io::stdin().read_line(&mut input)?; if !matches!(input.trim(), "y" | "Y" | "yes") { println!("Aborted."); - return Ok(()); + return Ok(false); } } println!( @@ -272,10 +278,16 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result<()> { } println!("\n{}", "Done.".green()); - println!( - "Next: `stacksup start`, then `stacksup chainstate status` to confirm the tips line up." - ); - Ok(()) + if opts.start { + println!( + "Run `stacksup chainstate status` once services are up to confirm the tips line up." + ); + } else { + println!( + "Next: `stacksup start`, then `stacksup chainstate status` to confirm the tips line up." + ); + } + Ok(true) } // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index ca2ff15..40f54df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -157,6 +157,10 @@ enum ChainstateCommand { /// Keep downloaded archives after a successful restore #[arg(long)] keep_archives: bool, + /// Start the deployment (render + `stacksup start`) once the + /// chainstate is restored + #[arg(long)] + start: bool, }, } @@ -265,6 +269,7 @@ fn run() -> Result<()> { no_verify, skip_version_check, keep_archives, + start, } => { let service = match service { ServiceArg::Node => chainstate::download::ServiceSel::Node, @@ -276,8 +281,18 @@ fn run() -> Result<()> { "--archive requires exactly one service: --service node or --service api" ); } + if start && check_only { + anyhow::bail!("--start cannot be combined with --check-only"); + } let deployment = config::load(&cli.config)?; - chainstate::download::run( + if start { + // Render BEFORE the restore: the API restore runs + // pg_restore through the compose postgres service, which + // needs rendered/docker-compose.yml — on a fresh setup + // nothing has rendered it yet. + config::render::render(&deployment, &cli.data_dir)?; + } + let restored = chainstate::download::run( &deployment, &cli.data_dir, chainstate::download::Opts { @@ -288,8 +303,14 @@ fn run() -> Result<()> { no_verify, skip_version_check, keep_archives, + start, }, - ) + )?; + if start && restored { + println!("\nStarting the deployment."); + utils::docker::start(&deployment, &cli.data_dir, None)?; + } + Ok(()) } }, } From afb10953fe00cb643abd304d7b4cb9305846f671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:37:32 -0600 Subject: [PATCH 11/19] feat: split compose into bitcoin/core/services networks (#23) * network split * review: loopback-bind bitcoind RPC so containers can't bypass the network split via host-gateway --- README.md | 8 +++ src/config/render.rs | 119 ++++++++++++++++++++++++++++++++++++++++--- src/logs/export.rs | 6 ++- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 690f663..14786b6 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ be configured to *push* to them; `stacksup config render` emits `rendered/apply-to-your-node.toml` with the exact blocks to add on your side, and `stacksup config check` verifies the loop is closed. +Containers are segmented across three docker networks so a compromised +API-side container has no direct line to the signer or bitcoind: `bitcoin` +(bitcoind + node), `core` (node + signer), and `services` (API, mesh API, +Postgres). The node bridges all three — everything talks to it — and each +network is only declared when it has members. bitcoind's RPC port is +published loopback-only (unless the node is external and needs it +off-host), so containers can't sidestep the split via `host.docker.internal`. + ## Secrets Credentials never live in `stacks.toml` — the tool rejects them there. They go diff --git a/src/config/render.rs b/src/config/render.rs index c552ece..9d0a5c0 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -19,8 +19,12 @@ use crate::utils::services::*; pub const COMPOSE_PROJECT: &str = "stacks"; -/// The docker network every service in the stack joins. -const COMPOSE_NETWORK: &str = "stacks"; +/// Docker networks, split so a compromised API-side container cannot reach +/// the signer (or bitcoind) directly. Each service joins only the networks +/// it needs; the node bridges all three because everything talks to it. +const NET_BITCOIN: &str = "bitcoin"; // bitcoind + node +const NET_CORE: &str = "core"; // node + signer +const NET_SERVICES: &str = "services"; // api + mesh + postgres (+ node for RPC/events) /// Path of the compose file within a data dir. pub fn compose_file(data_dir: &Path) -> PathBuf { @@ -96,7 +100,8 @@ impl ComposeService { image: image.into(), container_name, restart: "unless-stopped".into(), - networks: vec![COMPOSE_NETWORK.into()], + // Every builder sets its own network membership (least access). + networks: Vec::new(), // Lets managed services reach "external" services running on the // operator's localhost — a common setup that trips people up. extra_hosts: vec!["host.docker.internal:host-gateway".into()], @@ -111,7 +116,7 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { let mut compose = ComposeFile { services: BTreeMap::new(), - networks: BTreeMap::from([(COMPOSE_NETWORK.to_string(), None)]), + networks: BTreeMap::new(), secrets: BTreeMap::new(), }; @@ -193,6 +198,13 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { .insert("postgres".into(), postgres_service(deployment)); } + // Declare exactly the networks the rendered services joined. + for svc in compose.services.values() { + for net in &svc.networks { + compose.networks.entry(net.clone()).or_insert(None); + } + } + let yaml = serde_yaml::to_string(&compose)?; std::fs::write(compose_file(data_dir), format!("{GENERATED_HEADER}{yaml}"))?; @@ -218,7 +230,8 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { "-rpcallowip=0.0.0.0/0".into(), // A salted hash — unlike -rpcpassword, safe to expose via docker // inspect. The plaintext lives in secrets.toml and the node's - // mounted Config.toml only. + // mounted Config.toml only. The `$` separating salt from digest must + // be `$$` in the compose file or interpolation swallows the digest. format!( "-rpcauth={}", secrets::bitcoind_rpcauth( @@ -229,10 +242,23 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { .as_deref() .unwrap_or_default(), ) + .replace('$', "$$") ), ]; - svc.ports = vec![format!("{rpc}:{rpc}"), format!("{p2p}:{p2p}")]; + // Loopback-bind the RPC port: every container carries a + // host.docker.internal alias, so a 0.0.0.0 publish would let API-side + // containers reach bitcoind through the host, bypassing the network + // split. Operators still get bitcoin-cli on localhost. Only an external + // node (running off-host) needs the wide binding. P2P stays open — it + // exists to accept peers. + let rpc_publish = if deployment.stacks_node.mode == ServiceMode::External { + format!("{rpc}:{rpc}") + } else { + format!("127.0.0.1:{rpc}:{rpc}") + }; + svc.ports = vec![rpc_publish, format!("{p2p}:{p2p}")]; svc.volumes = vec!["../chainstate/bitcoind:/home/bitcoin/.bitcoin".into()]; + svc.networks = vec![NET_BITCOIN.into()]; svc } @@ -260,6 +286,18 @@ fn node_service(deployment: &Deployment) -> ComposeService { if deployment.stacks_api.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-api".into()); } + // Always on `core` (its home even with no signer); `bitcoin` only with a + // managed bitcoind; `services` only when an API-side consumer needs its + // RPC or receives its event pushes. + svc.networks = vec![NET_CORE.into()]; + if deployment.bitcoind.mode == ServiceMode::Enabled { + svc.networks.insert(0, NET_BITCOIN.into()); + } + if deployment.stacks_api.mode == ServiceMode::Enabled + || deployment.stacks_mesh_api.mode == ServiceMode::Enabled + { + svc.networks.push(NET_SERVICES.into()); + } svc } @@ -278,6 +316,7 @@ fn signer_service(deployment: &Deployment) -> ComposeService { if deployment.stacks_node.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-node".into()); } + svc.networks = vec![NET_CORE.into()]; svc } @@ -288,6 +327,7 @@ fn api_service(deployment: &Deployment) -> ComposeService { if deployment.postgres.mode == ServiceMode::Enabled { svc.depends_on.push("postgres".into()); } + svc.networks = vec![NET_SERVICES.into()]; svc } @@ -298,6 +338,7 @@ fn mesh_api_service(deployment: &Deployment) -> ComposeService { if deployment.stacks_node.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-node".into()); } + svc.networks = vec![NET_SERVICES.into()]; svc } @@ -317,6 +358,7 @@ fn postgres_major(deployment: &Deployment) -> Option { fn postgres_service(deployment: &Deployment) -> ComposeService { let mut svc = ComposeService::new("postgres", &postgres_image(deployment)); svc.ports = vec![format!("{POSTGRES_PORT}:{POSTGRES_PORT}")]; + svc.networks = vec![NET_SERVICES.into()]; svc.environment.insert( "POSTGRES_USER".into(), deployment @@ -722,6 +764,67 @@ mod tests { assert!(cfg.contains("password = \"test-btc-password\"")); } + #[test] + fn bitcoind_rpc_port_is_loopback_unless_node_is_external() { + // managed node talks over the docker network; the host publish is + // diagnostic only -> loopback (blocks the host.docker.internal bypass) + let s = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"", + ); + let ports = bitcoind_service(&s).ports; + assert!(ports[0].starts_with("127.0.0.1:"), "got: {ports:?}"); + assert!(!ports[1].starts_with("127.0.0.1:"), "p2p must accept peers"); + + // an off-host node needs the wide binding + let s = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"", + ); + assert!(!bitcoind_service(&s).ports[0].starts_with("127.0.0.1:")); + } + + #[test] + fn bitcoind_rpcauth_dollar_is_escaped_for_compose() { + let s = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"", + ); + let rpcauth = bitcoind_service(&s) + .command + .iter() + .find(|a| a.starts_with("-rpcauth=")) + .unwrap() + .clone(); + // `$$` survives compose interpolation as a literal `$`; a bare `$` + // would swallow the digest ("variable is not set" -> blank). + assert!(rpcauth.contains("$$"), "got: {rpcauth}"); + assert!(!rpcauth.replace("$$", "").contains('$')); + } + + #[test] + fn networks_isolate_signer_from_api_side() { + // Full mainnet stack: three networks, least membership everywhere. + let s = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[stacks-api]\nmode = \"enabled\"\n[stacks-mesh-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"", + ); + assert_eq!(bitcoind_service(&s).networks, vec!["bitcoin"]); + // the node bridges all three — everything talks to it + assert_eq!( + node_service(&s).networks, + vec!["bitcoin", "core", "services"] + ); + // the signer shares ONLY `core` with the node: unreachable from the + // api/mesh/postgres side, and no direct line to bitcoind + assert_eq!(signer_service(&s).networks, vec!["core"]); + assert_eq!(api_service(&s).networks, vec!["services"]); + assert_eq!(mesh_api_service(&s).networks, vec!["services"]); + assert_eq!(postgres_service(&s).networks, vec!["services"]); + + // node alone: just `core`, no service networks dangling + let s = deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"external\"\nhost = \"h\"\n[stacks-node]\nmode = \"enabled\"", + ); + assert_eq!(node_service(&s).networks, vec!["core"]); + } + #[test] fn apply_to_your_node_only_for_push_edges() { // external node + enabled api -> snippet with the api observer @@ -760,6 +863,10 @@ mod tests { } // testnet must NOT render a managed bitcoind assert!(!services.contains_key("bitcoind")); + // ... so only the networks with members are declared + let networks = compose["networks"].as_mapping().unwrap(); + assert!(networks.contains_key("core") && networks.contains_key("services")); + assert!(!networks.contains_key("bitcoin")); assert!(dir.join("rendered/stacks-node/Config.toml").exists()); assert!(dir.join("rendered/stacks-api/.env").exists()); assert!(dir.join("chainstate/stacks-node").is_dir()); diff --git a/src/logs/export.rs b/src/logs/export.rs index 7f87666..6503aff 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -230,7 +230,11 @@ fn secret_values(deployment: &Deployment) -> Vec { &deployment.bitcoind.rpc_user, &deployment.bitcoind.rpc_password, ) { - v.push(crate::utils::secrets::bitcoind_rpcauth(user, password)); + let rpcauth = crate::utils::secrets::bitcoind_rpcauth(user, password); + // The compose file carries the `$$`-escaped form (compose + // interpolation); scrub both spellings. + v.push(rpcauth.replace('$', "$$")); + v.push(rpcauth); } // Longest first: the rpcauth line embeds the username, so scrubbing the // shorter username first would split it and leave the verifier exposed. From 17f59389942a42d4212c9cb8cc0360279091748b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:58:21 -0600 Subject: [PATCH 12/19] feat: run multiple deployments per machine via name and port_offset (#24) * bitcoin rpc fix * feat: run multiple deployments per machine via name and port_offset * review: centralize DEFAULT_PROJECT, checked port addition, fix probe comment --- README.md | 30 ++++++++++--- src/config/check.rs | 28 +++++++++--- src/config/mod.rs | 83 ++++++++++++++++++++++++++++++++++ src/config/render.rs | 105 ++++++++++++++++++++++++++++++++++--------- src/logs/export.rs | 21 +++++++++ src/utils/docker.rs | 14 +++--- 6 files changed, 242 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 14786b6..29590ea 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,32 @@ be configured to *push* to them; `stacksup config render` emits and `stacksup config check` verifies the loop is closed. Containers are segmented across three docker networks so a compromised -API-side container has no direct line to the signer or bitcoind: `bitcoin` -(bitcoind + node), `core` (node + signer), and `services` (API, mesh API, -Postgres). The node bridges all three — everything talks to it — and each -network is only declared when it has members. bitcoind's RPC port is +API-side container has no route to the signer or to bitcoind's RPC +interface: `bitcoin` (bitcoind + node), `core` (node + signer), and +`services` (API, mesh API, Postgres). The node joins each network only when +it's present — everything talks to the node. bitcoind's RPC port is published loopback-only (unless the node is external and needs it -off-host), so containers can't sidestep the split via `host.docker.internal`. +off-host), so containers can't sidestep the split via +`host.docker.internal`; the P2P port stays open on purpose — it exists to +accept peers from anywhere. + +## Running multiple deployments + +One machine can host several stacks side by side. Give each deployment its +own directory (config + `--data-dir`), a distinct `name`, and a +`port_offset`: + +```toml +name = "testnet-b" # compose project + container prefix (default: "stacks") +port_offset = 100 # shifts every published HOST port; container-internal + # ports and service wiring never change +``` + +With `port_offset = 100` the node RPC publishes on 20543, the API on 4099, +postgres on 5532, and so on. The rendered compose file embeds the project +name, so `stacksup` commands (and bare `docker compose -f` runs) are always +scoped to the deployment whose directory you're in — `stop`, `logs`, and +`chainstate wipe` can't touch a neighbour. ## Secrets diff --git a/src/config/check.rs b/src/config/check.rs index e6a90e8..f417cba 100644 --- a/src/config/check.rs +++ b/src/config/check.rs @@ -57,8 +57,14 @@ pub fn run(deployment: &Deployment) -> Result<()> { } println!("\nconnectivity"); - // External services are checked from the host. Managed services publish - // their ports on localhost, so they are checkable the same way once up. + // External services are checked from the host at their configured port. + // Managed services are probed via 127.0.0.1 at their published (offset) + // port — reachable there whether the mapping binds loopback or all + // interfaces. + let probe_port = |mode: ServiceMode, base: u16| match mode { + ServiceMode::Enabled => deployment.published(base), + _ => base, + }; check_tcp( &mut r, "bitcoind rpc", @@ -66,7 +72,7 @@ pub fn run(deployment: &Deployment) -> Result<()> { deployment.bitcoind.mode, deployment.bitcoind.host.as_deref(), ), - bitcoind_rpc_port(deployment), + probe_port(deployment.bitcoind.mode, bitcoind_rpc_port(deployment)), ); check_tcp( &mut r, @@ -75,7 +81,7 @@ pub fn run(deployment: &Deployment) -> Result<()> { deployment.stacks_node.mode, deployment.stacks_node.rpc_host.as_deref(), ), - node_rpc_port(deployment), + probe_port(deployment.stacks_node.mode, node_rpc_port(deployment)), ); check_tcp( &mut r, @@ -84,7 +90,7 @@ pub fn run(deployment: &Deployment) -> Result<()> { deployment.postgres.mode, deployment.postgres.host.as_deref(), ), - postgres_port(deployment), + probe_port(deployment.postgres.mode, postgres_port(deployment)), ); check_tcp( &mut r, @@ -93,7 +99,10 @@ pub fn run(deployment: &Deployment) -> Result<()> { deployment.stacks_api.mode, deployment.stacks_api.host.as_deref(), ), - deployment.stacks_api.port.unwrap_or(API_PORT), + probe_port( + deployment.stacks_api.mode, + deployment.stacks_api.port.unwrap_or(API_PORT), + ), ); println!("\nchain"); @@ -147,7 +156,12 @@ fn check_node_info(r: &mut Report, deployment: &Deployment) { }, ServiceMode::Disabled => return r.skip("stacks-node /v2/info: node is disabled"), }; - let url = format!("http://{host}:{}/v2/info", node_rpc_port(deployment)); + let port = match deployment.stacks_node.mode { + // published (offset) port when we run the node; configured as-is when external + ServiceMode::Enabled => deployment.published(node_rpc_port(deployment)), + _ => node_rpc_port(deployment), + }; + let url = format!("http://{host}:{port}/v2/info"); match ureq::get(&url).timeout(CONNECT_TIMEOUT).call() { Ok(resp) => match resp.into_json::() { Ok(info) => { diff --git a/src/config/mod.rs b/src/config/mod.rs index 33c4b28..1359616 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -39,6 +39,18 @@ pub enum NodeRole { /// The deployment configuration. This is the root of the configuration and contains all the /// services and their configurations. pub struct Deployment { + /// Deployment name: becomes the compose project name and the container + /// name prefix, so several deployments can share a machine. Lowercase + /// alphanumerics and dashes. The default keeps the historical single- + /// deployment naming (project `stacks`, containers `stacks-node`, ...). + #[serde(default = "default_deployment_name")] + pub name: String, + /// Added to every published HOST port (container-internal ports never + /// change), so a second deployment can run beside the first: + /// `port_offset = 100` publishes the node RPC on 20543, the API on 4099, + /// postgres on 5532, and so on. + #[serde(default)] + pub port_offset: u16, /// Network name: a built-in definition (mainnet, testnet) or a custom definition file resolved /// relative to stacks.toml. pub network: String, @@ -167,7 +179,33 @@ pub struct Postgres { pub password: Option, } +/// The default deployment name: compose project `stacks` and the historical +/// container names. Render-time naming branches on this same constant. +pub const DEFAULT_PROJECT: &str = "stacks"; + +fn default_deployment_name() -> String { + DEFAULT_PROJECT.into() +} + impl Deployment { + /// The docker compose project (embedded in the rendered compose file as + /// its top-level `name:`), and the container-name prefix. + pub fn project(&self) -> &str { + &self.name + } + + /// A service's host-published port: the base shifted by `port_offset`. + /// Validation bounds the offset, but guard the addition anyway so an + /// unvalidated path can never wrap around the u16 range silently. + pub fn published(&self, base: u16) -> u16 { + base.checked_add(self.port_offset).unwrap_or_else(|| { + panic!( + "port {base} + port_offset {} exceeds 65535 — lower port_offset in stacks.toml", + self.port_offset + ) + }) + } + /// Secret-bearing fields that must NOT be set in stacks.toml. fn config_secret_leaks(&self) -> Vec<(&'static str, &'static str)> { let mut leaks = Vec::new(); @@ -230,6 +268,33 @@ impl Deployment { pub fn validate(&self) -> (Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); + // The name becomes a compose project and container-name prefix; keep + // it to the safe intersection of docker's naming rules. + if self.name.is_empty() + || self.name.len() > 32 + || !self + .name + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + || !self + .name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + errors.push(format!( + "name `{}` is invalid: 1-32 lowercase letters, digits, or dashes, starting with a letter or digit", + self.name + )); + } + // Highest published base port is the node p2p (20444); keep the + // shifted ports inside the u16 range with room to spare. + if self.port_offset > 40000 { + errors.push(format!( + "port_offset {} is too large (max 40000)", + self.port_offset + )); + } // A node needs a burnchain source: its own/external bitcoind, or the // network's hosted default endpoint. if self.stacks_node.mode == ServiceMode::Enabled @@ -439,6 +504,11 @@ const DEFAULT_STACK_TOML: &str = r#"# stacksup config network = "testnet" # mainnet | testnet | custom network definition file +# To run several deployments on one machine, give each a distinct name +# (compose project + container prefix) and shift its published host ports: +# name = "testnet-b" +# port_offset = 100 + [bitcoind] # Only needed on mainnet. Testnet (krypton) follows the Hiro-hosted bitcoin # regtest. @@ -606,6 +676,19 @@ mod tests { assert!(w.iter().any(|m| m.contains("apply-to-your-node"))); } + #[test] + fn deployment_name_and_offset_are_validated() { + let e = errors("name = \"Bad_Name\"\nnetwork = \"testnet\""); + assert!(e.iter().any(|m| m.contains("name `Bad_Name` is invalid"))); + let e = errors("name = \"-leading\"\nnetwork = \"testnet\""); + assert!(e.iter().any(|m| m.contains("is invalid"))); + let e = errors("port_offset = 50000\nnetwork = \"testnet\""); + assert!(e.iter().any(|m| m.contains("port_offset"))); + assert!( + errors("name = \"testnet-b\"\nport_offset = 100\nnetwork = \"testnet\"").is_empty() + ); + } + #[test] fn secrets_in_stacks_toml_are_detected_as_leaks() { let d = deployment( diff --git a/src/config/render.rs b/src/config/render.rs index 9d0a5c0..21b4112 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -17,7 +17,7 @@ use crate::config::{Deployment, ServiceMode}; use crate::utils::secrets; use crate::utils::services::*; -pub const COMPOSE_PROJECT: &str = "stacks"; +pub use crate::config::DEFAULT_PROJECT; /// Docker networks, split so a compromised API-side container cannot reach /// the signer (or bitcoind) directly. Each service joins only the networks @@ -50,6 +50,10 @@ const API_PG_SCHEMA: &str = "stacks_blockchain_api"; #[derive(Serialize)] struct ComposeFile { + /// Compose project name, embedded so every `docker compose -f` invocation + /// (ours or the operator's) lands on this deployment's containers and + /// networks without a -p flag. + name: String, services: BTreeMap, networks: BTreeMap>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -88,13 +92,19 @@ struct ComposeService { } impl ComposeService { - fn new(name: &str, image: &str) -> Self { - // Namespace generic names (postgres, bitcoind) under the project - // prefix; stacks-* names are already unambiguous. - let container_name = if name.starts_with("stacks-") { - name.to_string() + fn new(deployment: &Deployment, name: &str, image: &str) -> Self { + let project = deployment.project(); + // The default project keeps the historical names (stacks-* bare, + // generic ones prefixed). A named deployment prefixes everything — + // container names are a docker-wide namespace. + let container_name = if project == DEFAULT_PROJECT { + if name.starts_with("stacks-") { + name.to_string() + } else { + format!("{project}-{name}") + } } else { - format!("{COMPOSE_PROJECT}-{name}") + format!("{project}-{name}") }; ComposeService { image: image.into(), @@ -115,6 +125,7 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { std::fs::create_dir_all(&dir)?; let mut compose = ComposeFile { + name: deployment.project().to_string(), services: BTreeMap::new(), networks: BTreeMap::new(), secrets: BTreeMap::new(), @@ -221,7 +232,7 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { fn bitcoind_service(deployment: &Deployment) -> ComposeService { let rpc = bitcoind_rpc_port(deployment); let p2p = bitcoind_p2p_port(deployment); - let mut svc = ComposeService::new("bitcoind", &bitcoind_image(deployment)); + let mut svc = ComposeService::new(deployment, "bitcoind", &bitcoind_image(deployment)); svc.command = vec![ "-server=1".into(), "-txindex=0".into(), @@ -252,18 +263,18 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { // node (running off-host) needs the wide binding. P2P stays open — it // exists to accept peers. let rpc_publish = if deployment.stacks_node.mode == ServiceMode::External { - format!("{rpc}:{rpc}") + format!("{}:{rpc}", deployment.published(rpc)) } else { - format!("127.0.0.1:{rpc}:{rpc}") + format!("127.0.0.1:{}:{rpc}", deployment.published(rpc)) }; - svc.ports = vec![rpc_publish, format!("{p2p}:{p2p}")]; + svc.ports = vec![rpc_publish, format!("{}:{p2p}", deployment.published(p2p))]; svc.volumes = vec!["../chainstate/bitcoind:/home/bitcoin/.bitcoin".into()]; svc.networks = vec![NET_BITCOIN.into()]; svc } fn node_service(deployment: &Deployment) -> ComposeService { - let mut svc = ComposeService::new("stacks-node", &stacks_node_image(deployment)); + let mut svc = ComposeService::new(deployment, "stacks-node", &stacks_node_image(deployment)); svc.command = vec![ "stacks-node".into(), "start".into(), @@ -271,8 +282,8 @@ fn node_service(deployment: &Deployment) -> ComposeService { "/etc/stacks/Config.toml".into(), ]; svc.ports = vec![ - format!("{NODE_RPC_PORT}:{NODE_RPC_PORT}"), - format!("{NODE_P2P_PORT}:{NODE_P2P_PORT}"), + format!("{}:{NODE_RPC_PORT}", deployment.published(NODE_RPC_PORT)), + format!("{}:{NODE_P2P_PORT}", deployment.published(NODE_P2P_PORT)), ]; svc.volumes = vec![ "./stacks-node/Config.toml:/etc/stacks/Config.toml:ro".into(), @@ -302,7 +313,11 @@ fn node_service(deployment: &Deployment) -> ComposeService { } fn signer_service(deployment: &Deployment) -> ComposeService { - let mut svc = ComposeService::new("stacks-signer", &stacks_signer_image(deployment)); + let mut svc = ComposeService::new( + deployment, + "stacks-signer", + &stacks_signer_image(deployment), + ); svc.command = vec![ "stacks-signer".into(), "run".into(), @@ -321,8 +336,8 @@ fn signer_service(deployment: &Deployment) -> ComposeService { } fn api_service(deployment: &Deployment) -> ComposeService { - let mut svc = ComposeService::new("stacks-api", &stacks_api_image(deployment)); - svc.ports = vec![format!("{API_PORT}:{API_PORT}")]; + let mut svc = ComposeService::new(deployment, "stacks-api", &stacks_api_image(deployment)); + svc.ports = vec![format!("{}:{API_PORT}", deployment.published(API_PORT))]; svc.env_file = vec!["./stacks-api/.env".into()]; if deployment.postgres.mode == ServiceMode::Enabled { svc.depends_on.push("postgres".into()); @@ -332,8 +347,15 @@ fn api_service(deployment: &Deployment) -> ComposeService { } fn mesh_api_service(deployment: &Deployment) -> ComposeService { - let mut svc = ComposeService::new("stacks-mesh-api", &stacks_mesh_api_image(deployment)); - svc.ports = vec![format!("{MESH_API_PORT}:{MESH_API_PORT}")]; + let mut svc = ComposeService::new( + deployment, + "stacks-mesh-api", + &stacks_mesh_api_image(deployment), + ); + svc.ports = vec![format!( + "{}:{MESH_API_PORT}", + deployment.published(MESH_API_PORT) + )]; svc.env_file = vec!["./stacks-mesh-api/.env".into()]; if deployment.stacks_node.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-node".into()); @@ -356,8 +378,11 @@ fn postgres_major(deployment: &Deployment) -> Option { } fn postgres_service(deployment: &Deployment) -> ComposeService { - let mut svc = ComposeService::new("postgres", &postgres_image(deployment)); - svc.ports = vec![format!("{POSTGRES_PORT}:{POSTGRES_PORT}")]; + let mut svc = ComposeService::new(deployment, "postgres", &postgres_image(deployment)); + svc.ports = vec![format!( + "{}:{POSTGRES_PORT}", + deployment.published(POSTGRES_PORT) + )]; svc.networks = vec![NET_SERVICES.into()]; svc.environment.insert( "POSTGRES_USER".into(), @@ -799,6 +824,44 @@ mod tests { assert!(!rpcauth.replace("$$", "").contains('$')); } + #[test] + fn named_deployment_prefixes_and_shifts_only_host_ports() { + let toml = "name = \"testnet-b\"\nport_offset = 100\nnetwork = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[stacks-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\""; + let s = deployment(toml); + // container names get the project prefix — a docker-wide namespace + assert_eq!(node_service(&s).container_name, "testnet-b-stacks-node"); + assert_eq!(postgres_service(&s).container_name, "testnet-b-postgres"); + // host side shifts, container side doesn't + assert_eq!(node_service(&s).ports, vec!["20543:20443", "20544:20444"]); + assert_eq!(api_service(&s).ports, vec!["4099:3999"]); + assert_eq!(postgres_service(&s).ports, vec!["5532:5432"]); + // internal wiring is offset-blind: the API still reaches postgres + // and the node at their in-network ports + let env = api_env(&s); + assert!(env.contains("PG_PORT=5432"), "got: {env}"); + assert!(!env.contains("5532"), "offset leaked into internal wiring"); + + // the default deployment keeps the historical naming and ports + let s = deployment(TESTNET_FULL); + assert_eq!(node_service(&s).container_name, "stacks-node"); + assert_eq!(postgres_service(&s).container_name, "stacks-postgres"); + assert_eq!(node_service(&s).ports[0], "20443:20443"); + } + + #[test] + fn compose_file_embeds_project_name() { + let dir = std::env::temp_dir().join(format!("stacksup-name-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = deployment(TESTNET_FULL); + s.name = "testnet-b".into(); + render(&s, &dir).unwrap(); + let compose: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(compose_file(&dir)).unwrap()).unwrap(); + assert_eq!(compose["name"].as_str(), Some("testnet-b")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn networks_isolate_signer_from_api_side() { // Full mainnet stack: three networks, least membership everywhere. diff --git a/src/logs/export.rs b/src/logs/export.rs index 6503aff..14cdb61 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -323,6 +323,27 @@ mod tests { assert_eq!(out, "connecting with now\n"); } + #[test] + fn redacts_both_rpcauth_spellings() { + // Bundles include node config (plain `$`) AND the compose file + // (compose-escaped `$$`) — the verifier must vanish from both. + let mut d = crate::config::test_deployment( + "network = \"mainnet\"\n[bitcoind]\nmode = \"enabled\"\n[stacks-node]\nmode = \"enabled\"", + ); + d.bitcoind.rpc_user = Some("rafa1234".into()); + d.bitcoind.rpc_password = Some("btc-pass-1234".into()); + let secrets = secret_values(&d); + let rpcauth = crate::utils::secrets::bitcoind_rpcauth("rafa1234", "btc-pass-1234"); + let text = format!( + "config: -rpcauth={rpcauth}\ncompose: -rpcauth={}\n", + rpcauth.replace('$', "$$") + ); + let out = redact(&text, &secrets); + let digest = rpcauth.split('$').next_back().unwrap(); + assert!(!out.contains(digest), "digest survived: {out}"); + assert!(!out.contains("rafa1234"), "username survived: {out}"); + } + #[test] fn copies_tree_with_redaction() { let src = std::env::temp_dir().join(format!("stacksup-export-src-{}", std::process::id())); diff --git a/src/utils/docker.rs b/src/utils/docker.rs index 2415cff..068abf7 100644 --- a/src/utils/docker.rs +++ b/src/utils/docker.rs @@ -8,14 +8,16 @@ use std::process::Command; use anyhow::{Context, Result, bail}; use colored::Colorize; -use crate::config::render::{COMPOSE_PROJECT, compose_file}; +use crate::config::render::compose_file; use crate::config::{Deployment, ServiceMode}; use crate::utils::services::roster; fn compose(data_dir: &Path) -> Command { let mut cmd = Command::new("docker"); - cmd.args(["compose", "-p", COMPOSE_PROJECT, "-f"]) - .arg(compose_file(data_dir)); + // No -p: the rendered compose file carries its project via `name:`, + // so every invocation (ours or a bare `docker compose -f ...`) is scoped + // to this deployment. + cmd.args(["compose", "-f"]).arg(compose_file(data_dir)); cmd } @@ -359,9 +361,9 @@ mod tests { .get_args() .map(|a| a.to_string_lossy().into_owned()) .collect(); - assert_eq!(args[..4], ["compose", "-p", COMPOSE_PROJECT, "-f"]); - assert!(args[4].ends_with("rendered/docker-compose.yml")); - assert!(args[4].starts_with("/data")); + assert_eq!(args[..2], ["compose", "-f"]); + assert!(args[2].ends_with("rendered/docker-compose.yml")); + assert!(args[2].starts_with("/data")); } #[test] From 235e41cbb850ddb3d796f2a33b5115e5674bce0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:33:48 -0600 Subject: [PATCH 13/19] feat: start preflight guards and external-node event-port warnings (#25) * feat: start preflight guards (port bind test, project-name collision) + external-node event-port warnings * review: dual-stack port check, typed compose ls parse, underflow-proof test --- README.md | 6 ++ src/config/mod.rs | 27 +++++++- src/utils/docker.rs | 144 ++++++++++++++++++++++++++++++++++++++++++ src/utils/services.rs | 31 +++++++++ 4 files changed, 206 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 29590ea..31fab6b 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,12 @@ name, so `stacksup` commands (and bare `docker compose -f` runs) are always scoped to the deployment whose directory you're in — `stop`, `logs`, and `chainstate wipe` can't touch a neighbour. +`stacksup start` refuses to run before doing damage when it detects a +collision: it test-binds every port it is about to publish (pointing at +`port_offset` when one is taken) and rejects a `name` already in use by a +stack rendered from a different directory (which compose would otherwise +silently adopt). + ## Secrets Credentials never live in `stacks.toml` — the tool rejects them there. They go diff --git a/src/config/mod.rs b/src/config/mod.rs index 1359616..2e0f74e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -399,7 +399,10 @@ impl Deployment { ServiceMode::External => { warnings.push( "signer is managed but the node is external: apply `rendered/apply-to-your-node.toml` \ - to your node (stacker = true, matching auth_password, signer events_observer)" + to your node (stacker = true, matching auth_password, signer events_observer). \ + Note: the signer's event endpoint (port 30000) is not published on this host — \ + an off-host node cannot push to it; run the node on this machine or expose the \ + port yourself (compose override)" .into(), ); } @@ -414,7 +417,9 @@ impl Deployment { { warnings.push( "stacks-api is managed but the node is external: add the [[events_observer]] block \ - from `rendered/apply-to-your-node.toml` to your node config, then verify with `stacksup config check`" + from `rendered/apply-to-your-node.toml` to your node config, then verify with `stacksup config check`. \ + Note: the API's event port (3700) is not published on this host — an off-host node \ + cannot push to it; run the node on this machine or expose the port yourself (compose override)" .into(), ); } @@ -676,6 +681,24 @@ mod tests { assert!(w.iter().any(|m| m.contains("apply-to-your-node"))); } + #[test] + fn external_node_with_managed_receivers_warns_about_unpublished_ports() { + let w = warnings( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"", + ); + assert!( + w.iter().any(|m| m.contains("event port (3700)")), + "got: {w:?}" + ); + let w = warnings( + "network = \"testnet\"\n[stacks-node]\nmode = \"external\"\nrpc_host = \"h\"\n[stacks-signer]\nmode = \"enabled\"", + ); + assert!( + w.iter().any(|m| m.contains("event endpoint (port 30000)")), + "got: {w:?}" + ); + } + #[test] fn deployment_name_and_offset_are_validated() { let e = errors("name = \"Bad_Name\"\nnetwork = \"testnet\""); diff --git a/src/utils/docker.rs b/src/utils/docker.rs index 068abf7..727d204 100644 --- a/src/utils/docker.rs +++ b/src/utils/docker.rs @@ -60,6 +60,101 @@ fn ensure_docker() -> Result<()> { Ok(()) } +/// Refuse to start when this deployment's name is already in use by a compose +/// project rendered from a DIFFERENT directory — otherwise compose silently +/// adopts (and replaces) the other deployment's containers. +fn guard_project_collision(deployment: &Deployment, data_dir: &Path) -> Result<()> { + let out = Command::new("docker") + .args(["compose", "ls", "--all", "--format", "json"]) + .output(); + let Ok(out) = out else { return Ok(()) }; // best effort + let listing = String::from_utf8_lossy(&out.stdout); + if let Some(other) = project_conflict(&listing, deployment.project(), &compose_file(data_dir)) { + bail!( + "deployment name `{}` is already in use by a stack rendered from {other} — set a \ + distinct `name` in stacks.toml (compose would otherwise adopt that stack's containers)", + deployment.project() + ); + } + Ok(()) +} + +/// One row of `docker compose ls --format json`. +#[derive(serde::Deserialize)] +struct ComposeLsEntry { + #[serde(rename = "Name")] + name: String, + /// Comma-separated compose file paths. + #[serde(rename = "ConfigFiles", default)] + config_files: String, +} + +/// Pure half of the collision check: does `ls_json` (docker compose ls +/// output) contain `project` with a config file other than ours? +fn project_conflict(ls_json: &str, project: &str, our_compose_file: &Path) -> Option { + let ours = our_compose_file + .canonicalize() + .unwrap_or_else(|_| our_compose_file.to_path_buf()); + let entries: Vec = serde_json::from_str(ls_json).ok()?; + for e in entries { + if e.name != project { + continue; + } + // Any path matching ours means it's us. + let is_ours = e.config_files.split(',').map(str::trim).any(|f| { + Path::new(f) + .canonicalize() + .map(|p| p == ours) + .unwrap_or(f == ours.to_string_lossy()) + }); + if !is_ours && !e.config_files.is_empty() { + return Some(e.config_files); + } + } + None +} + +/// A port conflicts only when a bind fails with AddrInUse on SOME family — +/// an unsupported family (e.g. no IPv6) or a permission error is not a +/// conflict. Mirrors how docker publishes on both stacks. +fn port_taken(port: u16) -> bool { + ["0.0.0.0", "::"] + .iter() + .any(|ip| match std::net::TcpListener::bind((*ip, port)) { + Ok(_) => false, + Err(e) => e.kind() == std::io::ErrorKind::AddrInUse, + }) +} + +/// Test-bind every host port the deployment is about to publish, skipping +/// services that are already running (their ports are legitimately ours). +/// Catches port squatting BEFORE compose creates half a stack. +fn guard_published_ports( + deployment: &Deployment, + data_dir: &Path, + only_service: Option<&str>, +) -> Result<()> { + let running = running_services(data_dir).unwrap_or_default(); + for (service, port) in crate::utils::services::published_ports(deployment) { + if running.iter().any(|r| r == service) { + continue; + } + if let Some(only) = only_service + && only != service + { + continue; + } + if port_taken(port) { + bail!( + "host port {port} (published by {service}) is already in use — another deployment \ + or process owns it; set or raise `port_offset` in stacks.toml, or stop \ + whatever holds the port" + ); + } + } + Ok(()) +} + /// Names of this stack's currently running compose services. Best effort: /// `None` when docker or the rendered compose file is unavailable. pub fn running_services(data_dir: &Path) -> Option> { @@ -165,6 +260,9 @@ pub fn start(deployment: &Deployment, data_dir: &Path, service: Option<&str>) -> ); } + guard_project_collision(deployment, data_dir)?; + guard_published_ports(deployment, data_dir, service)?; + if let Some(name) = service { ensure_enabled(deployment, name)?; println!("Starting {name} (and its dependencies)..."); @@ -366,6 +464,52 @@ mod tests { assert!(args[2].starts_with("/data")); } + #[test] + fn project_conflict_flags_other_directories_only() { + let ours = Path::new("/data/rendered/docker-compose.yml"); + let ls = r#"[ + {"Name": "stacks", "Status": "running(2)", "ConfigFiles": "/other/rendered/docker-compose.yml"}, + {"Name": "testnet-b", "Status": "running(1)", "ConfigFiles": "/data/rendered/docker-compose.yml"} + ]"#; + // same name, different directory -> conflict + assert_eq!( + project_conflict(ls, "stacks", ours).as_deref(), + Some("/other/rendered/docker-compose.yml") + ); + // same name, same file -> that's us, no conflict + assert!(project_conflict(ls, "testnet-b", ours).is_none()); + // name not present -> no conflict + assert!(project_conflict(ls, "mainnet-c", ours).is_none()); + // garbage json -> best effort, no conflict + assert!(project_conflict("not json", "stacks", ours).is_none()); + } + + #[test] + fn guard_published_ports_reports_taken_port() { + // hold a port, then ask the guard about a deployment that publishes + // it. Ephemeral ports are virtually always >= 32768, but re-roll to + // guarantee `taken - POSTGRES_PORT` can't underflow anywhere. + let (listener, taken) = loop { + let l = std::net::TcpListener::bind(("0.0.0.0", 0)).unwrap(); + let p = l.local_addr().unwrap().port(); + if p >= crate::utils::services::POSTGRES_PORT { + break (l, p); + } + }; + let mut d = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); + // shift postgres (5432) onto the taken port + d.port_offset = taken - crate::utils::services::POSTGRES_PORT; + // data_dir without a compose file -> no services considered running + let err = guard_published_ports(&d, Path::new("/nonexistent"), None) + .unwrap_err() + .to_string(); + assert!(err.contains(&format!("host port {taken}")), "got: {err}"); + assert!(err.contains("port_offset")); + // a free port passes + drop(listener); + assert!(guard_published_ports(&d, Path::new("/nonexistent"), None).is_ok()); + } + #[test] fn ensure_enabled_accepts_enabled_services() { let s = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); diff --git a/src/utils/services.rs b/src/utils/services.rs index a83db88..8abe82f 100644 --- a/src/utils/services.rs +++ b/src/utils/services.rs @@ -142,6 +142,37 @@ pub fn roster(deployment: &Deployment) -> Vec<(&'static str, ServiceMode)> { ] } +/// Every (service, host port) this deployment publishes when started — +/// the base ports shifted by `port_offset`. Used by the start preflight to +/// test-bind before compose does. +pub fn published_ports(deployment: &Deployment) -> Vec<(&'static str, u16)> { + let mut ports = Vec::new(); + if deployment.bitcoind.mode == ServiceMode::Enabled { + ports.push(( + "bitcoind", + deployment.published(bitcoind_rpc_port(deployment)), + )); + ports.push(( + "bitcoind", + deployment.published(bitcoind_p2p_port(deployment)), + )); + } + if deployment.stacks_node.mode == ServiceMode::Enabled { + ports.push(("stacks-node", deployment.published(NODE_RPC_PORT))); + ports.push(("stacks-node", deployment.published(NODE_P2P_PORT))); + } + if deployment.stacks_api.mode == ServiceMode::Enabled { + ports.push(("stacks-api", deployment.published(API_PORT))); + } + if deployment.stacks_mesh_api.mode == ServiceMode::Enabled { + ports.push(("stacks-mesh-api", deployment.published(MESH_API_PORT))); + } + if deployment.postgres.mode == ServiceMode::Enabled { + ports.push(("postgres", deployment.published(POSTGRES_PORT))); + } + ports +} + #[cfg(test)] mod tests { use super::*; From 34be5ccfed20d535daa993447e1bd52cb1da092b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:14:47 -0600 Subject: [PATCH 14/19] fix: render secret-bearing service configs with 0600 permissions (#28) * fix: render secret-bearing service configs with 0600 permissions * dont render env in tests --- src/config/render.rs | 53 ++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/config/render.rs b/src/config/render.rs index 21b4112..b0fa547 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -147,9 +147,10 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { if deployment.stacks_node.mode == ServiceMode::Enabled { chainstate_subdir("stacks-node")?; std::fs::create_dir_all(dir.join("stacks-node"))?; - std::fs::write( - dir.join("stacks-node/Config.toml"), - format!("{GENERATED_HEADER}{}", node_config_toml(deployment)), + // Carries the bitcoind rpc password and node auth token -> 0600. + secrets::write_0600( + &dir.join("stacks-node/Config.toml"), + &format!("{GENERATED_HEADER}{}", node_config_toml(deployment)), )?; compose .services @@ -159,9 +160,10 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { if deployment.stacks_signer.mode == ServiceMode::Enabled { chainstate_subdir("stacks-signer")?; std::fs::create_dir_all(dir.join("stacks-signer"))?; - std::fs::write( - dir.join("stacks-signer/signer.toml"), - format!("{GENERATED_HEADER}{}", signer_config_toml(deployment)), + // Carries the node auth token (auth_password) -> 0600. + secrets::write_0600( + &dir.join("stacks-signer/signer.toml"), + &format!("{GENERATED_HEADER}{}", signer_config_toml(deployment)), )?; compose .services @@ -170,9 +172,10 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { if deployment.stacks_api.mode == ServiceMode::Enabled { std::fs::create_dir_all(dir.join("stacks-api"))?; - std::fs::write( - dir.join("stacks-api/.env"), - format!("{GENERATED_HEADER}{}", api_env(deployment)), + // Carries PG_PASSWORD and the node auth token -> 0600. + secrets::write_0600( + &dir.join("stacks-api/.env"), + &format!("{GENERATED_HEADER}{}", api_env(deployment)), )?; compose .services @@ -181,9 +184,10 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { if deployment.stacks_mesh_api.mode == ServiceMode::Enabled { std::fs::create_dir_all(dir.join("stacks-mesh-api"))?; - std::fs::write( - dir.join("stacks-mesh-api/.env"), - format!("{GENERATED_HEADER}{}", mesh_api_env(deployment)), + // Carries the node auth token -> 0600. + secrets::write_0600( + &dir.join("stacks-mesh-api/.env"), + &format!("{GENERATED_HEADER}{}", mesh_api_env(deployment)), )?; compose .services @@ -223,7 +227,8 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { if deployment.stacks_node.mode == ServiceMode::External && let Some(snippet) = apply_to_your_node(deployment) { - std::fs::write(dir.join("apply-to-your-node.toml"), snippet)?; + // Carries the node auth token -> 0600. + secrets::write_0600(&dir.join("apply-to-your-node.toml"), &snippet)?; } Ok(dir) @@ -838,7 +843,7 @@ mod tests { // internal wiring is offset-blind: the API still reaches postgres // and the node at their in-network ports let env = api_env(&s); - assert!(env.contains("PG_PORT=5432"), "got: {env}"); + assert!(env.contains("PG_PORT=5432")); assert!(!env.contains("5532"), "offset leaked into internal wiring"); // the default deployment keeps the historical naming and ports @@ -848,6 +853,26 @@ mod tests { assert_eq!(node_service(&s).ports[0], "20443:20443"); } + #[test] + fn secret_bearing_rendered_files_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("stacksup-perm-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + render(&deployment(TESTNET_FULL), &dir).unwrap(); + for f in [ + "rendered/stacks-node/Config.toml", + "rendered/stacks-signer/signer.toml", + "rendered/stacks-api/.env", + "rendered/stacks-mesh-api/.env", + "rendered/secrets/pg_password", + ] { + let mode = std::fs::metadata(dir.join(f)).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{f} should be 0600"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn compose_file_embeds_project_name() { let dir = std::env::temp_dir().join(format!("stacksup-name-test-{}", std::process::id())); From bf96bd192a70f7e03bcda7f18f247678059e1ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:23:02 -0600 Subject: [PATCH 15/19] chore: rework console comments (#29) --- src/chainstate/download.rs | 115 ++++++++++++++++----------------- src/chainstate/mod.rs | 45 ++++++------- src/config/check.rs | 25 +++----- src/config/mod.rs | 104 +++++++++++++++--------------- src/config/network.rs | 26 ++++---- src/config/render.rs | 127 +++++++++++++++++-------------------- src/logs/export.rs | 17 +++-- src/logs/mod.rs | 2 +- src/main.rs | 22 +++---- src/upgrade.rs | 47 +++++++------- src/utils/docker.rs | 64 +++++++++---------- src/utils/mod.rs | 2 +- src/utils/secrets.rs | 72 ++++++++++----------- src/utils/services.rs | 8 +-- src/utils/versions.rs | 23 ++++--- 15 files changed, 323 insertions(+), 376 deletions(-) diff --git a/src/chainstate/download.rs b/src/chainstate/download.rs index 8262d05..78b4a03 100644 --- a/src/chainstate/download.rs +++ b/src/chainstate/download.rs @@ -1,10 +1,8 @@ -//! `stacksup chainstate download` — seed chainstate from the Hiro Archive. +//! `stacksup chainstate download`. Seed chainstate from the Hiro Archive. //! -//! Archives are huge (10s–100s of GB), so the pipeline is two-phase by -//! design: download to `/downloads/.partial` (resumable via -//! HTTP Range; re-running the command continues where it left off), verify -//! sha256, and only then restore — never extract an unverified archive over -//! existing chainstate. +//! Archives are huge (10s–100s of GB), so the pipeline is two-phase by design: download to +//! `/downloads/.partial` (resumable via HTTP Range; re-running the command +//! continues where it left off), verify sha256, and only then restore. use std::fs::{self, File, OpenOptions}; use std::io::{self, BufReader, BufWriter, Read, Write}; @@ -36,8 +34,8 @@ pub struct Opts { pub no_verify: bool, pub skip_version_check: bool, pub keep_archives: bool, - /// The caller will start the deployment after a successful restore - /// (only changes the final hint; starting is the caller's job). + /// The caller will start the deployment after a successful restore (only changes the final + /// hint; starting is the caller's job). pub start: bool, } @@ -59,14 +57,13 @@ struct Job { image: String, } -/// Returns `true` when archives were actually restored — `false` on -/// `--check-only` or a declined confirmation, so callers gating follow-up -/// work (like `--start`) don't act on a no-op run. +/// Returns `true` when archives were actually restored, `false` on `--check-only` or a declined +/// confirmation, so callers gating follow-up work (like `--start`) don't act on a no-op run. pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result { let network = deployment.net.hiro_archive_path.clone().with_context(|| { format!( - "network `{}` has no published archives (no hiro_archive_path in its definition) — \ - use --archive to point at a file explicitly", + "network `{}` has no published archives (no hiro_archive_path in its definition). \ + Use --archive to point at a file explicitly.", deployment.network ) })?; @@ -76,7 +73,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result && !running.is_empty() { bail!( - "the stack is running ({}) — run `stacksup stop` first", + "the stack is running ({}). Run `stacksup stop` first", running.join(", ") ); } @@ -153,7 +150,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result println!( " {}", format!( - "✗ archive {a} is NEWER than configured {c} — chainstate from newer \ + "✗ archive {a} is NEWER than configured {c}. Chainstate from newer \ software cannot be used by older software.\n Fix: raise [{}] version \ in stacks.toml, or pick an older archive with --archive.", section_name(job.kind) @@ -176,7 +173,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result if opts.no_verify { println!( "\n{}", - "⚠ sha256 verification disabled — a corrupted download may fail at restore or, \ + "⚠ sha256 verification disabled. A corrupted download may fail at restore or, \ worse, restore silently." .yellow() ); @@ -220,7 +217,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result } println!( "{}", - "\nTip: safe to Ctrl-C and re-run later — downloads resume where they left off.\n\ + "\nTip: safe to Ctrl-C and re-run later. Downloads resume where they left off.\n\ To run unattended: nohup stacksup chainstate download --yes > download.log 2>&1 &\n" .dimmed() ); @@ -254,7 +251,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result fs::rename(&job.file, &quarantine)?; bail!( "sha256 mismatch for {name}\n expected {expected}\n actual {actual}\n\ - moved to {} — re-run to download again", + moved to {}. Re-run to download again", quarantine.display() ); } @@ -262,7 +259,7 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result } None => println!( " {}", - "⚠ no .sha256 available for this archive — skipping verification".yellow() + "⚠ no .sha256 available for this archive. Skipping verification".yellow() ), } } @@ -293,10 +290,9 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result // --------------------------------------------------------------------------- // Job construction -/// Resolve the newest *versioned* archive from the bucket listing. We never -/// use the `-latest` objects: they are byte-identical pointers to the newest -/// dated archive but carry no version in their name, which would defeat the -/// archive-version ≤ configured-version check. +/// Resolve the newest *versioned* archive from the bucket listing. We never use the `-latest` +/// objects: they are byte-identical pointers to the newest dated archive but carry no version in +/// their name, which would defeat the archive-version ≤ configured-version check. fn latest_node_job(network: &str, deployment: &Deployment, downloads: &Path) -> Result { let base = format!("{ARCHIVE_BASE}/{network}/stacks-blockchain"); let name = newest_in_listing( @@ -336,8 +332,8 @@ fn versioned_job( }) } -/// Fetch the archive directory's HTML bucket listing and return the newest -/// dated archive matching `prefix` + one of `extensions` (excluding -latest). +/// Fetch the archive directory's HTML bucket listing and return the newest dated archive matching +/// `prefix` + one of `extensions` (excluding -latest). fn newest_in_listing(base: &str, prefix: &str, extensions: &[&str]) -> Result { let listing_url = format!("{base}/"); let html = ureq::get(&listing_url) @@ -367,8 +363,8 @@ fn newest_in_listing(base: &str, prefix: &str, extensions: &[&str]) -> Result VersionVerdict { let tag = image_tag(&job.image); - // Only inspected when the tag alone can't settle it (floating tags like - // `9` or `latest`): the pulled image's OCI version label carries the - // concrete version (e.g. tag `9` -> label `9.0.2`). + // Only inspected when the tag alone can't settle it (floating tags like `9` or `latest`): the + // pulled image's OCI version label carries the concrete version (e.g. tag `9` -> label + // `9.0.2`). classify_version(job.archive_version.as_deref(), &tag, || { pulled_image_version(&job.image) }) @@ -552,9 +547,9 @@ fn classify_version( if compare_versions(archive, &conf) != std::cmp::Ordering::Greater { return VersionVerdict::Ok(a, version_string(&conf)); } - // Archive "newer" than the tag — but a partial tag like `9` is a - // floating tag that may currently BE 9.0.2. If the tag is a - // prefix of the archive version, ask the pulled image. + // Archive "newer" than the tag, but a partial tag like `9` is a floating tag that may + // currently BE 9.0.2. If the tag is a prefix of the archive version, ask the pulled + // image. if archive.starts_with(&conf) { match pulled() { Some(p) if compare_versions(archive, &p) != std::cmp::Ordering::Greater => { @@ -566,13 +561,13 @@ fn classify_version( Some(p) => VersionVerdict::TooNew( a, format!( - "{} (pulled image for tag `{tag}` — `docker pull` a newer one)", + "{} (pulled image for tag `{tag}`. `docker pull` a newer one)", version_string(&p) ), ), None => VersionVerdict::Unknown(format!( "tag `{tag}` is a floating tag and the archive is {a}; could not read the \ - pulled image's version label (image not pulled?) — `docker pull` it and re-run" + pulled image's version label (image not pulled?). `docker pull` it and re-run" )), } } else { @@ -589,12 +584,12 @@ fn classify_version( Some(p) => VersionVerdict::TooNew( a, format!( - "{} (pulled image for tag `{tag}` — `docker pull` a newer one)", + "{} (pulled image for tag `{tag}`. `docker pull` a newer one)", version_string(&p) ), ), None => VersionVerdict::Unknown(format!( - "configured tag is `{tag}` and no pulled image to inspect — pin a version in \ + "configured tag is `{tag}` and no pulled image to inspect. Pin a version in \ stacks.toml or `docker pull` the image to make this check meaningful" )), }, @@ -604,8 +599,7 @@ fn classify_version( /// Extract the service version from a versioned archive filename. /// node: `mainnet-stacks-blockchain-3.1.0.0.8-20260803.tar.gz` /// api: `stacks-blockchain-api-pg-17-8.1.0-20260803.dump` -/// The version is the dotted numeric segment (dates have no dots, the pg -/// major has no dots). +/// The version is the dotted numeric segment (dates have no dots, the pg major has no dots). fn parse_version_from_name(name: &str, _kind: Kind) -> Option> { name.split(['-', '_']) .filter(|seg| seg.contains('.')) @@ -639,9 +633,9 @@ fn parse_pg_major_from_name(name: &str) -> Option> { // --------------------------------------------------------------------------- // Download engine -/// Follow redirects manually and return the final URL. archive.hiro.so 302s -/// to short-lived presigned R2 URLs, and resume only works if the Range -/// header is sent to the final URL — so every request path resolves first. +/// Follow redirects manually and return the final URL. archive.hiro.so 302s to short-lived +/// presigned R2 URLs, and resume only works if the Range header is sent to the final URL, so every +/// request path resolves first. fn resolve_redirects(url: &str) -> Result { let agent = ureq::AgentBuilder::new().redirects(0).build(); let mut current = url.to_string(); @@ -667,8 +661,8 @@ fn resolve_redirects(url: &str) -> Result { bail!("too many redirects for {url}"); } -/// Size probe via a 1-byte Range GET (HEAD doesn't survive the presigned -/// redirect). 206 -> total from Content-Range; 200 -> Content-Length. +/// Size probe via a 1-byte Range GET (HEAD doesn't survive the presigned redirect). 206 -> total +/// from Content-Range; 200 -> Content-Length. fn head_content_length(url: &str) -> Option> { let resolved = resolve_redirects(url).ok()?; let resp = ureq::get(&resolved).set("Range", "bytes=0-0").call().ok()?; @@ -686,9 +680,8 @@ fn head_content_length(url: &str) -> Option> { } } -/// Resumable download: writes `.partial` + `.etag`, continues via -/// HTTP Range, restarts cleanly if the server object changed or Range is -/// unsupported, renames into place when complete. +/// Resumable download: writes `.partial` + `.etag`, continues via HTTP Range, restarts +/// cleanly if the server object changed or Range is unsupported, renames into place when complete. fn download_resumable(url: &str, dest: &Path, expected_size: Option) -> Result<()> { let partial = dest.with_extension(format!( "{}.partial", @@ -708,8 +701,8 @@ fn download_resumable(url: &str, dest: &Path, expected_size: Option) -> Res .call() .with_context(|| format!("request failed: {url}"))?; - // Stale-partial guard: if the server's object changed since we started, - // the old bytes belong to a different archive. + // Stale-partial guard: if the server's object changed since we started, the old bytes belong to + // a different archive. let server_etag = resp .header("ETag") .or_else(|| resp.header("Last-Modified")) @@ -934,8 +927,8 @@ fn restore_api( data_dir: &Path, downloads_dir: &Path, ) -> Result<()> { - // pg_restore --jobs needs a seekable file inside the container; the - // postgres service mounts /downloads at /downloads (read-only). + // pg_restore --jobs needs a seekable file inside the container; the postgres service mounts + // /downloads at /downloads (read-only). let dump = if dump.starts_with(downloads_dir) { dump.to_path_buf() } else { @@ -1088,8 +1081,8 @@ fn format_bytes(b: u64) -> String { format!("{v:.1} {}", UNITS[unit]) } -/// Free bytes on the filesystem containing `dir`, via `df -Pk` (portable -/// across macOS/Linux; no direct std API for statvfs). +/// Free bytes on the filesystem containing `dir`, via `df -Pk` (portable across macOS/Linux; no +/// direct std API for statvfs). fn free_disk_bytes(dir: &Path) -> Option { let out = Command::new("df").arg("-Pk").arg(dir).output().ok()?; if !out.status.success() { diff --git a/src/chainstate/mod.rs b/src/chainstate/mod.rs index c445e04..6b22b6f 100644 --- a/src/chainstate/mod.rs +++ b/src/chainstate/mod.rs @@ -1,4 +1,4 @@ -//! `stacksup chainstate` — operations on the stack's on-disk state. +//! `stacksup chainstate`. Operations on the stack's on-disk state. //! More subcommands (snapshot, restore, ...) will land here. pub mod download; @@ -21,12 +21,11 @@ struct Tip { note: Option, } -/// `stacksup chainstate status` — compare every enabled service's chain tip. +/// `stacksup chainstate status`. Compare every enabled service's chain tip. /// -/// Works whether the stack is running or stopped, with different coverage: -/// the node's tips are read straight from its sqlite files (safe read-only -/// even while the node writes), while the API's tip lives in Postgres and -/// bitcoind's in LevelDB — those two are only checkable while running. +/// Works whether the stack is running or stopped, with different coverage: the node's tips are read +/// straight from its sqlite files (safe read-only even while the node writes), while the API's tip +/// lives in Postgres and bitcoind's in LevelDB — those two are only checkable while running. pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { let running = crate::utils::docker::running_services(data_dir).unwrap_or_default(); let mut tips: Vec = Vec::new(); @@ -66,8 +65,8 @@ pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { } } - // Verdict: what matters is the stacks height of the node vs the API, - // and crucially WHICH ONE is ahead — the failure modes are asymmetric. + // Verdict: what matters is the stacks height of the node vs the API, and crucially WHICH ONE is + // ahead, the failure modes are asymmetric. let node_stacks = tips .iter() .find(|t| t.service == "stacks-node") @@ -115,14 +114,13 @@ pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { Ok(()) } -/// The node's canonical view, read from its on-disk sqlite DBs (read-only -/// opens are safe while the node runs). +/// The node's canonical view, read from its on-disk sqlite DBs (read-only opens are safe while the +/// node runs). /// -/// Bitcoin height: latest pox-valid snapshot in the sortition DB. Stacks -/// height: MAX over the chainstate headers DB (nakamoto_block_headers + -/// epoch2 block_headers) — NOT the sortition snapshot's -/// canonical_stacks_tip_height, which only advances per *burn* block and so -/// lags by up to a tenure's worth of stacks blocks post-Nakamoto. +/// Bitcoin height: latest pox-valid snapshot in the sortition DB. Stacks height: MAX over the +/// chainstate headers DB (nakamoto_block_headers + epoch2 block_headers), NOT the sortition +/// snapshot's canonical_stacks_tip_height, which only advances per *burn* block and so lags by up +/// to a tenure's worth of stacks blocks post-Nakamoto. fn node_tip(deployment: &Deployment, data_dir: &Path) -> Tip { let mode = deployment.net.node.burnchain_mode.as_str(); let node_dir = data_dir.join("chainstate/stacks-node").join(mode); @@ -180,8 +178,8 @@ fn node_tip(deployment: &Deployment, data_dir: &Path) -> Tip { tip } -/// bitcoind's own height via bitcoin-cli inside the running container. -/// Offline its state is LevelDB, which we don't parse. +/// bitcoind's own height via bitcoin-cli inside the running container. Offline its state is +/// LevelDB, which we don't parse. fn bitcoind_tip(deployment: &Deployment, running: bool) -> Tip { let mut tip = Tip { service: "bitcoind", @@ -196,8 +194,8 @@ fn bitcoind_tip(deployment: &Deployment, running: bool) -> Tip { return tip; } let chain = deployment.net.bitcoind.chain.as_str(); - // bitcoin-cli inside the container authenticates via the datadir cookie - // file — no credentials needed (and none appear in process args). + // bitcoin-cli inside the container authenticates via the datadir cookie file, no credentials + // needed (and none appear in process args). let out = Command::new("docker") .args([ "exec", @@ -222,8 +220,8 @@ fn bitcoind_tip(deployment: &Deployment, running: bool) -> Tip { tip } -/// The API's indexed tip from its Postgres chain_tip table (single-row table -/// with block_height and burn_block_height). Needs the postgres server up. +/// The API's indexed tip from its Postgres chain_tip table (single-row table with block_height and +/// burn_block_height). Needs the postgres server up. fn api_tip(deployment: &Deployment, postgres_running: bool) -> Tip { let mut tip = Tip { service: "stacks-api", @@ -275,9 +273,8 @@ fn api_tip(deployment: &Deployment, postgres_running: bool) -> Tip { tip } -/// Services that keep on-disk state under chainstate/, with the running -/// services that would be corrupted by wiping it out from under them -/// (stacks-api writes into postgres's data). +/// Services that keep on-disk state under chainstate/, with the running services that would be +/// corrupted by wiping it out from under them (stacks-api writes into postgres's data). const CHAINSTATE_SERVICES: &[(&str, &[&str])] = &[ ("bitcoind", &["bitcoind"]), ("stacks-node", &["stacks-node"]), diff --git a/src/config/check.rs b/src/config/check.rs index f417cba..683ad1e 100644 --- a/src/config/check.rs +++ b/src/config/check.rs @@ -1,8 +1,8 @@ -//! `stacksup config check` — answers "why isn't my stack working?". +//! `stacksup config check` answers "why isn't my stack working?". //! -//! Config coherence is already enforced at load time (config::validate); this -//! module checks the *live* side: can every consumer actually reach its -//! producers, and are they on the chain we think they're on? +//! Config coherence is already enforced at load time (config::validate); this module checks the +//! *live* side: can every consumer actually reach its producers, and are they on the chain we think +//! they're on? use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; @@ -57,10 +57,9 @@ pub fn run(deployment: &Deployment) -> Result<()> { } println!("\nconnectivity"); - // External services are checked from the host at their configured port. - // Managed services are probed via 127.0.0.1 at their published (offset) - // port — reachable there whether the mapping binds loopback or all - // interfaces. + // External services are checked from the host at their configured port. Managed services are + // probed via 127.0.0.1 at their published (offset) port, reachable there whether the mapping + // binds loopback or all interfaces. let probe_port = |mode: ServiceMode, base: u16| match mode { ServiceMode::Enabled => deployment.published(base), _ => base, @@ -107,10 +106,6 @@ pub fn run(deployment: &Deployment) -> Result<()> { println!("\nchain"); check_node_info(&mut r, deployment); - // TODO(hackathon): the checks that catch the silent failure modes — - // - bitcoind getblockchaininfo: chain matches stacks.toml network - // - API /extended chain tip vs node /v2/info tip (event stream actually flowing) - // - node /v3/health difference_from_max_peer (sync lag vs peers) if r.failures > 0 { println!(); @@ -120,8 +115,8 @@ pub fn run(deployment: &Deployment) -> Result<()> { Ok(()) } -/// Where to reach a service from the host: its configured host when external, -/// localhost when enabled (published ports), None when disabled. +/// Where to reach a service from the host: its configured host when external, localhost when +/// enabled (published ports), None when disabled. fn external_or_local(mode: ServiceMode, host: Option<&str>) -> Option { match mode { ServiceMode::Enabled => Some("127.0.0.1".into()), @@ -174,7 +169,7 @@ fn check_node_info(r: &mut Report, deployment: &Deployment) { Err(e) => r.fail(&format!("stacks-node /v2/info: invalid response ({e})")), }, Err(e) => r.fail(&format!( - "stacks-node /v2/info: {e} — node down or still booting?" + "stacks-node /v2/info: {e}. Node down or still booting?" )), } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 2e0f74e..1895e45 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -39,16 +39,14 @@ pub enum NodeRole { /// The deployment configuration. This is the root of the configuration and contains all the /// services and their configurations. pub struct Deployment { - /// Deployment name: becomes the compose project name and the container - /// name prefix, so several deployments can share a machine. Lowercase - /// alphanumerics and dashes. The default keeps the historical single- - /// deployment naming (project `stacks`, containers `stacks-node`, ...). + /// Deployment name: becomes the compose project name and the container name prefix, so several + /// deployments can share a machine. Lowercase alphanumerics and dashes. The default keeps the + /// historical single- deployment naming (project `stacks`, containers `stacks-node`, ...). #[serde(default = "default_deployment_name")] pub name: String, - /// Added to every published HOST port (container-internal ports never - /// change), so a second deployment can run beside the first: - /// `port_offset = 100` publishes the node RPC on 20543, the API on 4099, - /// postgres on 5532, and so on. + /// Added to every published HOST port (container-internal ports never change), so a second + /// deployment can run beside the first: `port_offset = 100` publishes the node RPC on 20543, + /// the API on 4099, postgres on 5532, and so on. #[serde(default)] pub port_offset: u16, /// Network name: a built-in definition (mainnet, testnet) or a custom definition file resolved @@ -77,7 +75,7 @@ pub struct Bitcoind { #[serde(default)] pub mode: ServiceMode, /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the - /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1`, mutually exclusive /// with `version`). pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) @@ -97,7 +95,7 @@ pub struct StacksNode { #[serde(default)] pub mode: ServiceMode, /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the - /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1`, mutually exclusive /// with `version`). pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) @@ -107,9 +105,8 @@ pub struct StacksNode { /// Required when mode = "external" pub rpc_host: Option, pub rpc_port: Option, - /// The node's `connection_options.auth_token`. SECRET: set in - /// secrets.toml ([stacks-node] auth_token), never here. For an external - /// node it must match what your node runs with. + /// The node's `connection_options.auth_token`. SECRET: set in secrets.toml ([stacks-node] + /// auth_token), never here. For an external node it must match what your node runs with. pub auth_token: Option, } @@ -118,9 +115,9 @@ pub struct StacksNode { pub struct StacksSigner { #[serde(default)] pub mode: ServiceMode, - /// Docker image override: a repository (`myorg/stacks-node`) that keeps - /// using `version`/the default tag, or a full ref with its own tag - /// (`myorg/stacks-node:4.0.1` — mutually exclusive with `version`). + /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1`, mutually exclusive + /// with `version`). pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) pub version: Option, @@ -132,7 +129,7 @@ pub struct StacksApi { #[serde(default)] pub mode: ServiceMode, /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the - /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1`, mutually exclusive /// with `version`). pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) @@ -151,7 +148,7 @@ pub struct StacksMeshApi { #[serde(default)] pub mode: ServiceMode, /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the - /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1`, mutually exclusive /// with `version`). pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) @@ -166,7 +163,7 @@ pub struct Postgres { #[serde(default)] pub mode: ServiceMode, /// Docker image override: a repository (`myorg/stacks-node`) that keeps using `version`/the - /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1` — mutually exclusive + /// default tag, or a full ref with its own tag (`myorg/stacks-node:4.0.1`, mutually exclusive /// with `version`). pub image: Option, /// Docker image tag for the managed container (defaults to a pinned tag) @@ -179,8 +176,8 @@ pub struct Postgres { pub password: Option, } -/// The default deployment name: compose project `stacks` and the historical -/// container names. Render-time naming branches on this same constant. +/// The default deployment name: compose project `stacks` and the historical container names. +/// Render-time naming branches on this same constant. pub const DEFAULT_PROJECT: &str = "stacks"; fn default_deployment_name() -> String { @@ -188,19 +185,19 @@ fn default_deployment_name() -> String { } impl Deployment { - /// The docker compose project (embedded in the rendered compose file as - /// its top-level `name:`), and the container-name prefix. + /// The docker compose project (embedded in the rendered compose file as its top-level `name:`), + /// and the container-name prefix. pub fn project(&self) -> &str { &self.name } - /// A service's host-published port: the base shifted by `port_offset`. - /// Validation bounds the offset, but guard the addition anyway so an - /// unvalidated path can never wrap around the u16 range silently. + /// A service's host-published port: the base shifted by `port_offset`. Validation bounds the + /// offset, but guard the addition anyway so an unvalidated path can never wrap around the u16 + /// range silently. pub fn published(&self, base: u16) -> u16 { base.checked_add(self.port_offset).unwrap_or_else(|| { panic!( - "port {base} + port_offset {} exceeds 65535 — lower port_offset in stacks.toml", + "port {base} + port_offset {} exceeds 65535. Lower port_offset in stacks.toml", self.port_offset ) }) @@ -232,9 +229,8 @@ impl Deployment { self.stacks_node.auth_token = overlay.stacks_node.auth_token; } - /// Secrets that MUST be present given the enabled services. The tool - /// never writes secrets.toml itself — missing values are the user's to - /// add, so the error carries a paste-ready snippet. + /// Secrets that MUST be present given the enabled services. The tool never writes secrets.toml + /// itself. Missing values are the user's to add, so the error carries a paste-ready snippet. fn check_required_secrets(&self, config_dir: &Path) -> Result<()> { let mut missing: Vec<(&str, &str)> = Vec::new(); if self.postgres.mode != ServiceMode::Disabled && self.postgres.password.is_none() { @@ -263,8 +259,8 @@ impl Deployment { Ok(()) } - /// Cross-service validation: the rules that make invalid stacks fail at - /// `up` time instead of becoming runtime mysteries. + /// Cross-service validation: the rules that make invalid stacks fail at `up` time instead of + /// becoming runtime mysteries. pub fn validate(&self) -> (Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); @@ -287,16 +283,16 @@ impl Deployment { self.name )); } - // Highest published base port is the node p2p (20444); keep the - // shifted ports inside the u16 range with room to spare. + // Highest published base port is the node p2p (20444); keep the shifted ports inside the + // u16 range with room to spare. if self.port_offset > 40000 { errors.push(format!( "port_offset {} is too large (max 40000)", self.port_offset )); } - // A node needs a burnchain source: its own/external bitcoind, or the - // network's hosted default endpoint. + // A node needs a burnchain source: its own/external bitcoind, or the network's hosted + // default endpoint. if self.stacks_node.mode == ServiceMode::Enabled && self.bitcoind.mode == ServiceMode::Disabled && self.net.bitcoind.default_host.is_none() @@ -315,8 +311,8 @@ impl Deployment { )); } - // `image` with an explicit tag and `version` are two sources of truth - // for the same tag — reject the ambiguity. + // `image` with an explicit tag and `version` are two sources of truth for the same tag, + // reject the ambiguity. for (name, image, version) in [ ("bitcoind", &self.bitcoind.image, &self.bitcoind.version), ( @@ -345,7 +341,7 @@ impl Deployment { && crate::utils::versions::has_explicit_tag(image) { errors.push(format!( - "[{name}] `image` already pins a tag (`{image}`) — remove `version` or drop the tag from `image`" + "[{name}] `image` already pins a tag (`{image}`). Remove `version` or drop the tag from `image`" )); } } @@ -410,15 +406,15 @@ impl Deployment { } } - // Reversed (push) edges: the node's config must name its observers. When - // the node is external we can't write that config, only emit it. + // Reversed (push) edges: the node's config must name its observers. When the node is + // external we can't write that config, only emit it. if self.stacks_node.mode == ServiceMode::External && self.stacks_api.mode == ServiceMode::Enabled { warnings.push( "stacks-api is managed but the node is external: add the [[events_observer]] block \ from `rendered/apply-to-your-node.toml` to your node config, then verify with `stacksup config check`. \ - Note: the API's event port (3700) is not published on this host — an off-host node \ + Note: the API's event port (3700) is not published on this host, an off-host node \ cannot push to it; run the node on this machine or expose the port yourself (compose override)" .into(), ); @@ -446,15 +442,15 @@ pub fn load(path: &Path) -> Result { let config_dir = path.parent().unwrap_or(Path::new(".")); deployment.net = network::load(&deployment.network, config_dir)?; - // Secrets never belong in the committable config; they come from the - // secrets.toml overlay beside it. + // Secrets never belong in the committable config; they come from the secrets.toml overlay + // beside it. let leaked = deployment.config_secret_leaks(); if !leaked.is_empty() { for (section, field) in &leaked { eprintln!( "{}", format!( - "error: [{section}] {field} is a secret — remove it from {} and set it in secrets.toml instead", + "error: [{section}] {field} is a secret. Remove it from {} and set it in secrets.toml instead", path.display() ) .red() @@ -486,8 +482,8 @@ pub fn init(force: bool) -> Result<()> { bail!("stacks.toml already exists (use --force to overwrite)"); } std::fs::write(path, DEFAULT_STACK_TOML)?; - // secrets.toml lives beside stacks.toml. Generated ONLY when missing — - // an existing file is the user's and is never touched, even with --force. + // secrets.toml lives beside stacks.toml. Generated ONLY when missing, an existing file is the + // user's and is never touched, even with --force. crate::utils::secrets::generate_if_missing(Path::new("."))?; println!("Wrote stacks.toml — edit it, then run `stacksup start`."); Ok(()) @@ -497,15 +493,15 @@ const DEFAULT_STACK_TOML: &str = r#"# stacksup config # Everything under rendered/ is generated from this file; edit here, not there. # # Every service has a `mode`: -# "enabled" — run and managed by this tool (docker compose) -# "external" — you run it elsewhere; we wire configs to it and health-check it -# "disabled" — not part of this stack +# "enabled" run and managed by this tool (docker compose) +# "external" you run it elsewhere; we wire configs to it and health-check it +# "disabled" not part of this stack # # Managed services also take: -# `version` — the docker image tag to run (omit for this tool's default) -# `image` — a custom docker image: a repository that keeps using -# `version`/the default tag, or a full ref with its own tag -# (then omit `version`) +# `version` the docker image tag to run (omit for this tool's default) +# `image` a custom docker image: a repository that keeps using +# `version`/the default tag, or a full ref with its own tag +# (then omit `version`) network = "testnet" # mainnet | testnet | custom network definition file diff --git a/src/config/network.rs b/src/config/network.rs index 4862e59..0552972 100644 --- a/src/config/network.rs +++ b/src/config/network.rs @@ -1,7 +1,7 @@ //! Network definitions: everything that varies between Stacks networks (burnchain endpoint, chain //! id, epochs, seeded balances, bootstrap peers) lives in a TOML definition file, not in code. //! -//! Standard networks ship embedded in the binary from the repo's `networks/` directory — publishing +//! Standard networks ship embedded in the binary from the repo's `networks/` directory, publishing //! a new testnet means adding a file there. A `network` value that names no embedded definition is //! resolved as a file path (or `networks/.toml`) relative to the stacks.toml, so users can //! define custom networks without a tool release. @@ -22,8 +22,7 @@ const EMBEDDED: &[(&str, &str)] = &[ pub struct NetworkDef { pub name: String, pub chain_id: u32, - /// Path segment under archive.hiro.so; absent when no Hiro archives exist - /// for this network. + /// Path segment under archive.hiro.so; absent when no Hiro archives exist for this network. pub hiro_archive_path: Option, pub bitcoind: BitcoindNet, pub node: NodeNet, @@ -36,8 +35,8 @@ pub struct NetworkDef { #[derive(Debug, Clone, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct BitcoindNet { - /// Whether this network can run its own bitcoind. False for networks - /// following a hosted burnchain (a fresh local chain can't join it). + /// Whether this network can run its own bitcoind. False for networks following a hosted + /// burnchain (a fresh local chain can't join it). pub allow_managed: bool, /// bitcoind -chain flag ("main", "test", "regtest") pub chain: String, @@ -50,8 +49,7 @@ pub struct BitcoindNet { #[derive(Debug, Clone, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct NodeNet { - /// stacks-node [burnchain] mode; also the node's on-disk chainstate - /// subdirectory name. + /// stacks-node [burnchain] mode; also the node's on-disk chainstate subdirectory name. pub burnchain_mode: String, pub bootstrap_node: Option, pub pox_prepare_length: Option, @@ -72,15 +70,15 @@ pub struct Epoch { pub start_height: u64, } -/// Resolve a `network = "..."` value: embedded name first, then a custom -/// definition file relative to the directory holding stacks.toml. +/// Resolve a `network = "..."` value: embedded name first, then a custom definition file relative +/// to the directory holding stacks.toml. pub fn load(name: &str, config_dir: &Path) -> Result { if let Some((_, raw)) = EMBEDDED.iter().find(|(n, _)| *n == name) { return parse(raw).with_context(|| format!("embedded network definition `{name}`")); } - // Custom definitions resolve relative to stacks.toml only — an absolute - // path would silently bypass config_dir (Path::join discards the base). + // Custom definitions resolve relative to stacks.toml only, an absolute path would silently + // bypass config_dir (Path::join discards the base). if Path::new(name).is_absolute() { bail!( "network `{name}`: custom definition paths must be relative to the \ @@ -99,7 +97,7 @@ pub fn load(name: &str, config_dir: &Path) -> Result { } bail!( - "unknown network `{name}` — built-in networks: {}; or point at a custom \ + "unknown network `{name}`. Built-in networks: {}; or point at a custom \ definition file (`network = \"my-net.toml\"`, resolved next to stacks.toml)", EMBEDDED .iter() @@ -111,8 +109,8 @@ pub fn load(name: &str, config_dir: &Path) -> Result { fn parse(raw: &str) -> Result { let def: NetworkDef = toml::from_str(raw)?; - // Missing fields are already serde errors (nothing here is defaulted); - // these guards catch the empty/zero values serde accepts. + // Missing fields are already serde errors (nothing here is defaulted); these guards catch the + // empty/zero values serde accepts. if def.name.is_empty() || def.node.burnchain_mode.is_empty() { bail!("network definition must set `name` and [node] burnchain_mode"); } diff --git a/src/config/render.rs b/src/config/render.rs index b0fa547..a33fe88 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -4,8 +4,8 @@ //! /rendered/ compose file + per-service configs (regenerated) //! /chainstate/ service state: chainstate, Postgres, signer db //! -//! Service state is bind-mounted from `chainstate/` (not named Docker volumes) so the entire stack -//! — configs and data — lives where the operator said. +//! Service state is bind-mounted from `chainstate/` (not named Docker volumes) so the entire stack, +//! configs and data, lives where the operator said. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -19,9 +19,9 @@ use crate::utils::services::*; pub use crate::config::DEFAULT_PROJECT; -/// Docker networks, split so a compromised API-side container cannot reach -/// the signer (or bitcoind) directly. Each service joins only the networks -/// it needs; the node bridges all three because everything talks to it. +/// Docker networks, split so a compromised API-side container cannot reach the signer (or bitcoind) +/// directly. Each service joins only the networks it needs; the node bridges all three because +/// everything talks to it. const NET_BITCOIN: &str = "bitcoin"; // bitcoind + node const NET_CORE: &str = "core"; // node + signer const NET_SERVICES: &str = "services"; // api + mesh + postgres (+ node for RPC/events) @@ -50,9 +50,8 @@ const API_PG_SCHEMA: &str = "stacks_blockchain_api"; #[derive(Serialize)] struct ComposeFile { - /// Compose project name, embedded so every `docker compose -f` invocation - /// (ours or the operator's) lands on this deployment's containers and - /// networks without a -p flag. + /// Compose project name, embedded so every `docker compose -f` invocation (ours or the + /// operator's) lands on this deployment's containers and networks without a -p flag. name: String, services: BTreeMap, networks: BTreeMap>, @@ -94,9 +93,8 @@ struct ComposeService { impl ComposeService { fn new(deployment: &Deployment, name: &str, image: &str) -> Self { let project = deployment.project(); - // The default project keeps the historical names (stacks-* bare, - // generic ones prefixed). A named deployment prefixes everything — - // container names are a docker-wide namespace. + // The default project keeps the historical names (stacks-* bare, generic ones prefixed). A + // named deployment prefixes everything — container names are a docker-wide namespace. let container_name = if project == DEFAULT_PROJECT { if name.starts_with("stacks-") { name.to_string() @@ -112,8 +110,8 @@ impl ComposeService { restart: "unless-stopped".into(), // Every builder sets its own network membership (least access). networks: Vec::new(), - // Lets managed services reach "external" services running on the - // operator's localhost — a common setup that trips people up. + // Lets managed services reach "external" services running on the operator's localhost, + // a common setup that trips people up. extra_hosts: vec!["host.docker.internal:host-gateway".into()], ..Default::default() } @@ -244,10 +242,9 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { format!("-chain={}", deployment.net.bitcoind.chain), "-rpcbind=0.0.0.0".into(), "-rpcallowip=0.0.0.0/0".into(), - // A salted hash — unlike -rpcpassword, safe to expose via docker - // inspect. The plaintext lives in secrets.toml and the node's - // mounted Config.toml only. The `$` separating salt from digest must - // be `$$` in the compose file or interpolation swallows the digest. + // A salted hash, unlike -rpcpassword, safe to expose via docker inspect. The plaintext + // lives in secrets.toml and the node's mounted Config.toml only. The `$` separating salt + // from digest must be `$$` in the compose file or interpolation swallows the digest. format!( "-rpcauth={}", secrets::bitcoind_rpcauth( @@ -261,12 +258,10 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { .replace('$', "$$") ), ]; - // Loopback-bind the RPC port: every container carries a - // host.docker.internal alias, so a 0.0.0.0 publish would let API-side - // containers reach bitcoind through the host, bypassing the network - // split. Operators still get bitcoin-cli on localhost. Only an external - // node (running off-host) needs the wide binding. P2P stays open — it - // exists to accept peers. + // Loopback-bind the RPC port: every container carries a host.docker.internal alias, so a + // 0.0.0.0 publish would let API-side containers reach bitcoind through the host, bypassing the + // network split. Operators still get bitcoin-cli on localhost. Only an external node (running + // off-host) needs the wide binding. P2P stays open, it exists to accept peers. let rpc_publish = if deployment.stacks_node.mode == ServiceMode::External { format!("{}:{rpc}", deployment.published(rpc)) } else { @@ -297,14 +292,13 @@ fn node_service(deployment: &Deployment) -> ComposeService { if deployment.bitcoind.mode == ServiceMode::Enabled { svc.depends_on.push("bitcoind".into()); } - // The node pushes events to the API; if the API is down at boot the node - // retries, but starting after the API avoids stalling block processing. + // The node pushes events to the API; if the API is down at boot the node retries, but starting + // after the API avoids stalling block processing. if deployment.stacks_api.mode == ServiceMode::Enabled { svc.depends_on.push("stacks-api".into()); } - // Always on `core` (its home even with no signer); `bitcoin` only with a - // managed bitcoind; `services` only when an API-side consumer needs its - // RPC or receives its event pushes. + // Always on `core` (its home even with no signer); `bitcoin` only with a managed bitcoind; + // `services` only when an API-side consumer needs its RPC or receives its event pushes. svc.networks = vec![NET_CORE.into()]; if deployment.bitcoind.mode == ServiceMode::Enabled { svc.networks.insert(0, NET_BITCOIN.into()); @@ -369,8 +363,8 @@ fn mesh_api_service(deployment: &Deployment) -> ComposeService { svc } -/// Postgres major from the configured version tag: "17" -> 17, -/// "17.5-alpine" -> 17, "latest"/absent -> None. +/// Postgres major from the configured version tag: "17" -> 17, "17.5-alpine" -> 17, "latest"/absent +/// -> None. fn postgres_major(deployment: &Deployment) -> Option { deployment .postgres @@ -397,8 +391,8 @@ fn postgres_service(deployment: &Deployment) -> ComposeService { .clone() .unwrap_or_else(|| "postgres".into()), ); - // Password arrives via a compose secret file, not container env — - // `docker inspect` shows only the path. + // Password arrives via a compose secret file, not container env, `docker inspect` shows only + // the path. svc.environment.insert( "POSTGRES_PASSWORD_FILE".into(), "/run/secrets/pg_password".into(), @@ -406,27 +400,25 @@ fn postgres_service(deployment: &Deployment) -> ComposeService { svc.secrets = vec!["pg_password".into()]; svc.environment .insert("POSTGRES_DB".into(), API_PG_DATABASE.into()); - // Postgres 18+ images keep data in a version-specific subdirectory and - // require the mount at /var/lib/postgresql (enabling pg_upgrade across - // majors); images <= 17 use PGDATA=/var/lib/postgresql/data and need the - // mount there. Unknown majors (e.g. `latest`) get the 18+ convention. + // Postgres 18+ images keep data in a version-specific subdirectory and require the mount at + // /var/lib/postgresql (enabling pg_upgrade across majors); images <= 17 use + // PGDATA=/var/lib/postgresql/data and need the mount there. Unknown majors (e.g. `latest`) get + // the 18+ convention. let data_target = match postgres_major(deployment) { Some(major) if major <= 17 => "/var/lib/postgresql/data", _ => "/var/lib/postgresql", }; svc.volumes = vec![ format!("../chainstate/postgres:{data_target}"), - // Read-only view of downloaded archives so pg_restore (which needs a - // seekable file, not stdin) can restore dumps fetched by - // `stacksup chainstate download`. + // Read-only view of downloaded archives so pg_restore (which needs a seekable file, not + // stdin) can restore dumps fetched by `stacksup chainstate download`. "../downloads:/downloads:ro".into(), ]; - // Ensure the API's schema exists on EVERY boot, not just first init — - // /docker-entrypoint-initdb.d only runs on an empty data directory. - // The schema creator runs as a background job while postgres is exec'd - // as PID 1, so SIGTERM from `stacksup stop` reaches postgres directly and - // shutdown is clean (a backgrounded postgres under bash would be + // Ensure the API's schema exists on EVERY boot, not just first init, + // /docker-entrypoint-initdb.d only runs on an empty data directory. The schema creator runs as + // a background job while postgres is exec'd as PID 1, so SIGTERM from `stacksup stop` reaches + // postgres directly and shutdown is clean (a backgrounded postgres under bash would be // SIGKILLed after the grace period instead). let user = deployment .postgres @@ -446,10 +438,9 @@ fn postgres_service(deployment: &Deployment) -> ComposeService { svc } -/// The stacks-node Config.toml. Cross-service values (bitcoind endpoint, event -/// observers, signer auth) are derived from the same `Deployment` the other configs -/// come from — matched by construction. The testnet shape mirrors -/// stacks-core's sample/conf/testnet-follower-conf.toml. +/// The stacks-node Config.toml. Cross-service values (bitcoind endpoint, event observers, signer +/// auth) are derived from the same `Deployment` the other configs come from, matched by +/// construction. The testnet shape mirrors stacks-core's sample/conf/testnet-follower-conf.toml. fn node_config_toml(deployment: &Deployment) -> String { let net = &deployment.net; let mut out = String::new(); @@ -484,8 +475,8 @@ fn node_config_toml(deployment: &Deployment) -> String { "mode = \"{}\"\nchain = \"bitcoin\"\n", net.node.burnchain_mode )); - // Burnchain endpoint: managed/external bitcoind, else the network's - // hosted default (validation guarantees one of these exists for a node). + // Burnchain endpoint: managed/external bitcoind, else the network's hosted default (validation + // guarantees one of these exists for a node). if let Some(host) = bitcoind_host(deployment).or_else(|| net.bitcoind.default_host.clone()) { out.push_str(&format!("peer_host = \"{host}\"\n")); out.push_str(&format!( @@ -493,9 +484,9 @@ fn node_config_toml(deployment: &Deployment) -> String { bitcoind_rpc_port(deployment), bitcoind_p2p_port(deployment) )); - // Credentials: our managed bitcoind always has them (tool defaults); - // an external one only when both are explicitly configured — - // validation rejects partial credentials, so no silent fallbacks. + // Credentials: our managed bitcoind always has them (tool defaults); an external one only + // when both are explicitly configured, validation rejects partial credentials, so no + // silent fallbacks. if deployment.bitcoind.mode == ServiceMode::Enabled { out.push_str(&format!( "username = \"{}\"\npassword = \"{}\"\n", @@ -578,12 +569,11 @@ stacks_private_key = "REPLACE_ME" ) } -/// The node's `connection_options.auth_token`, which the signer's -/// `auth_password` must match. External nodes bring their own token -/// (`[stacks-node] auth_token`); managed nodes use a tool-managed one. -/// The auth token shared by node config, signer, and mesh env — a single -/// deployment field, populated from secrets.toml (required by validation -/// whenever something consumes it). +/// The node's `connection_options.auth_token`, which the signer's `auth_password` must match. +/// External nodes bring their own token (`[stacks-node] auth_token`); managed nodes use a +/// tool-managed one. The auth token shared by node config, signer, and mesh env, a single +/// deployment field, populated from secrets.toml (required by validation whenever something +/// consumes it). fn node_auth_token(deployment: &Deployment) -> String { deployment .stacks_node @@ -627,10 +617,9 @@ TESTNET_SBTC_FAUCET_ENABLED=false ) } -/// Env for the Stacks Mesh API (packages/api/src/env.ts is the schema of -/// record). Online mode needs the node's RPC endpoint and — critically — the -/// node's `connection_options.auth_token`, which this render guarantees -/// matches by deriving both from the same source. Cache sizes/TTLs and +/// Env for the Stacks Mesh API (packages/api/src/env.ts is the schema of record). Online mode needs +/// the node's RPC endpoint and critically the node's `connection_options.auth_token`, which +/// this render guarantees matches by deriving both from the same source. Cache sizes/TTLs and /// BLOCK_HASH_MODE are left to the service's own defaults. fn mesh_api_env(deployment: &Deployment) -> String { let node_host = node_rpc_host(deployment).unwrap_or_default(); @@ -649,8 +638,8 @@ STACKS_CORE_RPC_AUTH_TOKEN={auth_token} ) } -/// Config the user must apply to their *external* node so push edges -/// (events to the API, signer wiring) actually close. +/// Config the user must apply to their *external* node so push edges (events to the API, signer +/// wiring) actually close. fn apply_to_your_node(deployment: &Deployment) -> Option { let mut out = String::from( "# Add these blocks to your stacks-node config, then restart it.\n\ @@ -659,8 +648,6 @@ fn apply_to_your_node(deployment: &Deployment) -> Option { let mut needed = false; if deployment.stacks_api.mode == ServiceMode::Enabled { - // TODO(hackathon): make the advertised host configurable; the external - // node must be able to reach the machine running this stack. out.push_str(&format!( "[[events_observer]]\nendpoint = \":{API_EVENT_PORT}\"\nevents_keys = {API_EVENTS_KEYS}\n\n" )); @@ -683,9 +670,9 @@ fn apply_to_your_node(deployment: &Deployment) -> Option { mod tests { use super::*; - /// Parse and merge the same test secrets `load()` would pull from a - /// secrets.toml, so generators see a post-merge deployment. Managed - /// bitcoind gets credentials; external keeps whatever the toml set. + /// Parse and merge the same test secrets `load()` would pull from a secrets.toml, so generators + /// see a post-merge deployment. Managed bitcoind gets credentials; external keeps whatever the + /// toml set. fn deployment(toml_str: &str) -> Deployment { let mut d = crate::config::test_deployment(toml_str); d.postgres diff --git a/src/logs/export.rs b/src/logs/export.rs index 14cdb61..4f1e966 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -1,9 +1,9 @@ -//! `stacksup logs export` — package logs (and diagnostic context) into a shareable file for +//! `stacksup logs export` packages logs (and diagnostic context) into a shareable file for //! troubleshooting. //! //! Default output is a support bundle: per-service logs plus versions, ps, images, redacted //! configs, and the chainstate/config check reports. All text passes through redaction (known -//! secret values + password/token/key lines) so the artifact is safe to hand to someone else — +//! secret values + password/token/key lines) so the artifact is safe to hand to someone else, //! though the final message still tells the user to review it. use std::fs; @@ -41,8 +41,8 @@ pub fn run(deployment: &Deployment, config_path: &Path, data_dir: &Path, opts: O bail!("no enabled services in stacks.toml — nothing to export"); } - // Containers that still exist (logs live inside them). `stacksup stop` - // keeps them; `stacksup stop --destroy` removes them along with their logs. + // Containers that still exist (logs live inside them). `stacksup stop` keeps them; `stacksup + // stop --destroy` removes them along with their logs. let existing: Vec = crate::utils::docker::compose_capture(data_dir, &["ps", "-a", "--services"])? .lines() @@ -186,8 +186,8 @@ fn capture_logs(data_dir: &Path, service: &str, since: &str) -> String { .unwrap_or_else(|e| format!("failed to collect logs: {e}\n")) } -/// Run one of our own subcommands and capture its report (colored degrades to -/// plain text automatically because the output is not a terminal). +/// Run one of our own subcommands and capture its report (colored degrades to plain text +/// automatically because the output is not a terminal). fn self_report(config_path: &Path, data_dir: &Path, args: &[&str]) -> String { let exe = match std::env::current_exe() { Ok(p) => p, @@ -243,9 +243,8 @@ fn secret_values(deployment: &Deployment) -> Vec { v } -/// Two-pass scrub: exact secret values anywhere, then any line whose key -/// looks credential-ish (covers defaults we didn't collect and container -/// startup echoes). +/// Two-pass scrub: exact secret values anywhere, then any line whose key looks credential-ish +/// (covers defaults we didn't collect and container startup echoes). fn redact(text: &str, secrets: &[String]) -> String { let mut out = text.to_string(); for secret in secrets { diff --git a/src/logs/mod.rs b/src/logs/mod.rs index 99efa18..4e835c9 100644 --- a/src/logs/mod.rs +++ b/src/logs/mod.rs @@ -1,3 +1,3 @@ -//! `stacksup logs` — tailing lives in crate::docker; export lives here. +//! `stacksup logs` tails logs lives in crate::docker; export lives here. pub mod export; diff --git a/src/main.rs b/src/main.rs index 40f54df..20e6644 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,10 +121,10 @@ enum ConfigCommand { #[derive(Subcommand)] enum ChainstateCommand { - /// Permanently delete on-disk chainstate — asks for confirmation first + /// Permanently delete on-disk chainstate, asks for confirmation first Wipe { - /// Single service's state to wipe (bitcoind, stacks-node, - /// stacks-signer, postgres); omit to wipe everything + /// Single service's state to wipe (bitcoind, stacks-node, stacks-signer, postgres); omit to + /// wipe everything service: Option, /// Skip the confirmation prompt (for scripts) #[arg(long)] @@ -137,9 +137,8 @@ enum ChainstateCommand { /// Which service's archive to fetch #[arg(long, value_enum, default_value_t = ServiceArg::All)] service: ServiceArg, - /// Specific archive: a filename (resolved against the network's - /// archive path), a full URL, or a local file path. Requires - /// --service node or --service api. + /// Specific archive: a filename (resolved against the network's archive path), a full URL, + /// or a local file path. Requires --service node or --service api. #[arg(long)] archive: Option, /// Print the plan (sizes, versions, disk) and exit @@ -157,8 +156,7 @@ enum ChainstateCommand { /// Keep downloaded archives after a successful restore #[arg(long)] keep_archives: bool, - /// Start the deployment (render + `stacksup start`) once the - /// chainstate is restored + /// Start the deployment (render + `stacksup start`) once the chainstate is restored #[arg(long)] start: bool, }, @@ -173,8 +171,8 @@ enum ServiceArg { fn main() { if let Err(e) = run() { - // `{e:#}` renders the whole context chain on one line; colored - // degrades to plain text when stderr isn't a terminal. + // `{e:#}` renders the whole context chain on one line; colored degrades to plain text when + // stderr isn't a terminal. eprintln!("{}", format!("Error: {e:#}").red()); std::process::exit(1); } @@ -321,8 +319,8 @@ mod tests { use super::*; use clap::CommandFactory; - /// clap's built-in validation of the whole CLI definition: conflicting - /// flags, bad defaults, ambiguous subcommands all panic here. + /// clap's built-in validation of the whole CLI definition: conflicting flags, bad defaults, + /// ambiguous subcommands all panic here. #[test] fn cli_definition_is_coherent() { Cli::command().debug_assert(); diff --git a/src/upgrade.rs b/src/upgrade.rs index cc85c77..6d3da95 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -1,10 +1,9 @@ -//! `stacksup upgrade` — suggestions only, never applies anything. +//! `stacksup upgrade`, suggestions only, never applies anything. //! -//! Compares each enabled service's configured version against what the image -//! registries offer and prints per-service guidance. Domain rules baked in: -//! same-major bumps are routine (edit stacks.toml, pull, restart); a MAJOR -//! stacks-api bump is DB-breaking and requires wiping the API's postgres data -//! and re-seeding via `chainstate download`; a major postgres bump requires a +//! Compares each enabled service's configured version against what the image registries offer and +//! prints per-service guidance. Domain rules baked in: same-major bumps are routine (edit +//! stacks.toml, pull, restart); a MAJOR stacks-api bump is DB-breaking and requires wiping the +//! API's postgres data and re-seeding via `chainstate download`; a major postgres bump requires a //! data migration; node and signer versions should move together. use anyhow::{Context, Result}; @@ -17,12 +16,11 @@ struct Row { name: &'static str, current: Option>, current_note: &'static str, - /// The tag is pinned inside the `image` override — upgrade guidance must - /// say "update the image tag", not "set version" (validation rejects - /// `version` alongside a tagged `image`). + /// The tag is pinned inside the `image` override, upgrade guidance must say "update the image + /// tag", not "set version" (validation rejects `version` alongside a tagged `image`). tag_from_image: bool, - /// Tag pins only a major (e.g. postgres `17`): minors float with pulls, - /// so only a new major is a real suggestion. + /// Tag pins only a major (e.g. postgres `17`): minors float with pulls, so only a new major is + /// a real suggestion. major_pin: bool, same_major: Option>, next_major: Option>, @@ -154,7 +152,7 @@ pub fn run(deployment: &Deployment, service: Option<&str>) -> Result<()> { println!( "\n{}", format!( - "⚠ stacks-node ({}) and stacks-signer ({}) versions differ — they should be upgraded together", + "⚠ stacks-node ({}) and stacks-signer ({}) versions differ. They should be upgraded together", version_string(&node), version_string(&signer) ) @@ -167,7 +165,7 @@ pub fn run(deployment: &Deployment, service: Option<&str>) -> Result<()> { } else { println!( "\n{}", - format!("{upgrades} upgrade(s) available. This command only suggests — apply by editing stacks.toml.") + format!("{upgrades} upgrade(s) available. This command only suggests. Apply by editing stacks.toml.") .yellow() ); } @@ -185,7 +183,7 @@ fn verdict(row: &Row, upgrades: &mut u32) -> (String, String) { .or(row.next_major.as_ref()) .map(|v| version_string(v)) .unwrap_or_else(|| "-".into()), - "⚠ current version unknown — pin a version or `stacksup pull`" + "⚠ current version unknown. Pin a version or `stacksup pull`" .yellow() .to_string(), ); @@ -267,7 +265,7 @@ fn guidance(row: &Row) -> Option { if let Some(mj) = major { out.push_str( &format!( - " stacks-api {mj}: MAJOR — DB-BREAKING. The new API cannot migrate the old database:\n\ + " stacks-api {mj}: MAJOR, DB-BREAKING. The new API cannot migrate the old database:\n\ 1. `stacksup stop`\n\ 2. `stacksup chainstate wipe postgres` (API data only)\n\ 3. {set_step} and `stacksup pull`\n\ @@ -284,14 +282,14 @@ fn guidance(row: &Row) -> Option { "postgres" => { if let Some(m) = minor { out.push_str(&format!( - " postgres {}: patch/minor — safe. {}, then pull + restart.\n", + " postgres {}: patch/minor. Safe. {}, then pull + restart.\n", version_string(m), set_tag("postgres", &version_string(m)), )); } if let Some(mj) = major { out.push_str(&format!( - " postgres {}: MAJOR — the data directory does not migrate itself. Either stay on \ + " postgres {}: MAJOR. The data directory does not migrate itself. Either stay on \ the current major, or wipe + re-seed the API database after switching.", version_string(mj) )); @@ -316,7 +314,7 @@ fn guidance(row: &Row) -> Option { } if let Some(mj) = major { out.push_str(&format!( - " {}: major {} also available — review its release notes before crossing majors.", + " {}: major {} also available. Review its release notes before crossing majors.", row.name, version_string(mj) )); @@ -330,8 +328,8 @@ fn check_service(name: &'static str, image: &str, tag_from_image: bool) -> Row { let repo = image.rsplit_once(':').map(|(r, _)| r).unwrap_or(image); let tag = image_tag(image); - // Current: full pin from the tag; a bare-major tag (`17`) is a deliberate - // floating pin within that major; otherwise the pulled image's label. + // Current: full pin from the tag; a bare-major tag (`17`) is a deliberate floating pin within + // that major; otherwise the pulled image's label. let (current, current_note, major_pin) = match parse_version(&tag) { Some(v) if v.len() >= 2 => (Some(v), "", false), Some(v) => (Some(v), ".x", true), @@ -381,8 +379,8 @@ fn check_service(name: &'static str, image: &str, tag_from_image: bool) -> Row { row } -/// Concrete version tags (>= 2 numeric components) available in the image's -/// registry. Anonymous APIs: Docker Hub's tag listing, GHCR's token + v2 flow. +/// Concrete version tags (>= 2 numeric components) available in the image's registry. Anonymous +/// APIs: Docker Hub's tag listing, GHCR's token + v2 flow. fn registry_versions(repo: &str) -> Result>> { let tags = if let Some(path) = repo.strip_prefix("ghcr.io/") { ghcr_tags(path)? @@ -396,9 +394,8 @@ fn registry_versions(repo: &str) -> Result>> { .collect()) } -/// Docker Hub API path for a repository: strips the canonical Hub host -/// prefixes (`docker.io/`, `index.docker.io/`, `registry-1.docker.io/`) and -/// qualifies official images under `library/`. +/// Docker Hub API path for a repository: strips the canonical Hub host prefixes (`docker.io/`, +/// `index.docker.io/`, `registry-1.docker.io/`) and qualifies official images under `library/`. fn dockerhub_path(repo: &str) -> String { let repo = repo .strip_prefix("docker.io/") diff --git a/src/utils/docker.rs b/src/utils/docker.rs index 727d204..9be8d81 100644 --- a/src/utils/docker.rs +++ b/src/utils/docker.rs @@ -1,6 +1,5 @@ -//! Lifecycle: thin wrapper over `docker compose` for managed services. -//! Observation (status/health) will move to the Docker Engine API (bollard) -//! later; compose owns orchestration either way. +//! Lifecycle: thin wrapper over `docker compose` for managed services. Observation (status/health) +//! will move to the Docker Engine API (bollard) later; compose owns orchestration either way. use std::path::Path; use std::process::Command; @@ -14,15 +13,14 @@ use crate::utils::services::roster; fn compose(data_dir: &Path) -> Command { let mut cmd = Command::new("docker"); - // No -p: the rendered compose file carries its project via `name:`, - // so every invocation (ours or a bare `docker compose -f ...`) is scoped - // to this deployment. + // No -p: the rendered compose file carries its project via `name:`, so every invocation (ours + // or a bare `docker compose -f ...`) is scoped to this deployment. cmd.args(["compose", "-f"]).arg(compose_file(data_dir)); cmd } -/// Docker Engine server version, distinguishing "not installed" from -/// "daemon not running" so the operator gets the right fix. +/// Docker Engine server version, distinguishing "not installed" from "daemon not running" so the +/// operator gets the right fix. pub fn daemon_version() -> Result { match Command::new("docker") .args(["version", "--format", "{{.Server.Version}}"]) @@ -60,9 +58,9 @@ fn ensure_docker() -> Result<()> { Ok(()) } -/// Refuse to start when this deployment's name is already in use by a compose -/// project rendered from a DIFFERENT directory — otherwise compose silently -/// adopts (and replaces) the other deployment's containers. +/// Refuse to start when this deployment's name is already in use by a compose project rendered from +/// a DIFFERENT directory, otherwise compose silently adopts (and replaces) the other deployment's +/// containers. fn guard_project_collision(deployment: &Deployment, data_dir: &Path) -> Result<()> { let out = Command::new("docker") .args(["compose", "ls", "--all", "--format", "json"]) @@ -89,8 +87,8 @@ struct ComposeLsEntry { config_files: String, } -/// Pure half of the collision check: does `ls_json` (docker compose ls -/// output) contain `project` with a config file other than ours? +/// Pure half of the collision check: does `ls_json` (docker compose ls output) contain `project` +/// with a config file other than ours? fn project_conflict(ls_json: &str, project: &str, our_compose_file: &Path) -> Option { let ours = our_compose_file .canonicalize() @@ -114,9 +112,9 @@ fn project_conflict(ls_json: &str, project: &str, our_compose_file: &Path) -> Op None } -/// A port conflicts only when a bind fails with AddrInUse on SOME family — -/// an unsupported family (e.g. no IPv6) or a permission error is not a -/// conflict. Mirrors how docker publishes on both stacks. +/// A port conflicts only when a bind fails with AddrInUse on SOME family, an unsupported family +/// (e.g. no IPv6), or a permission error is not a conflict. Mirrors how docker publishes on both +/// stacks. fn port_taken(port: u16) -> bool { ["0.0.0.0", "::"] .iter() @@ -126,9 +124,9 @@ fn port_taken(port: u16) -> bool { }) } -/// Test-bind every host port the deployment is about to publish, skipping -/// services that are already running (their ports are legitimately ours). -/// Catches port squatting BEFORE compose creates half a stack. +/// Test-bind every host port the deployment is about to publish, skipping services that are already +/// running (their ports are legitimately ours). Catches port squatting BEFORE compose creates half +/// a stack. fn guard_published_ports( deployment: &Deployment, data_dir: &Path, @@ -155,8 +153,8 @@ fn guard_published_ports( Ok(()) } -/// Names of this stack's currently running compose services. Best effort: -/// `None` when docker or the rendered compose file is unavailable. +/// Names of this stack's currently running compose services. Best effort: `None` when docker or the +/// rendered compose file is unavailable. pub fn running_services(data_dir: &Path) -> Option> { if !compose_file(data_dir).exists() { return None; @@ -190,8 +188,8 @@ pub fn compose_stop_service(data_dir: &Path, service: &str) -> Result<()> { run(cmd, "docker compose stop") } -/// Run a compose subcommand and capture its stdout (stderr appended on -/// failure instead of erroring — support bundles want best-effort output). +/// Run a compose subcommand and capture its stdout (stderr appended on failure instead of erroring, +/// support bundles want best-effort output). pub(crate) fn compose_capture(data_dir: &Path, args: &[&str]) -> Result { let mut cmd = compose(data_dir); cmd.args(args); @@ -339,8 +337,8 @@ pub fn stop( if let Some(name) = service { ensure_enabled(deployment, name)?; - // Single service: `compose stop` halts just that container, leaving - // the rest of the stack (and the network) running. + // Single service: `compose stop` halts just that container, leaving the rest of the stack + // (and the network) running. let mut cmd = compose(data_dir); cmd.args(["stop", name]); run(cmd, "docker compose stop")?; @@ -349,15 +347,15 @@ pub fn stop( } if destroy { - // `down` removes containers (and their logs) and the network. It only - // ever touches the compose project; external services and their data - // are outside this tool's blast radius by construction. + // `down` removes containers (and their logs) and the network. It only ever touches the + // compose project; external services and their data are outside this tool's blast radius by + // construction. let mut cmd = compose(data_dir); cmd.arg("down"); run(cmd, "docker compose down")?; } else { - // Halt containers but keep them (and their logs) so issues can still - // be inspected/exported after stopping. `--destroy` for full cleanup. + // Halt containers but keep them (and their logs) so issues can still be inspected/exported + // after stopping. `--destroy` for full cleanup. let mut cmd = compose(data_dir); cmd.arg("stop"); run(cmd, "docker compose stop")?; @@ -378,9 +376,9 @@ pub fn stop( Ok(()) } -/// Restart = stop + up. Deliberately not `docker compose restart`, which -/// reuses the existing container: going through `up` means a re-rendered -/// config or freshly pulled image takes effect on restart. +/// Restart = stop + up. Deliberately not `docker compose restart`, which reuses the existing +/// container: going through `up` means a re-rendered config or freshly pulled image takes effect on +/// restart. pub fn restart(deployment: &Deployment, data_dir: &Path, service: Option<&str>) -> Result<()> { preflight(data_dir)?; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e5f79a9..bb6e2b1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ -//! Shared helpers used across commands — not commands themselves. +//! Shared helpers used across commands, not commands themselves. pub mod docker; pub mod secrets; diff --git a/src/utils/secrets.rs b/src/utils/secrets.rs index 1bdcbff..982f0ef 100644 --- a/src/utils/secrets.rs +++ b/src/utils/secrets.rs @@ -1,13 +1,12 @@ -//! The secrets overlay: `secrets.toml` lives beside `stacks.toml`, holds ONLY -//! sensitive values (clear text, mode 0600, never committed), and is merged -//! into the deployment config at load time. +//! The secrets overlay: `secrets.toml` lives beside `stacks.toml`, holds ONLY sensitive values +//! (clear text, mode 0600, never committed), and is merged into the deployment config at load time. //! //! Contract: -//! - The tool NEVER modifies an existing secrets.toml. `config init` creates -//! one with generated values only when none exists; anything missing later -//! is an error telling the user exactly what to add. -//! - Secret-bearing fields are rejected in stacks.toml — they must come from -//! the overlay, so the committable config never contains credentials. +//! - The tool NEVER modifies an existing secrets.toml. `config init` creates one with generated +//! values only when none exists; anything missing later is an error telling the user exactly what +//! to add. +//! - Secret-bearing fields are rejected in stacks.toml — they must come from the overlay, so the +//! committable config never contains credentials. use std::path::{Path, PathBuf}; @@ -18,16 +17,14 @@ use sha2::Sha256; pub const SECRETS_FILE: &str = "secrets.toml"; -/// Secret values are interpolated into rendered TOML, env files, and the -/// compose file, so they are validated to an alphabet that is inert in all -/// of those formats: printable ASCII without whitespace, quotes, backslash, -/// `$` (compose interpolation), or backtick. +/// Secret values are interpolated into rendered TOML, env files, and the compose file, so they are +/// validated to an alphabet that is inert in all of those formats: printable ASCII without +/// whitespace, quotes, backslash, `$` (compose interpolation), or backtick. const MIN_SECRET_LEN: usize = 8; const MAX_SECRET_LEN: usize = 128; -/// The overlay mirrors stacks.toml's structure, restricted to secret-bearing -/// fields. `deny_unknown_fields` makes typos and non-secret config in this -/// file loud errors. +/// The overlay mirrors stacks.toml's structure, restricted to secret-bearing fields. +/// `deny_unknown_fields` makes typos and non-secret config in this file loud errors. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct SecretsOverlay { @@ -62,18 +59,16 @@ fn secrets_path(data_dir: &Path) -> PathBuf { data_dir.join(SECRETS_FILE) } -/// Load the overlay. Missing file is `None`; `config::load` then reports the -/// deployment's required secrets as missing. Present values are validated -/// (permissions, length, alphabet) so bad ones fail here with a precise -/// message instead of producing broken rendered configs. +/// Load the overlay. Missing file is `None`; `config::load` then reports the deployment's required +/// secrets as missing. Present values are validated (permissions, length, alphabet) so bad ones +/// fail here with a precise message instead of producing broken rendered configs. pub fn load(data_dir: &Path) -> Result> { let path = secrets_path(data_dir); if !path.exists() { return Ok(None); } - // The file holds clear-text credentials: refuse to proceed while other - // users can read it. Fixing the mode is the user's move — this tool - // never modifies the file, permissions included. + // The file holds clear-text credentials: refuse to proceed while other users can read it. + // Fixing the mode is the user's move, this tool never modifies the file, permissions included. let mode = std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path)?.permissions()); if mode & 0o077 != 0 { bail!( @@ -101,9 +96,8 @@ pub fn load(data_dir: &Path) -> Result> { Ok(Some(overlay)) } -/// Every present value must be long enough to redact safely and free of -/// characters that need escaping in any rendered format (TOML strings, env -/// files, compose commands). +/// Every present value must be long enough to redact safely and free of characters that need +/// escaping in any rendered format (TOML strings, env files, compose commands). fn validate_values(overlay: &SecretsOverlay, path: &Path) -> Result<()> { let fields: [(&str, &str, &Option); 4] = [ ("bitcoind", "rpc_user", &overlay.bitcoind.rpc_user), @@ -135,8 +129,8 @@ fn validate_values(overlay: &SecretsOverlay, path: &Path) -> Result<()> { Ok(()) } -/// Create secrets.toml with generated values — ONLY when it does not exist. -/// An existing file is the user's; it is never touched. +/// Create secrets.toml with generated values, ONLY when it does not exist. An existing file is the +/// user's; it is never touched. pub fn generate_if_missing(data_dir: &Path) -> Result<()> { let path = secrets_path(data_dir); if path.exists() { @@ -151,8 +145,8 @@ pub fn generate_if_missing(data_dir: &Path) -> Result<()> { [bitcoind]\nrpc_user = \"stacksup-{}\"\nrpc_password = \"{}\"\n\n\ [stacks-node]\nauth_token = \"{}\"\n", random_hex(32)?, - // rpc_user is redacted in `logs export`; a random suffix keeps that - // exact-value scrub from also eating every plain "stacksup" in logs. + // rpc_user is redacted in `logs export`; a random suffix keeps that exact-value scrub from + // also eating every plain "stacksup" in logs. random_hex(4)?, random_hex(32)?, random_hex(32)?, @@ -165,8 +159,7 @@ pub fn generate_if_missing(data_dir: &Path) -> Result<()> { Ok(()) } -/// Write a file readable only by the owner; permissions are set before the -/// content is written. +/// Write a file readable only by the owner; permissions are set before the content is written. pub fn write_0600(path: &Path, content: &str) -> Result<()> { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; @@ -192,9 +185,9 @@ fn random_hex(bytes: usize) -> Result { Ok(buf.iter().map(|b| format!("{b:02x}")).collect()) } -/// bitcoind's `-rpcauth` line derived from the clear-text credentials -/// (share/rpcauth/rpcauth.py): `user:salt$hex(hmac_sha256(key=salt, msg=password))`. -/// Never stored — a hand-edited password can't drift from its hash. +/// bitcoind's `-rpcauth` line derived from the clear-text credentials (share/rpcauth/rpcauth.py): +/// `user:salt$hex(hmac_sha256(key=salt, msg=password))`. Never stored — a hand-edited password +/// can't drift from its hash. pub fn bitcoind_rpcauth(user: &str, password: &str) -> String { let salt = derived_salt(user, password); let mut mac = Hmac::::new_from_slice(salt.as_bytes()).expect("hmac accepts any key"); @@ -208,18 +201,17 @@ pub fn bitcoind_rpcauth(user: &str, password: &str) -> String { format!("{user}:{salt}${digest}") } -/// Deterministic salt so the rpcauth line (and the rendered compose file) is -/// stable across renders. Salt secrecy is not load-bearing in the rpcauth -/// scheme — bitcoind stores it in plaintext beside the hash. +/// Deterministic salt so the rpcauth line (and the rendered compose file) is stable across renders. +/// Salt secrecy is not load-bearing in the rpcauth scheme, bitcoind stores it in plaintext beside +/// the hash. fn derived_salt(user: &str, password: &str) -> String { use sha2::Digest; let digest = Sha256::digest(format!("{user}:{password}").as_bytes()); digest[..8].iter().map(|b| format!("{b:02x}")).collect() } -/// bail! with a ready-to-paste snippet listing the missing secret fields, -/// one table per section (fields sharing a section are grouped so the -/// snippet is valid TOML). +/// bail! with a ready-to-paste snippet listing the missing secret fields, one table per section +/// (fields sharing a section are grouped so the snippet is valid TOML). pub fn missing_secrets_error(data_dir: &Path, missing: &[(&str, &str)]) -> anyhow::Error { let mut sections: Vec<(&str, Vec<&str>)> = Vec::new(); for (section, field) in missing { diff --git a/src/utils/services.rs b/src/utils/services.rs index 8abe82f..98214b9 100644 --- a/src/utils/services.rs +++ b/src/utils/services.rs @@ -16,8 +16,7 @@ fn image( version: Option<&str>, ) -> String { match image_override { - // Full ref with its own tag: used verbatim (validation rejects a - // conflicting `version`). + // Full ref with its own tag: used verbatim (validation rejects a conflicting `version`). Some(full) if crate::utils::versions::has_explicit_tag(full) => full.to_string(), // Repository override: tag still comes from `version`/the default. Some(repo) => format!("{repo}:{}", version.unwrap_or(default_tag)), @@ -142,9 +141,8 @@ pub fn roster(deployment: &Deployment) -> Vec<(&'static str, ServiceMode)> { ] } -/// Every (service, host port) this deployment publishes when started — -/// the base ports shifted by `port_offset`. Used by the start preflight to -/// test-bind before compose does. +/// Every (service, host port) this deployment publishes when started, the base ports shifted by +/// `port_offset`. Used by the start preflight to test-bind before compose does. pub fn published_ports(deployment: &Deployment) -> Vec<(&'static str, u16)> { let mut ports = Vec::new(); if deployment.bitcoind.mode == ServiceMode::Enabled { diff --git a/src/utils/versions.rs b/src/utils/versions.rs index 3d94d4b..9c27e18 100644 --- a/src/utils/versions.rs +++ b/src/utils/versions.rs @@ -1,6 +1,6 @@ -//! Version parsing/comparison shared by `chainstate download` and `upgrade`: -//! numeric-tuple versions (semver `9.1.0` and stacks-core's five-part -//! `3.1.0.0.8` compare the same way) and pulled-image version resolution. +//! Version parsing/comparison shared by `chainstate download` and `upgrade`: numeric-tuple versions +//! (semver `9.1.0` and stacks-core's five-part `3.1.0.0.8` compare the same way) and pulled-image +//! version resolution. use std::process::Command; @@ -25,9 +25,8 @@ pub fn version_string(v: &[u64]) -> String { v.iter().map(u64::to_string).collect::>().join(".") } -/// Numeric prefix of a distro-suffixed tag ("16.5-alpine" -> "16.5", -/// "17-bookworm" -> "17"); tags without a numeric prefix ("latest", -/// "bookworm") are returned unchanged. +/// Numeric prefix of a distro-suffixed tag ("16.5-alpine" -> "16.5", "17-bookworm" -> "17"); tags +/// without a numeric prefix ("latest", "bookworm") are returned unchanged. pub fn strip_tag_suffix(tag: &str) -> &str { match tag.split_once('-') { Some((prefix, _)) if parse_version(prefix).is_some() => prefix, @@ -35,14 +34,14 @@ pub fn strip_tag_suffix(tag: &str) -> &str { } } -/// Whether an image ref carries an explicit tag. A colon whose right side -/// contains `/` is a registry port (`localhost:5000/repo`), not a tag. +/// Whether an image ref carries an explicit tag. A colon whose right side contains `/` is a +/// registry port (`localhost:5000/repo`), not a tag. pub fn has_explicit_tag(image: &str) -> bool { matches!(image.rsplit_once(':'), Some((_, tag)) if !tag.contains('/')) } -/// Tag of an image ref, defaulting to `latest` when untagged. A colon whose -/// right side contains `/` is a registry port (`localhost:5000/repo`), not a tag. +/// Tag of an image ref, defaulting to `latest` when untagged. A colon whose right side contains `/` +/// is a registry port (`localhost:5000/repo`), not a tag. pub fn image_tag(image: &str) -> String { match image.rsplit_once(':') { Some((_, tag)) if !tag.contains('/') => tag.to_string(), @@ -51,8 +50,8 @@ pub fn image_tag(image: &str) -> String { } /// Concrete version of a locally pulled image, from its OCI version label -/// (`org.opencontainers.image.version` — present on stacks-core and -/// stacks-blockchain-api images). Local inspect only; never pulls. +/// (`org.opencontainers.image.version`, present on stacks-core and stacks-blockchain-api images). +/// Local inspect only; never pulls. pub fn pulled_image_version(image: &str) -> Option> { let out = Command::new("docker") .args([ From 67b194a672c19111e266f2e27e078ead6420d5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:13:22 -0600 Subject: [PATCH 16/19] ci: release flow with docker and binary artifacts (#30) --- .github/workflows/release.yml | 161 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + Dockerfile | 16 ++++ README.md | 18 +++- release.sh | 60 +++++++++++++ 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile create mode 100755 release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..514a27d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,161 @@ +name: Release + +# Releases are cut from an existing signed tag: bump Cargo.toml's version, tag the commit +# `v`, push the tag, then dispatch this workflow with the same version. It cross-checks the +# tag against Cargo.toml, builds binaries on native runners (no QEMU compiles), attaches them to a +# GitHub Release, and pushes a multi-arch image to GHCR. +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 1.3.0 or 1.3.0-beta.0); tag v must exist' + required: true + type: string + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: true + matrix: + include: + # musl -> fully static Linux binaries: portable across distros and drop-in for the + # alpine-based docker image. + - target: x86_64-unknown-linux-musl + runner: ubuntu-24.04 + docker_arch: amd64 + - target: aarch64-unknown-linux-musl + runner: ubuntu-24.04-arm + docker_arch: arm64 + - target: x86_64-apple-darwin + runner: macos-13 + - target: aarch64-apple-darwin + runner: macos-latest + steps: + - name: Checkout tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: v${{ inputs.version }} + + - name: Verify tag matches Cargo.toml version + run: | + CARGO_V=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) + if [ "$CARGO_V" != "${{ inputs.version }}" ]; then + echo "Cargo.toml version ($CARGO_V) does not match release version (${{ inputs.version }})" + exit 1 + fi + + - name: Install musl toolchain + if: contains(matrix.target, 'musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Install Rust toolchain + run: | + rustup toolchain install --profile minimal + rustup target add ${{ matrix.target }} + + - name: Build + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package + run: | + ARCHIVE="stacksup-v${{ inputs.version }}-${{ matrix.target }}.tar.gz" + mkdir -p dist + tar -czf "dist/$ARCHIVE" -C "target/${{ matrix.target }}/release" stacksup + (cd dist && shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256") + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: stacksup-${{ matrix.target }} + path: dist/* + if-no-files-found: error + + github-release: + runs-on: ubuntu-latest + needs: build + permissions: + contents: write + steps: + - name: Checkout tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: v${{ inputs.version }} + + - name: Download binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PRERELEASE_FLAG="" + if echo "${{ inputs.version }}" | grep -q "-"; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "v${{ inputs.version }}" dist/* \ + --verify-tag \ + --generate-notes \ + $PRERELEASE_FLAG + + publish-docker: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + packages: write + steps: + - name: Checkout tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: v${{ inputs.version }} + + - name: Download Linux binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: stacksup-*-linux-musl + path: artifacts + + # Lay the static binaries out as dist//stacksup for the Dockerfile's per-arch + # COPY. The image has no RUN steps, so the multi-platform build needs no QEMU emulation. + - name: Stage binaries per arch + run: | + mkdir -p dist/amd64 dist/arm64 + tar -xzf artifacts/stacksup-x86_64-unknown-linux-musl/*.tar.gz -C dist/amd64 + tar -xzf artifacts/stacksup-aarch64-unknown-linux-musl/*.tar.gz -C dist/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Docker meta + id: docker_meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }} + type=semver,pattern={{major}},value=${{ inputs.version }} + type=raw,value=latest,enable=${{ !contains(inputs.version, '-') }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build/Tag/Push Image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + push: true diff --git a/.gitignore b/.gitignore index b141ffa..da3fbe9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ stacks.toml /.export-tmp .DS_Store /secrets.toml +/dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..110ba8c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Release image. The static musl binaries are built by the release workflow and staged as +# dist//stacksup before this builds. There are no RUN steps, so the multi-platform build +# needs no emulation. docker:cli ships the docker CLI + compose plugin stacksup shells out to. +# +# stacksup drives the HOST's docker daemon, and the compose file it renders uses bind mounts the +# daemon resolves as host paths, so mount the deployment directory at the SAME path inside the +# container: +# +# docker run --rm -it \ +# -v /var/run/docker.sock:/var/run/docker.sock \ +# -v "$PWD":"$PWD" -w "$PWD" \ +# ghcr.io/stx-labs/stacksup start +FROM docker:28-cli +ARG TARGETARCH +COPY dist/${TARGETARCH}/stacksup /usr/local/bin/stacksup +ENTRYPOINT ["stacksup"] diff --git a/README.md b/README.md index 31fab6b..d78aba2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,22 @@ stacksup chainstate status # compare every service's chain tip (stacks + bitcoin stacksup chainstate download # seed chainstate from the Hiro Archive (resumable, verified) ``` +## Installing + +Grab a binary from [GitHub Releases](https://github.com/stx-labs/stacksup/releases) +(macOS arm64/x86_64, Linux arm64/x86_64 — the Linux builds are fully static) +and put `stacksup` on your PATH. Or run it as a container: the image drives +the **host's** docker daemon, and the compose file it renders uses bind +mounts the daemon resolves as host paths — so mount your deployment +directory at the *same path* inside the container: + +```bash +docker run --rm -it \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD":"$PWD" -w "$PWD" \ + ghcr.io/stx-labs/stacksup start +``` + ## Networks Standard network definitions (burnchain endpoint, chain id, epochs, seeded @@ -151,5 +167,5 @@ the service's configured `version` in stacks.toml. - [ ] `upgrade`: image update with pre-upgrade pg backup, ordered restart, post-check - [ ] `snapshot`: stop-consistent chainstate + pg_dump pairs with version metadata - [ ] Profiles: `exchange` (readonly API replicas, pruned mode), richer `signer` (monitor-signers wiring) -- [ ] Release: `dist init` for GitHub Releases + Homebrew tap (`brew install ...`) +- [x] Release: binaries on GitHub Releases + multi-arch image on GHCR (`.github/workflows/release.yml`); Homebrew tap still to come - [ ] Pin real image tags (stacks-core, signer, API, mesh API) diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..706eb2c --- /dev/null +++ b/release.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-}" + +if [ -z "$VERSION" ]; then + echo "Usage: $0 " + echo " e.g. $0 1.3.0" + echo " e.g. $0 1.3.0-beta.0" + exit 1 +fi + +VERSION="${VERSION#v}" +TAG="v${VERSION}" +if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "Error: '${VERSION}' is not a valid version (expected X.Y.Z or X.Y.Z-pre.N)." + exit 1 +fi + +# Ensure we're on a clean working tree +if [ -n "$(git status --porcelain)" ]; then + echo "Error: Working tree is not clean. Commit or stash changes first." + exit 1 +fi + +# Ensure we're up to date with remote +git fetch origin +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/${BRANCH}")" ]; then + echo "Error: Local branch '${BRANCH}' is not up to date with origin. Pull or push first." + exit 1 +fi + +# Check tag doesn't already exist +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: Tag '${TAG}' already exists." + exit 1 +fi + +echo "Updating version to ${VERSION} (tag ${TAG}) on branch ${BRANCH}" +echo "" + +echo "Updating Cargo.toml..." +perl -0pi -e 's/(\[package\][^\[]*?^version = ")[^"]+(")/${1}'"${VERSION}"'${2}/ms' Cargo.toml + +echo "Updating Cargo.lock..." +cargo update --workspace --quiet + +NEW_VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) +if [ "$NEW_VERSION" != "$VERSION" ]; then + echo "Error: Cargo.toml version is '${NEW_VERSION}' after the edit, expected '${VERSION}'." + exit 1 +fi + +echo "" +echo "Done! Version ${VERSION} updated in Cargo.toml and Cargo.lock." +echo "Next steps:" +echo " Commit and merge a Pull Request with the version bump (git add Cargo.toml Cargo.lock)" +echo " Push the (signed) tag from the merged commit: git tag ${TAG} && git push origin ${TAG}" +echo " Trigger the Release workflow via GitHub UI with version: ${VERSION}" From bdc8ed94c5d8a350a5b8bbce9baaa26f34465113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:43:38 -0600 Subject: [PATCH 17/19] feat: add support for signer-sidekick (#31) * signer sidekick init * testnet tweaks * fixes * sidekick updats * chore: pin signer-sidekick 2.3.0 --- README.md | 35 +++++ networks/mainnet.toml | 1 + networks/testnet.toml | 1 + src/chainstate/mod.rs | 26 ++-- src/config/mod.rs | 228 ++++++++++++++++++++++++++++ src/config/network.rs | 3 + src/config/render.rs | 336 +++++++++++++++++++++++++++++++++++++++++- src/logs/export.rs | 2 + src/upgrade.rs | 24 +++ src/utils/secrets.rs | 27 +++- src/utils/services.rs | 41 +++++- src/utils/versions.rs | 9 ++ 12 files changed, 716 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d78aba2..9527205 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,41 @@ off-host), so containers can't sidestep the split via `host.docker.internal`; the P2P port stays open on purpose — it exists to accept peers from anywhere. +## Signer Sidekick + +Any deployment with a node can enable [Signer Sidekick](https://github.com/stx-labs/signer-sidekick), +the PoX-5 operations dashboard that monitors registration, pool membership, +rewards, and signer health against your node. A signer in the deployment is +recommended (it feeds the Signer Health page's direct telemetry) but not +required — sidekick can monitor any deployed, compatible signer-manager +read-only: + +```toml +[signer-sidekick] +mode = "enabled" +manager_principal = "SP....signer-manager" # your deployed PoX-5 signer-manager +``` + +Sidekick's database lives under `chainstate/signer-sidekick/` like every +other service's state (the container runs as the directory's owner, so the +image's non-root user can write the bind mount). For an online backup while +sidekick is running — especially before relying on an operator-run gas +wallet — use its own tooling: + +```bash +docker compose -f rendered/docker-compose.yml run --rm --no-deps signer-sidekick database backup /data/backup.sqlite +``` + +stacksup wires it by construction: node RPC and (when managed) node/signer +telemetry endpoints, the managed stacks-api as its indexed API (falling back +to the network's Hiro API), the dashboard auth token from `secrets.toml` +(`[signer-sidekick] auth_token`), and the release's manager/compatibility +profiles fetched once per version. The dashboard publishes loopback-only on +port 3997 (shifted by `port_offset`); `stacksup chainstate wipe +signer-sidekick` wipes its database. The engine defaults to `observe`; set +`engine_mode = "operator-run"` only after reading sidekick's operator docs +(its gas wallet is created inside the dashboard, never in config files). + ## Running multiple deployments One machine can host several stacks side by side. Give each deployment its diff --git a/networks/mainnet.toml b/networks/mainnet.toml index 6cb28fa..14fbaf0 100644 --- a/networks/mainnet.toml +++ b/networks/mainnet.toml @@ -3,6 +3,7 @@ name = "mainnet" chain_id = 0x00000001 +hiro_api_url = "https://api.mainnet.hiro.so" hiro_archive_path = "mainnet" [bitcoind] diff --git a/networks/testnet.toml b/networks/testnet.toml index 51c8ff0..d3a45b4 100644 --- a/networks/testnet.toml +++ b/networks/testnet.toml @@ -3,6 +3,7 @@ name = "testnet" chain_id = 0x80000000 +hiro_api_url = "https://api.testnet.hiro.so" hiro_archive_path = "testnet" [bitcoind] diff --git a/src/chainstate/mod.rs b/src/chainstate/mod.rs index 6b22b6f..1c9ff34 100644 --- a/src/chainstate/mod.rs +++ b/src/chainstate/mod.rs @@ -25,7 +25,7 @@ struct Tip { /// /// Works whether the stack is running or stopped, with different coverage: the node's tips are read /// straight from its sqlite files (safe read-only even while the node writes), while the API's tip -/// lives in Postgres and bitcoind's in LevelDB — those two are only checkable while running. +/// lives in Postgres and bitcoind's in LevelDB, those two are only checkable while running. pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { let running = crate::utils::docker::running_services(data_dir).unwrap_or_default(); let mut tips: Vec = Vec::new(); @@ -44,7 +44,7 @@ pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { } if tips.is_empty() { - bail!("no enabled services hold chainstate — nothing to check"); + bail!("no enabled services hold chainstate, nothing to check"); } println!( @@ -89,7 +89,7 @@ pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { let warning = format!( "⚠ stacks-node ({node}) is BEHIND stacks-api ({api}).\n \ This is recoverable: as the node syncs it will reach height {api} and\n \ - the API will follow along normally from there. No action needed —\n \ + the API will follow along normally from there. No action needed,\n \ let the node catch up." ); println!("{}", warning.yellow()); @@ -107,7 +107,7 @@ pub fn status(deployment: &Deployment, data_dir: &Path) -> Result<()> { bail!("chainstate is inconsistent"); } _ => { - println!("Not enough readable tips to compare — need both the stacks-node and"); + println!("Not enough readable tips to compare, need both the stacks-node and"); println!("stacks-api heights (start the stack for full coverage)."); } } @@ -135,7 +135,7 @@ fn node_tip(deployment: &Deployment, data_dir: &Path) -> Tip { }; if !sort_db.exists() { tip.note = Some(format!( - "no sortition db at {} — has the node run yet?", + "no sortition db at {}, has the node run yet?", sort_db.display() )); return tip; @@ -190,7 +190,7 @@ fn bitcoind_tip(deployment: &Deployment, running: bool) -> Tip { }; if !running { tip.note = - Some("bitcoind is not running — height in LevelDB is not readable offline".into()); + Some("bitcoind is not running, height in LevelDB is not readable offline".into()); return tip; } let chain = deployment.net.bitcoind.chain.as_str(); @@ -231,11 +231,11 @@ fn api_tip(deployment: &Deployment, postgres_running: bool) -> Tip { note: None, }; if deployment.postgres.mode != ServiceMode::Enabled { - tip.note = Some("API uses an external/disabled postgres — not checked by this tool".into()); + tip.note = Some("API uses an external/disabled postgres, not checked by this tool".into()); return tip; } if !postgres_running { - tip.note = Some("postgres is not running — `stacksup start` to check the API's tip".into()); + tip.note = Some("postgres is not running, `stacksup start` to check the API's tip".into()); return tip; } let user = deployment.postgres.user.as_deref().unwrap_or("postgres"); @@ -259,7 +259,7 @@ fn api_tip(deployment: &Deployment, postgres_running: bool) -> Tip { tip.stacks = parts.next().and_then(|s| s.trim().parse().ok()); tip.bitcoin = parts.next().and_then(|s| s.trim().parse().ok()); if tip.stacks.is_none() { - tip.note = Some("chain_tip is empty — the API hasn't indexed a block yet".into()); + tip.note = Some("chain_tip is empty, the API hasn't indexed a block yet".into()); } } Ok(o) => { @@ -279,6 +279,7 @@ const CHAINSTATE_SERVICES: &[(&str, &[&str])] = &[ ("bitcoind", &["bitcoind"]), ("stacks-node", &["stacks-node"]), ("stacks-signer", &["stacks-signer"]), + ("signer-sidekick", &["signer-sidekick"]), ("postgres", &["postgres", "stacks-api"]), ]; @@ -287,7 +288,7 @@ pub fn wipe(data_dir: &Path, service: Option<&str>, yes: bool) -> Result<()> { Some(name) => match CHAINSTATE_SERVICES.iter().find(|(n, _)| *n == name) { Some((_, users)) => users, None => bail!( - "`{name}` has no on-disk chainstate — services with state: {}", + "`{name}` has no on-disk chainstate, services with state: {}", CHAINSTATE_SERVICES .iter() .map(|(n, _)| *n) @@ -314,7 +315,7 @@ pub fn wipe(data_dir: &Path, service: Option<&str>, yes: bool) -> Result<()> { .collect(); if !blocking.is_empty() { bail!( - "still running ({}) — stop them first with `stacksup stop [service]`", + "still running ({}), stop them first with `stacksup stop [service]`", blocking .iter() .map(|s| s.as_str()) @@ -352,13 +353,14 @@ pub fn wipe(data_dir: &Path, service: Option<&str>, yes: bool) -> Result<()> { let mut input = String::new(); io::stdin().read_line(&mut input)?; if input.trim() != "yes" { - println!("Aborted — nothing was deleted."); + println!("Aborted, nothing was deleted."); return Ok(()); } } std::fs::remove_dir_all(&dir).with_context(|| format!("failed to delete {}", dir.display()))?; println!("Deleted {}", dir.display()); + Ok(()) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 1895e45..3c263a0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -65,6 +65,8 @@ pub struct Deployment { pub stacks_api: StacksApi, #[serde(default, rename = "stacks-mesh-api")] pub stacks_mesh_api: StacksMeshApi, + #[serde(default, rename = "signer-sidekick")] + pub signer_sidekick: SignerSidekick, #[serde(default)] pub postgres: Postgres, } @@ -157,6 +159,35 @@ pub struct StacksMeshApi { pub port: Option, } +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +/// signer-sidekick (github.com/stx-labs/signer-sidekick): PoX-5 operations dashboard for a +/// signer and its pool. Optional; requires a node. A signer in this deployment is recommended +/// (direct Signer Health telemetry) but not required — sidekick can monitor any deployed, +/// compatible signer-manager read-only. +pub struct SignerSidekick { + #[serde(default)] + pub mode: ServiceMode, + /// Docker image override: a repository that keeps using `version`/the default tag, or a full + /// ref with its own tag (mutually exclusive with `version`). + pub image: Option, + /// The image tag (semver, e.g. "2.3.0"; releases before 2.1.0 used v-prefixed tags). + pub version: Option, + /// The deployed PoX-5 signer-manager contract this deployment's signer registers through + /// (SP....contract-name). Required when enabled. + pub manager_principal: Option, + /// "observe" (default) or "operator-run" (sidekick signs reward calls with the gas wallet it + /// generates in its own Settings UI; enable deliberately). + pub engine_mode: Option, + /// Indexed Stacks API. Defaults to the managed stacks-api when enabled, else the network's + /// Hiro API. + pub stacks_api_url: Option, + /// SECRET: set in secrets.toml ([signer-sidekick] auth_token), never here. + pub auth_token: Option, + /// SECRET: optional Hiro API key, set in secrets.toml ([signer-sidekick] stacks_api_key). + pub stacks_api_key: Option, +} + #[derive(Debug, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Postgres { @@ -184,6 +215,25 @@ fn default_deployment_name() -> String { DEFAULT_PROJECT.into() } +/// `ADDRESS.contract-name`: a c32 Stacks address (starts with S, c32 alphabet — no I/L/O/U) plus +/// a lowercase contract name. Shape and alphabet only: full checksum verification is delegated to +/// sidekick's own `connection check` / `manager verify`, which validate the principal against the +/// chain — strictly stronger than a checksum. +fn is_contract_principal(p: &str) -> bool { + const C32_ALPHABET: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let Some((addr, name)) = p.split_once('.') else { + return false; + }; + (28..=41).contains(&addr.len()) + && addr.starts_with('S') + && addr.chars().all(|c| C32_ALPHABET.contains(c)) + && !name.is_empty() + && name.len() <= 40 + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + impl Deployment { /// The docker compose project (embedded in the rendered compose file as its top-level `name:`), /// and the container-name prefix. @@ -218,6 +268,12 @@ impl Deployment { if self.stacks_node.auth_token.is_some() { leaks.push(("stacks-node", "auth_token")); } + if self.signer_sidekick.auth_token.is_some() { + leaks.push(("signer-sidekick", "auth_token")); + } + if self.signer_sidekick.stacks_api_key.is_some() { + leaks.push(("signer-sidekick", "stacks_api_key")); + } leaks } @@ -227,6 +283,8 @@ impl Deployment { self.bitcoind.rpc_password = overlay.bitcoind.rpc_password; self.postgres.password = overlay.postgres.password; self.stacks_node.auth_token = overlay.stacks_node.auth_token; + self.signer_sidekick.auth_token = overlay.signer_sidekick.auth_token; + self.signer_sidekick.stacks_api_key = overlay.signer_sidekick.stacks_api_key; } /// Secrets that MUST be present given the enabled services. The tool never writes secrets.toml @@ -251,6 +309,11 @@ impl Deployment { if node_needs_token && self.stacks_node.auth_token.is_none() { missing.push(("stacks-node", "auth_token")); } + if self.signer_sidekick.mode == ServiceMode::Enabled + && self.signer_sidekick.auth_token.is_none() + { + missing.push(("signer-sidekick", "auth_token")); + } if !missing.is_empty() { return Err(crate::utils::secrets::missing_secrets_error( config_dir, &missing, @@ -335,6 +398,11 @@ impl Deployment { &self.stacks_mesh_api.image, &self.stacks_mesh_api.version, ), + ( + "signer-sidekick", + &self.signer_sidekick.image, + &self.signer_sidekick.version, + ), ("postgres", &self.postgres.image, &self.postgres.version), ] { if let (Some(image), Some(_)) = (image, version) @@ -406,6 +474,73 @@ impl Deployment { } } + // signer-sidekick monitors THIS deployment's signer through the node RPC and a deployed + // signer-manager contract; both ends must exist and the manager must be named. + match self.signer_sidekick.mode { + ServiceMode::External => errors.push( + "[signer-sidekick] mode = \"external\" is not supported — run it here (\"enabled\") or disable it" + .into(), + ), + ServiceMode::Enabled => { + if self.stacks_node.mode == ServiceMode::Disabled { + errors.push( + "[signer-sidekick] requires a stacks-node; set [stacks-node] mode = \"enabled\" or \"external\"" + .into(), + ); + } + // A signer is recommended, not required: sidekick can monitor any deployed + // manager read-only; without a signer its Signer Health page just lacks the + // direct process telemetry. + if self.stacks_signer.mode == ServiceMode::Disabled { + warnings.push( + "signer-sidekick is enabled without a signer in this deployment — pool and \ + manager monitoring work, but the Signer Health page will have no direct \ + signer telemetry" + .into(), + ); + } + match self.signer_sidekick.manager_principal.as_deref() { + None => errors.push( + "[signer-sidekick] requires manager_principal — the deployed PoX-5 signer-manager contract (e.g. SP2ABC....signer-manager)" + .into(), + ), + Some(p) if !is_contract_principal(p) => errors.push(format!( + "[signer-sidekick] manager_principal `{p}` is not a contract principal (expected ADDRESS.contract-name)" + )), + _ => {} + } + if let Some(mode) = self.signer_sidekick.engine_mode.as_deref() + && !matches!(mode, "observe" | "operator-run") + { + errors.push(format!( + "[signer-sidekick] engine_mode `{mode}` is invalid: \"observe\" or \"operator-run\"" + )); + } + // An explicit URL must actually be one — an empty value would silently render + // STACKS_API_URL= and suppress the managed-API default. + if let Some(url) = self.signer_sidekick.stacks_api_url.as_deref() + && !(url.starts_with("http://") || url.starts_with("https://")) + { + errors.push(format!( + "[signer-sidekick] stacks_api_url `{url}` must be an http(s) URL (or be removed to use the default)" + )); + } + // Sidekick needs an indexed API from somewhere: the managed one, an explicit + // URL, or the network's Hiro API. + if self.stacks_api.mode != ServiceMode::Enabled + && self.signer_sidekick.stacks_api_url.is_none() + && self.net.hiro_api_url.is_none() + { + errors.push(format!( + "[signer-sidekick] needs an indexed Stacks API: enable [stacks-api], set \ + stacks_api_url, or add `hiro_api_url` to the `{}` network definition", + self.network + )); + } + } + ServiceMode::Disabled => {} + } + // Reversed (push) edges: the node's config must name its observers. When the node is // external we can't write that config, only emit it. if self.stacks_node.mode == ServiceMode::External @@ -541,6 +676,15 @@ mode = "enabled" [stacks-mesh-api] mode = "disabled" +# PoX-5 operations dashboard for a signer and its pool +# (github.com/stx-labs/signer-sidekick). Requires a node; a signer in this +# deployment is recommended but not required (monitor-only works). +[signer-sidekick] +mode = "disabled" +# manager_principal = "SP....signer-manager" # your deployed PoX-5 signer-manager (required) +# engine_mode = "observe" # or "operator-run" (deliberate opt-in) +# The dashboard auth token goes in secrets.toml ([signer-sidekick] auth_token). + [postgres] mode = "enabled" # version = "17" @@ -708,6 +852,90 @@ mod tests { ); } + #[test] + fn sidekick_requires_signer_node_and_manager() { + let base = "network = \"testnet\"\n[signer-sidekick]\nmode = \"enabled\""; + let e = errors(base); + assert!( + e.iter().any(|m| m.contains("requires a stacks-node")), + "got: {e:?}" + ); + assert!(e.iter().any(|m| m.contains("requires manager_principal"))); + // a signer is recommended, not required: monitor-only deployments are valid + assert!(!e.iter().any(|m| m.contains("signer-sidekick] monitors"))); + let monitor_only = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\""; + assert!( + errors(monitor_only).is_empty(), + "got: {:?}", + errors(monitor_only) + ); + assert!( + warnings(monitor_only) + .iter() + .any(|m| m.contains("Signer Health")) + ); + + let bad_principal = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"not-a-principal\""; + assert!( + errors(bad_principal) + .iter() + .any(|m| m.contains("not a contract principal")) + ); + + let bad_engine = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\"\nengine_mode = \"autopilot\""; + assert!(errors(bad_engine).iter().any(|m| m.contains("engine_mode"))); + + let external = "network = \"testnet\"\n[signer-sidekick]\nmode = \"external\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\""; + assert!(errors(external).iter().any(|m| m.contains("not supported"))); + + let ok = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\""; + assert!(errors(ok).is_empty(), "got: {:?}", errors(ok)); + } + + #[test] + fn sidekick_review_validations() { + // image with explicit tag + version -> rejected like every other service + let both = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nimage = \"myorg/sidekick:v9\"\nversion = \"2.0.0\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\""; + assert!( + errors(both) + .iter() + .any(|m| m.contains("already pins a tag")) + ); + // empty explicit stacks_api_url -> rejected instead of rendering STACKS_API_URL= + let empty_url = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nstacks_api_url = \"\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\""; + assert!( + errors(empty_url) + .iter() + .any(|m| m.contains("must be an http(s) URL")) + ); + // c32 alphabet: I, L, O, U never appear in a Stacks address + assert!(!is_contract_principal( + "SPILOU00000000000000000002Q6VF78.signer-manager" + )); + assert!(is_contract_principal( + "SP000000000000000000002Q6VF78.signer-manager" + )); + } + + #[test] + fn sidekick_auth_token_is_required_and_leak_checked() { + let d = deployment( + "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\"", + ); + let err = d + .check_required_secrets(Path::new(".")) + .unwrap_err() + .to_string(); + assert!(err.contains("[signer-sidekick]\nauth_token"), "got: {err}"); + + let mut leaky = deployment("network = \"testnet\""); + leaky.signer_sidekick.auth_token = Some("x".into()); + leaky.signer_sidekick.stacks_api_key = Some("y".into()); + let leaks = leaky.config_secret_leaks(); + assert!(leaks.contains(&("signer-sidekick", "auth_token"))); + assert!(leaks.contains(&("signer-sidekick", "stacks_api_key"))); + } + #[test] fn secrets_in_stacks_toml_are_detected_as_leaks() { let d = deployment( diff --git a/src/config/network.rs b/src/config/network.rs index 0552972..532d3cb 100644 --- a/src/config/network.rs +++ b/src/config/network.rs @@ -24,6 +24,9 @@ pub struct NetworkDef { pub chain_id: u32, /// Path segment under archive.hiro.so; absent when no Hiro archives exist for this network. pub hiro_archive_path: Option, + /// Hiro-hosted indexed Stacks API for this network; the sidekick default when no local + /// stacks-api is enabled and no explicit URL is configured. + pub hiro_api_url: Option, pub bitcoind: BitcoindNet, pub node: NodeNet, #[serde(default, rename = "ustx_balance")] diff --git a/src/config/render.rs b/src/config/render.rs index a33fe88..e827cd5 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -88,6 +88,20 @@ struct ComposeService { secrets: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] extra_hosts: Vec, + /// Container user override ("uid:gid"): lets a non-root image write our bind mounts. + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, + // Hardening knobs (used by services whose upstream compose ships them). + #[serde(skip_serializing_if = "Option::is_none")] + init: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + read_only: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + tmpfs: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + security_opt: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + cap_drop: Vec, } impl ComposeService { @@ -192,6 +206,25 @@ pub fn render(deployment: &Deployment, data_dir: &Path) -> Result { .insert("stacks-mesh-api".into(), mesh_api_service(deployment)); } + if deployment.signer_sidekick.mode == ServiceMode::Enabled { + chainstate_subdir("signer-sidekick")?; + std::fs::create_dir_all(dir.join("signer-sidekick"))?; + // Carries the dashboard auth token (and optionally a Hiro API key) -> 0600. + secrets::write_0600( + &dir.join("signer-sidekick/.env"), + &format!("{GENERATED_HEADER}{}", sidekick_env(deployment)), + )?; + ensure_sidekick_profiles(deployment, data_dir, &dir.join("signer-sidekick"))?; + // The image bakes in a non-root user (uid 10001) with no root entrypoint to chown a + // mount, so the service runs as the OWNER of the chainstate dir instead — that keeps + // its sqlite inside --data-dir like every other service's state (back up one unit). + let owner = std::fs::metadata(data_dir.join("chainstate/signer-sidekick"))?; + compose.services.insert( + "signer-sidekick".into(), + sidekick_service(deployment, &owner), + ); + } + if deployment.postgres.mode == ServiceMode::Enabled { chainstate_subdir("postgres")?; std::fs::create_dir_all(data_dir.join("downloads"))?; @@ -457,6 +490,12 @@ fn node_config_toml(deployment: &Deployment) -> String { } else { out.push_str("miner = false\nstacker = false\n"); } + if deployment.signer_sidekick.mode == ServiceMode::Enabled { + // Sidekick's Signer Health page reads node telemetry (container-internal endpoint). + out.push_str(&format!( + "prometheus_bind = \"0.0.0.0:{NODE_METRICS_PORT}\"\n" + )); + } if let Some(extra) = &net.node.node_extra { out.push_str(extra.trim()); out.push('\n'); @@ -553,19 +592,26 @@ fn node_config_toml(deployment: &Deployment) -> String { fn signer_config_toml(deployment: &Deployment) -> String { let node_host = node_rpc_host(deployment).unwrap_or_default(); + let metrics = if deployment.signer_sidekick.mode == ServiceMode::Enabled { + // Sidekick reads /info, /heartbeat, /metrics from here (container-internal). + format!("metrics_endpoint = \"0.0.0.0:{SIGNER_METRICS_PORT}\"\n") + } else { + String::new() + }; format!( r#"node_host = "{node_host}:{rpc_port}" endpoint = "0.0.0.0:{endpoint_port}" auth_password = "{auth}" network = "{network}" db_path = "/var/lib/stacks-signer/signerdb.sqlite" -# TODO(hackathon): source the signing key from a secrets file, never this rendered config +{metrics}# TODO(hackathon): source the signing key from a secrets file, never this rendered config stacks_private_key = "REPLACE_ME" "#, rpc_port = node_rpc_port(deployment), endpoint_port = SIGNER_ENDPOINT_PORT, auth = node_auth_token(deployment), - network = deployment.network, + // resolved name, not the config reference (which may be a definition file path) + network = deployment.net.name, ) } @@ -638,6 +684,171 @@ STACKS_CORE_RPC_AUTH_TOKEN={auth_token} ) } +/// Env for signer-sidekick (compose.yaml in stx-labs/signer-sidekick is the schema of record). +/// The engine defaults to observe; operator-run is a deliberate operator opt-in, and its gas +/// wallet is generated inside sidekick's own UI — no key ever passes through here. +fn sidekick_env(deployment: &Deployment) -> String { + let sk = &deployment.signer_sidekick; + let mut out = format!( + "SIDEKICK_NETWORK={network}\n\ + STACKS_NODE_RPC_URL=http://{node_host}:{node_rpc}\n\ + STACKS_API_URL={api_url}\n\ + SIDEKICK_MANAGER_PRINCIPAL={manager}\n\ + SIDEKICK_AUTH_TOKEN={auth}\n\ + SIDEKICK_ENGINE_MODE={engine}\n\ + SIDEKICK_TRUSTED_MANAGER_PROFILES_DIR=/etc/sidekick/trusted-managers\n\ + SIDEKICK_COMPATIBILITY_PROFILES_DIR=/etc/sidekick/network-compatibility\n", + // The RESOLVED definition's name — `deployment.network` may be a custom definition + // file path. Plain "testnet" is canonical since sidekick 2.1.0 (pox5-testnet is a + // legacy alias; 2.0.0 targeted a chain id that no longer exists). + network = deployment.net.name, + node_host = node_rpc_host(deployment).unwrap_or_default(), + node_rpc = node_rpc_port(deployment), + api_url = sidekick_api_url(deployment), + manager = sk.manager_principal.as_deref().unwrap_or_default(), + auth = sk.auth_token.as_deref().unwrap_or_default(), + engine = sk.engine_mode.as_deref().unwrap_or("observe"), + ); + if let Some(key) = sk.stacks_api_key.as_deref() { + out.push_str(&format!("STACKS_API_KEY={key}\n")); + } + // Direct telemetry for the Signer Health page — only endpoints we render ourselves. + if deployment.stacks_node.mode == ServiceMode::Enabled { + out.push_str(&format!( + "STACKS_NODE_METRICS_URL=http://stacks-node:{NODE_METRICS_PORT}/metrics\n" + )); + } + if deployment.stacks_signer.mode == ServiceMode::Enabled { + out.push_str(&format!( + "STACKS_SIGNER_MONITORING_URL=http://stacks-signer:{SIGNER_METRICS_PORT}\n" + )); + } + out +} + +/// The indexed Stacks API sidekick reads roster/history from: explicit config wins, then the +/// deployment's own API, then the network's Hiro API. +fn sidekick_api_url(deployment: &Deployment) -> String { + if let Some(url) = deployment.signer_sidekick.stacks_api_url.as_deref() { + return url.to_string(); + } + if deployment.stacks_api.mode == ServiceMode::Enabled { + return format!("http://stacks-api:{API_PORT}"); + } + deployment.net.hiro_api_url.clone().unwrap_or_default() +} + +fn sidekick_service(deployment: &Deployment, data_owner: &std::fs::Metadata) -> ComposeService { + use std::os::unix::fs::MetadataExt; + let mut svc = ComposeService::new( + deployment, + "signer-sidekick", + &signer_sidekick_image(deployment), + ); + // Run as the chainstate dir's owner so the bind mount is writable by construction. + svc.user = Some(format!("{}:{}", data_owner.uid(), data_owner.gid())); + svc.env_file = vec!["./signer-sidekick/.env".into()]; + // Dashboard only; bearer-token login is the only auth, so loopback-only. The event listener + // (3700) stays container-internal. + svc.ports = vec![format!( + "127.0.0.1:{}:{SIDEKICK_CONTAINER_PORT}", + deployment.published(SIDEKICK_PORT) + )]; + svc.volumes = vec![ + "../chainstate/signer-sidekick:/data".into(), + "./signer-sidekick/trusted-managers:/etc/sidekick/trusted-managers:ro".into(), + "./signer-sidekick/network-compatibility:/etc/sidekick/network-compatibility:ro".into(), + ]; + // It talks to the node RPC and signer monitoring (core); joins services only to reach a + // managed stacks-api when that is its indexed API. + svc.networks = vec![NET_CORE.into()]; + if deployment.stacks_api.mode == ServiceMode::Enabled + && deployment.signer_sidekick.stacks_api_url.is_none() + { + svc.networks.push(NET_SERVICES.into()); + } + if deployment.stacks_node.mode == ServiceMode::Enabled { + svc.depends_on.push("stacks-node".into()); + } + if deployment.stacks_signer.mode == ServiceMode::Enabled { + svc.depends_on.push("stacks-signer".into()); + } + // Upstream compose hardening, minus its journald logging (which would break + // `stacksup logs`). + svc.init = Some(true); + svc.read_only = true; + svc.tmpfs = vec!["/tmp:size=64m,mode=1777".into()]; + svc.security_opt = vec!["no-new-privileges:true".into()]; + svc.cap_drop = vec!["ALL".into()]; + svc +} + +/// The release image does not bundle sidekick's manager/compatibility profile directories (its +/// own deployment flow clones the repo at the release tag). Fetch them once per version from the +/// tag's tarball into rendered/signer-sidekick/, or accept user-provided directories as-is. +fn ensure_sidekick_profiles(deployment: &Deployment, data_dir: &Path, dest: &Path) -> Result<()> { + let tag = crate::utils::versions::image_tag(&signer_sidekick_image(deployment)); + // Image tags are bare semver since 2.1.0 but the repo's git tags stay v-prefixed. + let git_tag = if tag.starts_with('v') { + tag.clone() + } else { + format!("v{tag}") + }; + let marker = dest.join(".profiles-version"); + let have_dirs = + dest.join("trusted-managers").is_dir() && dest.join("network-compatibility").is_dir(); + if have_dirs + && std::fs::read_to_string(&marker) + .map(|v| v.trim() == tag) + .unwrap_or(false) + { + return Ok(()); + } + if have_dirs && !marker.exists() { + // User-provided profiles: theirs to manage, never overwritten. + return Ok(()); + } + + let url = + format!("https://codeload.github.com/stx-labs/signer-sidekick/tar.gz/refs/tags/{git_tag}"); + println!("Fetching signer-sidekick {git_tag} manager/compatibility profiles..."); + let resp = ureq::get(&url).call().map_err(|e| { + anyhow::anyhow!( + "cannot download sidekick profiles for {git_tag} ({e}) — offline, or the tag has \ + no GitHub release; place `trusted-managers/` and `network-compatibility/` from \ + the signer-sidekick repo under {} yourself", + dest.display() + ) + })?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(resp.into_reader())); + let staging = data_dir.join("downloads").join("sidekick-profiles-tmp"); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging)?; + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?.into_owned(); + // Entries are prefixed "signer-sidekick-/"; keep only the two profile trees. + let Ok(rel) = path.strip_prefix(path.components().next().unwrap()) else { + continue; + }; + let rel = rel.to_path_buf(); + if rel.starts_with("trusted-managers") || rel.starts_with("network-compatibility") { + entry.unpack(staging.join(&rel))?; + } + } + for sub in ["trusted-managers", "network-compatibility"] { + if !staging.join(sub).is_dir() { + anyhow::bail!("sidekick tarball for {tag} did not contain {sub}/"); + } + let target = dest.join(sub); + let _ = std::fs::remove_dir_all(&target); + std::fs::rename(staging.join(sub), &target)?; + } + let _ = std::fs::remove_dir_all(&staging); + std::fs::write(&marker, format!("{tag}\n"))?; + Ok(()) +} + /// Config the user must apply to their *external* node so push edges (events to the API, signer /// wiring) actually close. fn apply_to_your_node(deployment: &Deployment) -> Option { @@ -874,6 +1085,127 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + const SIDEKICK_FULL: &str = "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[stacks-api]\nmode = \"enabled\"\n[postgres]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\""; + + fn sidekick_deployment() -> Deployment { + let mut d = deployment(SIDEKICK_FULL); + d.signer_sidekick.auth_token = Some("test-sidekick-token".into()); + d + } + + #[test] + fn sidekick_env_wires_node_api_and_telemetry() { + let d = sidekick_deployment(); + let env = sidekick_env(&d); + // plain "testnet" is canonical since sidekick 2.1.0 + assert!(env.contains("SIDEKICK_NETWORK=testnet"), "got: {env}"); + assert!(env.contains("STACKS_NODE_RPC_URL=http://stacks-node:20443")); + // managed stacks-api is the indexed API by default + assert!(env.contains("STACKS_API_URL=http://stacks-api:3999")); + assert!( + env.contains("SIDEKICK_MANAGER_PRINCIPAL=SP000000000000000000002Q6VF78.signer-manager") + ); + assert!(env.contains("SIDEKICK_AUTH_TOKEN=test-sidekick-token")); + assert!(env.contains("SIDEKICK_ENGINE_MODE=observe")); + // managed node + signer -> direct telemetry endpoints + assert!(env.contains("STACKS_NODE_METRICS_URL=http://stacks-node:9153/metrics")); + assert!(env.contains("STACKS_SIGNER_MONITORING_URL=http://stacks-signer:30001")); + + // no managed API -> the network's Hiro API + let mut d = deployment( + "network = \"testnet\"\n[stacks-node]\nmode = \"enabled\"\nrole = \"signer-host\"\n[stacks-signer]\nmode = \"enabled\"\n[signer-sidekick]\nmode = \"enabled\"\nmanager_principal = \"SP000000000000000000002Q6VF78.signer-manager\"", + ); + d.signer_sidekick.auth_token = Some("test-sidekick-token".into()); + assert!( + sidekick_env(&d).contains("STACKS_API_URL=https://api.testnet.hiro.so"), + "got: {}", + sidekick_env(&d) + ); + } + + #[test] + fn sidekick_service_hardening_networks_and_ports() { + let meta = std::fs::metadata(std::env::temp_dir()).unwrap(); + let d = sidekick_deployment(); + let svc = sidekick_service(&d, &meta); + // loopback dashboard on 3997 host-side; container keeps 3998 + assert_eq!(svc.ports, vec!["127.0.0.1:3997:3998"]); + // core (node+signer) plus services (managed API is its indexed API) + assert_eq!(svc.networks, vec!["core", "services"]); + assert!(svc.read_only); + assert_eq!(svc.cap_drop, vec!["ALL"]); + // state is a data-dir bind mount, writable because the service runs + // as the chainstate dir's owner + assert!( + svc.volumes + .iter() + .any(|v| v == "../chainstate/signer-sidekick:/data") + ); + use std::os::unix::fs::MetadataExt; + assert_eq!( + svc.user.as_deref(), + Some(format!("{}:{}", meta.uid(), meta.gid()).as_str()) + ); + + // explicit external API URL -> no services membership + let mut d = sidekick_deployment(); + d.signer_sidekick.stacks_api_url = Some("https://api.example.com".into()); + assert_eq!(sidekick_service(&d, &meta).networks, vec!["core"]); + } + + #[test] + fn sidekick_enables_node_and_signer_telemetry_endpoints() { + let with = sidekick_deployment(); + assert!(node_config_toml(&with).contains("prometheus_bind = \"0.0.0.0:9153\"")); + assert!(signer_config_toml(&with).contains("metrics_endpoint = \"0.0.0.0:30001\"")); + // ... and ONLY when sidekick is enabled + let without = deployment(TESTNET_FULL); + assert!(!node_config_toml(&without).contains("prometheus_bind")); + assert!(!signer_config_toml(&without).contains("metrics_endpoint")); + } + + #[test] + fn render_with_user_provided_sidekick_profiles() { + let dir = + std::env::temp_dir().join(format!("stacksup-sidekick-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // Pre-place the profile dirs: user-managed, so render must not fetch or overwrite. + for sub in ["trusted-managers", "network-compatibility"] { + std::fs::create_dir_all(dir.join("rendered/signer-sidekick").join(sub)).unwrap(); + } + std::fs::write( + dir.join("rendered/signer-sidekick/trusted-managers/mine.json"), + "{}", + ) + .unwrap(); + + let d = sidekick_deployment(); + render(&d, &dir).unwrap(); + + let compose_text = std::fs::read_to_string(compose_file(&dir)).unwrap(); + let compose: serde_yaml::Value = serde_yaml::from_str(&compose_text).unwrap(); + assert!( + compose["services"] + .as_mapping() + .unwrap() + .contains_key("signer-sidekick") + ); + assert!(dir.join("chainstate/signer-sidekick").is_dir()); + // user-provided profiles untouched + assert!( + dir.join("rendered/signer-sidekick/trusted-managers/mine.json") + .exists() + ); + // env is owner-only (auth token inside) + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("rendered/signer-sidekick/.env")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn networks_isolate_signer_from_api_side() { // Full mainnet stack: three networks, least membership everywhere. diff --git a/src/logs/export.rs b/src/logs/export.rs index 4f1e966..fc91213 100644 --- a/src/logs/export.rs +++ b/src/logs/export.rs @@ -222,6 +222,8 @@ fn secret_values(deployment: &Deployment) -> Vec { deployment.bitcoind.rpc_user.clone(), deployment.bitcoind.rpc_password.clone(), deployment.stacks_node.auth_token.clone(), + deployment.signer_sidekick.auth_token.clone(), + deployment.signer_sidekick.stacks_api_key.clone(), ] .into_iter() .flatten() diff --git a/src/upgrade.rs b/src/upgrade.rs index 6d3da95..363768d 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -79,6 +79,16 @@ pub fn run(deployment: &Deployment, service: Option<&str>) -> Result<()> { .as_deref() .is_some_and(has_explicit_tag), ), + ( + "signer-sidekick", + deployment.signer_sidekick.mode, + crate::utils::services::signer_sidekick_image(deployment), + deployment + .signer_sidekick + .image + .as_deref() + .is_some_and(has_explicit_tag), + ), ( "postgres", deployment.postgres.mode, @@ -542,6 +552,20 @@ mod tests { assert!(text.contains("update the tag in [stacks-api] `image` to \"9.0.2\"")); } + #[test] + fn sidekick_guidance_uses_bare_semver_tags() { + // sidekick's image tags are bare semver since 2.1.0 — generic guidance applies + // (parse_version still tolerates the v-prefixed tags of older releases) + let mut r = row(Some(vec![2, 0, 0]), false, Some(vec![2, 1, 1]), None); + r.name = "signer-sidekick"; + r.tag_from_image = true; + let text = guidance(&r).unwrap(); + assert!( + text.contains("update the tag in [signer-sidekick] `image` to \"2.1.1\""), + "got: {text}" + ); + } + #[test] fn dockerhub_path_normalizes_hub_hosts() { assert_eq!(dockerhub_path("docker.io/myorg/api"), "myorg/api"); diff --git a/src/utils/secrets.rs b/src/utils/secrets.rs index 982f0ef..f6592a5 100644 --- a/src/utils/secrets.rs +++ b/src/utils/secrets.rs @@ -34,6 +34,8 @@ pub struct SecretsOverlay { pub postgres: PostgresSecrets, #[serde(default, rename = "stacks-node")] pub stacks_node: NodeSecrets, + #[serde(default, rename = "signer-sidekick")] + pub signer_sidekick: SidekickSecrets, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -55,6 +57,15 @@ pub struct NodeSecrets { pub auth_token: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct SidekickSecrets { + /// Dashboard bearer-token login (sidekick's own credential, distinct from the node token). + pub auth_token: Option, + /// Optional Hiro API key for indexed-API backfill. + pub stacks_api_key: Option, +} + fn secrets_path(data_dir: &Path) -> PathBuf { data_dir.join(SECRETS_FILE) } @@ -99,11 +110,21 @@ pub fn load(data_dir: &Path) -> Result> { /// Every present value must be long enough to redact safely and free of characters that need /// escaping in any rendered format (TOML strings, env files, compose commands). fn validate_values(overlay: &SecretsOverlay, path: &Path) -> Result<()> { - let fields: [(&str, &str, &Option); 4] = [ + let fields: [(&str, &str, &Option); 6] = [ ("bitcoind", "rpc_user", &overlay.bitcoind.rpc_user), ("bitcoind", "rpc_password", &overlay.bitcoind.rpc_password), ("postgres", "password", &overlay.postgres.password), ("stacks-node", "auth_token", &overlay.stacks_node.auth_token), + ( + "signer-sidekick", + "auth_token", + &overlay.signer_sidekick.auth_token, + ), + ( + "signer-sidekick", + "stacks_api_key", + &overlay.signer_sidekick.stacks_api_key, + ), ]; for (section, field, value) in fields { let Some(v) = value else { continue }; @@ -143,13 +164,15 @@ pub fn generate_if_missing(data_dir: &Path) -> Result<()> { # modifies it; missing values are reported as errors at render time.\n\n\ [postgres]\npassword = \"{}\"\n\n\ [bitcoind]\nrpc_user = \"stacksup-{}\"\nrpc_password = \"{}\"\n\n\ - [stacks-node]\nauth_token = \"{}\"\n", + [stacks-node]\nauth_token = \"{}\"\n\n\ + [signer-sidekick]\nauth_token = \"{}\"\n", random_hex(32)?, // rpc_user is redacted in `logs export`; a random suffix keeps that exact-value scrub from // also eating every plain "stacksup" in logs. random_hex(4)?, random_hex(32)?, random_hex(32)?, + random_hex(32)?, ); write_0600(&path, &body)?; println!( diff --git a/src/utils/services.rs b/src/utils/services.rs index 98214b9..a6b3a25 100644 --- a/src/utils/services.rs +++ b/src/utils/services.rs @@ -9,6 +9,9 @@ const REPO_STACKS_SIGNER: (&str, &str) = ("ghcr.io/stacks-network/stacks-signer" const REPO_STACKS_API: (&str, &str) = ("hirosystems/stacks-blockchain-api", "latest"); const REPO_STACKS_MESH_API: (&str, &str) = ("ghcr.io/stx-labs/stacks-mesh-api", "latest"); const REPO_POSTGRES: (&str, &str) = ("postgres", "latest"); +// sidekick publishes pinned semver image tags only (no floating `latest`); releases before +// 2.1.0 used v-prefixed tags. +const REPO_SIDEKICK: (&str, &str) = ("ghcr.io/stx-labs/signer-sidekick", "2.3.0"); fn image( (default_repo, default_tag): (&str, &str), @@ -79,6 +82,14 @@ pub const API_EVENT_PORT: u16 = 3700; pub const MESH_API_PORT: u16 = 3998; pub const SIGNER_ENDPOINT_PORT: u16 = 30000; pub const POSTGRES_PORT: u16 = 5432; +/// Host-side dashboard publish (container listens on 3998, which collides with the mesh API's +/// host port). Loopback-only: bearer-token login is the only auth. +pub const SIDEKICK_PORT: u16 = 3997; +pub const SIDEKICK_CONTAINER_PORT: u16 = 3998; +/// Node prometheus + signer monitoring endpoints, wired (container-internal) when sidekick is +/// enabled so its Signer Health page gets direct telemetry. +pub const NODE_METRICS_PORT: u16 = 9153; +pub const SIGNER_METRICS_PORT: u16 = 30001; /// bitcoind ports: per-stack override, else the network definition's default. pub fn bitcoind_rpc_port(deployment: &Deployment) -> u16 { @@ -137,10 +148,19 @@ pub fn roster(deployment: &Deployment) -> Vec<(&'static str, ServiceMode)> { ("stacks-signer", deployment.stacks_signer.mode), ("stacks-api", deployment.stacks_api.mode), ("stacks-mesh-api", deployment.stacks_mesh_api.mode), + ("signer-sidekick", deployment.signer_sidekick.mode), ("postgres", deployment.postgres.mode), ] } +pub fn signer_sidekick_image(deployment: &Deployment) -> String { + image( + REPO_SIDEKICK, + deployment.signer_sidekick.image.as_deref(), + deployment.signer_sidekick.version.as_deref(), + ) +} + /// Every (service, host port) this deployment publishes when started, the base ports shifted by /// `port_offset`. Used by the start preflight to test-bind before compose does. pub fn published_ports(deployment: &Deployment) -> Vec<(&'static str, u16)> { @@ -165,6 +185,9 @@ pub fn published_ports(deployment: &Deployment) -> Vec<(&'static str, u16)> { if deployment.stacks_mesh_api.mode == ServiceMode::Enabled { ports.push(("stacks-mesh-api", deployment.published(MESH_API_PORT))); } + if deployment.signer_sidekick.mode == ServiceMode::Enabled { + ports.push(("signer-sidekick", deployment.published(SIDEKICK_PORT))); + } if deployment.postgres.mode == ServiceMode::Enabled { ports.push(("postgres", deployment.published(POSTGRES_PORT))); } @@ -249,12 +272,28 @@ mod tests { assert_eq!(postgres_port(&s), POSTGRES_PORT); } + #[test] + fn sidekick_image_uses_semver_tags() { + // tags are bare semver since 2.1.0; the version passes through verbatim, so a user + // pinning an old release writes its actual tag ("v2.0.0") + let d = deployment( + "network = \"testnet\"\n[signer-sidekick]\nmode = \"enabled\"\nversion = \"2.1.0\"", + ); + assert_eq!( + signer_sidekick_image(&d), + "ghcr.io/stx-labs/signer-sidekick:2.1.0" + ); + // pinned default tag, never `latest` (sidekick publishes no floating tag) + let d = deployment("network = \"testnet\"\n[signer-sidekick]\nmode = \"enabled\""); + assert!(signer_sidekick_image(&d).ends_with(":2.3.0")); + } + #[test] fn roster_lists_all_services_with_modes() { use crate::config::ServiceMode; let s = deployment("network = \"testnet\"\n[postgres]\nmode = \"enabled\""); let roster = roster(&s); - assert_eq!(roster.len(), 6); + assert_eq!(roster.len(), 7); let (name, mode) = roster.iter().find(|(n, _)| *n == "postgres").unwrap(); assert_eq!(*name, "postgres"); assert_eq!(*mode, ServiceMode::Enabled); diff --git a/src/utils/versions.rs b/src/utils/versions.rs index 9c27e18..dc5a7f1 100644 --- a/src/utils/versions.rs +++ b/src/utils/versions.rs @@ -5,6 +5,8 @@ use std::process::Command; pub fn parse_version(s: &str) -> Option> { + // Tolerate v-prefixed tags (signer-sidekick releases as v2.0.0). + let s = s.strip_prefix('v').unwrap_or(s); let parts: Result, _> = s.split('.').map(str::parse).collect(); parts.ok().filter(|v: &Vec| !v.is_empty()) } @@ -77,6 +79,13 @@ fn version_from_label(label: &str) -> Option> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn parse_version_tolerates_v_prefix() { + assert_eq!(parse_version("v2.0.0"), Some(vec![2, 0, 0])); + assert_eq!(parse_version("2.0.0"), Some(vec![2, 0, 0])); + assert_eq!(parse_version("vNot"), None); + } use std::cmp::Ordering::*; #[test] From 772b4af16dfeb98bffcd57cfff72b1a72006e957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=20C=C3=A1rdenas?= <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:07:30 -0600 Subject: [PATCH 18/19] feat: add staking-testnet as a built-in network (#32) * add staking testnet network * fix: require signet chain and burnchain mode together --- README.md | 6 ++ networks/staking-testnet.toml | 96 ++++++++++++++++++++++++ src/chainstate/download.rs | 72 +++++++++++++++--- src/config/mod.rs | 21 +++++- src/config/network.rs | 134 ++++++++++++++++++++++++++++++++++ src/config/render.rs | 109 ++++++++++++++++++++++++--- 6 files changed, 416 insertions(+), 22 deletions(-) create mode 100644 networks/staking-testnet.toml diff --git a/README.md b/README.md index 9527205..f8e4d9f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ migration. Unknown names resolve as custom definition files next to your stacks.toml (`networks/.toml`), so you can define private networks without a tool release. +| network | burnchain | chain id | +|---|---|---| +| `mainnet` | Bitcoin mainnet (bring your own bitcoind) | `0x00000001` | +| `testnet` | Hiro-hosted bitcoin regtest (krypton) | `0x80000000` | +| `staking-testnet` | Hiro-hosted custom bitcoin signet (PoX-5 / Bitcoin staking; stacks-core 4.0.4+) | `0x00000500` | + ## The model `stacks.toml` is the single source of truth. Every service has a `mode`: diff --git a/networks/staking-testnet.toml b/networks/staking-testnet.toml new file mode 100644 index 0000000..9fb51be --- /dev/null +++ b/networks/staking-testnet.toml @@ -0,0 +1,96 @@ +# Network definition: the Stacks staking testnet (PoX-5 / Bitcoin staking), a Stacks chain on the +# Hiro-hosted custom Bitcoin signet. Referenced from stacks.toml as `network = "staking-testnet"`. +# +# Mirrors the live follower configs (stx-labs/infrastructure stacks-seed/signet). Requires a +# stacks-core build with `burnchain.mode = "signet"` (4.0.4+). + +name = "staking-testnet" +chain_id = 0x00000500 +hiro_api_url = "https://api.staking-testnet.hiro.so" +hiro_archive_path = "staking-testnet" +hiro_archive_prefix = "signet" + +[bitcoind] +allow_managed = false +chain = "signet" +rpc_port = 38332 +p2p_port = 38333 +default_host = "bitcoin.staking-testnet.hiro.so" +signet_challenge = "0014511787db620184f553cfa06bb2a6607497f09f4a" + +[node] +burnchain_mode = "signet" +bootstrap_node = "025b2f33eedfa699fa375f115d0c32a36820dd351a3d7f670a18fc324bfd6d1f63@3.136.174.30:20444" +node_extra = ''' +pox_5_sbtc_contract = "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS.sbtc-token" +pox_5_sbtc_registry_contract = "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS.sbtc-registry" +pox_5_bond_admin = "ST1V2ASRWGR81W7GBN1Z4W2JQKXJWCADPVZG30X45" +''' + +[[ustx_balance]] +address = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST319CF5WV77KYR1H3GT0GZ7B8Q4AQPY42ETP1VPF" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST221Z6TDTC5E0BYR2V624Q2ST6R0Q71T78WTAX6H" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST2TFVBMRPS5SSNP98DQKQ5JNB2B6NZM91C4K3P7B" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST31XHNM0GZ2K978FPP4QA3STNQ73Z8C9G9MJEPK2" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST1B38CGQRPXEMRH7B66VXTS22DQTNMSW4YJJ7QK1" +amount = 10000000000000000 + +[[ustx_balance]] +address = "STDMN71Z0H9EF8CRKAWTGBB5YS0BNV26HZ79QFFP" +amount = 1000000000000000 + +[[ustx_balance]] +address = "ST1E0PSCH72JMQH9QCH293ZTEEH7BPA40Y3F39XQ" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST3QBTK0Q438YVNX8EG6Z85HN0WKQPXYT25H5SPPK" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST10BX04F9PC6N1WBXKW3H7CG0NS0A3PK650T3P3R" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST3AF1BBQAFSFCM8K4ZBR1FBXP3P8J1CKGSGDHWR5" +amount = 100000000000000 + +[[ustx_balance]] +address = "STHY13V44422NAN6D3NSJPY9CDR3ED1M6HH9WZ6Y" +amount = 10000000000000 + +[[ustx_balance]] +address = "ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0" +amount = 10000000000000000 + +[[ustx_balance]] +address = "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS" +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST1V2ASRWGR81W7GBN1Z4W2JQKXJWCADPVZG30X45" +amount = 10000000000000000 diff --git a/src/chainstate/download.rs b/src/chainstate/download.rs index 78b4a03..a96b274 100644 --- a/src/chainstate/download.rs +++ b/src/chainstate/download.rs @@ -295,9 +295,11 @@ pub fn run(deployment: &Deployment, data_dir: &Path, opts: Opts) -> Result /// their name, which would defeat the archive-version ≤ configured-version check. fn latest_node_job(network: &str, deployment: &Deployment, downloads: &Path) -> Result { let base = format!("{ARCHIVE_BASE}/{network}/stacks-blockchain"); + // Usually the path segment, but e.g. staking-testnet's archives are named signet-*. + let prefix = deployment.net.hiro_archive_prefix().unwrap_or(network); let name = newest_in_listing( &base, - &format!("{network}-stacks-blockchain-"), + &format!("{prefix}-stacks-blockchain-"), &[".tar.zst", ".tar.gz"], )?; versioned_job(Kind::Node, &base, &name, deployment, downloads) @@ -448,14 +450,12 @@ fn pinned_job( ); } - // A filename that names the other network is a subtle disaster; block it. - let other = if network == "mainnet" { - "testnet" - } else { - "mainnet" - }; - if name.starts_with(other) { - bail!("archive `{name}` is for {other}, but stacks.toml says network = \"{network}\""); + // A filename that names another network is a subtle disaster (a different genesis); block it. + if let Some(other) = other_network_archive(&name, deployment) { + bail!( + "archive `{name}` is for {other}, but stacks.toml says network = \"{}\"", + deployment.net.name + ); } let size = head_content_length(&url).with_context(|| format!("archive not found at {url}"))?; @@ -469,6 +469,18 @@ fn pinned_job( }) } +/// The built-in network a node archive filename belongs to, when that isn't this deployment's. +fn other_network_archive(name: &str, deployment: &Deployment) -> Option<&'static str> { + let own = deployment.net.hiro_archive_prefix(); + crate::config::network::builtin_archive_prefixes() + .into_iter() + .find(|(_, prefix)| { + Some(prefix.as_str()) != own + && name.starts_with(&format!("{prefix}-stacks-blockchain-")) + }) + .map(|(other, _)| other) +} + // --------------------------------------------------------------------------- // Version validation @@ -1226,6 +1238,48 @@ mod tests { ); } + #[test] + fn staking_testnet_archives_use_the_signet_prefix() { + assert_eq!( + parse_version_from_name( + "signet-stacks-blockchain-4.0.4-20260927.tar.zst", + Kind::Node + ), + Some(vec![4, 0, 4]) + ); + let d = crate::config::test_deployment("network = \"staking-testnet\""); + assert_eq!(d.net.hiro_archive_path.as_deref(), Some("staking-testnet")); + assert_eq!(d.net.hiro_archive_prefix(), Some("signet")); + } + + #[test] + fn archives_from_another_network_are_named() { + let staking = crate::config::test_deployment("network = \"staking-testnet\""); + let testnet = crate::config::test_deployment("network = \"testnet\""); + let mainnet = crate::config::test_deployment("network = \"mainnet\""); + let signet = "signet-stacks-blockchain-4.0.4-20260927.tar.zst"; + let krypton = "testnet-stacks-blockchain-4.0.4-20260927.tar.zst"; + assert_eq!(other_network_archive(signet, &staking), None); + assert_eq!(other_network_archive(krypton, &staking), Some("testnet")); + assert_eq!( + other_network_archive(signet, &testnet), + Some("staking-testnet") + ); + assert_eq!(other_network_archive(krypton, &mainnet), Some("testnet")); + assert_eq!( + other_network_archive( + "mainnet-stacks-blockchain-3.1.0.0.8-20260803.tar.gz", + &testnet + ), + Some("mainnet") + ); + // API dumps carry no network in their name + assert_eq!( + other_network_archive("stacks-blockchain-api-pg-17-9.3.1-20260927.dump", &staking), + None + ); + } + #[test] fn extracts_version_with_trailing_extension() { // version segment glued to extension: `...-8.1.0.tar.gz` style diff --git a/src/config/mod.rs b/src/config/mod.rs index 3c263a0..2e40cef 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -49,8 +49,8 @@ pub struct Deployment { /// the API on 4099, postgres on 5532, and so on. #[serde(default)] pub port_offset: u16, - /// Network name: a built-in definition (mainnet, testnet) or a custom definition file resolved - /// relative to stacks.toml. + /// Network name: a built-in definition (mainnet, testnet, staking-testnet) or a custom + /// definition file resolved relative to stacks.toml. pub network: String, /// The resolved definition; populated by `load()` after parsing. #[serde(skip)] @@ -638,7 +638,7 @@ const DEFAULT_STACK_TOML: &str = r#"# stacksup config # `version`/the default tag, or a full ref with its own tag # (then omit `version`) -network = "testnet" # mainnet | testnet | custom network definition file +network = "testnet" # mainnet | testnet | staking-testnet | custom network definition file # To run several deployments on one machine, give each a distinct name # (compose project + container prefix) and shift its published host ports: @@ -647,7 +647,7 @@ network = "testnet" # mainnet | testnet | custom network definition file [bitcoind] # Only needed on mainnet. Testnet (krypton) follows the Hiro-hosted bitcoin -# regtest. +# regtest, staking-testnet the Hiro-hosted bitcoin signet. mode = "disabled" # version = "29" # For mode = "external": @@ -732,6 +732,19 @@ mod tests { assert!(e.iter().any(|m| m.contains("requires bitcoind"))); } + #[test] + fn staking_testnet_follows_the_hosted_signet() { + assert!( + errors("network = \"staking-testnet\"\n[stacks-node]\nmode = \"enabled\"").is_empty() + ); + let e = errors("network = \"staking-testnet\"\n[bitcoind]\nmode = \"enabled\""); + assert!( + e.iter() + .any(|m| m.contains("bitcoin.staking-testnet.hiro.so")), + "{e:?}" + ); + } + #[test] fn testnet_rejects_managed_bitcoind() { let e = errors("network = \"testnet\"\n[bitcoind]\nmode = \"enabled\""); diff --git a/src/config/network.rs b/src/config/network.rs index 532d3cb..263cc0b 100644 --- a/src/config/network.rs +++ b/src/config/network.rs @@ -11,10 +11,17 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use serde::Deserialize; +const CHAIN_ID_MAINNET: u32 = 0x00000001; +const CHAIN_ID_TESTNET: u32 = 0x80000000; + /// Standard definitions compiled into the binary. const EMBEDDED: &[(&str, &str)] = &[ ("mainnet", include_str!("../../networks/mainnet.toml")), ("testnet", include_str!("../../networks/testnet.toml")), + ( + "staking-testnet", + include_str!("../../networks/staking-testnet.toml"), + ), ]; #[derive(Debug, Clone, Deserialize, Default)] @@ -24,6 +31,9 @@ pub struct NetworkDef { pub chain_id: u32, /// Path segment under archive.hiro.so; absent when no Hiro archives exist for this network. pub hiro_archive_path: Option, + /// Filename prefix of the node archives under that path (`-stacks-blockchain-*`); + /// defaults to `hiro_archive_path`. + pub hiro_archive_prefix: Option, /// Hiro-hosted indexed Stacks API for this network; the sidekick default when no local /// stacks-api is enabled and no explicit URL is configured. pub hiro_api_url: Option, @@ -47,6 +57,8 @@ pub struct BitcoindNet { pub p2p_port: u16, /// Hosted burnchain endpoint used when [bitcoind] is disabled. pub default_host: Option, + /// Custom signet challenge (hex script, bitcoind's -signetchallenge); only with chain "signet". + pub signet_challenge: Option, } #[derive(Debug, Clone, Deserialize, Default)] @@ -73,6 +85,42 @@ pub struct Epoch { pub start_height: u64, } +impl NetworkDef { + /// Stacks network family: address/transaction versions and the network name the signer and + /// sidekick are configured with. Only mainnet runs on chain id 1; every other id is a testnet + /// (e.g. staking-testnet's 0x00000500). + pub fn family(&self) -> &'static str { + if self.chain_id == CHAIN_ID_MAINNET { + "mainnet" + } else { + "testnet" + } + } + + /// True when clients configured by family alone would assume a different chain id, so the + /// id must be passed to them explicitly. + pub fn has_custom_chain_id(&self) -> bool { + !matches!(self.chain_id, CHAIN_ID_MAINNET | CHAIN_ID_TESTNET) + } + + pub fn hiro_archive_prefix(&self) -> Option<&str> { + self.hiro_archive_prefix + .as_deref() + .or(self.hiro_archive_path.as_deref()) + } +} + +/// (name, node-archive filename prefix) of every built-in network that publishes archives. +pub fn builtin_archive_prefixes() -> Vec<(&'static str, String)> { + EMBEDDED + .iter() + .filter_map(|(name, raw)| { + let def = parse(raw).ok()?; + Some((*name, def.hiro_archive_prefix()?.to_string())) + }) + .collect() +} + /// Resolve a `network = "..."` value: embedded name first, then a custom definition file relative /// to the directory holding stacks.toml. pub fn load(name: &str, config_dir: &Path) -> Result { @@ -123,6 +171,28 @@ fn parse(raw: &str) -> Result { if def.bitcoind.rpc_port == 0 || def.bitcoind.p2p_port == 0 { bail!("network definition must set non-zero [bitcoind] rpc_port and p2p_port"); } + // Signet on one side only means a managed bitcoind and the node follow different burnchains. + if (def.bitcoind.chain == "signet") != (def.node.burnchain_mode == "signet") { + bail!( + "[bitcoind] chain = \"signet\" and [node] burnchain_mode = \"signet\" must be set \ + together" + ); + } + if let Some(challenge) = &def.bitcoind.signet_challenge { + // stacks-node rejects signet_challenge outside signet mode; bitcoind needs -chain=signet. + if def.bitcoind.chain != "signet" { + bail!( + "[bitcoind] signet_challenge requires [bitcoind] chain = \"signet\" and \ + [node] burnchain_mode = \"signet\"" + ); + } + if challenge.is_empty() + || challenge.len() % 2 != 0 + || !challenge.chars().all(|c| c.is_ascii_hexdigit()) + { + bail!("[bitcoind] signet_challenge must be a hex script"); + } + } Ok(def) } @@ -158,6 +228,70 @@ mod tests { ); } + #[test] + fn staking_testnet_definition_parses() { + let net = load("staking-testnet", Path::new(".")).unwrap(); + assert_eq!(net.family(), "testnet"); + assert_eq!(net.chain_id, 0x00000500); + assert!(net.has_custom_chain_id()); + assert!(!net.bitcoind.allow_managed); + assert_eq!(net.bitcoind.chain, "signet"); + assert_eq!( + (net.bitcoind.rpc_port, net.bitcoind.p2p_port), + (38332, 38333) + ); + assert_eq!( + net.bitcoind.signet_challenge.as_deref(), + Some("0014511787db620184f553cfa06bb2a6607497f09f4a") + ); + assert_eq!(net.node.burnchain_mode, "signet"); + assert_eq!(net.hiro_archive_path.as_deref(), Some("staking-testnet")); + assert_eq!(net.hiro_archive_prefix(), Some("signet")); + // the live chain's 17-entry genesis; the signet build owns epochs and PoX lengths + assert_eq!(net.ustx_balances.len(), 17); + assert!(net.epochs.is_empty()); + assert!(net.node.pox_prepare_length.is_none() && net.node.pox_reward_length.is_none()); + + // the standard networks keep their implicit family and default chain ids + let testnet = load("testnet", Path::new(".")).unwrap(); + assert_eq!(testnet.family(), "testnet"); + assert!(!testnet.has_custom_chain_id()); + assert_eq!(testnet.hiro_archive_prefix(), Some("testnet")); + assert!( + !load("mainnet", Path::new(".")) + .unwrap() + .has_custom_chain_id() + ); + } + + #[test] + fn signet_challenge_is_validated() { + let base = "name = \"x\"\nchain_id = 0x80000000\n[bitcoind]\nallow_managed = false\nchain = \"{chain}\"\nrpc_port = 38332\np2p_port = 38333\n{challenge}\n[node]\nburnchain_mode = \"{mode}\"\n"; + let def = |chain: &str, mode: &str, challenge: &str| { + base.replace("{chain}", chain) + .replace("{mode}", mode) + .replace("{challenge}", challenge) + }; + assert!(parse(&def("signet", "signet", "")).is_ok()); // public signet + assert!(parse(&def("signet", "signet", "signet_challenge = \"51\"")).is_ok()); + let err = parse(&def("test", "krypton", "signet_challenge = \"51\"")) + .unwrap_err() + .to_string(); + assert!(err.contains("requires")); + // signet on one side only, with or without a challenge + for (chain, mode) in [("test", "signet"), ("signet", "krypton")] { + let err = parse(&def(chain, mode, "")).unwrap_err().to_string(); + assert!( + err.contains("must be set together"), + "{chain}/{mode}: {err}" + ); + } + let err = parse(&def("signet", "signet", "signet_challenge = \"5g\"")) + .unwrap_err() + .to_string(); + assert!(err.contains("hex")); + } + #[test] fn unknown_network_lists_builtins() { let err = load("nope", Path::new("/nonexistent")) diff --git a/src/config/render.rs b/src/config/render.rs index e827cd5..18ee0b5 100644 --- a/src/config/render.rs +++ b/src/config/render.rs @@ -273,6 +273,11 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { "-server=1".into(), "-txindex=0".into(), format!("-chain={}", deployment.net.bitcoind.chain), + ]; + if let Some(challenge) = &deployment.net.bitcoind.signet_challenge { + svc.command.push(format!("-signetchallenge={challenge}")); + } + svc.command.extend([ "-rpcbind=0.0.0.0".into(), "-rpcallowip=0.0.0.0/0".into(), // A salted hash, unlike -rpcpassword, safe to expose via docker inspect. The plaintext @@ -290,7 +295,7 @@ fn bitcoind_service(deployment: &Deployment) -> ComposeService { ) .replace('$', "$$") ), - ]; + ]); // Loopback-bind the RPC port: every container carries a host.docker.internal alias, so a // 0.0.0.0 publish would let API-side containers reach bitcoind through the host, bypassing the // network split. Operators still get bitcoin-cli on localhost. Only an external node (running @@ -514,6 +519,12 @@ fn node_config_toml(deployment: &Deployment) -> String { "mode = \"{}\"\nchain = \"bitcoin\"\n", net.node.burnchain_mode )); + // Always explicit: each mode has its own default (signet's is 0x80000001), and a custom-id + // network only syncs with peers that share its id. + out.push_str(&format!("chain_id = 0x{:08x}\n", net.chain_id)); + if let Some(challenge) = &net.bitcoind.signet_challenge { + out.push_str(&format!("signet_challenge = \"{challenge}\"\n")); + } // Burnchain endpoint: managed/external bitcoind, else the network's hosted default (validation // guarantees one of these exists for a node). if let Some(host) = bitcoind_host(deployment).or_else(|| net.bitcoind.default_host.clone()) { @@ -603,6 +614,7 @@ fn signer_config_toml(deployment: &Deployment) -> String { endpoint = "0.0.0.0:{endpoint_port}" auth_password = "{auth}" network = "{network}" +chain_id = 0x{chain_id:08x} db_path = "/var/lib/stacks-signer/signerdb.sqlite" {metrics}# TODO(hackathon): source the signing key from a secrets file, never this rendered config stacks_private_key = "REPLACE_ME" @@ -610,8 +622,10 @@ stacks_private_key = "REPLACE_ME" rpc_port = node_rpc_port(deployment), endpoint_port = SIGNER_ENDPOINT_PORT, auth = node_auth_token(deployment), - // resolved name, not the config reference (which may be a definition file path) - network = deployment.net.name, + // the family, not the definition name: the signer only knows mainnet/testnet/signet, and + // chain_id pins custom-id networks + network = deployment.net.family(), + chain_id = deployment.net.chain_id, ) } @@ -632,7 +646,7 @@ fn api_env(deployment: &Deployment) -> String { let chain_id = format!("0x{:08x}", deployment.net.chain_id); let node_host = node_rpc_host(deployment).unwrap_or_default(); let pg_host = postgres_host(deployment).unwrap_or_default(); - format!( + let mut out = format!( r#"NODE_ENV=production STACKS_CHAIN_ID={chain_id} STACKS_BLOCKCHAIN_API_HOST=0.0.0.0 @@ -660,7 +674,15 @@ TESTNET_SBTC_FAUCET_ENABLED=false pg_user = deployment.postgres.user.as_deref().unwrap_or("postgres"), pg_password = deployment.postgres.password.as_deref().unwrap_or_default(), pg_db = API_PG_DATABASE, - ) + ); + // The API rejects every /new_block carrying a chain id it doesn't know. + if deployment.net.has_custom_chain_id() { + out.push_str(&format!( + "CUSTOM_CHAIN_IDS={}={chain_id}\n", + deployment.net.family() + )); + } + out } /// Env for the Stacks Mesh API (packages/api/src/env.ts is the schema of record). Online mode needs @@ -698,10 +720,10 @@ fn sidekick_env(deployment: &Deployment) -> String { SIDEKICK_ENGINE_MODE={engine}\n\ SIDEKICK_TRUSTED_MANAGER_PROFILES_DIR=/etc/sidekick/trusted-managers\n\ SIDEKICK_COMPATIBILITY_PROFILES_DIR=/etc/sidekick/network-compatibility\n", - // The RESOLVED definition's name — `deployment.network` may be a custom definition - // file path. Plain "testnet" is canonical since sidekick 2.1.0 (pox5-testnet is a - // legacy alias; 2.0.0 targeted a chain id that no longer exists). - network = deployment.net.name, + // The family, not the definition name: sidekick knows only mainnet/testnet (plain + // "testnet" is canonical since 2.1.0; pox5-testnet is a legacy alias). Custom-id + // networks pin SIDEKICK_NETWORK_ID below. + network = deployment.net.family(), node_host = node_rpc_host(deployment).unwrap_or_default(), node_rpc = node_rpc_port(deployment), api_url = sidekick_api_url(deployment), @@ -709,6 +731,12 @@ fn sidekick_env(deployment: &Deployment) -> String { auth = sk.auth_token.as_deref().unwrap_or_default(), engine = sk.engine_mode.as_deref().unwrap_or("observe"), ); + if deployment.net.has_custom_chain_id() { + out.push_str(&format!( + "SIDEKICK_NETWORK_ID={}\n", + deployment.net.chain_id + )); + } if let Some(key) = sk.stacks_api_key.as_deref() { out.push_str(&format!("STACKS_API_KEY={key}\n")); } @@ -928,6 +956,59 @@ mod tests { assert_eq!(parsed["burnchain"]["epochs"].as_array().unwrap().len(), 14); } + #[test] + fn staking_testnet_node_config_follows_the_signet() { + let s = deployment("network = \"staking-testnet\"\n[stacks-node]\nmode = \"enabled\""); + let parsed: toml::Value = + toml::from_str(&node_config_toml(&s)).expect("rendered node config is valid TOML"); + let burnchain = &parsed["burnchain"]; + assert_eq!(burnchain["mode"].as_str(), Some("signet")); + assert_eq!(burnchain["chain_id"].as_integer(), Some(0x00000500)); + assert_eq!( + burnchain["signet_challenge"].as_str(), + Some("0014511787db620184f553cfa06bb2a6607497f09f4a") + ); + assert_eq!( + burnchain["peer_host"].as_str(), + Some("bitcoin.staking-testnet.hiro.so") + ); + assert_eq!(burnchain["rpc_port"].as_integer(), Some(38332)); + assert_eq!(burnchain["peer_port"].as_integer(), Some(38333)); + // the signet build owns the epoch schedule and PoX lengths + assert!(burnchain.get("epochs").is_none()); + assert!(burnchain.get("pox_prepare_length").is_none()); + assert!( + parsed["node"]["bootstrap_node"] + .as_str() + .unwrap() + .ends_with("@3.136.174.30:20444") + ); + assert!(parsed["node"]["pox_5_sbtc_contract"].as_str().is_some()); + assert_eq!(parsed["ustx_balance"].as_array().unwrap().len(), 17); + } + + #[test] + fn chain_id_is_explicit_everywhere_it_matters() { + let full = TESTNET_FULL.replace("\"testnet\"", "\"staking-testnet\""); + let s = deployment(&full); + let signer: toml::Value = toml::from_str(&signer_config_toml(&s)).unwrap(); + // the signer knows networks by family; chain_id pins the custom id + assert_eq!(signer["network"].as_str(), Some("testnet")); + assert_eq!(signer["chain_id"].as_integer(), Some(0x00000500)); + let env = api_env(&s); + assert!(env.contains("STACKS_CHAIN_ID=0x00000500")); + assert!(env.contains("CUSTOM_CHAIN_IDS=testnet=0x00000500")); + + // standard networks: explicit ids, but no custom-id plumbing + let s = deployment(TESTNET_FULL); + let node: toml::Value = toml::from_str(&node_config_toml(&s)).unwrap(); + assert_eq!(node["burnchain"]["chain_id"].as_integer(), Some(0x80000000)); + assert!(node["burnchain"].get("signet_challenge").is_none()); + let signer: toml::Value = toml::from_str(&signer_config_toml(&s)).unwrap(); + assert_eq!(signer["chain_id"].as_integer(), Some(0x80000000)); + assert!(!api_env(&s).contains("CUSTOM_CHAIN_IDS")); + } + #[test] fn signer_flips_stacker_and_shares_auth_token() { let s = deployment(TESTNET_FULL); @@ -1093,6 +1174,16 @@ mod tests { d } + #[test] + fn sidekick_env_pins_custom_chain_id() { + let mut d = deployment(&SIDEKICK_FULL.replace("\"testnet\"", "\"staking-testnet\"")); + d.signer_sidekick.auth_token = Some("test-sidekick-token".into()); + let env = sidekick_env(&d); + assert!(env.contains("SIDEKICK_NETWORK=testnet\n"), "got: {env}"); + assert!(env.contains("SIDEKICK_NETWORK_ID=1280\n"), "got: {env}"); + assert!(!sidekick_env(&sidekick_deployment()).contains("SIDEKICK_NETWORK_ID")); + } + #[test] fn sidekick_env_wires_node_api_and_telemetry() { let d = sidekick_deployment(); From 8e799fde295f2716e5e59b1f7853a887a33173e8 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:24:15 -0600 Subject: [PATCH 19/19] fix: use salt from username only --- src/utils/secrets.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/utils/secrets.rs b/src/utils/secrets.rs index f6592a5..783cb5d 100644 --- a/src/utils/secrets.rs +++ b/src/utils/secrets.rs @@ -212,7 +212,7 @@ fn random_hex(bytes: usize) -> Result { /// `user:salt$hex(hmac_sha256(key=salt, msg=password))`. Never stored — a hand-edited password /// can't drift from its hash. pub fn bitcoind_rpcauth(user: &str, password: &str) -> String { - let salt = derived_salt(user, password); + let salt = derived_salt(user); let mut mac = Hmac::::new_from_slice(salt.as_bytes()).expect("hmac accepts any key"); mac.update(password.as_bytes()); let digest: String = mac @@ -226,10 +226,12 @@ pub fn bitcoind_rpcauth(user: &str, password: &str) -> String { /// Deterministic salt so the rpcauth line (and the rendered compose file) is stable across renders. /// Salt secrecy is not load-bearing in the rpcauth scheme, bitcoind stores it in plaintext beside -/// the hash. -fn derived_salt(user: &str, password: &str) -> String { +/// the hash. Derived from the username only: the salt is published (compose file, docker inspect), +/// so a password-derived one would be a fast unsalted password hash. Generated usernames carry a +/// random suffix, which keeps the salt unique per deployment. +fn derived_salt(user: &str) -> String { use sha2::Digest; - let digest = Sha256::digest(format!("{user}:{password}").as_bytes()); + let digest = Sha256::digest(format!("stacksup-rpcauth-salt:{user}").as_bytes()); digest[..8].iter().map(|b| format!("{b:02x}")).collect() } @@ -377,6 +379,19 @@ mod tests { assert_ne!(a, bitcoind_rpcauth("user", "other-password")); } + #[test] + fn rpcauth_salt_does_not_depend_on_the_password() { + let salt = |line: String| line.split(['$', ':']).nth(1).unwrap().to_string(); + assert_eq!( + salt(bitcoind_rpcauth("user", "password")), + salt(bitcoind_rpcauth("user", "other-password")) + ); + assert_ne!( + salt(bitcoind_rpcauth("user", "password")), + salt(bitcoind_rpcauth("other-user", "password")) + ); + } + #[test] fn missing_secrets_error_groups_fields_by_section() { let err = missing_secrets_error(