archipelago bd96c0475d feat(config): ARCHIPELAGO_USE_QUADLET_BACKENDS env override
Adds an env-var lever for Phase 3.2's use_quadlet_backends flag so the
20× harness can flip the path on per-node without a config.json edit
(which would require an archipelago.service restart — and that triggers
FM3 cgroup cascade until Phase 3.5 ships, so we can't ask anyone to
reconfigure live nodes that way today).

Truthy parsing centralised in `parse_truthy_env` (1, true, yes, on —
case-insensitive, whitespace-trimmed). Anything else is false. The
helper is unit-tested so future env-var flags can reuse the same shape.

Also adds a default-off regression test for use_quadlet_backends so
flipping the default ahead of the 20× verification fires immediately.

TESTING.md documents the Environment= snippet for the systemd drop-in
so the next operator can flip the flag on a debug node without
re-deriving the recipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:44:09 -04:00

484 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContainerRuntime {
Podman,
Docker,
Auto,
}
impl ContainerRuntime {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"podman" => ContainerRuntime::Podman,
"docker" => ContainerRuntime::Docker,
"auto" | _ => ContainerRuntime::Auto,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BitcoinSimulation {
Mock,
Testnet,
Mainnet,
None,
}
impl BitcoinSimulation {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"mock" => BitcoinSimulation::Mock,
"testnet" => BitcoinSimulation::Testnet,
"mainnet" => BitcoinSimulation::Mainnet,
"none" | _ => BitcoinSimulation::None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub data_dir: PathBuf,
pub bind_host: String,
pub bind_port: u16,
pub log_level: String,
/// Host IP for container env vars (FM_API_URL, BACKEND_MAINNET_HTTP_HOST, etc.)
pub host_ip: String,
// Dev mode configuration
pub dev_mode: bool,
pub container_runtime: ContainerRuntime,
pub port_offset: u16,
pub bitcoin_simulation: BitcoinSimulation,
pub dev_data_dir: PathBuf,
/// Nostr discovery: opt-in only. When true + relays non-empty, publish node to relays.
#[serde(default)]
pub nostr_discovery_enabled: bool,
/// Nostr relay URLs (comma-separated). Only used when nostr_discovery_enabled.
#[serde(default)]
pub nostr_relays: Vec<String>,
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
#[serde(default)]
pub nostr_tor_proxy: Option<String>,
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
/// through Quadlet (`.container` units in ~/.config/containers/systemd
/// + systemctl --user start) instead of `podman create + start`. Default
/// off so the legacy path stays the production path until the harness
/// at tests/lifecycle/run-20x.sh has gone green against the new path
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
#[serde(default)]
pub use_quadlet_backends: bool,
}
impl Config {
/// Detect primary host IP (first non-loopback IPv4)
fn detect_host_ip() -> Result<String> {
let output = std::process::Command::new("hostname")
.args(["-I"])
.output()
.context("Failed to run hostname -I")?;
let s = String::from_utf8_lossy(&output.stdout);
let ip = s
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.unwrap_or("127.0.0.1");
Ok(ip.to_string())
}
pub async fn load() -> Result<Self> {
// Default configuration
let mut config = Self::default();
// Detect if running from macOS app bundle
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_str) = exe_path.to_str() {
if exe_str.contains(".app/Contents/MacOS") {
// Running from macOS bundle - use user's Library directory
if let Some(home) = std::env::var_os("HOME") {
let app_support =
PathBuf::from(home).join("Library/Application Support/Archipelago");
config.data_dir = app_support.join("data");
config.dev_data_dir = app_support.join("data");
tracing::info!(
"🍎 Detected macOS bundle, using: {}",
app_support.display()
);
}
}
}
}
// Try to load from config file
let config_path = Path::new("/etc/archipelago/config.toml");
if config_path.exists() {
let content = fs::read_to_string(config_path)
.await
.context("Failed to read config file")?;
let file_config: Config =
toml::de::from_str(&content).context("Failed to parse config file")?;
config = file_config;
}
// Override with environment variables
if let Ok(data_dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
config.data_dir = PathBuf::from(data_dir);
}
if let Ok(bind) = std::env::var("ARCHIPELAGO_BIND") {
let parts: Vec<&str> = bind.split(':').collect();
if parts.len() == 2 {
config.bind_host = parts[0].to_string();
config.bind_port = parts[1]
.parse()
.context("Invalid port in ARCHIPELAGO_BIND")?;
}
}
if let Ok(level) = std::env::var("ARCHIPELAGO_LOG_LEVEL") {
config.log_level = level;
}
// Production binaries must not be switched into dev orchestration by
// host environment. Several live nodes carried a stale systemd
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
// mounts into /tmp and prevented real installs from starting.
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
}
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
config.container_runtime = ContainerRuntime::from_str(&runtime);
}
if let Ok(offset) = std::env::var("ARCHIPELAGO_PORT_OFFSET") {
config.port_offset = offset
.parse()
.context("Invalid port offset in ARCHIPELAGO_PORT_OFFSET")?;
}
if let Ok(sim) = std::env::var("ARCHIPELAGO_BITCOIN_SIMULATION") {
config.bitcoin_simulation = BitcoinSimulation::from_str(&sim);
}
if let Ok(dev_data_dir) = std::env::var("ARCHIPELAGO_DEV_DATA_DIR") {
config.dev_data_dir = PathBuf::from(dev_data_dir);
}
// Nostr discovery (opt-in, secure by default)
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED") {
config.nostr_discovery_enabled = v.parse().unwrap_or(false);
}
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_RELAYS") {
config.nostr_relays = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY") {
let s = v.trim().to_string();
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
}
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
// route backend installs through the Quadlet path without requiring a
// config.json edit + archipelago.service restart (which would trigger
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
// the config.json value untouched.
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
if parse_truthy_env(&v) {
config.use_quadlet_backends = true;
}
}
// Host IP for container env vars (detect if not set)
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
config.host_ip = ip;
} else {
config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string());
}
// Ensure data directory exists
fs::create_dir_all(&config.data_dir)
.await
.context("Failed to create data directory")?;
// Ensure dev data directory exists if in dev mode
if config.dev_mode {
fs::create_dir_all(&config.dev_data_dir)
.await
.context("Failed to create dev data directory")?;
}
Ok(config)
}
}
impl Default for Config {
fn default() -> Self {
Self {
data_dir: PathBuf::from("/var/lib/archipelago"),
bind_host: "127.0.0.1".to_string(),
bind_port: 5678,
log_level: "info".to_string(),
host_ip: "127.0.0.1".to_string(),
dev_mode: false,
container_runtime: ContainerRuntime::Auto,
port_offset: 10000,
bitcoin_simulation: BitcoinSimulation::Mock,
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
// Discoverability is opt-in. Until the user explicitly enables it
// (Settings UI / `nostr_discovery_enabled = true` in config), no
// presence event is ever published and `handshake.poll` never
// contacts a relay. This is the sole knob that controls whether
// we leak our DID + npub to the public Nostr relays.
nostr_discovery_enabled: false,
nostr_relays: vec![
"wss://relay.damus.io".into(),
"wss://relay.nostr.info".into(),
],
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
use_quadlet_backends: false,
}
}
}
/// Recognise the canonical "the user meant true" forms for boolean env
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
/// trimmed). Anything else — including the typo'd "ture" or the empty
/// string — counts as false. Centralised so future env flags stay
/// consistent with each other.
fn parse_truthy_env(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_values() {
let config = Config::default();
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
assert_eq!(config.bind_host, "127.0.0.1");
assert_eq!(config.bind_port, 5678);
assert_eq!(config.log_level, "info");
assert_eq!(config.host_ip, "127.0.0.1");
assert!(!config.dev_mode);
assert_eq!(config.port_offset, 10000);
assert!(!config.nostr_discovery_enabled);
assert_eq!(config.nostr_relays.len(), 2);
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
}
#[test]
fn test_container_runtime_from_str_podman() {
assert!(matches!(
ContainerRuntime::from_str("podman"),
ContainerRuntime::Podman
));
assert!(matches!(
ContainerRuntime::from_str("Podman"),
ContainerRuntime::Podman
));
assert!(matches!(
ContainerRuntime::from_str("PODMAN"),
ContainerRuntime::Podman
));
}
#[test]
fn test_container_runtime_from_str_docker() {
assert!(matches!(
ContainerRuntime::from_str("docker"),
ContainerRuntime::Docker
));
assert!(matches!(
ContainerRuntime::from_str("Docker"),
ContainerRuntime::Docker
));
assert!(matches!(
ContainerRuntime::from_str("DOCKER"),
ContainerRuntime::Docker
));
}
#[test]
fn test_container_runtime_from_str_auto() {
assert!(matches!(
ContainerRuntime::from_str("auto"),
ContainerRuntime::Auto
));
assert!(matches!(
ContainerRuntime::from_str("Auto"),
ContainerRuntime::Auto
));
// Unknown strings default to Auto
assert!(matches!(
ContainerRuntime::from_str("unknown"),
ContainerRuntime::Auto
));
assert!(matches!(
ContainerRuntime::from_str(""),
ContainerRuntime::Auto
));
}
#[test]
fn test_bitcoin_simulation_from_str() {
assert!(matches!(
BitcoinSimulation::from_str("mock"),
BitcoinSimulation::Mock
));
assert!(matches!(
BitcoinSimulation::from_str("Mock"),
BitcoinSimulation::Mock
));
assert!(matches!(
BitcoinSimulation::from_str("testnet"),
BitcoinSimulation::Testnet
));
assert!(matches!(
BitcoinSimulation::from_str("Testnet"),
BitcoinSimulation::Testnet
));
assert!(matches!(
BitcoinSimulation::from_str("mainnet"),
BitcoinSimulation::Mainnet
));
assert!(matches!(
BitcoinSimulation::from_str("Mainnet"),
BitcoinSimulation::Mainnet
));
assert!(matches!(
BitcoinSimulation::from_str("none"),
BitcoinSimulation::None
));
}
#[test]
fn test_bitcoin_simulation_unknown_defaults_to_none() {
assert!(matches!(
BitcoinSimulation::from_str(""),
BitcoinSimulation::None
));
assert!(matches!(
BitcoinSimulation::from_str("signet"),
BitcoinSimulation::None
));
assert!(matches!(
BitcoinSimulation::from_str("garbage"),
BitcoinSimulation::None
));
}
#[test]
fn test_config_serialization_roundtrip() {
let config = Config::default();
let json = serde_json::to_string(&config).unwrap();
let deserialized: Config = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.bind_host, config.bind_host);
assert_eq!(deserialized.bind_port, config.bind_port);
assert_eq!(deserialized.data_dir, config.data_dir);
assert_eq!(deserialized.log_level, config.log_level);
assert_eq!(deserialized.dev_mode, config.dev_mode);
assert_eq!(deserialized.port_offset, config.port_offset);
assert_eq!(
deserialized.nostr_discovery_enabled,
config.nostr_discovery_enabled
);
assert_eq!(deserialized.nostr_relays, config.nostr_relays);
}
#[test]
fn test_config_toml_parsing() {
let toml_str = r#"
data_dir = "/tmp/test-data"
bind_host = "127.0.0.1"
bind_port = 9999
log_level = "debug"
host_ip = "192.168.1.100"
dev_mode = true
container_runtime = "Podman"
port_offset = 20000
bitcoin_simulation = "Testnet"
dev_data_dir = "/tmp/dev-test"
nostr_discovery_enabled = false
nostr_relays = ["wss://example.com"]
"#;
let config: Config = toml::de::from_str(toml_str).unwrap();
assert_eq!(config.data_dir, PathBuf::from("/tmp/test-data"));
assert_eq!(config.bind_host, "127.0.0.1");
assert_eq!(config.bind_port, 9999);
assert_eq!(config.log_level, "debug");
assert_eq!(config.host_ip, "192.168.1.100");
assert!(config.dev_mode);
assert_eq!(config.port_offset, 20000);
assert!(!config.nostr_discovery_enabled);
assert_eq!(config.nostr_relays, vec!["wss://example.com"]);
}
#[test]
fn test_config_data_dir_is_pathbuf() {
let config = Config::default();
assert!(config.data_dir.is_absolute());
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
}
#[test]
fn test_config_host_ip_default() {
let config = Config::default();
assert_eq!(config.host_ip, "127.0.0.1");
}
#[test]
fn test_config_dev_mode_defaults_off() {
let config = Config::default();
assert!(!config.dev_mode);
assert_eq!(config.dev_data_dir, PathBuf::from("/tmp/archipelago-dev"));
}
#[test]
fn test_config_nostr_discovery_disabled_by_default() {
// Discoverability is opt-in: nothing is published to public relays
// until the user explicitly turns it on. Flipping this back to
// `true` would silently start leaking the local DID + npub on every
// boot — guard rail.
let config = Config::default();
assert!(!config.nostr_discovery_enabled);
assert!(config.nostr_tor_proxy.is_some());
}
#[test]
fn test_config_nostr_relays_default_not_empty() {
let config = Config::default();
assert!(!config.nostr_relays.is_empty());
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
}
#[test]
fn parse_truthy_env_recognises_canonical_forms() {
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
}
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
}
}
#[test]
fn test_config_use_quadlet_backends_defaults_off() {
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
// harness goes green on .228 and .198. Flipping this default
// ahead of that would route every backend install through code
// we haven't fleet-validated yet.
let config = Config::default();
assert!(!config.use_quadlet_backends);
}
}