From fd6b3210c7f4d4f1f981fba087970c42eeb7e2a5 Mon Sep 17 00:00:00 2001 From: jbride Date: Thu, 5 Mar 2026 14:17:45 -0700 Subject: [PATCH 1/4] yaml based configuration --- Cargo.toml | 2 + configs/mujina.example.yaml | 110 +++++++ docs/configuration.md | 186 ++++++++++++ mujina-miner/Cargo.toml | 3 + mujina-miner/src/bin/minerd.rs | 59 +++- mujina-miner/src/config.rs | 294 +++++++++++++++---- mujina-miner/src/daemon.rs | 76 ++--- mujina-miner/tests/config_priority_tests.rs | 304 ++++++++++++++++++++ 8 files changed, 935 insertions(+), 99 deletions(-) create mode 100644 configs/mujina.example.yaml create mode 100644 docs/configuration.md create mode 100644 mujina-miner/tests/config_priority_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 813481a7..6ed32ba4 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 00000000..ac354fda --- /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 00000000..f88243b0 --- /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 b3520e0b..2fa9f249 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 fa25f4a2..44b303b6 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 43deb450..c2058260 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 73f7835d..646627da 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 00000000..9b6e578c --- /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)" + ); +} From 3b5ec97805322979ec4a14bc81c3fa024800c72a Mon Sep 17 00:00:00 2001 From: jbride Date: Thu, 16 Apr 2026 10:23:24 -0600 Subject: [PATCH 2/4] yaml config: fix(config): complete migration from legacy env vars to unified config system The CPU miner board factory was still reading MUJINA_CPUMINER_THREADS via from_env() rather than using config passed through the transport event, causing the board to fail when only the new MUJINA__* style vars were set. This commit also fixes the following: - Add mujina.yaml to .gitignore - Thread CpuDeviceInfo through VirtualBoardFactoryFn so the board factory receives its config from the transport event instead of legacy env vars - Migrate ForcedRateConfig::from_env() to pool.forced_rate config key - Remove dead CpuMinerConfig::from_env() and its tests - Add bootstrap variable validation in Config::load_with() to detect MUJINA__CONFIG__* and MUJINA__DEFAULT__* misuse with a clear error - Document bootstrap variables (MUJINA_CONFIG_FILE_PATH, MUJINA_DEFAULT_CONFIG_PATH) and their distinction from MUJINA__* keys - Update all docs and source to use MUJINA__* style; purge legacy var references from README, api.md, container.md, cpu-mining.md, issue triage template, and stratum_v1 integration test - Consolidate config_priority_tests.rs and cpu_miner_tests.rs into daemon_integration_tests.rs --- .github/DISCUSSION_TEMPLATE/issue-triage.yml | 4 +- .gitignore | 1 + README.md | 22 ++- configs/mujina.example.yaml | 7 + docs/api.md | 6 +- docs/configuration.md | 20 +++ docs/container.md | 16 ++- docs/cpu-mining.md | 57 ++++---- mujina-miner/src/backplane.rs | 2 +- mujina-miner/src/board/cpu.rs | 15 +- mujina-miner/src/board/mod.rs | 14 +- mujina-miner/src/config.rs | 59 ++++++++ mujina-miner/src/cpu_miner/config.rs | 74 +--------- mujina-miner/src/cpu_miner/mod.rs | 11 +- mujina-miner/src/daemon.rs | 4 +- mujina-miner/src/job_source/forced_rate.rs | 40 +----- mujina-miner/src/stratum_v1/client.rs | 16 +-- ...y_tests.rs => daemon_integration_tests.rs} | 128 +++++++++++++++--- 18 files changed, 295 insertions(+), 201 deletions(-) rename mujina-miner/tests/{config_priority_tests.rs => daemon_integration_tests.rs} (72%) diff --git a/.github/DISCUSSION_TEMPLATE/issue-triage.yml b/.github/DISCUSSION_TEMPLATE/issue-triage.yml index 69ad9acf..e484970c 100644 --- a/.github/DISCUSSION_TEMPLATE/issue-triage.yml +++ b/.github/DISCUSSION_TEMPLATE/issue-triage.yml @@ -163,8 +163,8 @@ body: placeholder: | Environment variables: RUST_LOG=debug - MUJINA_POOL_URL=stratum+tcp://pool.example.com:3333 - MUJINA_POOL_USER=your_wallet.worker_name + MUJINA__POOL__URL=stratum+tcp://pool.example.com:3333 + MUJINA__POOL__USER=your_wallet.worker_name render: bash validations: required: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 80faedec..9f131909 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Cargo.lock /target/ +mujina.yaml diff --git a/README.md b/README.md index c8d35501..a3d6f5c1 100644 --- a/README.md +++ b/README.md @@ -106,26 +106,24 @@ cargo test ## Running -At this point in development, configuration is done via environment variables. -Once configuration storage and API functionality are more complete, persistent -configuration will be available through the REST API and CLI tools. +Configuration is managed through a YAML config file, environment variables, or +CLI flags. See [Configuration](docs/configuration.md) for the full reference. ### Pool Configuration Connect to a Stratum v1 mining pool: ```bash -MUJINA_POOL_URL="stratum+tcp://localhost:3333" \ -MUJINA_POOL_USER="bc1qce93hy5rhg02s6aeu7mfdvxg76x66pqqtrvzs3.mujina" \ -MUJINA_POOL_PASS="custom-password" \ +MUJINA__POOL__URL="stratum+tcp://localhost:3333" \ +MUJINA__POOL__USER="bc1qce93hy5rhg02s6aeu7mfdvxg76x66pqqtrvzs3.mujina" \ +MUJINA__POOL__PASSWORD="custom-password" \ cargo run ``` The password defaults to "x" if not specified. -Without `MUJINA_POOL_URL`, the miner runs with a dummy job source that -generates synthetic mining work, which is useful for testing hardware without a -pool connection. +Without `pool.url` set, the miner runs with a dummy job source that generates +synthetic mining work, useful for testing hardware without a pool connection. ### API Server @@ -133,7 +131,7 @@ The REST API listens on `127.0.0.1:7785` by default. To listen on all interfaces: ```bash -MUJINA_API_LISTEN="0.0.0.0" cargo run +MUJINA__API__LISTEN="0.0.0.0" cargo run ``` See [REST API](docs/api.md) for endpoints and details. @@ -176,8 +174,8 @@ Combine pool configuration with logging as needed: ```bash RUST_LOG=mujina_miner=debug \ -MUJINA_POOL_URL="stratum+tcp://localhost:3333" \ -MUJINA_POOL_USER="your-address.worker" \ +MUJINA__POOL__URL="stratum+tcp://localhost:3333" \ +MUJINA__POOL__USER="your-address.worker" \ cargo run ``` diff --git a/configs/mujina.example.yaml b/configs/mujina.example.yaml index ac354fda..f5f8fa0a 100644 --- a/configs/mujina.example.yaml +++ b/configs/mujina.example.yaml @@ -52,6 +52,13 @@ pool: # Worker password. Most pools accept "x". password: "x" + # Target share rate in shares per minute for the forced-rate wrapper. + # When set, overrides the pool's share target so a CPU miner finds shares + # at this rate regardless of pool difficulty. Useful for testing share + # submission flow without waiting days for a real share. + # Null disables the wrapper (normal pool difficulty applies). + forced_rate: ~ + # ------------------------------------------------------------ # backplane — board discovery and lifecycle management diff --git a/docs/api.md b/docs/api.md index 58cb23b8..fc11f993 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,14 +2,14 @@ Mujina exposes an HTTP API on port 7785 (ASCII "MU") for monitoring and control. It binds to localhost by default. Set -`MUJINA_API_LISTEN` to override the listen address: +`api.listen` in the config file, or use the env var: ```bash -MUJINA_API_LISTEN="0.0.0.0" cargo run +MUJINA__API__LISTEN="0.0.0.0" cargo run ``` The port defaults to 7785 if not specified, or you can -override it with `MUJINA_API_LISTEN="0.0.0.0:9000"`. +override it with `MUJINA__API__LISTEN="0.0.0.0:9000"`. The API currently has no authentication or encryption, so binding to a non-localhost address exposes it to the network diff --git a/docs/configuration.md b/docs/configuration.md index f88243b0..d438af85 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,7 @@ This document describes the configuration system for `mujina-minerd`. - [2. Config Files](#2-config-files) - [2.1. Default location](#21-default-location) - [2.2. User-specified location](#22-user-specified-location) + - [2.3. Bootstrap variables](#23-bootstrap-variables) - [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) @@ -39,6 +40,9 @@ will start with sensible defaults (dummy job source, API on localhost). This is the standard system-wide config file, suitable for installation by a package manager or system administrator. +> [!NOTE] +> This path can be overridden via `MUJINA_DEFAULT_CONFIG_PATH`. + ### 2.2. User-specified location Set `MUJINA_CONFIG_FILE_PATH` to an absolute path to load a second config file @@ -54,6 +58,21 @@ default file, then to hard-coded defaults. An example config file is provided at `configs/mujina.example.yaml`. +### 2.3. Bootstrap variables + +`MUJINA_CONFIG_FILE_PATH` and `MUJINA_DEFAULT_CONFIG_PATH` use single +underscores and are **not** part of the `MUJINA__*` config-key system. They are +bootstrap variables read by the loader before the config system is constructed, +so the double-underscore naming convention does not apply to them. + +`MUJINA_DEFAULT_CONFIG_PATH` overrides the default system config path +(`/etc/mujina/mujina.yaml`). It is primarily intended for testing, where +writing to `/etc` requires root access: + +```sh +MUJINA_DEFAULT_CONFIG_PATH=/tmp/mujina-test.yaml mujina-minerd +``` + ## 3. Environment Variables Individual config keys can be overridden with environment variables. The @@ -108,6 +127,7 @@ These are superseded by the unified config system: | `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_POOL_FORCED_RATE` | `pool.forced_rate` | `MUJINA__POOL__FORCED_RATE` | | `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` | diff --git a/docs/container.md b/docs/container.md index e01a156e..9da1b3c6 100644 --- a/docs/container.md +++ b/docs/container.md @@ -18,15 +18,16 @@ Pull and run from GitHub Container Registry: ```bash podman run --rm -it \ - -e MUJINA_USB_DISABLE=1 \ - -e MUJINA_CPUMINER_THREADS=2 \ - -e MUJINA_POOL_URL="stratum+tcp://pool.example.com:3333" \ - -e MUJINA_POOL_USER="your-address.worker" \ + -e MUJINA__BACKPLANE__USB_ENABLED=false \ + -e MUJINA__BOARDS__CPU_MINER__ENABLED=true \ + -e MUJINA__BOARDS__CPU_MINER__THREADS=2 \ + -e MUJINA__POOL__URL="stratum+tcp://pool.example.com:3333" \ + -e MUJINA__POOL__USER="your-address.worker" \ ghcr.io/256foundation/mujina-minerd:latest ``` This starts a 2-thread CPU miner connected to your pool. See -[CPU Mining](cpu-mining.md) for all environment variables. +[CPU Mining](cpu-mining.md) for all config options. ## Building the Image @@ -60,8 +61,9 @@ The REST API listens on port 7785. To access it from the host: ```bash podman run --rm -it \ -p 7785:7785 \ - -e MUJINA_USB_DISABLE=1 \ - -e MUJINA_CPUMINER_THREADS=2 \ + -e MUJINA__BACKPLANE__USB_ENABLED=false \ + -e MUJINA__BOARDS__CPU_MINER__ENABLED=true \ + -e MUJINA__BOARDS__CPU_MINER__THREADS=2 \ mujina-minerd:latest ``` diff --git a/docs/cpu-mining.md b/docs/cpu-mining.md index d95b98a6..44347a7e 100644 --- a/docs/cpu-mining.md +++ b/docs/cpu-mining.md @@ -13,23 +13,27 @@ Use cases: ## Enabling CPU Mining -Set `MUJINA_CPUMINER_THREADS` to enable CPU mining. The value specifies how -many parallel hashing threads to run: +Set `boards.cpu_miner.enabled = true` and `boards.cpu_miner.threads` in your +config file, or use env vars: ```bash -MUJINA_CPUMINER_THREADS=2 cargo run +MUJINA__BOARDS__CPU_MINER__ENABLED=true \ +MUJINA__BOARDS__CPU_MINER__THREADS=2 \ +cargo run ``` -Without this variable, the miner only looks for USB-connected ASIC hardware. +Without CPU mining enabled, the miner only looks for USB-connected ASIC +hardware. -When running CPU-only, also set `MUJINA_USB_DISABLE=1` to skip USB device -discovery. This ignores any real mining boards you might have connected---they -run at vastly different hashrates and would complicate testing. It also avoids -USB-related noise on cloud systems: +When running CPU-only, also set `backplane.usb_enabled = false` to skip USB +device discovery. This ignores any real mining boards you might have +connected---they run at vastly different hashrates and would complicate +testing. It also avoids USB-related noise on cloud systems: ```bash -MUJINA_CPUMINER_THREADS=2 \ -MUJINA_USB_DISABLE=1 \ +MUJINA__BOARDS__CPU_MINER__ENABLED=true \ +MUJINA__BOARDS__CPU_MINER__THREADS=2 \ +MUJINA__BACKPLANE__USB_ENABLED=false \ cargo run ``` @@ -39,7 +43,7 @@ By default, each mining thread hashes for 50ms then sleeps for 50ms---a 50% duty cycle. This prevents CPU mining from starving other processes and avoids tripping CPU usage limits on cloud instances. -Adjust with `MUJINA_CPUMINER_DUTY`: +Adjust with `boards.cpu_miner.duty_percent` (or `MUJINA__BOARDS__CPU_MINER__DUTY_PERCENT`): - `100` --- Full speed, no throttling - `50` --- Hash half the time, sleep half (default) @@ -52,19 +56,20 @@ hashrate. Pools set share difficulty for ASIC-speed miners. A CPU running at MH/s instead of TH/s would wait days or weeks to find a share at typical pool difficulty. To -test the share submission flow, use `MUJINA_POOL_FORCED_RATE` to artificially +test the share submission flow, use `pool.forced_rate` (or `MUJINA__POOL__FORCED_RATE`) to artificially lower the target: ```bash -MUJINA_CPUMINER_THREADS=2 \ -MUJINA_USB_DISABLE=1 \ -MUJINA_POOL_FORCED_RATE=6 \ -MUJINA_POOL_URL="stratum+tcp://pool.example.com:3333" \ -MUJINA_POOL_USER="your-address.worker" \ +MUJINA__BOARDS__CPU_MINER__ENABLED=true \ +MUJINA__BOARDS__CPU_MINER__THREADS=2 \ +MUJINA__BACKPLANE__USB_ENABLED=false \ +MUJINA__POOL__FORCED_RATE=6 \ +MUJINA__POOL__URL="stratum+tcp://pool.example.com:3333" \ +MUJINA__POOL__USER="your-address.worker" \ cargo run ``` -The value is target shares per minute. With `MUJINA_POOL_FORCED_RATE=6`, the +The value is target shares per minute. With `MUJINA__POOL__FORCED_RATE=6`, the miner targets one share every 10 seconds. The forced rate wrapper intercepts jobs from the pool and replaces the share @@ -82,12 +87,13 @@ caps the per-thread rate to prevent flooding. ## Running Without a Pool -Without `MUJINA_POOL_URL`, the miner uses a dummy job source that generates +Without `pool.url` set, the miner uses a dummy job source that generates synthetic work: ```bash -MUJINA_CPUMINER_THREADS=2 \ -MUJINA_USB_DISABLE=1 \ +MUJINA__BOARDS__CPU_MINER__ENABLED=true \ +MUJINA__BOARDS__CPU_MINER__THREADS=2 \ +MUJINA__BACKPLANE__USB_ENABLED=false \ RUST_LOG=mujina_miner=debug \ cargo run ``` @@ -104,7 +110,8 @@ For deploying to cloud infrastructure or container orchestration platforms, see | Variable | Description | |----------|-------------| -| `MUJINA_CPUMINER_THREADS` | Number of mining threads; presence enables CPU mining | -| `MUJINA_CPUMINER_DUTY` | Duty cycle percentage, 1-100 (default: 50) | -| `MUJINA_USB_DISABLE` | Set to `1` to skip USB device discovery | -| `MUJINA_POOL_FORCED_RATE` | Target share rate in shares/min | \ No newline at end of file +| `MUJINA__BOARDS__CPU_MINER__ENABLED` | Set to `true` to enable CPU mining | +| `MUJINA__BOARDS__CPU_MINER__THREADS` | Number of mining threads | +| `MUJINA__BOARDS__CPU_MINER__DUTY_PERCENT` | Duty cycle percentage, 1-100 (default: 50) | +| `MUJINA__BACKPLANE__USB_ENABLED` | Set to `false` to skip USB device discovery | +| `MUJINA__POOL__FORCED_RATE` | Target share rate in shares/min | \ No newline at end of file diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index 750011c1..f3b41a4d 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -245,7 +245,7 @@ impl Backplane { ); // Create the board using the descriptor's factory function - let (mut board, registration) = match (descriptor.create_fn)().await { + let (mut board, registration) = match (descriptor.create_fn)(device_info.clone()).await { Ok(result) => result, Err(e) => { error!( diff --git a/mujina-miner/src/board/cpu.rs b/mujina-miner/src/board/cpu.rs index b27b38e6..0f43ae13 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -11,6 +11,7 @@ use crate::{ api_client::types::BoardState, asic::hash_thread::HashThread, cpu_miner::{CpuHashThread, CpuMinerConfig}, + transport::cpu::CpuDeviceInfo, }; /// CPU mining board. @@ -80,11 +81,13 @@ impl Board for CpuBoard { // --------------------------------------------------------------------------- /// Factory function for creating CpuBoard instances. -async fn create_cpu_board() --> crate::error::Result<(Box, super::BoardRegistration)> { - let config = CpuMinerConfig::from_env().ok_or_else(|| { - crate::error::Error::Config("CPU miner not configured (MUJINA_CPU_MINER not set)".into()) - })?; +async fn create_cpu_board( + device_info: CpuDeviceInfo, +) -> crate::error::Result<(Box, super::BoardRegistration)> { + let config = CpuMinerConfig { + thread_count: device_info.thread_count, + duty_percent: device_info.duty_percent, + }; let serial = format!("cpu-{}x{}%", config.thread_count, config.duty_percent); let initial_state = BoardState { @@ -104,6 +107,6 @@ inventory::submit! { VirtualBoardDescriptor { device_type: "cpu_miner", name: "CPU Miner", - create_fn: || Box::pin(create_cpu_board()), + create_fn: |info| Box::pin(create_cpu_board(info)), } } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index d8ce752e..a64351fc 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -8,7 +8,9 @@ use std::{error::Error, fmt, future::Future, pin::Pin}; use tokio::sync::watch; use crate::{ - api_client::types::BoardState, asic::hash_thread::HashThread, transport::UsbDeviceInfo, + api_client::types::BoardState, + asic::hash_thread::HashThread, + transport::{UsbDeviceInfo, cpu::CpuDeviceInfo}, }; /// Represents a mining board containing one or more ASIC chips. @@ -143,11 +145,11 @@ inventory::collect!(BoardDescriptor); /// Factory function signature for creating a virtual board. /// /// Same contract as [`BoardFactoryFn`] (create watch channel, seed with -/// identity, return [`BoardRegistration`]), but virtual boards don't -/// receive USB device info---they're configured via environment -/// variables or other means. -pub type VirtualBoardFactoryFn = - fn() -> BoxFuture<'static, crate::error::Result<(Box, BoardRegistration)>>; +/// identity, return [`BoardRegistration`]), but virtual boards receive their +/// configuration via [`CpuDeviceInfo`] rather than USB device info. +pub type VirtualBoardFactoryFn = fn( + CpuDeviceInfo, +) -> BoxFuture<'static, crate::error::Result<(Box, BoardRegistration)>>; /// Descriptor for virtual boards (CPU miner, test boards, etc.). /// diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index c2058260..6d84814c 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -17,6 +17,7 @@ use std::path::PathBuf; use config::{Environment, File, FileFormat}; use serde::{Deserialize, Serialize}; +use tracing::debug; const DEFAULT_CONFIG_PATH: &str = "/etc/mujina/mujina.yaml"; const DEFAULT_CONFIG_PATH_ENV_VAR: &str = "MUJINA_DEFAULT_CONFIG_PATH"; @@ -103,6 +104,11 @@ pub struct PoolConfig { pub url: Option, pub user: String, pub password: String, + /// Target share rate in shares per minute for the forced-rate wrapper. + /// When set, overrides the pool's share target to achieve this rate. + /// Intended for CPU mining tests against pools that set difficulty too + /// high for software hashers. `None` disables the wrapper. + pub forced_rate: Option, } impl Default for PoolConfig { @@ -111,6 +117,7 @@ impl Default for PoolConfig { url: None, user: "mujina-testing".to_string(), password: "x".to_string(), + forced_rate: None, } } } @@ -214,6 +221,58 @@ impl Config { /// `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 { + // Detect misuse of bootstrap variables before the config system is + // constructed. Neither `config` nor `default` are valid config + // sections — the likely mistakes are: + // MUJINA__CONFIG__FILE__PATH instead of MUJINA_CONFIG_FILE_PATH + // MUJINA__DEFAULT__CONFIG__PATH instead of MUJINA_DEFAULT_CONFIG_PATH + let bad_vars: Vec = std::env::vars() + .filter(|(k, _)| { + let u = k.to_uppercase(); + u.starts_with("MUJINA__CONFIG") || u.starts_with("MUJINA__DEFAULT") + }) + .map(|(k, _)| k) + .collect(); + if !bad_vars.is_empty() { + anyhow::bail!( + "Invalid environment variable(s): {}\n\n\ + These are not config sections. Did you mean one of the \ + bootstrap variables `MUJINA_CONFIG_FILE_PATH` or \ + `MUJINA_DEFAULT_CONFIG_PATH` (single underscores)?", + bad_vars.join(", ") + ); + } + + // Log config file sources so startup problems are easy to diagnose. + { + let default_path = default_config_path(); + let default_exists = std::path::Path::new(&default_path).exists(); + debug!( + path = %default_path, + exists = default_exists, + env_var = std::env::var(DEFAULT_CONFIG_PATH_ENV_VAR).ok().as_deref().unwrap_or("(not set)"), + "Default config file (MUJINA_DEFAULT_CONFIG_PATH)" + ); + + let user_path = cli_config_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + .or_else(|| std::env::var(CONFIG_FILE_ENV_VAR).ok()); + match &user_path { + Some(path) => { + let exists = std::path::Path::new(path).exists(); + debug!( + path = %path, + exists = exists, + "User config file (MUJINA_CONFIG_FILE_PATH)" + ); + } + None => { + debug!("User config file (MUJINA_CONFIG_FILE_PATH): not set"); + } + } + } + let mut builder = config::Config::builder() // Layer 4 (lowest): default system config file .add_source( diff --git a/mujina-miner/src/cpu_miner/config.rs b/mujina-miner/src/cpu_miner/config.rs index 67905e78..1fcabb31 100644 --- a/mujina-miner/src/cpu_miner/config.rs +++ b/mujina-miner/src/cpu_miner/config.rs @@ -1,8 +1,6 @@ -//! Configuration for CPU miner. -//! -//! Parses environment variables to configure the CPU mining backend. +//! Runtime configuration for the CPU miner board. -/// CPU miner configuration parsed from environment variables. +/// Configuration for a CPU miner board instance. #[derive(Debug, Clone)] pub struct CpuMinerConfig { /// Number of mining threads to spawn. @@ -15,71 +13,3 @@ pub struct CpuMinerConfig { /// instances that monitor for sustained CPU usage. pub duty_percent: u8, } - -impl CpuMinerConfig { - /// Parse configuration from environment variables. - /// - /// Returns `Some(config)` if `MUJINA_CPUMINER_THREADS` is set, - /// `None` otherwise. - /// - /// # Environment Variables - /// - /// - `MUJINA_CPUMINER_THREADS`: Number of threads (presence enables CPU mining) - /// - `MUJINA_CPUMINER_DUTY`: Duty cycle % (default: 50, clamped to 1-100) - pub fn from_env() -> Option { - let thread_count = std::env::var("MUJINA_CPUMINER_THREADS") - .ok() - .and_then(|s| s.parse().ok())?; - - let duty_percent = std::env::var("MUJINA_CPUMINER_DUTY") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(50) - .clamp(1, 100); - - Some(Self { - thread_count, - duty_percent, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - - #[test] - #[serial] - fn test_from_env_disabled_when_not_set() { - // SAFETY: Test runs serially, no concurrent env access - unsafe { std::env::remove_var("MUJINA_CPUMINER_THREADS") }; - - let config = CpuMinerConfig::from_env(); - assert!(config.is_none()); - } - - #[test] - #[serial] - fn test_duty_clamped_to_valid_range() { - // SAFETY: Test runs serially, no concurrent env access - unsafe { - // Upper bound: 150 -> 100 - std::env::set_var("MUJINA_CPUMINER_THREADS", "99"); - std::env::set_var("MUJINA_CPUMINER_DUTY", "150"); - } - if let Some(config) = CpuMinerConfig::from_env() { - assert_eq!(config.duty_percent, 100); - } - - // SAFETY: Test runs serially, no concurrent env access - unsafe { - // Lower bound: 0 -> 1 - std::env::set_var("MUJINA_CPUMINER_THREADS", "99"); - std::env::set_var("MUJINA_CPUMINER_DUTY", "0"); - } - if let Some(config) = CpuMinerConfig::from_env() { - assert_eq!(config.duty_percent, 1); - } - } -} diff --git a/mujina-miner/src/cpu_miner/mod.rs b/mujina-miner/src/cpu_miner/mod.rs index dc8e4a1d..79e78b3b 100644 --- a/mujina-miner/src/cpu_miner/mod.rs +++ b/mujina-miner/src/cpu_miner/mod.rs @@ -5,10 +5,15 @@ //! //! # Configuration //! -//! Enable via environment variables: +//! Enable via the config system (see `docs/configuration.md`): //! -//! - `MUJINA_CPUMINER_THREADS=N` - Number of mining threads (presence enables) -//! - `MUJINA_CPUMINER_DUTY=P` - Duty cycle percentage (default: 50) +//! ```yaml +//! boards: +//! cpu_miner: +//! enabled: true +//! threads: 2 +//! duty_percent: 50 +//! ``` mod config; mod hasher; diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index 646627da..8362fc08 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -126,7 +126,9 @@ impl Daemon { }; // Optionally wrap with ForcedRateSource for testing - if let Some(forced_rate_config) = ForcedRateConfig::from_env() { + if let Some(forced_rate_config) = config.pool.forced_rate.map(|rate| ForcedRateConfig { + target_rate: crate::types::ShareRate::per_minute(rate), + }) { info!( rate = %forced_rate_config.target_rate, "Forced share rate wrapper enabled" diff --git a/mujina-miner/src/job_source/forced_rate.rs b/mujina-miner/src/job_source/forced_rate.rs index 62c5f20a..7f30bf00 100644 --- a/mujina-miner/src/job_source/forced_rate.rs +++ b/mujina-miner/src/job_source/forced_rate.rs @@ -6,10 +6,11 @@ //! //! # Usage //! -//! Set `MUJINA_POOL_FORCED_RATE=18` to enable the wrapper targeting 18 -//! shares per minute (~3.33 seconds between shares). The wrapper intercepts -//! job templates from the inner source and replaces their share_target with -//! one computed to achieve the target rate at the current hashrate. +//! Set `pool.forced_rate = 18` (or `MUJINA__POOL__FORCED_RATE=18`) to enable +//! the wrapper targeting 18 shares per minute (~3.33 seconds between shares). +//! The wrapper intercepts job templates from the inner source and replaces +//! their share_target with one computed to achieve the target rate at the +//! current hashrate. //! //! Shares are forwarded to the inner source regardless of whether they meet //! the pool's actual difficulty. The pool may accept (if configured with low @@ -21,7 +22,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use tracing::{debug, trace, warn}; +use tracing::{debug, trace}; use super::{JobTemplate, SourceCommand, SourceEvent}; use crate::types::{Difficulty, HashRate, ShareRate, target_for_share_rate}; @@ -32,35 +33,6 @@ pub struct ForcedRateConfig { pub target_rate: ShareRate, } -impl ForcedRateConfig { - /// Parse from environment variables. - /// - /// Returns `Some` if `MUJINA_POOL_FORCED_RATE` is set. The value specifies - /// the target rate in shares per minute; defaults to 18 if unparseable. - pub fn from_env() -> Option { - let val = std::env::var("MUJINA_POOL_FORCED_RATE").ok()?; - let shares_per_min: f64 = match val.parse::() { - Ok(v) if v.is_finite() && v > 0.0 => v, - Ok(v) => { - warn!( - value = v, - "MUJINA_POOL_FORCED_RATE must be finite and positive, using default 18" - ); - 18.0 - } - Err(_) => { - warn!( - value = %val, - "Invalid MUJINA_POOL_FORCED_RATE, using default 18" - ); - 18.0 - } - }; - Some(Self { - target_rate: ShareRate::per_minute(shares_per_min), - }) - } -} /// Wrapper that overrides share_target to force a specific share rate. /// diff --git a/mujina-miner/src/stratum_v1/client.rs b/mujina-miner/src/stratum_v1/client.rs index 5eb2beff..127ed621 100644 --- a/mujina-miner/src/stratum_v1/client.rs +++ b/mujina-miner/src/stratum_v1/client.rs @@ -871,19 +871,19 @@ mod tests { /// /// # Environment Variables /// - /// - `MUJINA_POOL_URL` - Pool URL (required, e.g., "stratum+tcp://localhost:3333") - /// - `MUJINA_POOL_USER` - Username/wallet address (optional, defaults to test address) + /// - `MUJINA__POOL__URL` - Pool URL (required, e.g., "stratum+tcp://localhost:3333") + /// - `MUJINA__POOL__USER` - Username/wallet address (optional, defaults to test address) /// /// # Running /// /// ```bash /// # Minimal (uses default test address) - /// MUJINA_POOL_URL="stratum+tcp://localhost:3333" \ + /// MUJINA__POOL__URL="stratum+tcp://localhost:3333" \ /// cargo test --lib test_pool_from_env -- --ignored --nocapture /// /// # With custom username - /// MUJINA_POOL_URL="stratum+tcp://localhost:3333" \ - /// MUJINA_POOL_USER="bc1qce93hy5rhg02s6aeu7mfdvxg76x66pqqtrvzs3.my-worker" \ + /// MUJINA__POOL__URL="stratum+tcp://localhost:3333" \ + /// MUJINA__POOL__USER="bc1qce93hy5rhg02s6aeu7mfdvxg76x66pqqtrvzs3.my-worker" \ /// cargo test --lib test_pool_from_env -- --ignored --nocapture /// ``` /// @@ -892,9 +892,9 @@ mod tests { #[tokio::test] #[ignore] async fn test_pool_from_env() { - let pool_url = - std::env::var("MUJINA_POOL_URL").expect("MUJINA_POOL_URL environment variable not set"); - let username = std::env::var("MUJINA_POOL_USER").unwrap_or_else(|_| { + let pool_url = std::env::var("MUJINA__POOL__URL") + .expect("MUJINA__POOL__URL environment variable not set"); + let username = std::env::var("MUJINA__POOL__USER").unwrap_or_else(|_| { "bc1qce93hy5rhg02s6aeu7mfdvxg76x66pqqtrvzs3.mujina-integration-test".to_string() }); diff --git a/mujina-miner/tests/config_priority_tests.rs b/mujina-miner/tests/daemon_integration_tests.rs similarity index 72% rename from mujina-miner/tests/config_priority_tests.rs rename to mujina-miner/tests/daemon_integration_tests.rs index 9b6e578c..ea137dba 100644 --- a/mujina-miner/tests/config_priority_tests.rs +++ b/mujina-miner/tests/daemon_integration_tests.rs @@ -1,4 +1,10 @@ -//! Integration tests for the configuration priority chain. +//! Daemon integration tests. +//! +//! Each test starts a real `Daemon` instance and verifies runtime behaviour +//! end-to-end: config priority, board lifecycle, API responses. +//! +//! Tests mutate process-wide environment variables and are serialized with +//! `#[serial]`. Do not run with `--test-threads > 1`. use std::time::Duration; @@ -23,8 +29,32 @@ async fn wait_for_port(addr: &str, timeout: Duration) -> bool { false } +/// Poll `GET /api/v0/miner` until the boards list is non-empty or `timeout` +/// elapses. Returns the number of boards registered. +async fn wait_for_boards(base_url: &str, timeout: Duration) -> usize { + let deadline = tokio::time::Instant::now() + timeout; + let client = reqwest::Client::new(); + while tokio::time::Instant::now() < deadline { + if let Ok(resp) = client + .get(format!("{base_url}/api/v0/miner")) + .send() + .await + { + if let Ok(state) = resp.json::().await { + if let Some(boards) = state.get("boards").and_then(|b| b.as_array()) { + if !boards.is_empty() { + return boards.len(); + } + } + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + 0 +} + // --------------------------------------------------------------------------- -// Tests +// Config priority tests // --------------------------------------------------------------------------- /// Verify that the daemon reads the default config file and binds the API @@ -42,21 +72,25 @@ 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"); + std::fs::write( + &config_path, + format!("api:\n listen: \"{listen_addr}\"\nbackplane:\n usb_enabled: false\n"), + ) + .expect("failed to write temp config"); - // Redirect the default config path to our temp file. + // Redirect the default config path to our temp file. Also clear + // MUJINA_CONFIG_FILE_PATH so a value set in the shell cannot override + // the default config file we are trying to test. // SAFETY: test is marked #[serial] so no other threads touch the environment. - unsafe { std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &config_path) }; + unsafe { + std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &config_path); + std::env::remove_var("MUJINA_CONFIG_FILE_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") }; @@ -65,16 +99,12 @@ async fn test_default_config_file() { "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; @@ -99,7 +129,6 @@ async fn test_user_config_override() { 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( @@ -108,7 +137,6 @@ async fn test_user_config_override() { ) .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( @@ -167,7 +195,6 @@ async fn test_env_var_override() { 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( @@ -176,7 +203,6 @@ async fn test_env_var_override() { ) .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( @@ -228,7 +254,6 @@ async fn test_env_var_override() { /// 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) @@ -245,7 +270,6 @@ async fn test_command_line_arg_override() { 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( @@ -254,7 +278,6 @@ async fn test_command_line_arg_override() { ) .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( @@ -302,3 +325,66 @@ async fn test_command_line_arg_override() { "mujina-minerd should be listening on {cli_listen} (port from CLI --api-listen flag)" ); } + +// --------------------------------------------------------------------------- +// Board lifecycle tests +// --------------------------------------------------------------------------- + +/// Verify that the CPU miner board starts when configured via the unified +/// config system (`boards.cpu_miner.enabled = true`), without the legacy +/// `MUJINA_CPUMINER_THREADS` environment variable being set. +#[tokio::test] +#[serial] +async fn test_cpu_miner_starts_from_config() { + const TEST_PORT: u16 = 17790; + let listen_addr = format!("127.0.0.1:{TEST_PORT}"); + let base_url = format!("http://{listen_addr}"); + + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let config_path = tmp.path().join("mujina.yaml"); + std::fs::write( + &config_path, + format!( + "api:\n listen: \"{listen_addr}\"\n\ + backplane:\n usb_enabled: false\n\ + boards:\n cpu_miner:\n enabled: true\n threads: 1\n" + ), + ) + .expect("failed to write temp config"); + + // SAFETY: test is marked #[serial] so no other threads touch the environment. + unsafe { + std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &config_path); + // Clear the user config file path so a shell value cannot override + // the test config. + std::env::remove_var("MUJINA_CONFIG_FILE_PATH"); + // Ensure legacy env vars are absent — they must not be required. + std::env::remove_var("MUJINA_CPUMINER_THREADS"); + std::env::remove_var("MUJINA_CPUMINER_DUTY"); + } + + let config = Config::load().expect("Config::load() failed"); + + // SAFETY: same serial-test guarantee as above. + unsafe { std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH") }; + + assert!(config.boards.cpu_miner.enabled, "config should have cpu_miner.enabled = true"); + assert_eq!(config.boards.cpu_miner.threads, 1, "config should have cpu_miner.threads = 1"); + + 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(&listen_addr, Duration::from_secs(5)).await; + assert!(listening, "API server did not start on {listen_addr}"); + + let board_count = wait_for_boards(&base_url, Duration::from_secs(5)).await; + + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), daemon_handle).await; + + assert!( + board_count > 0, + "CPU miner board should be registered when enabled via config" + ); +} \ No newline at end of file From eb6b6c984b1227c6b05ad9e715323d3d089db94c Mon Sep 17 00:00:00 2001 From: jbride Date: Thu, 16 Apr 2026 13:29:43 -0600 Subject: [PATCH 3/4] fixing source code formatting issues --- mujina-miner/src/config.rs | 29 ++----------------- mujina-miner/src/daemon.rs | 4 ++- mujina-miner/src/job_source/forced_rate.rs | 1 - .../tests/daemon_integration_tests.rs | 18 +++++++----- 4 files changed, 16 insertions(+), 36 deletions(-) diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index 6d84814c..b8714714 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -30,15 +30,14 @@ const ENV_SEPARATOR: &str = "__"; /// 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()) + std::env::var(DEFAULT_CONFIG_PATH_ENV_VAR).unwrap_or_else(|_| DEFAULT_CONFIG_PATH.to_string()) } // --------------------------------------------------------------------------- // Top-level config // --------------------------------------------------------------------------- -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct Config { pub daemon: DaemonConfig, @@ -49,19 +48,6 @@ pub struct Config { pub hash_thread: HashThreadConfig, } -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 // --------------------------------------------------------------------------- @@ -134,22 +120,13 @@ impl Default for BackplaneConfig { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct BoardsConfig { pub bitaxe: BitaxeConfig, pub cpu_miner: CpuMinerConfig, } -impl Default for BoardsConfig { - fn default() -> Self { - Self { - bitaxe: BitaxeConfig::default(), - cpu_miner: CpuMinerConfig::default(), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct BitaxeConfig { diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index e4abbb7f..62c555bc 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -250,7 +250,9 @@ impl Daemon { self.tracker.spawn({ let shutdown = self.shutdown.clone(); async move { - let api_config = ApiConfig { bind_addr: api_listen }; + let api_config = ApiConfig { + bind_addr: api_listen, + }; if let Err(e) = api::serve( api_config, shutdown, diff --git a/mujina-miner/src/job_source/forced_rate.rs b/mujina-miner/src/job_source/forced_rate.rs index 0c4401c9..e837b5ed 100644 --- a/mujina-miner/src/job_source/forced_rate.rs +++ b/mujina-miner/src/job_source/forced_rate.rs @@ -33,7 +33,6 @@ pub struct ForcedRateConfig { pub target_rate: ShareRate, } - /// Wrapper that overrides share_target to force a specific share rate. /// /// Sits between the scheduler and an inner source (typically StratumV1Source), diff --git a/mujina-miner/tests/daemon_integration_tests.rs b/mujina-miner/tests/daemon_integration_tests.rs index ea137dba..b262d21c 100644 --- a/mujina-miner/tests/daemon_integration_tests.rs +++ b/mujina-miner/tests/daemon_integration_tests.rs @@ -35,11 +35,7 @@ async fn wait_for_boards(base_url: &str, timeout: Duration) -> usize { let deadline = tokio::time::Instant::now() + timeout; let client = reqwest::Client::new(); while tokio::time::Instant::now() < deadline { - if let Ok(resp) = client - .get(format!("{base_url}/api/v0/miner")) - .send() - .await - { + if let Ok(resp) = client.get(format!("{base_url}/api/v0/miner")).send().await { if let Ok(state) = resp.json::().await { if let Some(boards) = state.get("boards").and_then(|b| b.as_array()) { if !boards.is_empty() { @@ -368,8 +364,14 @@ async fn test_cpu_miner_starts_from_config() { // SAFETY: same serial-test guarantee as above. unsafe { std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH") }; - assert!(config.boards.cpu_miner.enabled, "config should have cpu_miner.enabled = true"); - assert_eq!(config.boards.cpu_miner.threads, 1, "config should have cpu_miner.threads = 1"); + assert!( + config.boards.cpu_miner.enabled, + "config should have cpu_miner.enabled = true" + ); + assert_eq!( + config.boards.cpu_miner.threads, 1, + "config should have cpu_miner.threads = 1" + ); let daemon = Daemon::new(config); let shutdown = daemon.shutdown_token(); @@ -387,4 +389,4 @@ async fn test_cpu_miner_starts_from_config() { board_count > 0, "CPU miner board should be registered when enabled via config" ); -} \ No newline at end of file +} From 5b87ff228c192cf2178e26188e09283cd8ac13b3 Mon Sep 17 00:00:00 2001 From: jbride Date: Tue, 21 Apr 2026 14:52:28 -0600 Subject: [PATCH 4/4] YAML based configuration: - Remove unimplemented BitaxeConfig and HashThreadConfig from config struct and example YAML; accepted keys should have effect - Drop MUJINA_CONFIG_FILE_PATH and MUJINA_DEFAULT_CONFIG_PATH bootstrap env vars; config file path is now CLI-only via --config - Replace named CLI flags (--pool-url, --log-level, etc.) with a generic --set key=value flag using the same dot-path namespace as the YAML config, giving full coverage without coupling CLI names to internal struct fields - Update integration tests, docs, and example YAML to match --- configs/mujina.example.yaml | 31 --- docs/configuration.md | 92 ++++---- mujina-miner/src/bin/minerd.rs | 60 ++--- mujina-miner/src/config.rs | 156 ++++--------- .../tests/daemon_integration_tests.rs | 206 ++++-------------- 5 files changed, 139 insertions(+), 406 deletions(-) diff --git a/configs/mujina.example.yaml b/configs/mujina.example.yaml index 9a8b5c60..032cac0b 100644 --- a/configs/mujina.example.yaml +++ b/configs/mujina.example.yaml @@ -75,23 +75,6 @@ backplane: # ------------------------------------------------------------ boards: - # -- Bitaxe family (BM1370-based boards) -- - # NOTE: These fields are parsed but not yet wired to board behavior. - # They are reserved for near-term implementation. - 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. @@ -105,17 +88,3 @@ boards: # 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 -# NOTE: These fields are parsed but not yet wired to board behavior. -# They are reserved for near-term implementation. -# ------------------------------------------------------------ -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 index d438af85..80647dc8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,8 +5,7 @@ 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) - - [2.3. Bootstrap variables](#23-bootstrap-variables) + - [2.2. Specifying a config file](#22-specifying-a-config-file) - [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) @@ -21,9 +20,9 @@ more than one source, the **highest-priority source wins**: | Priority | Source | |----------|--------| -| 1 (highest) | CLI flags (`--pool-url`, `--log-level`, etc.) | +| 1 (highest) | `--set key=value` CLI overrides | | 2 | Environment variables (`MUJINA__*`) | -| 3 | User config file (`$MUJINA_CONFIG_FILE_PATH`) | +| 3 | Config file specified via `--config` | | 4 | Default config file (`/etc/mujina/mujina.yaml`) | | 5 (lowest) | Hard-coded defaults | @@ -38,41 +37,23 @@ will start with sensible defaults (dummy job source, API on localhost). `/etc/mujina/mujina.yaml` This is the standard system-wide config file, suitable for installation by a -package manager or system administrator. +package manager or system administrator. It is optional — if absent, Mujina +starts with hard-coded defaults. -> [!NOTE] -> This path can be overridden via `MUJINA_DEFAULT_CONFIG_PATH`. +### 2.2. Specifying a config file -### 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: +Use the `--config` flag to load a config file from any path: ```sh -MUJINA_CONFIG_FILE_PATH=/home/operator/mujina.yaml mujina-minerd +mujina-minerd --config /home/operator/mujina.yaml ``` -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. +Keys in the specified file take precedence over `/etc/mujina/mujina.yaml`. +Keys absent from the file fall back to the default file, then to hard-coded +defaults. An example config file is provided at `configs/mujina.example.yaml`. -### 2.3. Bootstrap variables - -`MUJINA_CONFIG_FILE_PATH` and `MUJINA_DEFAULT_CONFIG_PATH` use single -underscores and are **not** part of the `MUJINA__*` config-key system. They are -bootstrap variables read by the loader before the config system is constructed, -so the double-underscore naming convention does not apply to them. - -`MUJINA_DEFAULT_CONFIG_PATH` overrides the default system config path -(`/etc/mujina/mujina.yaml`). It is primarily intended for testing, where -writing to `/etc` requires root access: - -```sh -MUJINA_DEFAULT_CONFIG_PATH=/tmp/mujina-test.yaml mujina-minerd -``` - ## 3. Environment Variables Individual config keys can be overridden with environment variables. The @@ -138,26 +119,34 @@ 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: +CLI flags override all other sources and are intended for one-off overrides and +testing, not permanent configuration. ``` 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 + -c, --config Config file path (overrides /etc/mujina/mujina.yaml) + --set Override a config key (may be repeated) + -h, --help Print help + -V, --version Print version +``` + +`--set` uses the same dot-path namespace as the YAML file, so any key from +the YAML structure can be overridden without a dedicated flag: + +```sh +mujina-minerd \ + --set pool.url=stratum+tcp://pool.example.com:3333 \ + --set pool.user=bc1q....worker \ + --set api.listen=0.0.0.0:7785 \ + --set boards.cpu_miner.enabled=true ``` +Multiple `--set` flags are applied in order; later values win if the same key +appears more than once. + ## 5. YAML Structure The config file uses YAML. The top-level keys correspond to subsystems: @@ -168,9 +157,7 @@ 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 @@ -180,27 +167,26 @@ 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 +`mujina-miner/tests/daemon_integration_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 | +| `test_cli_config_file_is_read` | `--config` file overrides hard-coded default | +| `test_env_var_overrides_config_file` | `MUJINA__*` env var overrides `--config` file | +| `test_command_line_arg_override` | `--set` override wins over env var and `--config` file | +| `test_cpu_miner_starts_from_config` | CPU miner board starts from `--config` file | 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. +**no root access is required**. Run only these tests with: ```sh -cargo test -p mujina-miner --test config_priority_tests +cargo test -p mujina-miner --test daemon_integration_tests ``` -Because the tests mutate process-wide environment variables they are serialized +Because some 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/src/bin/minerd.rs b/mujina-miner/src/bin/minerd.rs index 449e2385..8db1a9c2 100644 --- a/mujina-miner/src/bin/minerd.rs +++ b/mujina-miner/src/bin/minerd.rs @@ -9,30 +9,16 @@ use mujina_miner::{config::Config, daemon::Daemon, tracing}; #[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). + /// Config file path (overrides /etc/mujina/mujina.yaml). #[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, + /// Override individual config keys (may be repeated). + /// Format: KEY=VALUE using dot-path notation, e.g. --set pool.url=stratum+tcp://... + /// Mirrors the YAML structure: pool.url, api.listen, boards.cpu_miner.threads, etc. + /// Takes precedence over env vars and config files. + #[arg(long = "set", value_name = "KEY=VALUE")] + set: Vec, } #[tokio::main] @@ -41,26 +27,20 @@ async fn main() -> anyhow::Result<()> { 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 overrides = parse_set_flags(&cli.set)?; + let config = Config::load_with(cli.config, &overrides)?; let daemon = Daemon::new(config); daemon.run().await } + +fn parse_set_flags(flags: &[String]) -> anyhow::Result> { + flags + .iter() + .map(|s| { + s.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .ok_or_else(|| anyhow::anyhow!("--set value must be KEY=VALUE, got: {s:?}")) + }) + .collect() +} diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index b8714714..adf9599b 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -5,9 +5,8 @@ //! 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` +//! 3. Config file specified via `--config` (passed as `cli_config_path`) +//! 4. Default config file — `/etc/mujina/mujina.yaml` (optional, not required) //! 5. Hard-coded defaults — `Default` impls on each struct (lowest priority) //! //! See `docs/configuration.md` and `configs/mujina.example.yaml` for the full @@ -20,19 +19,9 @@ use serde::{Deserialize, Serialize}; use tracing::debug; 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 = "__"; -/// 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()) -} - // --------------------------------------------------------------------------- // Top-level config // --------------------------------------------------------------------------- @@ -45,7 +34,6 @@ pub struct Config { pub pool: PoolConfig, pub backplane: BackplaneConfig, pub boards: BoardsConfig, - pub hash_thread: HashThreadConfig, } // --------------------------------------------------------------------------- @@ -123,30 +111,9 @@ impl Default for BackplaneConfig { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct BoardsConfig { - pub bitaxe: BitaxeConfig, pub cpu_miner: CpuMinerConfig, } -#[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, - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct CpuMinerConfig { @@ -165,20 +132,6 @@ impl Default for CpuMinerConfig { } } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(default, deny_unknown_fields)] -pub struct HashThreadConfig { - pub chip_target_difficulty: u32, -} - -impl Default for HashThreadConfig { - fn default() -> Self { - Self { - chip_target_difficulty: 256, - } - } -} - // --------------------------------------------------------------------------- // Loading // --------------------------------------------------------------------------- @@ -186,98 +139,65 @@ impl Default for HashThreadConfig { impl Config { /// 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`]. + /// Equivalent to `Config::load_with(None, &[])`. Use when no `--config` + /// flag or `--set` overrides were supplied on the command line. pub fn load() -> anyhow::Result { - Self::load_with(None) + Self::load_with(None, &[]) } - /// Load configuration, optionally overriding the user config file path. + /// Load configuration with an optional config file and `--set` overrides. /// - /// `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 { - // Detect misuse of bootstrap variables before the config system is - // constructed. Neither `config` nor `default` are valid config - // sections — the likely mistakes are: - // MUJINA__CONFIG__FILE__PATH instead of MUJINA_CONFIG_FILE_PATH - // MUJINA__DEFAULT__CONFIG__PATH instead of MUJINA_DEFAULT_CONFIG_PATH - let bad_vars: Vec = std::env::vars() - .filter(|(k, _)| { - let u = k.to_uppercase(); - u.starts_with("MUJINA__CONFIG") || u.starts_with("MUJINA__DEFAULT") - }) - .map(|(k, _)| k) - .collect(); - if !bad_vars.is_empty() { - anyhow::bail!( - "Invalid environment variable(s): {}\n\n\ - These are not config sections. Did you mean one of the \ - bootstrap variables `MUJINA_CONFIG_FILE_PATH` or \ - `MUJINA_DEFAULT_CONFIG_PATH` (single underscores)?", - bad_vars.join(", ") - ); - } - + /// Priority order (highest wins): + /// 1. `overrides` — `--set key=value` pairs, applied in order + /// 2. `MUJINA__*` environment variables + /// 3. `cli_config_path` — `--config` file, required to exist if supplied + /// 4. `/etc/mujina/mujina.yaml` — optional system default + /// 5. Hard-coded `Default` impls + pub fn load_with( + cli_config_path: Option, + overrides: &[(String, String)], + ) -> anyhow::Result { // Log config file sources so startup problems are easy to diagnose. - { - let default_path = default_config_path(); - let default_exists = std::path::Path::new(&default_path).exists(); - debug!( - path = %default_path, - exists = default_exists, - env_var = std::env::var(DEFAULT_CONFIG_PATH_ENV_VAR).ok().as_deref().unwrap_or("(not set)"), - "Default config file (MUJINA_DEFAULT_CONFIG_PATH)" - ); - - let user_path = cli_config_path - .as_ref() - .map(|p| p.to_string_lossy().into_owned()) - .or_else(|| std::env::var(CONFIG_FILE_ENV_VAR).ok()); - match &user_path { - Some(path) => { - let exists = std::path::Path::new(path).exists(); - debug!( - path = %path, - exists = exists, - "User config file (MUJINA_CONFIG_FILE_PATH)" - ); - } - None => { - debug!("User config file (MUJINA_CONFIG_FILE_PATH): not set"); - } - } + let default_exists = std::path::Path::new(DEFAULT_CONFIG_PATH).exists(); + debug!( + path = DEFAULT_CONFIG_PATH, + exists = default_exists, + "Default config file" + ); + match &cli_config_path { + Some(path) => debug!(path = %path.display(), "--config file"), + None => debug!("--config: not specified"), } let mut builder = config::Config::builder() - // Layer 4 (lowest): default system config file + // Layer 4 (lowest file): default system config file .add_source( - File::with_name(&default_config_path()) + 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 { + // Layer 3: --config file + if let Some(path) = cli_config_path { builder = builder.add_source( - File::with_name(&path) + File::with_name(&path.to_string_lossy()) .format(FileFormat::Yaml) .required(true), ); } - // Layer 2 (highest file-based): environment variables + // Layer 2: environment variables builder = builder.add_source( Environment::with_prefix(ENV_PREFIX) .separator(ENV_SEPARATOR) .try_parsing(true), ); + // Layer 1 (highest): --set key=value overrides + for (key, value) in overrides { + builder = builder.set_override(key.as_str(), value.as_str())?; + } + Ok(builder.build()?.try_deserialize::()?) } } @@ -299,15 +219,13 @@ mod tests { 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); } #[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); + let result = Config::load_with(None, &[]); assert!(result.is_ok(), "load_with(None) failed: {:?}", result); } } diff --git a/mujina-miner/tests/daemon_integration_tests.rs b/mujina-miner/tests/daemon_integration_tests.rs index b262d21c..09b46e6e 100644 --- a/mujina-miner/tests/daemon_integration_tests.rs +++ b/mujina-miner/tests/daemon_integration_tests.rs @@ -3,7 +3,7 @@ //! Each test starts a real `Daemon` instance and verifies runtime behaviour //! end-to-end: config priority, board lifecycle, API responses. //! -//! Tests mutate process-wide environment variables and are serialized with +//! Tests that set process-wide environment variables are serialized with //! `#[serial]`. Do not run with `--test-threads > 1`. use std::time::Duration; @@ -53,46 +53,29 @@ async fn wait_for_boards(base_url: &str, timeout: Duration) -> usize { // Config priority tests // --------------------------------------------------------------------------- -/// Verify that the daemon reads the default config file and binds the API -/// server to the port declared there. +/// Verify that `--config` is read and its values take effect. /// /// 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. +/// actually read rather than the hard-coded default matching by coincidence. #[tokio::test] #[serial] -async fn test_default_config_file() { +async fn test_cli_config_file_is_read() { const TEST_PORT: u16 = 17785; let listen_addr = format!("127.0.0.1:{TEST_PORT}"); - let tmp_dir = tempfile::tempdir().expect("failed to create tempdir"); - let config_path = tmp_dir.path().join("mujina.yaml"); + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let config_path = tmp.path().join("mujina.yaml"); std::fs::write( &config_path, format!("api:\n listen: \"{listen_addr}\"\nbackplane:\n usb_enabled: false\n"), ) .expect("failed to write temp config"); - // Redirect the default config path to our temp file. Also clear - // MUJINA_CONFIG_FILE_PATH so a value set in the shell cannot override - // the default config file we are trying to test. - // SAFETY: test is marked #[serial] so no other threads touch the environment. - unsafe { - std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &config_path); - std::env::remove_var("MUJINA_CONFIG_FILE_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") }; + let config = Config::load_with(Some(config_path), &[]).expect("Config::load_with() failed"); assert_eq!( config.api.listen, listen_addr, - "config.api.listen should reflect the value from the default config file" + "config.api.listen should reflect the value from the --config file" ); let daemon = Daemon::new(config); @@ -106,127 +89,47 @@ async fn test_default_config_file() { assert!( listening, - "mujina-minerd should be listening on {listen_addr} (port from default config file)" + "mujina-minerd should be listening on {listen_addr} (port from --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. +/// Verify that a `MUJINA__*` environment variable overrides both the config +/// file and the hard-coded default. /// /// Priority chain exercised: -/// user config file (17786) > default config file (17785) > built-in default (7785) +/// env var (17787) > --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}"); - - 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"); - - 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 }); +async fn test_env_var_overrides_config_file() { + const TEST_PORT_CONFIG: u16 = 17785; + const TEST_PORT_ENV: u16 = 17787; - let listening = wait_for_port(&user_listen, Duration::from_secs(5)).await; + let config_listen = format!("127.0.0.1:{TEST_PORT_CONFIG}"); + let env_listen = format!("127.0.0.1:{TEST_PORT_ENV}"); - 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}"); - - 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"); - - let tmp_user = tempfile::tempdir().expect("failed to create tempdir for user config"); - let user_config_path = tmp_user.path().join("mujina.yaml"); + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let config_path = tmp.path().join("mujina.yaml"); std::fs::write( - &user_config_path, - format!("api:\n listen: \"{user_listen}\"\n"), + &config_path, + format!("api:\n listen: \"{config_listen}\"\nbackplane:\n usb_enabled: false\n"), ) - .expect("failed to write user temp config"); + .expect("failed to write 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"); + let config = Config::load_with(Some(config_path), &[]).expect("Config::load_with() 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})" + "MUJINA__API__LISTEN (port {TEST_PORT_ENV}) should override --config file (port {TEST_PORT_CONFIG})" ); let daemon = Daemon::new(config); @@ -244,7 +147,7 @@ async fn test_env_var_override() { ); } -/// Verify that a CLI flag override wins over env vars, both config files, and +/// Verify that a CLI flag override wins over env vars, the config file, and /// the hard-coded default. /// /// CLI flags are not handled inside `Config::load_with`; they are applied by @@ -252,59 +155,43 @@ async fn test_env_var_override() { /// The test mirrors that pattern exactly. /// /// Priority chain exercised: -/// CLI flag (17788) > env var (17787) > user config (17786) > default config (17785) > built-in default (7785) +/// CLI flag (17788) > env var (17787) > --config file (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}"); - - 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"); + const TEST_PORT_CONFIG: u16 = 17785; + const TEST_PORT_ENV: u16 = 17787; + const TEST_PORT_CLI: u16 = 17788; + + let config_listen = format!("127.0.0.1:{TEST_PORT_CONFIG}"); + let env_listen = format!("127.0.0.1:{TEST_PORT_ENV}"); + let cli_listen = format!("127.0.0.1:{TEST_PORT_CLI}"); - let tmp_user = tempfile::tempdir().expect("failed to create tempdir for user config"); - let user_config_path = tmp_user.path().join("mujina.yaml"); + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let config_path = tmp.path().join("mujina.yaml"); std::fs::write( - &user_config_path, - format!("api:\n listen: \"{user_listen}\"\n"), + &config_path, + format!("api:\n listen: \"{config_listen}\"\nbackplane:\n usb_enabled: false\n"), ) - .expect("failed to write user temp config"); + .expect("failed to write 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"); + let set_overrides = vec![("api.listen".to_string(), cli_listen.clone())]; + let config = + Config::load_with(Some(config_path), &set_overrides).expect("Config::load_with() 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})" + "CLI flag (port {TEST_PORT_CLI}) should override env var (port {TEST_PORT_ENV}) and --config file (port {TEST_PORT_CONFIG})" ); let daemon = Daemon::new(config); @@ -350,19 +237,12 @@ async fn test_cpu_miner_starts_from_config() { // SAFETY: test is marked #[serial] so no other threads touch the environment. unsafe { - std::env::set_var("MUJINA_DEFAULT_CONFIG_PATH", &config_path); - // Clear the user config file path so a shell value cannot override - // the test config. - std::env::remove_var("MUJINA_CONFIG_FILE_PATH"); // Ensure legacy env vars are absent — they must not be required. std::env::remove_var("MUJINA_CPUMINER_THREADS"); std::env::remove_var("MUJINA_CPUMINER_DUTY"); } - let config = Config::load().expect("Config::load() failed"); - - // SAFETY: same serial-test guarantee as above. - unsafe { std::env::remove_var("MUJINA_DEFAULT_CONFIG_PATH") }; + let config = Config::load_with(Some(config_path), &[]).expect("Config::load_with() failed"); assert!( config.boards.cpu_miner.enabled,