diff --git a/Cargo.toml b/Cargo.toml index 813481a..6ed32ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ repository = "https://github.com/256-Foundation/Mujina-Mining-Firmware" [workspace.dependencies] anyhow = "1.0" +clap = { version = "4", features = ["derive"] } +config = { version = "0.14", features = ["yaml"] } async-trait = "0.1" axum = "0.8" bitcoin = "0.32" diff --git a/configs/mujina.example.yaml b/configs/mujina.example.yaml new file mode 100644 index 0000000..ac354fd --- /dev/null +++ b/configs/mujina.example.yaml @@ -0,0 +1,110 @@ +# Mujina Miner — example configuration +# +# Copy this file to /etc/mujina/mujina.yaml and edit as needed. +# All keys are optional; values shown here are the hard-coded defaults. +# +# Full documentation: docs/configuration.md + +# ------------------------------------------------------------ +# daemon — process and logging +# ------------------------------------------------------------ +daemon: + # Minimum log level emitted to stdout or journald. + # Values: error | warn | info | debug | trace + log_level: "info" + + # PID file written on startup, removed on clean exit. + # Null disables PID file creation. + pid_file: ~ + + # Emit systemd sd_notify readiness notifications. + # Enable when running under a systemd Type=notify service unit. + systemd: false + + +# ------------------------------------------------------------ +# api — HTTP REST API server +# ------------------------------------------------------------ +api: + # TCP address and port for the API server. + # Use 127.0.0.1 (loopback) to restrict access to localhost. + # Use 0.0.0.0 to expose on all interfaces (see security note below). + listen: "127.0.0.1:7785" + + # WARNING: The API has no authentication. Binding to a non-loopback + # address exposes full miner control to the network. Only do this + # behind a firewall or reverse proxy that provides authentication. + + +# ------------------------------------------------------------ +# pool — primary mining pool +# ------------------------------------------------------------ +pool: + # Stratum v1 pool URL. + # Format: stratum+tcp://: + # Required for actual mining. When absent, the daemon starts with + # a dummy job source (useful for hardware testing). + url: ~ + + # Worker name, typically wallet_address.worker_name + user: "mujina-testing" + + # Worker password. Most pools accept "x". + password: "x" + + +# ------------------------------------------------------------ +# backplane — board discovery and lifecycle management +# ------------------------------------------------------------ +backplane: + # Enable USB device discovery and hotplug monitoring. + # Disable for environments without USB mining hardware + # (e.g., CPU-only testing). + usb_enabled: true + + +# ------------------------------------------------------------ +# boards — per-board-type hardware configuration +# ------------------------------------------------------------ +boards: + + # -- Bitaxe family (BM1370-based boards) -- + bitaxe: + # Emergency shutdown temperature in Celsius. + # The board halts hashing if any sensor reads above this value. + temp_limit_c: 85.0 + + # Fan speed bounds as a percentage of full speed. + # The thermal controller adjusts within this range. + fan_min_pct: 20 + fan_max_pct: 100 + + # Maximum board power consumption in watts. + # Null disables the power cap (hardware default applies). + power_limit_w: ~ + + # -- CPU miner (software SHA256d, for testing and development) -- + cpu_miner: + # Enable the software CPU miner. + # When false, no CPU mining threads are started. + enabled: false + + # Number of OS threads to dedicate to hashing. + threads: 1 + + # Target CPU utilisation per thread as a percentage (1–100). + # At 80%, each thread hashes for 800 ms then sleeps for 200 ms. + # Useful on cloud instances that alert on sustained 100% CPU usage. + duty_percent: 50 + + +# ------------------------------------------------------------ +# hash_thread — ASIC hash thread tuning +# ------------------------------------------------------------ +hash_thread: + # Difficulty target configured on-chip for share reporting. + # Lower values produce more frequent shares (useful for health + # monitoring); higher values reduce message volume on large + # installations. The scheduler still applies pool difficulty + # filtering before forwarding shares to the pool. + chip_target_difficulty: 256 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f88243b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,186 @@ +*Mujina Configuration* + +This document describes the configuration system for `mujina-minerd`. + +- [1. Priority Order](#1-priority-order) +- [2. Config Files](#2-config-files) + - [2.1. Default location](#21-default-location) + - [2.2. User-specified location](#22-user-specified-location) +- [3. Environment Variables](#3-environment-variables) + - [3.1. Migration from Legacy Environment Variables](#31-migration-from-legacy-environment-variables) +- [4. CLI Flags](#4-cli-flags) +- [5. YAML Structure](#5-yaml-structure) + - [5.1. Full reference with defaults](#51-full-reference-with-defaults) +- [6. Testing the Priority Chain](#6-testing-the-priority-chain) + +## 1. Priority Order + +Configuration is resolved from multiple sources. When the same key appears in +more than one source, the **highest-priority source wins**: + +| Priority | Source | +|----------|--------| +| 1 (highest) | CLI flags (`--pool-url`, `--log-level`, etc.) | +| 2 | Environment variables (`MUJINA__*`) | +| 3 | User config file (`$MUJINA_CONFIG_FILE_PATH`) | +| 4 | Default config file (`/etc/mujina/mujina.yaml`) | +| 5 (lowest) | Hard-coded defaults | + +All sources are optional except the hard-coded defaults, which are always +present. A minimal deployment with no config file and no environment variables +will start with sensible defaults (dummy job source, API on localhost). + +## 2. Config Files + +### 2.1. Default location + +`/etc/mujina/mujina.yaml` + +This is the standard system-wide config file, suitable for installation by a +package manager or system administrator. + +### 2.2. User-specified location + +Set `MUJINA_CONFIG_FILE_PATH` to an absolute path to load a second config file +that supplements (and overrides) the default location: + +```sh +MUJINA_CONFIG_FILE_PATH=/home/operator/mujina.yaml mujina-minerd +``` + +Keys present in the user-specified file take precedence over the same keys in +`/etc/mujina/mujina.yaml`. Keys absent from the user file fall back to the +default file, then to hard-coded defaults. + +An example config file is provided at `configs/mujina.example.yaml`. + +## 3. Environment Variables + +Individual config keys can be overridden with environment variables. The +naming convention is: + +``` +MUJINA__
__=value +``` + +Nesting levels are separated by double-underscores (`__`). The prefix is +`MUJINA` (single word, no trailing underscores). + +Double underscores are required because config key names themselves contain +single underscores (e.g. `cpu_miner`, `fan_min_pct`). A single-underscore +separator would make it impossible to tell whether `MUJINA_BOARDS_CPU_MINER` +means `boards.cpu_miner` (two levels) or `boards.cpu` with key `miner` (three +levels with a truncated name). Double underscores eliminate that ambiguity: +every `__` is a level boundary, every `_` is part of a name. + +Examples: + +```sh +# Override daemon.log_level +MUJINA__DAEMON__LOG_LEVEL=debug + +# Override api.listen +MUJINA__API__LISTEN=0.0.0.0:7785 + +# Override pool URL +MUJINA__POOL__URL=stratum+tcp://pool.example.com:3333 + +# Disable USB discovery +MUJINA__BACKPLANE__USB_ENABLED=false + +# Enable CPU miner +MUJINA__BOARDS__CPU_MINER__ENABLED=true +MUJINA__BOARDS__CPU_MINER__THREADS=4 +``` + +> **Note on log filtering:** `RUST_LOG` is handled separately by the +> `tracing-subscriber` crate and controls per-module log verbosity. It is not +> part of the mujina config system but remains fully supported. +> Example: `RUST_LOG=mujina_miner=debug` + +### 3.1. Migration from Legacy Environment Variables + +Earlier versions used ad-hoc environment variables with single underscores. +These are superseded by the unified config system: + +| Legacy variable | New config key | New env var | +|-----------------|---------------|-------------| +| `MUJINA_POOL_URL` | `pool.url` | `MUJINA__POOL__URL` | +| `MUJINA_POOL_USER` | `pool.user` | `MUJINA__POOL__USER` | +| `MUJINA_POOL_PASS` | `pool.password` | `MUJINA__POOL__PASSWORD` | +| `MUJINA_API_LISTEN` | `api.listen` | `MUJINA__API__LISTEN` | +| `MUJINA_USB_DISABLE` | `backplane.usb_enabled` | `MUJINA__BACKPLANE__USB_ENABLED` | +| `MUJINA_CPUMINER_THREADS` | `boards.cpu_miner.threads` | `MUJINA__BOARDS__CPU_MINER__THREADS` | +| `MUJINA_CPUMINER_DUTY` | `boards.cpu_miner.duty_percent` | `MUJINA__BOARDS__CPU_MINER__DUTY_PERCENT` | + +`MUJINA_API_URL` (used by `mujina-cli` to locate the daemon) is not part of +the daemon config and is unchanged. + +## 4. CLI Flags + +CLI flags override all other sources, including environment variables. They are +intended for one-off overrides and testing, not permanent configuration. + +`mujina-minerd` accepts the following flags: + +``` +USAGE: + mujina-minerd [OPTIONS] + +OPTIONS: + -c, --config Config file path (overrides MUJINA_CONFIG_FILE_PATH) + --log-level Log level: error, warn, info, debug, trace [default: info] + --api-listen API listen address [default: 127.0.0.1:7785] + --pool-url Pool URL, e.g. stratum+tcp://pool.example.com:3333 + --pool-user Pool worker username + --pool-pass Pool worker password + -h, --help Print help + -V, --version Print version +``` + +## 5. YAML Structure + +The config file uses YAML. The top-level keys correspond to subsystems: + +```yaml +daemon: # Process and logging settings +api: # HTTP API server +pool: # Mining pool connection (primary) +backplane: # Board discovery and lifecycle +boards: # Per-board-type hardware settings + bitaxe: # Bitaxe family boards (BM1370, etc.) + cpu_miner: # Software CPU miner (testing/development) +hash_thread: # ASIC hash thread tuning +``` + +### 5.1. Full reference with defaults + +See [configs/mujina.example.yaml](../configs/mujina.example.yaml) for the +annotated example file showing every key with its default value. + +## 6. Testing the Priority Chain + +`mujina-miner/tests/config_priority_tests.rs` contains integration tests that +exercise each layer of the priority chain end-to-end. Each test starts a real +`Daemon` instance and polls the API port to confirm the daemon bound to the +address that the winning source declared. + +| Test | Layer(s) exercised | +|------|--------------------| +| `test_default_config_file` | default config file overrides hard-coded default | +| `test_user_config_override` | user config file overrides default config file | +| `test_env_var_override` | `MUJINA__*` env var overrides both config files | +| `test_command_line_arg_override` | CLI flag (direct field assignment) overrides env var and both config files | + +The tests use `tempfile` to write config files in a temporary directory, so +**no root access is required** — the default config path is redirected via +`MUJINA_DEFAULT_CONFIG_PATH` during the test run. + +Run only these tests with: + +```sh +cargo test -p mujina-miner --test config_priority_tests +``` + +Because the tests mutate process-wide environment variables they are serialized +with `#[serial]` from the `serial_test` crate. Do not run them with `--test-threads > 1`. diff --git a/mujina-miner/Cargo.toml b/mujina-miner/Cargo.toml index b3520e0..2fa9f24 100644 --- a/mujina-miner/Cargo.toml +++ b/mujina-miner/Cargo.toml @@ -11,6 +11,8 @@ default-run = "mujina-minerd" [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +clap = { workspace = true } +config = { workspace = true } axum = { workspace = true } bitcoin = { workspace = true } bitflags = { workspace = true } @@ -72,6 +74,7 @@ skip-pty-tests = [] # Skip PTY-based serial tests that may hang in some environ http = "1" http-body-util = "0.1" serial_test = "3.3.1" +tempfile = "3" test-case = { workspace = true } tokio = { workspace = true, features = ["test-util"] } tower = "0.5" diff --git a/mujina-miner/src/bin/minerd.rs b/mujina-miner/src/bin/minerd.rs index fa25f4a..44b303b 100644 --- a/mujina-miner/src/bin/minerd.rs +++ b/mujina-miner/src/bin/minerd.rs @@ -1,11 +1,66 @@ //! Main entry point for the mujina-miner daemon. -use mujina_miner::{daemon::Daemon, tracing}; +use std::path::PathBuf; + +use clap::Parser; +use mujina_miner::{config::Config, daemon::Daemon, tracing}; + +/// Mujina Bitcoin mining daemon. +#[derive(Parser)] +#[command(name = "mujina-minerd", version)] +struct Cli { + /// Config file path (overrides MUJINA_CONFIG_FILE_PATH and the default + /// /etc/mujina/mujina.yaml location). + #[arg(short = 'c', long, value_name = "PATH")] + config: Option, + + /// Log level: error | warn | info | debug | trace + #[arg(long, value_name = "LEVEL")] + log_level: Option, + + /// API listen address, e.g. 0.0.0.0:7785 + #[arg(long, value_name = "ADDR")] + api_listen: Option, + + /// Pool URL, e.g. stratum+tcp://pool.example.com:3333 + #[arg(long, value_name = "URL")] + pool_url: Option, + + /// Pool worker username + #[arg(long, value_name = "USER")] + pool_user: Option, + + /// Pool worker password + #[arg(long, value_name = "PASS")] + pool_pass: Option, +} #[tokio::main] async fn main() -> anyhow::Result<()> { tracing::init_journald_or_stdout(); - let daemon = Daemon::new(); + let cli = Cli::parse(); + + // Load config through the standard hierarchy (files + env vars), then + // apply CLI flag overrides on top as the highest-priority source. + let mut config = Config::load_with(cli.config)?; + + if let Some(level) = cli.log_level { + config.daemon.log_level = level; + } + if let Some(listen) = cli.api_listen { + config.api.listen = listen; + } + if let Some(url) = cli.pool_url { + config.pool.url = Some(url); + } + if let Some(user) = cli.pool_user { + config.pool.user = user; + } + if let Some(pass) = cli.pool_pass { + config.pool.password = pass; + } + + let daemon = Daemon::new(config); daemon.run().await } diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index 43deb45..c205826 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -1,101 +1,277 @@ //! Configuration management for mujina-miner. //! -//! This module handles loading and validating configuration from TOML files, -//! environment variables, and command-line arguments. It supports hot-reload -//! via file watching. +//! Loads configuration from multiple sources in priority order (highest wins): +//! +//! 1. CLI flags (caller merges these on top after calling `Config::load`) +//! 2. Environment variables — prefix `MUJINA`, separator `__` +//! e.g. `MUJINA__POOL__URL=stratum+tcp://pool.example.com:3333` +//! 3. User config file — path from `MUJINA_CONFIG_FILE_PATH` env var, or the +//! `--config` path passed as `cli_config_path` to `Config::load_with` +//! 4. Default config file — `/etc/mujina/mujina.yaml` +//! 5. Hard-coded defaults — `Default` impls on each struct (lowest priority) +//! +//! See `docs/configuration.md` and `configs/mujina.example.yaml` for the full +//! key reference. +use std::path::PathBuf; + +use config::{Environment, File, FileFormat}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -/// Main configuration structure for the miner. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Config { - /// Daemon configuration - pub daemon: DaemonConfig, +const DEFAULT_CONFIG_PATH: &str = "/etc/mujina/mujina.yaml"; +const DEFAULT_CONFIG_PATH_ENV_VAR: &str = "MUJINA_DEFAULT_CONFIG_PATH"; +const CONFIG_FILE_ENV_VAR: &str = "MUJINA_CONFIG_FILE_PATH"; +const ENV_PREFIX: &str = "MUJINA"; +const ENV_SEPARATOR: &str = "__"; - /// Pool configuration - pub pools: Vec, +/// Returns the path to the default system config file. +/// +/// Normally `/etc/mujina/mujina.yaml`. Override via `MUJINA_DEFAULT_CONFIG_PATH` +/// (useful in tests to avoid requiring root access to `/etc`). +fn default_config_path() -> String { + std::env::var(DEFAULT_CONFIG_PATH_ENV_VAR) + .unwrap_or_else(|_| DEFAULT_CONFIG_PATH.to_string()) +} - /// Hardware configuration - pub hardware: HardwareConfig, +// --------------------------------------------------------------------------- +// Top-level config +// --------------------------------------------------------------------------- - /// API server configuration +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub daemon: DaemonConfig, pub api: ApiConfig, + pub pool: PoolConfig, + pub backplane: BackplaneConfig, + pub boards: BoardsConfig, + pub hash_thread: HashThreadConfig, } -/// Daemon process configuration. +impl Default for Config { + fn default() -> Self { + Self { + daemon: DaemonConfig::default(), + api: ApiConfig::default(), + pool: PoolConfig::default(), + backplane: BackplaneConfig::default(), + boards: BoardsConfig::default(), + hash_thread: HashThreadConfig::default(), + } + } +} + +// --------------------------------------------------------------------------- +// Subsection structs +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] pub struct DaemonConfig { - /// PID file location + pub log_level: String, pub pid_file: Option, + pub systemd: bool, +} - /// Log level - pub log_level: String, +impl Default for DaemonConfig { + fn default() -> Self { + Self { + log_level: "info".to_string(), + pid_file: None, + systemd: false, + } + } +} - /// Use systemd notification - #[serde(default)] - pub systemd: bool, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ApiConfig { + pub listen: String, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + listen: "127.0.0.1:7785".to_string(), + } + } } -/// Pool connection configuration. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] pub struct PoolConfig { - /// Pool URL (stratum+tcp://...) - pub url: String, + pub url: Option, + pub user: String, + pub password: String, +} - /// Worker name - pub worker: String, +impl Default for PoolConfig { + fn default() -> Self { + Self { + url: None, + user: "mujina-testing".to_string(), + password: "x".to_string(), + } + } +} - /// Password (if required) - pub password: Option, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct BackplaneConfig { + pub usb_enabled: bool, +} - /// Priority (lower is higher priority) - #[serde(default)] - pub priority: u32, +impl Default for BackplaneConfig { + fn default() -> Self { + Self { usb_enabled: true } + } } -/// Hardware configuration. #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct HardwareConfig { - /// Temperature limits - pub temp_limit: f32, +#[serde(default, deny_unknown_fields)] +pub struct BoardsConfig { + pub bitaxe: BitaxeConfig, + pub cpu_miner: CpuMinerConfig, +} - /// Fan control settings - pub fan_min_rpm: u32, - pub fan_max_rpm: u32, +impl Default for BoardsConfig { + fn default() -> Self { + Self { + bitaxe: BitaxeConfig::default(), + cpu_miner: CpuMinerConfig::default(), + } + } +} - /// Power limits - pub power_limit: Option, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct BitaxeConfig { + pub temp_limit_c: f32, + pub fan_min_pct: u8, + pub fan_max_pct: u8, + pub power_limit_w: Option, +} + +impl Default for BitaxeConfig { + fn default() -> Self { + Self { + temp_limit_c: 85.0, + fan_min_pct: 20, + fan_max_pct: 100, + power_limit_w: None, + } + } } -/// API server configuration. #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ApiConfig { - /// Listen address - pub listen: String, +#[serde(default, deny_unknown_fields)] +pub struct CpuMinerConfig { + pub enabled: bool, + pub threads: usize, + pub duty_percent: u8, +} - /// Enable TLS - #[serde(default)] - pub tls: bool, +impl Default for CpuMinerConfig { + fn default() -> Self { + Self { + enabled: false, + threads: 1, + duty_percent: 50, + } + } +} - /// TLS certificate path - pub cert_path: Option, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct HashThreadConfig { + pub chip_target_difficulty: u32, +} - /// TLS key path - pub key_path: Option, +impl Default for HashThreadConfig { + fn default() -> Self { + Self { + chip_target_difficulty: 256, + } + } } +// --------------------------------------------------------------------------- +// Loading +// --------------------------------------------------------------------------- + impl Config { - /// Load configuration from the default location. + /// Load configuration using the standard source hierarchy. + /// + /// The user config path is read from `MUJINA_CONFIG_FILE_PATH` if set. + /// To supply a path from a CLI `--config` flag instead, use + /// [`Config::load_with`]. pub fn load() -> anyhow::Result { - // TODO: Implement config loading from /etc/mujina/mujina.toml - // and ~/.config/mujina/mujina.toml with proper merging - unimplemented!("Config loading not yet implemented") + Self::load_with(None) + } + + /// Load configuration, optionally overriding the user config file path. + /// + /// `cli_config_path` corresponds to the `--config` CLI flag and takes + /// precedence over `MUJINA_CONFIG_FILE_PATH`. + pub fn load_with(cli_config_path: Option) -> anyhow::Result { + let mut builder = config::Config::builder() + // Layer 4 (lowest): default system config file + .add_source( + File::with_name(&default_config_path()) + .format(FileFormat::Yaml) + .required(false), + ); + + // Layer 3: user-specified config file (CLI flag beats env var) + let user_path = cli_config_path + .map(|p| p.to_string_lossy().into_owned()) + .or_else(|| std::env::var(CONFIG_FILE_ENV_VAR).ok()); + + if let Some(path) = user_path { + builder = builder.add_source( + File::with_name(&path) + .format(FileFormat::Yaml) + .required(true), + ); + } + + // Layer 2 (highest file-based): environment variables + builder = builder.add_source( + Environment::with_prefix(ENV_PREFIX) + .separator(ENV_SEPARATOR) + .try_parsing(true), + ); + + Ok(builder.build()?.try_deserialize::()?) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + let cfg = Config::default(); + assert_eq!(cfg.daemon.log_level, "info"); + assert!(!cfg.daemon.systemd); + assert_eq!(cfg.api.listen, "127.0.0.1:7785"); + assert!(cfg.pool.url.is_none()); + assert!(cfg.backplane.usb_enabled); + assert!(!cfg.boards.cpu_miner.enabled); + assert_eq!(cfg.boards.bitaxe.temp_limit_c, 85.0); + assert_eq!(cfg.hash_thread.chip_target_difficulty, 256); } - /// Load configuration from a specific file. - pub fn load_from(_path: &Path) -> anyhow::Result { - // TODO: Implement TOML parsing - unimplemented!("Config loading not yet implemented") + #[test] + fn load_with_no_files_uses_defaults() { + // Verify load succeeds when no config file is present (the default + // path won't exist in a dev environment). + let result = Config::load_with(None); + assert!(result.is_ok(), "load_with(None) failed: {:?}", result); } } diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index 73f7835..646627d 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -3,8 +3,6 @@ //! This module handles the core daemon functionality including initialization, //! task management, signal handling, and graceful shutdown. -use std::env; - use tokio::signal::unix::{self, SignalKind}; use tokio::sync::{mpsc, watch}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; @@ -15,7 +13,7 @@ use crate::{ api::{self, ApiConfig, commands::SchedulerCommand}, asic::hash_thread::HashThread, backplane::Backplane, - cpu_miner::CpuMinerConfig, + config::Config, job_source::{ SourceCommand, SourceEvent, dummy::DummySource, @@ -29,48 +27,61 @@ use crate::{ /// The main daemon. pub struct Daemon { + config: Config, shutdown: CancellationToken, tracker: TaskTracker, } impl Daemon { - /// Create a new daemon instance. - pub fn new() -> Self { + /// Create a new daemon instance with the provided configuration. + pub fn new(config: Config) -> Self { Self { + config, shutdown: CancellationToken::new(), tracker: TaskTracker::new(), } } + /// Return a cancellation token that triggers a clean shutdown when cancelled. + /// + /// Call this before [`run`] (which consumes `self`), then cancel the token + /// from a test or management interface to stop the daemon without a signal. + pub fn shutdown_token(&self) -> CancellationToken { + self.shutdown.clone() + } + /// Run the daemon until shutdown is requested. pub async fn run(self) -> anyhow::Result<()> { + let config = self.config; + // Create channels for component communication let (transport_tx, transport_rx) = mpsc::channel::(100); let (thread_tx, thread_rx) = mpsc::channel::>(10); let (source_reg_tx, source_reg_rx) = mpsc::channel::(10); // Create and start USB transport discovery - if std::env::var("MUJINA_USB_DISABLE").is_err() { + if config.backplane.usb_enabled { let usb_transport = UsbTransport::new(transport_tx.clone()); if let Err(e) = usb_transport.start_discovery(self.shutdown.clone()).await { error!("Failed to start USB discovery: {}", e); } } else { - info!("USB discovery disabled (MUJINA_USB_DISABLE set)"); + info!("USB discovery disabled (backplane.usb_enabled = false)"); } // Inject CPU miner virtual device if configured - if let Some(config) = CpuMinerConfig::from_env() { + if config.boards.cpu_miner.enabled { + let cpu_cfg = &config.boards.cpu_miner; info!( - threads = config.thread_count, - duty = config.duty_percent, + threads = cpu_cfg.threads, + duty = cpu_cfg.duty_percent, "CPU miner enabled" ); let event = TransportEvent::Cpu(cpu_transport::TransportEvent::CpuDeviceConnected( CpuDeviceInfo { - device_id: format!("cpu-{}x{}%", config.thread_count, config.duty_percent), - thread_count: config.thread_count, - duty_percent: config.duty_percent, + device_id: format!("cpu-{}x{}%", cpu_cfg.threads, cpu_cfg.duty_percent), + thread_count: cpu_cfg.threads, + duty_percent: cpu_cfg.duty_percent, }, )); if let Err(e) = transport_tx.send(event).await { @@ -100,24 +111,17 @@ impl Daemon { } }); - // Create job source (Stratum v1 or Dummy) - // Controlled by environment variables: - // - MUJINA_POOL_URL: Pool address (e.g., stratum+tcp://localhost:3333) - // - MUJINA_POOL_USER: Worker username (optional, defaults to "mujina-testing") - // - MUJINA_POOL_PASS: Worker password (optional, defaults to "x") + // Create job source (Stratum v1 or Dummy). + // pool.url in the config selects Stratum v1; absent means dummy source. let (source_event_tx, source_event_rx) = mpsc::channel::(100); let (source_cmd_tx, source_cmd_rx) = mpsc::channel(10); - if let Ok(pool_url) = env::var("MUJINA_POOL_URL") { + if let Some(pool_url) = config.pool.url.clone() { // Use Stratum v1 source - let pool_user = - env::var("MUJINA_POOL_USER").unwrap_or_else(|_| "mujina-testing".to_string()); - let pool_pass = env::var("MUJINA_POOL_PASS").unwrap_or_else(|_| "x".to_string()); - let stratum_config = StratumPoolConfig { url: pool_url.clone(), - username: pool_user, - password: pool_pass, + username: config.pool.user.clone(), + password: config.pool.password.clone(), user_agent: "mujina-miner/0.1.0-alpha".to_string(), }; @@ -199,7 +203,7 @@ impl Daemon { } } else { // Use DummySource - info!("Using dummy job source (set MUJINA_POOL_URL to use Stratum v1)"); + info!("Using dummy job source (set pool.url or MUJINA__POOL__URL to use Stratum v1)"); let dummy_source = DummySource::new( source_cmd_rx, @@ -240,20 +244,13 @@ impl Daemon { )); // Start the API server + let api_listen = config.api.listen.clone(); self.tracker.spawn({ let shutdown = self.shutdown.clone(); async move { - // ASCII 'M' (77) + 'U' (85) = 7785 - const API_PORT: u16 = 7785; - - let bind_addr = match env::var("MUJINA_API_LISTEN") { - Ok(addr) if addr.contains(':') => addr, - Ok(addr) => format!("{addr}:{API_PORT}"), - Err(_) => format!("127.0.0.1:{API_PORT}"), - }; - let config = ApiConfig { bind_addr }; + let api_config = ApiConfig { bind_addr: api_listen }; if let Err(e) = api::serve( - config, + api_config, shutdown, miner_state_rx, board_reg_rx, @@ -275,7 +272,7 @@ impl Daemon { let mut sigint = unix::signal(SignalKind::interrupt())?; let mut sigterm = unix::signal(SignalKind::terminate())?; - // Wait for shutdown signal + // Wait for shutdown signal or programmatic cancellation tokio::select! { _ = sigint.recv() => { info!("Received SIGINT."); @@ -283,6 +280,9 @@ impl Daemon { _ = sigterm.recv() => { info!("Received SIGTERM."); }, + _ = self.shutdown.cancelled() => { + info!("Shutdown requested programmatically."); + }, } // Initiate shutdown @@ -298,6 +298,6 @@ impl Daemon { impl Default for Daemon { fn default() -> Self { - Self::new() + Self::new(Config::default()) } } diff --git a/mujina-miner/tests/config_priority_tests.rs b/mujina-miner/tests/config_priority_tests.rs new file mode 100644 index 0000000..9b6e578 --- /dev/null +++ b/mujina-miner/tests/config_priority_tests.rs @@ -0,0 +1,304 @@ +//! Integration tests for the configuration priority chain. + +use std::time::Duration; + +use mujina_miner::{config::Config, daemon::Daemon}; +use serial_test::serial; +use tokio::net::TcpStream; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Poll a TCP address every 50 ms until it accepts a connection or `timeout` +/// elapses. Returns true if the port became reachable within the deadline. +async fn wait_for_port(addr: &str, timeout: Duration) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + while tokio::time::Instant::now() < deadline { + if TcpStream::connect(addr).await.is_ok() { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Verify that the daemon reads the default config file and binds the API +/// server to the port declared there. +/// +/// Uses port 17785 (≠ default 7785) so a passing test proves the file was +/// actually read, not that the default happened to match. +/// +/// A temporary directory is used instead of /etc/mujina so the test runs +/// without elevated permissions. `MUJINA_DEFAULT_CONFIG_PATH` redirects +/// `Config::load()` to that temp file. +#[tokio::test] +#[serial] +async fn test_default_config_file() { + const TEST_PORT: u16 = 17785; + let listen_addr = format!("127.0.0.1:{TEST_PORT}"); + + // Create a temp directory and write the config file into it. + let tmp_dir = tempfile::tempdir().expect("failed to create tempdir"); + let config_path = tmp_dir.path().join("mujina.yaml"); + let config_yaml = format!( + "api:\n listen: \"{listen_addr}\"\nbackplane:\n usb_enabled: false\n" + ); + std::fs::write(&config_path, &config_yaml).expect("failed to write temp config"); + + // Redirect the default config path to our temp file. + // SAFETY: test is marked #[serial] so no other threads touch the environment. + unsafe { std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &config_path) }; + + let config = Config::load().expect("Config::load() failed"); + + // Clean up the env var immediately — don't let it bleed into other tests. + // SAFETY: same serial-test guarantee as above. + unsafe { std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH") }; + + assert_eq!( + config.api.listen, listen_addr, + "config.api.listen should reflect the value from the default config file" + ); + + // Obtain a shutdown token before run() consumes the daemon. + let daemon = Daemon::new(config); + let shutdown = daemon.shutdown_token(); + + let daemon_handle = tokio::spawn(async move { daemon.run().await }); + + // Wait up to 5 s for the API port to become reachable. + let listening = wait_for_port(&listen_addr, Duration::from_secs(5)).await; + + // Always shut down before asserting so cleanup runs even on failure. + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), daemon_handle).await; + + assert!( + listening, + "mujina-minerd should be listening on {listen_addr} (port from default config file)" + ); +} + +/// Verify that a user-specified config file (via `MUJINA_CONFIG_FILE_PATH`) +/// overrides the value set in the default config file, which itself overrides +/// the hard-coded default listen address. +/// +/// Priority chain exercised: +/// user config file (17786) > default config file (17785) > built-in default (7785) +#[tokio::test] +#[serial] +async fn test_user_config_override() { + const TEST_PORT_DEFAULT_CONFIG: u16 = 17785; + const TEST_PORT_USER_CONFIG: u16 = 17786; + + let default_listen = format!("127.0.0.1:{TEST_PORT_DEFAULT_CONFIG}"); + let user_listen = format!("127.0.0.1:{TEST_PORT_USER_CONFIG}"); + + // Write the default config file (layer 4). + let tmp_default = tempfile::tempdir().expect("failed to create tempdir for default config"); + let default_config_path = tmp_default.path().join("mujina.yaml"); + std::fs::write( + &default_config_path, + format!("api:\n listen: \"{default_listen}\"\nbackplane:\n usb_enabled: false\n"), + ) + .expect("failed to write default temp config"); + + // Write the user config file (layer 3) — only overrides `api.listen`. + let tmp_user = tempfile::tempdir().expect("failed to create tempdir for user config"); + let user_config_path = tmp_user.path().join("mujina.yaml"); + std::fs::write( + &user_config_path, + format!("api:\n listen: \"{user_listen}\"\n"), + ) + .expect("failed to write user temp config"); + + // SAFETY: test is marked #[serial] so no other threads touch the environment. + unsafe { + std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &default_config_path); + std::env::set_var("MUJINA_CONFIG_FILE_PATH", &user_config_path); + } + + let config = Config::load().expect("Config::load() failed"); + + // SAFETY: same serial-test guarantee as above. + unsafe { + std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH"); + std::env::remove_var("MUJINA_CONFIG_FILE_PATH"); + } + + assert_eq!( + config.api.listen, user_listen, + "user config file (port {TEST_PORT_USER_CONFIG}) should override default config file (port {TEST_PORT_DEFAULT_CONFIG})" + ); + + let daemon = Daemon::new(config); + let shutdown = daemon.shutdown_token(); + let daemon_handle = tokio::spawn(async move { daemon.run().await }); + + let listening = wait_for_port(&user_listen, Duration::from_secs(5)).await; + + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), daemon_handle).await; + + assert!( + listening, + "mujina-minerd should be listening on {user_listen} (port from user config file)" + ); +} + +/// Verify that a `MUJINA__*` environment variable overrides both config files +/// and the hard-coded default. +/// +/// Priority chain exercised: +/// env var (17787) > user config file (17786) > default config file (17785) > built-in default (7785) +#[tokio::test] +#[serial] +async fn test_env_var_override() { + const TEST_PORT_DEFAULT_CONFIG: u16 = 17785; + const TEST_PORT_USER_CONFIG: u16 = 17786; + const TEST_PORT_ENV_VAR: u16 = 17787; + + let default_listen = format!("127.0.0.1:{TEST_PORT_DEFAULT_CONFIG}"); + let user_listen = format!("127.0.0.1:{TEST_PORT_USER_CONFIG}"); + let env_listen = format!("127.0.0.1:{TEST_PORT_ENV_VAR}"); + + // Write default config file (layer 4). + let tmp_default = tempfile::tempdir().expect("failed to create tempdir for default config"); + let default_config_path = tmp_default.path().join("mujina.yaml"); + std::fs::write( + &default_config_path, + format!("api:\n listen: \"{default_listen}\"\nbackplane:\n usb_enabled: false\n"), + ) + .expect("failed to write default temp config"); + + // Write user config file (layer 3). + let tmp_user = tempfile::tempdir().expect("failed to create tempdir for user config"); + let user_config_path = tmp_user.path().join("mujina.yaml"); + std::fs::write( + &user_config_path, + format!("api:\n listen: \"{user_listen}\"\n"), + ) + .expect("failed to write user temp config"); + + // SAFETY: test is marked #[serial] so no other threads touch the environment. + unsafe { + std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &default_config_path); + std::env::set_var("MUJINA_CONFIG_FILE_PATH", &user_config_path); + // Layer 2: env var override — highest priority short of CLI flags. + std::env::set_var("MUJINA__API__LISTEN", &env_listen); + } + + let config = Config::load().expect("Config::load() failed"); + + // SAFETY: same serial-test guarantee as above. + unsafe { + std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH"); + std::env::remove_var("MUJINA_CONFIG_FILE_PATH"); + std::env::remove_var("MUJINA__API__LISTEN"); + } + + assert_eq!( + config.api.listen, env_listen, + "MUJINA__API__LISTEN (port {TEST_PORT_ENV_VAR}) should override user config (port {TEST_PORT_USER_CONFIG}) and default config (port {TEST_PORT_DEFAULT_CONFIG})" + ); + + let daemon = Daemon::new(config); + let shutdown = daemon.shutdown_token(); + let daemon_handle = tokio::spawn(async move { daemon.run().await }); + + let listening = wait_for_port(&env_listen, Duration::from_secs(5)).await; + + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), daemon_handle).await; + + assert!( + listening, + "mujina-minerd should be listening on {env_listen} (port from MUJINA__API__LISTEN env var)" + ); +} + +/// Verify that a CLI flag override wins over env vars, both config files, and +/// the hard-coded default. +/// +/// CLI flags are not handled inside `Config::load_with`; they are applied by +/// the caller (see `minerd.rs`) as a direct field assignment after loading. +/// The test mirrors that pattern exactly. +/// No need to test clap's own argument parsing. +/// +/// Priority chain exercised: +/// CLI flag (17788) > env var (17787) > user config (17786) > default config (17785) > built-in default (7785) +#[tokio::test] +#[serial] +async fn test_command_line_arg_override() { + const TEST_PORT_DEFAULT_CONFIG: u16 = 17785; + const TEST_PORT_USER_CONFIG: u16 = 17786; + const TEST_PORT_ENV_VAR: u16 = 17787; + const TEST_PORT_COMMAND_LINE_ARG: u16 = 17788; + + let default_listen = format!("127.0.0.1:{TEST_PORT_DEFAULT_CONFIG}"); + let user_listen = format!("127.0.0.1:{TEST_PORT_USER_CONFIG}"); + let env_listen = format!("127.0.0.1:{TEST_PORT_ENV_VAR}"); + let cli_listen = format!("127.0.0.1:{TEST_PORT_COMMAND_LINE_ARG}"); + + // Write default config file (layer 4). + let tmp_default = tempfile::tempdir().expect("failed to create tempdir for default config"); + let default_config_path = tmp_default.path().join("mujina.yaml"); + std::fs::write( + &default_config_path, + format!("api:\n listen: \"{default_listen}\"\nbackplane:\n usb_enabled: false\n"), + ) + .expect("failed to write default temp config"); + + // Write user config file (layer 3). + let tmp_user = tempfile::tempdir().expect("failed to create tempdir for user config"); + let user_config_path = tmp_user.path().join("mujina.yaml"); + std::fs::write( + &user_config_path, + format!("api:\n listen: \"{user_listen}\"\n"), + ) + .expect("failed to write user temp config"); + + // SAFETY: test is marked #[serial] so no other threads touch the environment. + unsafe { + std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &default_config_path); + std::env::set_var("MUJINA_CONFIG_FILE_PATH", &user_config_path); + std::env::set_var("MUJINA__API__LISTEN", &env_listen); + } + + let mut config = Config::load().expect("Config::load() failed"); + + // SAFETY: same serial-test guarantee as above. + unsafe { + std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH"); + std::env::remove_var("MUJINA_CONFIG_FILE_PATH"); + std::env::remove_var("MUJINA__API__LISTEN"); + } + + // Layer 1 (highest): CLI flag applied as a direct field assignment, + // exactly as minerd.rs does after calling Config::load_with(). + config.api.listen = cli_listen.clone(); + + assert_eq!( + config.api.listen, cli_listen, + "CLI flag (port {TEST_PORT_COMMAND_LINE_ARG}) should override env var (port {TEST_PORT_ENV_VAR}), user config (port {TEST_PORT_USER_CONFIG}), and default config (port {TEST_PORT_DEFAULT_CONFIG})" + ); + + let daemon = Daemon::new(config); + let shutdown = daemon.shutdown_token(); + let daemon_handle = tokio::spawn(async move { daemon.run().await }); + + let listening = wait_for_port(&cli_listen, Duration::from_secs(5)).await; + + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), daemon_handle).await; + + assert!( + listening, + "mujina-minerd should be listening on {cli_listen} (port from CLI --api-listen flag)" + ); +}