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 3e075334..40697bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ Cargo.lock /target/ /.cache/ +mujina.yaml diff --git a/Cargo.toml b/Cargo.toml index 7276abb1..046f5bad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,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/README.md b/README.md index 02238f3f..4c02c7dd 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ On Debian or Ubuntu: git clone https://github.com/256foundation/mujina.git cd mujina sudo apt-get install libudev-dev libssl-dev -MUJINA_CPUMINER_THREADS=1 MUJINA_CPUMINER_DUTY=50 MUJINA_USB_DISABLE=1 \ +MUJINA__BOARDS__CPU_MINER__ENABLED=true \ +MUJINA__BOARDS__CPU_MINER__THREADS=1 \ +MUJINA__BACKPLANE__USB_ENABLED=false \ cargo run --bin mujina-minerd ``` @@ -120,22 +122,81 @@ without `just` installed. ## Running -Mujina is currently configured through environment variables. -Persistent configuration via the REST API and CLI will follow as those -interfaces mature. +Configuration is managed through a YAML config file, environment variables, or +CLI flags. See [Configuration](docs/configuration.md) for the full reference. ### Connecting to a job source Point Mujina at a Stratum v1 mining pool: ```bash -MUJINA_POOL_URL="stratum+tcp://pool.example.com:3333" \ -MUJINA_POOL_USER="your-address.worker" \ +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 `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 + +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 +``` + +See [REST API](docs/api.md) for endpoints and details. + +### Running Without Hardware + +For development and testing without physical mining hardware, the miner +includes a CPU mining backend. See [CPU Mining](docs/cpu-mining.md) for +details. + +A container image is available for deploying to cloud infrastructure or +Kubernetes for pool and miner testing. See [Container Image](docs/container.md). + +### Log Levels + +Control output verbosity with `RUST_LOG`: + +```bash +# Info level (default) -- shows pool connection, shares, errors +cargo run + +# Debug level -- adds job distribution, hardware state changes +RUST_LOG=mujina_miner=debug cargo run + +# Trace level -- shows all protocol traffic (serial, network, I2C) +RUST_LOG=mujina_miner=trace cargo run +``` + +Target specific modules for focused debugging: + +```bash +# Trace just the Stratum v1 client +RUST_LOG=mujina_miner::stratum_v1=trace cargo run + +# Debug Stratum v1, trace BM13xx protocol +RUST_LOG=mujina_miner::stratum_v1=debug,mujina_miner::asic::bm13xx=trace cargo run +``` + +Combine pool configuration with logging as needed: + +```bash +RUST_LOG=mujina_miner=debug \ +MUJINA__POOL__URL="stratum+tcp://pool.example.com:3333" \ +MUJINA__POOL__USER="your-address.worker" \ cargo run --bin mujina-minerd ``` -`MUJINA_POOL_USER` defaults to `mujina-testing` and `MUJINA_POOL_PASS` -defaults to `x`, so only `MUJINA_POOL_URL` is strictly required. +`MUJINA__POOL__USER` defaults to `mujina-testing` and `MUJINA__POOL__PASSWORD` +defaults to `x`, so only `MUJINA__POOL__URL` is strictly required. ### Testing without a pool @@ -178,10 +239,10 @@ Mujina logs the API bind address at startup. By default it's ```bash # All interfaces, default port -MUJINA_API_LISTEN="0.0.0.0" cargo run --bin mujina-minerd +MUJINA__API__LISTEN="0.0.0.0" cargo run --bin mujina-minerd # All interfaces, custom port -MUJINA_API_LISTEN="0.0.0.0:9000" cargo run --bin mujina-minerd +MUJINA__API__LISTEN="0.0.0.0:9000" cargo run --bin mujina-minerd ``` See [REST API](docs/api.md) for endpoints and conventions. The diff --git a/configs/mujina.example.yaml b/configs/mujina.example.yaml new file mode 100644 index 00000000..032cac0b --- /dev/null +++ b/configs/mujina.example.yaml @@ -0,0 +1,90 @@ +# 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" + + # 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 +# ------------------------------------------------------------ +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: + + # -- 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 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 new file mode 100644 index 00000000..80647dc8 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,192 @@ +*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. 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) +- [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) | `--set key=value` CLI overrides | +| 2 | Environment variables (`MUJINA__*`) | +| 3 | Config file specified via `--config` | +| 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. It is optional — if absent, Mujina +starts with hard-coded defaults. + +### 2.2. Specifying a config file + +Use the `--config` flag to load a config file from any path: + +```sh +mujina-minerd --config /home/operator/mujina.yaml +``` + +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`. + +## 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_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` | +| `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 and are intended for one-off overrides and +testing, not permanent configuration. + +``` +USAGE: + mujina-minerd [OPTIONS] + +OPTIONS: + -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: + +```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 + cpu_miner: # Software CPU miner (testing/development) +``` + +### 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/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_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**. + +Run only these tests with: + +```sh +cargo test -p mujina-miner --test daemon_integration_tests +``` + +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/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/Cargo.toml b/mujina-miner/Cargo.toml index 1cb651a9..e8bc11e0 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 } @@ -73,6 +75,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/backplane.rs b/mujina-miner/src/backplane.rs index dedbe374..0031f437 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -222,7 +222,7 @@ impl Backplane { "CPU miner board connected." ); - let conn = match (descriptor.create_fn)().await { + let conn = match (descriptor.create_fn)(device_info.clone()).await { Ok(conn) => conn, Err(e) => { error!( diff --git a/mujina-miner/src/bin/minerd.rs b/mujina-miner/src/bin/minerd.rs index 5ccaed8f..8db1a9c2 100644 --- a/mujina-miner/src/bin/minerd.rs +++ b/mujina-miner/src/bin/minerd.rs @@ -1,11 +1,46 @@ //! 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 /etc/mujina/mujina.yaml). + #[arg(short = 'c', long, value_name = "PATH")] + config: 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] async fn main() -> anyhow::Result<()> { tracing::init(); - let daemon = Daemon::new(); + let cli = Cli::parse(); + + 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/board/cpu.rs b/mujina-miner/src/board/cpu.rs index 5f07739f..62738c21 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -3,7 +3,8 @@ //! Provides a virtual board that uses CPU cores for SHA-256 hashing. //! See [`CpuMinerConfig`] for environment variable configuration. -use anyhow::{Result, anyhow}; +use anyhow::Result; +use futures::future::BoxFuture; use tokio::sync::watch; use super::{BackplaneConnector, BoardInfo, VirtualBoardDescriptor}; @@ -11,19 +12,22 @@ use crate::{ api_client::types::BoardTelemetry, asic::hash_thread::HashThread, cpu_miner::{CpuHashThread, CpuMinerConfig}, + transport::cpu::CpuDeviceInfo, }; 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)), } } -async fn create_cpu_board() -> Result { - let config = CpuMinerConfig::from_env() - .ok_or_else(|| anyhow!("cpu miner not configured (MUJINA_CPU_MINER not set)"))?; +async fn create_cpu_board(device_info: CpuDeviceInfo) -> Result { + let config = CpuMinerConfig { + thread_count: device_info.thread_count, + duty_percent: device_info.duty_percent, + }; let info = BoardInfo { model: "CPU Miner".into(), @@ -40,7 +44,7 @@ async fn create_cpu_board() -> Result { serial: info.serial_number.clone(), ..Default::default() }; - let (_telemetry_tx, telemetry_rx) = watch::channel(initial_state); + let (telemetry_tx, telemetry_rx) = watch::channel(initial_state); let threads: Vec> = (0..config.thread_count) .map(|i| { @@ -51,10 +55,17 @@ async fn create_cpu_board() -> Result { }) .collect(); + // Keep the telemetry sender alive until the board is shut down. + // Without this, the watch channel closes immediately and the API + // registry prunes the board as disconnected. + let shutdown: BoxFuture<'static, ()> = Box::pin(async move { + drop(telemetry_tx); + }); + Ok(BackplaneConnector { info, threads, telemetry_rx, - shutdown: None, + shutdown: Some(shutdown), }) } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 3b9e161b..a21bb54d 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -8,7 +8,9 @@ use futures::future::BoxFuture; use tokio::sync::watch; use crate::{ - api_client::types::BoardTelemetry, asic::hash_thread::HashThread, transport::UsbDeviceInfo, + api_client::types::BoardTelemetry, + asic::hash_thread::HashThread, + transport::{UsbDeviceInfo, cpu::CpuDeviceInfo}, }; /// Returned by board factory functions with everything the backplane @@ -80,10 +82,10 @@ inventory::collect!(BoardDescriptor); /// Factory function signature for creating a virtual board. /// -/// Same contract as [`BoardFactoryFn`], but virtual boards don't -/// receive USB device info. They are configured via environment -/// variables or other means. -pub type VirtualBoardFactoryFn = fn() -> BoxFuture<'static, Result>; +/// Same contract as [`BoardFactoryFn`], but virtual boards receive their +/// configuration via [`CpuDeviceInfo`] rather than USB device info. +pub type VirtualBoardFactoryFn = + fn(CpuDeviceInfo) -> BoxFuture<'static, Result>; /// Descriptor for virtual boards (CPU miner, test boards, etc.). /// diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index 43deb450..adf9599b 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -1,101 +1,231 @@ //! 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. 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 +//! key reference. -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -/// Main configuration structure for the miner. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Config { - /// Daemon configuration - pub daemon: DaemonConfig, +use config::{Environment, File, FileFormat}; +use serde::{Deserialize, Serialize}; +use tracing::debug; - /// Pool configuration - pub pools: Vec, +const DEFAULT_CONFIG_PATH: &str = "/etc/mujina/mujina.yaml"; +const ENV_PREFIX: &str = "MUJINA"; +const ENV_SEPARATOR: &str = "__"; - /// Hardware configuration - pub hardware: HardwareConfig, +// --------------------------------------------------------------------------- +// Top-level config +// --------------------------------------------------------------------------- - /// API server configuration +#[derive(Debug, Clone, Default, 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, } -/// Daemon process configuration. +// --------------------------------------------------------------------------- +// Subsection structs +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] pub struct DaemonConfig { - /// PID file location - pub pid_file: Option, - - /// Log level pub log_level: String, - - /// Use systemd notification - #[serde(default)] + pub pid_file: Option, pub systemd: bool, } -/// Pool connection configuration. +impl Default for DaemonConfig { + fn default() -> Self { + Self { + log_level: "info".to_string(), + pid_file: None, + systemd: false, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct PoolConfig { - /// Pool URL (stratum+tcp://...) - pub url: String, +#[serde(default, deny_unknown_fields)] +pub struct ApiConfig { + pub listen: String, +} - /// Worker name - pub worker: String, +impl Default for ApiConfig { + fn default() -> Self { + Self { + listen: "127.0.0.1:7785".to_string(), + } + } +} - /// Password (if required) - pub password: Option, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +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, +} - /// Priority (lower is higher priority) - #[serde(default)] - pub priority: u32, +impl Default for PoolConfig { + fn default() -> Self { + Self { + url: None, + user: "mujina-testing".to_string(), + password: "x".to_string(), + forced_rate: None, + } + } } -/// Hardware configuration. #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct HardwareConfig { - /// Temperature limits - pub temp_limit: f32, +#[serde(default, deny_unknown_fields)] +pub struct BackplaneConfig { + pub usb_enabled: bool, +} - /// Fan control settings - pub fan_min_rpm: u32, - pub fan_max_rpm: u32, +impl Default for BackplaneConfig { + fn default() -> Self { + Self { usb_enabled: true } + } +} - /// Power limits - pub power_limit: Option, +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct BoardsConfig { + pub cpu_miner: CpuMinerConfig, } -/// API server configuration. #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ApiConfig { - /// Listen address - pub listen: String, - - /// Enable TLS - #[serde(default)] - pub tls: bool, - - /// TLS certificate path - pub cert_path: Option, +#[serde(default, deny_unknown_fields)] +pub struct CpuMinerConfig { + pub enabled: bool, + pub threads: usize, + pub duty_percent: u8, +} - /// TLS key path - pub key_path: Option, +impl Default for CpuMinerConfig { + fn default() -> Self { + Self { + enabled: false, + threads: 1, + duty_percent: 50, + } + } } +// --------------------------------------------------------------------------- +// Loading +// --------------------------------------------------------------------------- + impl Config { - /// Load configuration from the default location. + /// Load configuration using the standard source hierarchy. + /// + /// 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 { - // 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 with an optional config file and `--set` overrides. + /// + /// 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_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 file): default system config file + .add_source( + File::with_name(DEFAULT_CONFIG_PATH) + .format(FileFormat::Yaml) + .required(false), + ); + + // Layer 3: --config file + if let Some(path) = cli_config_path { + builder = builder.add_source( + File::with_name(&path.to_string_lossy()) + .format(FileFormat::Yaml) + .required(true), + ); + } + + // 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::()?) + } +} + +// --------------------------------------------------------------------------- +// 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); } - /// 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/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 40ba4c1f..62c555bc 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,29 +111,24 @@ 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(), }; // 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" @@ -199,7 +205,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 +246,15 @@ 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 api_config = ApiConfig { + bind_addr: api_listen, }; - let config = ApiConfig { bind_addr }; if let Err(e) = api::serve( - config, + api_config, shutdown, miner_telemetry_rx, board_reg_rx, @@ -275,7 +276,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 +284,9 @@ impl Daemon { _ = sigterm.recv() => { info!("Received SIGTERM."); }, + _ = self.shutdown.cancelled() => { + info!("Shutdown requested programmatically."); + }, } // Initiate shutdown @@ -298,6 +302,6 @@ impl Daemon { impl Default for Daemon { fn default() -> Self { - Self::new() + Self::new(Config::default()) } } diff --git a/mujina-miner/src/job_source/forced_rate.rs b/mujina-miner/src/job_source/forced_rate.rs index 480f046a..e837b5ed 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 @@ -32,36 +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. /// /// Sits between the scheduler and an inner source (typically StratumV1Source), diff --git a/mujina-miner/src/stratum_v1/client.rs b/mujina-miner/src/stratum_v1/client.rs index 8e761f5e..debf35cb 100644 --- a/mujina-miner/src/stratum_v1/client.rs +++ b/mujina-miner/src/stratum_v1/client.rs @@ -869,19 +869,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 /// ``` /// @@ -890,9 +890,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/daemon_integration_tests.rs b/mujina-miner/tests/daemon_integration_tests.rs new file mode 100644 index 00000000..09b46e6e --- /dev/null +++ b/mujina-miner/tests/daemon_integration_tests.rs @@ -0,0 +1,272 @@ +//! Daemon integration tests. +//! +//! Each test starts a real `Daemon` instance and verifies runtime behaviour +//! end-to-end: config priority, board lifecycle, API responses. +//! +//! Tests that set process-wide environment variables are serialized with +//! `#[serial]`. Do not run with `--test-threads > 1`. + +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 +} + +/// 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 +} + +// --------------------------------------------------------------------------- +// Config priority tests +// --------------------------------------------------------------------------- + +/// 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 rather than the hard-coded default matching by coincidence. +#[tokio::test] +#[serial] +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 = 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"); + + 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 --config file" + ); + + 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; + + 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 --config file)" + ); +} + +/// Verify that a `MUJINA__*` environment variable overrides both the config +/// file and the hard-coded default. +/// +/// Priority chain exercised: +/// env var (17787) > --config file (17785) > built-in default (7785) +#[tokio::test] +#[serial] +async fn test_env_var_overrides_config_file() { + const TEST_PORT_CONFIG: u16 = 17785; + const TEST_PORT_ENV: u16 = 17787; + + let config_listen = format!("127.0.0.1:{TEST_PORT_CONFIG}"); + let env_listen = format!("127.0.0.1:{TEST_PORT_ENV}"); + + 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: \"{config_listen}\"\nbackplane:\n usb_enabled: false\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__API__LISTEN", &env_listen); + } + + 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__API__LISTEN"); + } + + assert_eq!( + config.api.listen, env_listen, + "MUJINA__API__LISTEN (port {TEST_PORT_ENV}) should override --config file (port {TEST_PORT_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, the config file, 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. +/// +/// Priority chain exercised: +/// 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_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 = tempfile::tempdir().expect("failed to create tempdir"); + let config_path = tmp.path().join("mujina.yaml"); + std::fs::write( + &config_path, + format!("api:\n listen: \"{config_listen}\"\nbackplane:\n usb_enabled: false\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__API__LISTEN", &env_listen); + } + + 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__API__LISTEN"); + } + + assert_eq!( + config.api.listen, cli_listen, + "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); + 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)" + ); +} + +// --------------------------------------------------------------------------- +// 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 { + // 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_with(Some(config_path), &[]).expect("Config::load_with() failed"); + + 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" + ); +}