From a12bc9928c60d11cb04bf751efe1311ceb602ca6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:11:16 -0500 Subject: [PATCH 01/32] chore(deps): Bump actions/checkout from 7.0.0 to 7.0.1 (#326) Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/links.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/security.yml | 4 ++-- .github/workflows/tool-bump.yml | 2 +- .github/workflows/xmrig-bump.yml | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a856e0..2c61427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: name: Lint (yamllint) runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked # pipx is preinstalled on ubuntu-24.04 (same path as diff-cover/zizmor); pin the version so the @@ -49,7 +49,7 @@ jobs: name: Lint (markdownlint) runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked # node is preinstalled on ubuntu-24.04; `make lint-md` runs the version-pinned markdownlint-cli2 @@ -66,7 +66,7 @@ jobs: SHFMT_VERSION: "3.13.1" SHFMT_SHA256: "fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # no pushes from CI; don't leave the token in .git/config (zizmor: artipacked) # Install PINNED, checksum-verified shellcheck + shfmt instead of the runner's preinstalled @@ -98,7 +98,7 @@ jobs: name: Test suite runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked # Ubuntu is RigForge's supported target. The suite still exercises the macOS code path here via @@ -111,7 +111,7 @@ jobs: name: Test suite (macOS) runs-on: macos-14 # Apple silicon; pinned (not macos-latest) to avoid image drift steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked # The Linux job only SIMULATES macOS (STUB_UNAME_S=Darwin + stubbed sed/launchctl). Here the suite @@ -137,7 +137,7 @@ jobs: name: End-to-end (Docker) runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked # Runs the real script end-to-end inside a disposable Ubuntu container (RigForge's documented @@ -156,7 +156,7 @@ jobs: # so fall back to the pushed branch itself — that's an empty diff that trivially passes. BASE_REF: ${{ github.base_ref || github.ref_name }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # diff-cover needs history to diff the PR against its base branch persist-credentials: false # the fetch below is read-only on a public repo (zizmor: artipacked) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 235222d..9d0be34 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -24,7 +24,7 @@ jobs: # lychee uses GITHUB_TOKEN to make authenticated GitHub requests and dodge the anon rate limit. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked - name: Install pinned lychee diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bfac7b..a76a847 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.ref_name }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Releasing goes through `gh` with GH_TOKEN below, not `git push`, so the checkout never # needs the token left in .git/config (zizmor: artipacked). diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b2d27f1..d942b03 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -36,7 +36,7 @@ jobs: GITLEAKS_VERSION: "8.30.1" GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # scan EVERY commit, not just the tip — a secret is still a leak once pushed persist-credentials: false # zizmor: artipacked @@ -60,7 +60,7 @@ jobs: env: ZIZMOR_VERSION: "1.25.2" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked # pipx is preinstalled on ubuntu-24.04; same install path as diff-cover in ci.yml's coverage job. diff --git a/.github/workflows/tool-bump.yml b/.github/workflows/tool-bump.yml index f8849f5..60cb09d 100644 --- a/.github/workflows/tool-bump.yml +++ b/.github/workflows/tool-bump.yml @@ -24,7 +24,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # zizmor: artipacked; the push authenticates explicitly below - name: Check every pinned tool, open one PR per stale pin diff --git a/.github/workflows/xmrig-bump.yml b/.github/workflows/xmrig-bump.yml index aa9426f..371b7bf 100644 --- a/.github/workflows/xmrig-bump.yml +++ b/.github/workflows/xmrig-bump.yml @@ -23,7 +23,7 @@ jobs: pull-requests: write actions: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # zizmor: artipacked — never persist the token in .git/config; the push step below # authenticates explicitly with the job token instead. From c383b0372639fe8b702f7b1838d9ee5c10ee5b3a Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 31 Jul 2026 23:23:02 -0500 Subject: [PATCH 02/32] fix(tune): runtime HugePages reservation is grow-only (#328) (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tune): runtime HugePages reservation is grow-only (#328) tune_kernel wrote the miner's computed requirement to vm.nr_hugepages absolutely, which shrinks a pool another consumer already reserved (a co-hosted pithead stack's p2pool/monerod share the same 2MB pool) down to its in-use floor — zero free pages on both sides, measured live on the pithead#797 appliance bench. The write is now availability-based: pages the miner can draw on = HugePages_Free + whatever a running miner already holds (it re-uses its pages across a restart). The pool grows by the shortfall only and never shrinks. Fresh single-purpose rigs see the same reservation as before; re-runs that find enough available write nothing. This is the runtime half of #305's co-resident keep-existing guard. The setup --dry-run plan previews the same decision from the same availability check. Closes #328 Co-Authored-By: Claude Fable 5 * test(tune): cover the held-pages probe, fallback, and dry-run no-change branches Three lines diff-cover flagged: the real-MainPID HugetlbPages read (now exercised via a stub systemctl reporting a live pid), the no-proposed-grub 3072 fallback (now asserted grow-only too), and the dry-run plan's covered-pool wording. The probe also always prints a number now — a status file without a HugetlbPages line previously produced empty output. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 12 +++++++ rigforge.sh | 54 ++++++++++++++++++++++++++-- tests/run.sh | 100 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3f385..1142337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +### Fixed + +- **Runtime HugePages reservation is grow-only (#328).** `tune_kernel`'s runtime sysctl wrote the + miner's computed requirement absolutely, which SHRINKS a pool another consumer already reserved + (a co-hosted pithead stack's p2pool/monerod share the same 2MB pool) down to its in-use floor — + zero free pages on both sides, measured live on the pithead#797 appliance bench. The write is now + availability-based: pages the miner can draw on = free + whatever a running miner already holds; + the pool grows by the shortfall only and never shrinks. Fresh single-purpose rigs see the same + reservation as before; re-runs that find enough available write nothing. This is the runtime half + of #305's co-resident keep-existing guard, and it applies with or without + `hugepages_reserve_extra_mb` set. The `setup --dry-run` plan previews the same decision. + ## [1.12.0] - 2026-07-19 The pithead#597 producer release: the control-upgrade `/status` contract a one-click worker diff --git a/rigforge.sh b/rigforge.sh index 276077c..330c17f 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -108,6 +108,7 @@ MEMINFO="${MEMINFO:-/proc/meminfo}" MSR_MODULE_DIR="${MSR_MODULE_DIR:-/sys/module/msr}" GOVERNOR_FILE="${GOVERNOR_FILE:-/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor}" HUGEPAGES_1G_NR="${HUGEPAGES_1G_NR:-/sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages}" +NR_HUGEPAGES_FILE="${NR_HUGEPAGES_FILE:-/proc/sys/vm/nr_hugepages}" # Hashrate-capping-hardware diagnostics (#67): RAM layout (dmidecode) + effective CPU clock under load. DMIDECODE="${DMIDECODE:-dmidecode}" RDMSR_BIN="${RDMSR_BIN:-rdmsr}" # msr-tools, for doctor's register-level MSR verification (#66) @@ -1337,6 +1338,48 @@ _cmdline_reserved_2mb() { # -> reserved 2MB-equivalent pages awk '{ cur=""; def=""; t=0; for (i=1;i<=NF;i++) { if ($i ~ /^hugepagesz=/) { split($i,a,"="); cur=a[2] } else if ($i ~ /^default_hugepagesz=/) { split($i,a,"="); def=a[2] } else if ($i ~ /^hugepages=/) { split($i,a,"="); n=a[2]+0; s=(cur!=""?cur:def); if (s=="1G"||s=="1073741824"||s=="1048576K") t+=n*512; else t+=n } } print t+0 }' <<<"$1" } +# 2MB pages the running miner already holds (kB -> pages), 0 when stopped: pages it holds now are +# pages it re-uses across a restart, so they count as available when sizing the pool. HugetlbPages +# spans every page size, so a dataset sitting in 1GB pages overstates this credit — in exactly the +# case where the 2MB pool is not needed for the dataset, so nothing actually used goes unreserved. +_miner_held_hugepages() { + local pid kb + pid=$(systemctl show "$SERVICE_NAME.service" -p MainPID --value 2>/dev/null) || pid="" + if [ -n "$pid" ] && [ "$pid" -gt 0 ] 2>/dev/null && [ -r "/proc/$pid/status" ]; then + kb=$(awk '/^HugetlbPages:/ { print $2; exit }' "/proc/$pid/status" 2>/dev/null) + echo $((${kb:-0} / 2048)) + return + fi + echo 0 +} + +# Grow-only runtime reservation (#328, the runtime half of #305's keep-existing guard): another +# consumer may hold pages from the same pool (a co-hosted pithead stack's p2pool/monerod), and +# writing our raw requirement shrinks that pool to its in-use floor — 0 free pages on both sides, +# measured live on the pithead#797 appliance bench. Ensure the miner's pages are AVAILABLE (free +# now, or already held by the running miner) and grow the pool by the shortfall only; never shrink +# a reservation someone else made. Idempotent: a re-run that finds enough available writes nothing. +# ponytail: with headroom configured AND the co-resident already running, its live pages and the +# headroom both count toward the target — a bounded over-reserve in the safe direction; tighten to +# max(headroom, live use) if a real box ever needs the difference. +_hugepages_avail() { # -> 2MB pages the miner could draw on right now (free + already held) + local free held + free=$(awk '/^HugePages_Free:/ { print $2; exit }' "$MEMINFO" 2>/dev/null) || free="" + held=$(_miner_held_hugepages) + echo $((${free:-0} + held)) +} + +_ensure_hugepages() { # + local required=$1 current avail + current=$(cat "$NR_HUGEPAGES_FILE" 2>/dev/null) || current=0 + avail=$(_hugepages_avail) + if [ "$avail" -lt "$required" ]; then + sudo sysctl -w vm.nr_hugepages=$((current + required - avail)) + else + log "HugePages pool already covers the miner ($avail of $required pages available; pool: $current) — leaving it as-is (#328)." + fi +} + tune_kernel() { if [ "$OS_TYPE" != "Linux" ]; then log "Skipping kernel tuning (Not supported on $OS_TYPE)." @@ -1361,13 +1404,13 @@ tune_kernel() { # Calculate exact requirement based on hardware, the tuned thread count, and 1GB page status REQUIRED_PAGES=$(RX_THREADS="$RX_SETUP_THREADS" RESERVE_EXTRA_MB="${HUGEPAGES_RESERVE_EXTRA_MB:-0}" THREADS_CAP="${THREADS_CAP:-}" "$SCRIPT_DIR/util/proposed-grub.sh" --runtime) log "Hardware-optimized HugePages: $REQUIRED_PAGES (2MB pages) calculated." - sudo sysctl -w vm.nr_hugepages="$REQUIRED_PAGES" + _ensure_hugepages "$REQUIRED_PAGES" else # Fallback when proposed-grub.sh is missing: 3072 × 2MB = 6 GB of huge pages — enough for the # ~2.3 GB RandomX dataset plus per-thread scratchpads on a large desktop/server, without over- # reserving on smaller hosts. proposed-grub.sh computes an exact, hardware-sized value instead. warn "Utility script not found. Fallback to safe default (3072)." - sudo sysctl -w vm.nr_hugepages=3072 + _ensure_hugepages 3072 fi log "Configuring bootloader (GRUB) for persistent HugePages..." @@ -1580,7 +1623,12 @@ _setup_plan() { fi fi fi - _p "tuning the kernel" "$_msr; reserve $_pages 2MB HugePages (runtime sysctl); $_grubline$_reboot" + # Grow-only preview (#328): same availability check _ensure_hugepages runs, read-only. + local _hpline="grow the pool so $_pages 2MB HugePages are available (grow-only runtime sysctl)" + if [[ "$_pages" =~ ^[0-9]+$ ]] && [ "$(_hugepages_avail)" -ge "$_pages" ] 2>/dev/null; then + _hpline="HugePages pool already covers the miner ($_pages pages needed) — no change" + fi + _p "tuning the kernel" "$_msr; $_hpline; $_grubline$_reboot" local _f1="hugetlbfs /dev/hugepages hugetlbfs defaults 0 0" _f2="hugetlbfs_1g $HUGEPAGES_1G_DIR hugetlbfs pagesize=1G 0 0" _add="" grep -qxF "$_f1" "$FSTAB" 2>/dev/null || _add=" '$_f1'" grep -qxF "$_f2" "$FSTAB" 2>/dev/null || _add="$_add '$_f2'" diff --git a/tests/run.sh b/tests/run.sh index b7cac82..6d028eb 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -2276,13 +2276,16 @@ case "${1:-}" in esac EOF chmod +x "$DR/util/proposed-grub.sh" +# MEMINFO is pinned (0 free) so the grow-only preview (#328) renders the same on any host — +# a runner with a real, large HugePages_Free would otherwise flip the plan line to "no change". +printf 'HugePages_Free: 0\n' >"$DR/etc/meminfo" dr_out="$(cd "$DR" && PATH="$STUBS:$PATH" CALL_LOG="$DR/calls.log" GRUB_DEFAULT="$DR/etc/grub" FSTAB="$DR/etc/fstab" \ - RIGFORGE_HOME="$PWD" bash "$SCRIPT" setup --dry-run &1)" + MEMINFO="$DR/etc/meminfo" RIGFORGE_HOME="$PWD" bash "$SCRIPT" setup --dry-run &1)" dr_rc=$? assert_rc "dry-run exits 0 (#146)" "$dr_rc" "0" assert_contains "plan: build line names the pinned version (#146)" "$dr_out" "build XMRig" assert_contains "plan: dependency probe ran (#146)" "$dr_out" "installing dependencies" -assert_contains "plan: computed HugePages count (#146)" "$dr_out" "reserve 2514 2MB HugePages" +assert_contains "plan: computed HugePages count, grow-only wording (#146/#328)" "$dr_out" "grow the pool so 2514 2MB HugePages are available" assert_contains "plan: GRUB before -> after diff (#146)" "$dr_out" "GRUB cmdline: 'quiet splash' -> 'quiet splash default_hugepagesz=2M hugepages=2514 msr.allow_writes=on'" assert_contains "plan: reboot called out when GRUB changes (#146)" "$dr_out" "a reboot WILL be required" assert_contains "plan: fstab append lines (#146)" "$dr_out" "hugetlbfs /dev/hugepages" @@ -2290,6 +2293,12 @@ assert_contains "plan: autotune timer with the configured target (#146)" "$dr_ou assert_contains "plan: sister API units (#146)" "$dr_out" "install rigforge-api.service" assert_contains "plan: add_to_path symlink (#146)" "$dr_out" "symlink" assert_contains "plan: footer says nothing changed (#146)" "$dr_out" "Dry run — nothing was changed" +# Grow-only preview, covered branch (#328): with plenty of free pages the plan says "no change" +# instead of the grow line — same availability check the mutation runs. +printf 'HugePages_Free: 99999\n' >"$DR/etc/meminfo_full" +dr_out_full="$(cd "$DR" && PATH="$STUBS:$PATH" CALL_LOG="$DR/calls2.log" GRUB_DEFAULT="$DR/etc/grub" FSTAB="$DR/etc/fstab" \ + MEMINFO="$DR/etc/meminfo_full" RIGFORGE_HOME="$PWD" bash "$SCRIPT" setup --dry-run &1)" +assert_contains "plan: covered pool renders the no-change line (#328)" "$dr_out_full" "HugePages pool already covers the miner (2514 pages needed) — no change" # No-mutation guard: none of the stubbed mutating commands were invoked (the stubs log every call). for mut in apt-get modprobe tee mount sysctl; do assert_absent "dry-run never invokes $mut (#146)" "$(cat "$DR/calls.log" 2>/dev/null)" "[$mut]" @@ -4925,6 +4934,93 @@ out="$(RIGFORGE_THREADS=abc run_tunekernel "$PGC")" assert_absent "garbage RIGFORGE_THREADS is sanitized away (#65)" "$out" "Sizing the HugePages reservation" assert_contains "sanitized RIGFORGE_THREADS -> empty RX_THREADS to proposed-grub (#65)" "$(cat "$PGC")" "RX_THREADS=[]" +# #328: the runtime HugePages write is grow-only — it must never shrink a pool another consumer +# (a co-hosted pithead stack) already reserved. The proposed-grub stub above says the miner needs +# 200 pages; MEMINFO/NR_HUGEPAGES_FILE fake the live pool and a recording sysctl captures writes. +echo "== black-box: runtime HugePages reservation is grow-only (#328) ==" +HP="$(mktemp -d "$SANDBOX/hp328.XXXXXX")" +mkdir -p "$HP/bin" +cat >"$HP/bin/sysctl" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"$SYSCTL_CALLS" +EOF +chmod +x "$HP/bin/sysctl" +run_tk328() { # [miner_held_pages] + ( + source "$SCRIPT" + OS_TYPE=Linux + SCRIPT_DIR="$TK" + WORKER_ROOT="$TK/home/worker" + MODULES_LOAD_DIR="$TK/nope" + MODULES_FILE="$TK/nope/modules" + GRUB_DEFAULT="$TK/nope/grub" # nonexistent -> the GRUB block is skipped + printf 'HugePages_Free: %s\n' "$2" >"$HP/meminfo" + printf '%s\n' "$3" >"$HP/nr_hugepages" + MEMINFO="$HP/meminfo" + NR_HUGEPAGES_FILE="$HP/nr_hugepages" + # The held-pages credit has its own seam: a running miner is out of scope for a sandbox. + [ -n "${4:-}" ] && eval "_miner_held_hugepages() { echo $4; }" + export PG_CALLS="$HP/pg_calls" SYSCTL_CALLS="$1" + set +e + PATH="$HP/bin:$STUBS:$PATH" tune_kernel 2>&1 + ) +} +SC="$HP/calls1" +: >"$SC" +out="$(run_tk328 "$SC" 0 3072)" +assert_contains "co-resident pool grows by the shortfall, never shrinks (#328)" "$(cat "$SC")" "vm.nr_hugepages=3272" +SC="$HP/calls2" +: >"$SC" +out="$(run_tk328 "$SC" 500 3072)" +assert_absent "enough free pages -> no write at all (#328)" "$(cat "$SC")" "vm.nr_hugepages" +assert_contains "enough free pages -> says it left the pool alone (#328)" "$out" "leaving it as-is" +SC="$HP/calls3" +: >"$SC" +out="$(run_tk328 "$SC" 0 0)" +assert_contains "fresh box -> plain requirement, unchanged behavior (#328)" "$(cat "$SC")" "vm.nr_hugepages=200" +SC="$HP/calls4" +: >"$SC" +out="$(run_tk328 "$SC" 50 1300 180)" +assert_absent "a running miner's held pages count as available — idempotent re-run (#328)" "$(cat "$SC")" "vm.nr_hugepages" +# The real held-pages probe (no seam): a stub systemctl reports this test shell as the miner's +# MainPID. On Linux /proc/$$/status exists with HugetlbPages: 0 kB — the probe reads it and +# credits 0; on macOS there is no /proc, the guard falls through to the same 0. Either way the +# outcome matches calls1: grow by the full shortfall. +cat >"$HP/bin/systemctl" <"$SC" +out="$(run_tk328 "$SC" 0 3072)" +assert_contains "real MainPID probe -> zero credit for a page-less process (#328)" "$(cat "$SC")" "vm.nr_hugepages=3272" +rm -f "$HP/bin/systemctl" +# proposed-grub.sh missing -> the 3072 fallback goes through the same grow-only path. +run_tk328_nopg() { # + ( + source "$SCRIPT" + OS_TYPE=Linux + SCRIPT_DIR="$HP/empty" # no util/proposed-grub.sh here + WORKER_ROOT="$TK/home/worker" + MODULES_LOAD_DIR="$TK/nope" + MODULES_FILE="$TK/nope/modules" + GRUB_DEFAULT="$TK/nope/grub" + printf 'HugePages_Free: 0\n' >"$HP/meminfo" + printf '0\n' >"$HP/nr_hugepages" + MEMINFO="$HP/meminfo" + NR_HUGEPAGES_FILE="$HP/nr_hugepages" + export SYSCTL_CALLS="$1" + set +e + PATH="$HP/bin:$STUBS:$PATH" tune_kernel 2>&1 + ) +} +mkdir -p "$HP/empty" +SC="$HP/calls6" +: >"$SC" +out="$(run_tk328_nopg "$SC")" +assert_contains "fallback (no proposed-grub.sh) is grow-only too (#328)" "$(cat "$SC")" "vm.nr_hugepages=3072" + # tune with no built worker fails clearly. TN2="$(mktemp -d "$SANDBOX/tune2.XXXXXX")" cp "$ROOT/VERSION" "$TN2/" From 32be3a433b261110dde371f0ed5dab88acafd6f4 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:10:21 -0500 Subject: [PATCH 03/32] =?UTF-8?q?feat(setup):=20appliance=20mode=20?= =?UTF-8?q?=E2=80=94=20baked=20deps,=20/run=20units,=20GRUB=20skip=20(pith?= =?UTF-8?q?ead#797=20R1)=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setup): appliance mode — baked deps, /run units, GRUB skip (pithead#797 R1) One opt-in env flag, RIGFORGE_APPLIANCE=1, for running setup on the Pithead appliance image: read-only root, volatile /etc overlay, and a boot leg that re-runs setup every boot instead of relying on persisted state. Under the flag: - dependencies are verified (command -v), never installed — a missing tool fails loudly naming the image-build gap; a prebuilt tree needs envsubst only (the R0 bench re-ran from cache with a half-broken compiler) - the GRUB leg takes the skip branch deliberately: the kernel cmdline, incl. any 1GB-hugepage reservation, is image-owned - units render into /run/systemd/system (SYSTEMD_DIR preset) and enable with systemctl enable --runtime - hugetlbfs mounts at runtime; no fstab or limits.conf appends (the unit already sets LimitMEMLOCK=infinity) - runtime tuning unchanged: modprobe msr, grow-only HugePages sysctl (#328), performance governor setup --dry-run previews the same decisions through the shared helpers, never a second copy. Every decision was proven on the pithead#797 R0 bench. Co-Authored-By: Claude Fable 5 * fix(appliance): close the two review gaps — jq install and the logrotate drop-in Adversarial review of the appliance flag found two writes that survived the R1 contract: check_prerequisites still apt/brew-installed a missing jq (appliance mode never installs — hard-fail naming the baked-image fix instead), and generate_xmrig_config still wrote /etc/logrotate.d/ xmrig onto the volatile overlay of an image that runs no logrotate (log policy belongs to the integration layer, pithead#797 R2). Tests cover both; the missing-jq case clears bash's command hash table first, since sourcing the script hashes jq's real path and command -v would ignore the emptied PATH. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 16 ++++ docs/how-it-works.md | 26 +++++ rigforge.sh | 158 ++++++++++++++++++++++++++----- tests/run.sh | 220 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 398 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1142337..21de146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +### Added + +- **Appliance mode: `RIGFORGE_APPLIANCE=1` (pithead#797 R1).** One opt-in env flag for running + `setup` on the Pithead appliance image, whose root filesystem is read-only and whose `/etc` is a + volatile overlay — every write there vanishes at reboot, and the image's boot path re-runs setup + each boot instead of relying on persisted state. Under the flag, setup installs no packages (the + toolchain is baked at image build; if a required tool is absent, setup fails naming it), skips the + GRUB leg deliberately (the kernel cmdline, including any 1 GB-hugepage reservation, is + image-owned), renders its systemd units into `/run/systemd/system` and enables them with + `--runtime`, mounts `hugetlbfs` at runtime instead of appending to `fstab`, and skips the + `limits.conf` memlock append (the unit already sets `LimitMEMLOCK=infinity`). Runtime tuning is + unchanged: `modprobe msr`, the grow-only HugePages sysctl (#328), and the performance governor all + work on a read-only root — each decision proven on the pithead#797 R0 bench. `setup --dry-run` + previews the same decisions. Everything else (a normal Linux install, macOS) behaves exactly as + before. + ### Fixed - **Runtime HugePages reservation is grow-only (#328).** `tune_kernel`'s runtime sysctl wrote the diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 130043e..8d380ab 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -209,6 +209,32 @@ XMRig accordingly (and there's no systemd service, so you run the miner yourself --- +## Appliance mode (`RIGFORGE_APPLIANCE=1`) + +The Pithead appliance image runs from a read-only root with a volatile `/etc` overlay: anything +setup writes there disappears at reboot, and the image's boot path re-runs setup every boot +instead. `RIGFORGE_APPLIANCE=1` adapts setup to that contract (pithead#797): + +- No package install. The toolchain is baked into the image at build; setup verifies the tools it + needs and fails naming any that are missing, rather than attempting an install that can't work + on a read-only root. A prebuilt worker needs no compiler at all — the every-boot re-run keeps + mining even without one. +- GRUB skipped deliberately. The kernel cmdline — including any 1 GB-hugepage reservation — is + owned by the image. Without that reservation XMRig falls back to 2 MB pages at 100%: working, + with a small known cost. +- Units in `/run`. The systemd units render into `/run/systemd/system` and are enabled with + `systemctl enable --runtime`, so the enablement lives and dies with the boot, matching the + re-run-every-boot model. +- Runtime mounts only. `hugetlbfs` is mounted directly instead of via `fstab`, and the + `limits.conf` memlock append is skipped — the unit already sets `LimitMEMLOCK=infinity`, and + interactive runs are not an appliance concern. + +Runtime tuning is untouched: `modprobe msr`, the grow-only HugePages sysctl, and the performance +governor all work on a read-only root. `setup --dry-run` previews the appliance decisions with the +same logic. On a normal install, leave the flag unset — nothing changes. + +--- + ## Safety & idempotency RigForge is built to be re-run: diff --git a/rigforge.sh b/rigforge.sh index 330c17f..3d7c2b1 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -90,6 +90,33 @@ XMRIG_COMMIT="${XMRIG_COMMIT:-b2ca72480c58d197e18c885d9fc1a0c8d517e60a}" # recompile and the service restart — making re-runs idempotent (#4). XMRIG_REBUILD=true +# Appliance mode (pithead#797 R1): opt-in via RIGFORGE_APPLIANCE=1 for running setup on the Pithead +# appliance image, whose root filesystem is read-only and whose /etc is a volatile overlay — every +# write there vanishes at reboot, and a pithead boot leg re-runs setup each boot instead. An env +# flag, not a config.json key, because the caller is the image's boot path (not the operator) and +# the mode is a preset bundle over the env-overridable system paths below — the same seam the R0 +# bench drove by hand with GRUB_DEFAULT=/nonexistent. Under the flag, setup: +# - never installs packages (the toolchain is baked at image build; apt cannot run on the RO +# root) — it verifies the tools it needs and fails naming what's missing; +# - skips the GRUB leg deliberately (the kernel cmdline, incl. any 1GB-hugepage reservation, is +# image-owned; update-grub aborted the whole run on the RO /boot); +# - renders systemd units into /run/systemd/system and enables them with --runtime; +# - mounts hugetlbfs at runtime instead of appending to fstab, and skips the limits.conf memlock +# append (the unit already sets LimitMEMLOCK=infinity; interactive-run memlock is not an +# appliance concern). +# Runtime tuning is unchanged: modprobe msr, the grow-only HugePages sysctl (#328), and the +# cpupower governor via ExecStartPre all work on a read-only root. +RIGFORGE_APPLIANCE="${RIGFORGE_APPLIANCE:-0}" # every consumer tests `= 1`; anything else is off +if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + # Preset only — an explicit SYSTEMD_DIR in the environment (the test sandbox) still wins, and + # the non-appliance default below keeps this value because it is now set. + SYSTEMD_DIR="${SYSTEMD_DIR:-/run/systemd/system}" +fi +# Unit-enablement mode: appliance units live in /run, so their wants/ symlinks must too (a plain +# `enable` would write them to the volatile /etc overlay — working until reboot, then gone). +ENABLE_RUNTIME="" +if [ "$RIGFORGE_APPLIANCE" = 1 ]; then ENABLE_RUNTIME="--runtime"; fi + # System paths the script writes to. Overridable so the test suite can redirect them at a sandbox # (the defaults are the real locations, so production behaviour is unchanged). LOGROTATE_DIR="${LOGROTATE_DIR:-/etc/logrotate.d}" @@ -228,6 +255,11 @@ _sed_escape_replacement() { # -> escaped value on stdout check_prerequisites() { log "Verifying system prerequisites..." if ! command -v jq &>/dev/null; then + # Appliance mode never installs packages — same contract as install_dependencies: the read-only + # image bakes everything at build, and an install here would silently violate it (pithead#797 R1). + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + error "jq is required but missing — appliance mode never installs packages; bake jq into the image." + fi if [ "$OS_TYPE" == "Darwin" ]; then if command -v brew &>/dev/null; then log "Installing prerequisite: jq..." @@ -738,7 +770,36 @@ _missing_deps() { printf '%s' "$missing" } +# Appliance-mode tool check (pithead#797 R1), shared with the setup --dry-run plan (#146 — never a +# second copy to drift). Echoes the tools setup would actually invoke that are absent. `command -v`, +# not the package manager: on the appliance, dpkg/alternatives state rides the volatile /etc overlay +# and can be stale while the tools in /usr are fine (observed on the R0 bench after a reboot). +# envsubst renders the unit on every run; the compiler chain only matters when a build is pending — +# the R0 bench restored mining from a cached build with a half-broken toolchain, and the every-boot +# re-run must keep doing that. +_missing_appliance_tools() { + local t missing="" tools="envsubst" + [ "$XMRIG_REBUILD" = true ] && tools="git cmake make cc envsubst" + for t in $tools; do + command -v "$t" >/dev/null 2>&1 || missing="$missing $t" + done + printf '%s' "$missing" +} + install_dependencies() { + # Appliance mode: the toolchain is baked into the image at build — apt cannot run on the + # read-only root, and even a rw-remounted install leaves its /etc state on the volatile overlay + # (pithead#797 R0 item 1). So never install here: verify and fail naming the missing tools — + # that's an image build bug, not something a rig can fix at runtime. + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + local _missing + _missing="$(_missing_appliance_tools)" + if [ -n "$_missing" ]; then + error "Appliance mode: required tool(s) missing from the image:$_missing. The toolchain must be baked at image build — package install cannot run on the read-only root." + fi + log "Appliance mode: dependencies are baked into the image — skipping package install." + return 0 + fi if [ "$OS_TYPE" == "Darwin" ]; then log "Installing macOS dependencies..." if command -v brew &>/dev/null; then @@ -1019,6 +1080,13 @@ generate_xmrig_config() { chmod 600 config.json if [ "$OS_TYPE" == "Linux" ]; then + # Appliance mode: no logrotate drop-in — /etc is a volatile overlay and the image does not run + # logrotate; log policy on the appliance belongs to the integration layer (pithead#797 R2), + # which re-renders the miner's config every boot and can cap or journald-route the log there. + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + log "Appliance mode: skipping the logrotate policy (image-owned logging)." + return + fi log "Configuring log rotation policy..." # Install logrotate configuration sudo tee "$LOGROTATE_DIR/xmrig" >/dev/null </dev/null sudo systemctl daemon-reload - sudo systemctl enable --now rigforge-autotune.timer 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} --now rigforge-autotune.timer 2>/dev/null || true } # Install (or remove) the systemd timer that runs the miner watchdog periodically, based on the @@ -1154,7 +1222,7 @@ install_watchdog() { envsubst '$WATCHDOG_INTERVAL_MIN' \ <"$SCRIPT_DIR/systemd/rigforge-watchdog.timer.template" | sudo tee "$tmr" >/dev/null sudo systemctl daemon-reload - sudo systemctl enable --now rigforge-watchdog.timer 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} --now rigforge-watchdog.timer 2>/dev/null || true } # Sister API (#99/#164, xmrig-model): one tiny persistent python3-stdlib server ships pre-computed @@ -1228,8 +1296,8 @@ install_api() { RIGFORGE_OPERATOR="$REAL_USER" SCRIPT_DIR="$SCRIPT_DIR" envsubst '$RIGFORGE_OPERATOR $SCRIPT_DIR' <"$SCRIPT_DIR/systemd/rigforge-api-refresh.service.template" | sudo tee "$rsvc" >/dev/null sudo tee "$rtmr" <"$SCRIPT_DIR/systemd/rigforge-api-refresh.timer.template" >/dev/null sudo systemctl daemon-reload - sudo systemctl enable --now rigforge-api-refresh.timer 2>/dev/null || true - sudo systemctl enable rigforge-api.service 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} --now rigforge-api-refresh.timer 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} rigforge-api.service 2>/dev/null || true # restart, not just enable --now: a bind/port/token change must be re-read (restart also starts). sudo systemctl restart rigforge-api.service 2>/dev/null || true # Prime the state files so the first poll isn't a 503 for a whole timer period. @@ -1281,11 +1349,11 @@ install_control() { sudo rm -f "$usvc" "$upath" fi sudo systemctl daemon-reload - sudo systemctl enable --now rigforge-control-apply.path 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} --now rigforge-control-apply.path 2>/dev/null || true if [ "${CONTROL_UPGRADE:-disabled}" = "enabled" ]; then - sudo systemctl enable --now rigforge-control-upgrade.path 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} --now rigforge-control-upgrade.path 2>/dev/null || true fi - sudo systemctl enable rigforge-control.service 2>/dev/null || true + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} rigforge-control.service 2>/dev/null || true # restart, not just enable --now: a bind/port/token/upgrade-flag change must be re-read (restart also starts). sudo systemctl restart rigforge-control.service 2>/dev/null || true } @@ -1389,7 +1457,13 @@ tune_kernel() { if [[ "$(uname -m)" == "x86_64" || "$(uname -m)" == "i686" ]]; then log "Enabling MSR module for hardware prefetcher tuning..." sudo modprobe msr 2>/dev/null || true - if [ -d "$MODULES_LOAD_DIR" ]; then + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + # The modprobe above is the whole job on the appliance: setup re-runs every boot (the + # pithead boot leg), so a modules-load drop-in is persistence machinery for a + # persistence layer the box doesn't have. Smaller than writing /run/modules-load.d + # for the same effect — that dir is only read at boot, before this run exists. + log "Appliance mode: msr loaded at runtime only (no modules-load drop-in — setup re-runs each boot)." + elif [ -d "$MODULES_LOAD_DIR" ]; then echo "msr" | sudo tee "$MODULES_LOAD_DIR/msr.conf" >/dev/null elif [ -f "$MODULES_FILE" ]; then append_once "$MODULES_FILE" "msr" @@ -1414,7 +1488,14 @@ tune_kernel() { fi log "Configuring bootloader (GRUB) for persistent HugePages..." - if [ -f "$SCRIPT_DIR/util/proposed-grub.sh" ] && [ -f "$GRUB_DEFAULT" ]; then + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + # A deliberate skip, not the utility-not-found fallback below: on the appliance /boot is + # read-only and the kernel cmdline (incl. any 1GB-hugepage reservation) is image-owned — + # an unguarded update-grub took the whole R0 run down after a completed compile. Without + # the 1G reservation XMRig falls back to 2MB pages at 100%: working, small known cost + # (pithead#797 R0 item 2). + log "Appliance mode: skipping GRUB updates — the kernel cmdline is image-owned." + elif [ -f "$SCRIPT_DIR/util/proposed-grub.sh" ] && [ -f "$GRUB_DEFAULT" ]; then # proposed-grub.sh prints a generic "quiet splash" prefix plus the HugePage/MSR params we # manage. Keep only the params we manage and MERGE them into the existing cmdline so we don't # clobber other kernel parameters the user/distro set (#19 — boot-safety). @@ -1445,6 +1526,22 @@ configure_limits() { return fi + # Appliance mode: mount hugetlbfs at RUNTIME only. The fstab lines would land on the volatile + # /etc overlay and vanish at reboot (proven on the R0 bench) — setup re-runs each boot and just + # mounts again. The limits.conf memlock append covers interactive runs only, which are not an + # appliance concern: the unit already sets LimitMEMLOCK=infinity. + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + log "Appliance mode: mounting hugetlbfs at runtime (no fstab or limits.conf writes)..." + sudo mkdir -p "$HUGEPAGES_1G_DIR" + mountpoint -q /dev/hugepages 2>/dev/null || + sudo mount -t hugetlbfs hugetlbfs /dev/hugepages || + warn "Could not mount /dev/hugepages. Check 'dmesg' for details." + mountpoint -q "$HUGEPAGES_1G_DIR" 2>/dev/null || + sudo mount -t hugetlbfs -o pagesize=1G hugetlbfs_1g "$HUGEPAGES_1G_DIR" || + warn "Could not mount $HUGEPAGES_1G_DIR (1G pages need an image-owned cmdline reservation)." + return 0 + fi + log "Configuring persistent HugePage mounts and memory limits..." sudo mkdir -p "$HUGEPAGES_1G_DIR" @@ -1583,7 +1680,16 @@ _setup_plan() { _p "checking the build" "skip the build — XMRig $XMRIG_VERSION already built at the pinned commit" fi _p "preparing workspace" "workspace at $WORKER_ROOT (an existing prior install would be archived first)" - if [ "$OS_TYPE" = "Darwin" ]; then + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + # Same check install_dependencies runs (shared _missing_appliance_tools — no second copy). + local _mt + _mt="$(_missing_appliance_tools)" + if [ -n "$_mt" ]; then + _p "installing dependencies" "appliance mode: FAIL — required tool(s) missing from the image:$_mt (the toolchain must be baked at image build)" + else + _p "installing dependencies" "appliance mode: baked into the image — no package install" + fi + elif [ "$OS_TYPE" = "Darwin" ]; then _p "installing dependencies" "install/verify via brew: cmake libuv openssl hwloc" elif _detect_pkg_manager; then local _md @@ -1610,10 +1716,16 @@ _setup_plan() { local _msr _pages="(proposed-grub.sh missing — fallback 3072)" _grubline="GRUB: will check at run time" _reboot="" _msr="write msr to $MODULES_LOAD_DIR/msr.conf (module autoload)" [ -e "$MODULES_LOAD_DIR/msr.conf" ] && _msr="msr module already configured" + # Appliance arms: the same flag checks tune_kernel makes, previewed (pithead#797 R1). The + # GRUB probe/diff below is also gated off — the appliance skip line must stand as-is. + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + _msr="modprobe msr at runtime only (appliance: no modules-load drop-in)" + _grubline="skipping GRUB updates (appliance: the kernel cmdline is image-owned)" + fi if [ -f "$SCRIPT_DIR/util/proposed-grub.sh" ]; then _rx_setup_threads _pages=$(RX_THREADS="$RX_SETUP_THREADS" RESERVE_EXTRA_MB="${HUGEPAGES_RESERVE_EXTRA_MB:-0}" THREADS_CAP="${THREADS_CAP:-}" "$SCRIPT_DIR/util/proposed-grub.sh" --runtime 2>/dev/null) || _pages="?" - if [ -f "$GRUB_DEFAULT" ]; then + if [ "$RIGFORGE_APPLIANCE" != 1 ] && [ -f "$GRUB_DEFAULT" ]; then _grub_proposed if [ "$CURRENT" = "$MERGED" ]; then _grubline="GRUB already configured (no reboot needed for it)" @@ -1629,15 +1741,19 @@ _setup_plan() { _hpline="HugePages pool already covers the miner ($_pages pages needed) — no change" fi _p "tuning the kernel" "$_msr; $_hpline; $_grubline$_reboot" - local _f1="hugetlbfs /dev/hugepages hugetlbfs defaults 0 0" _f2="hugetlbfs_1g $HUGEPAGES_1G_DIR hugetlbfs pagesize=1G 0 0" _add="" - grep -qxF "$_f1" "$FSTAB" 2>/dev/null || _add=" '$_f1'" - grep -qxF "$_f2" "$FSTAB" 2>/dev/null || _add="$_add '$_f2'" - if [ -n "$_add" ]; then - _p "configuring limits" "append to $FSTAB:$_add; memlock unlimited for $REAL_USER in $LIMITS_CONF" + if [ "$RIGFORGE_APPLIANCE" = 1 ]; then + _p "configuring limits" "appliance mode: mount hugetlbfs at runtime — no $FSTAB or $LIMITS_CONF writes (volatile /etc)" else - _p "configuring limits" "fstab already configured; memlock unlimited for $REAL_USER in $LIMITS_CONF" + local _f1="hugetlbfs /dev/hugepages hugetlbfs defaults 0 0" _f2="hugetlbfs_1g $HUGEPAGES_1G_DIR hugetlbfs pagesize=1G 0 0" _add="" + grep -qxF "$_f1" "$FSTAB" 2>/dev/null || _add=" '$_f1'" + grep -qxF "$_f2" "$FSTAB" 2>/dev/null || _add="$_add '$_f2'" + if [ -n "$_add" ]; then + _p "configuring limits" "append to $FSTAB:$_add; memlock unlimited for $REAL_USER in $LIMITS_CONF" + else + _p "configuring limits" "fstab already configured; memlock unlimited for $REAL_USER in $LIMITS_CONF" + fi fi - _p "installing the service" "render systemd/xmrig.service.template -> $SYSTEMD_DIR/$SERVICE_NAME.service (User=${MINER_USER:-root}), daemon-reload, enable --now" + _p "installing the service" "render systemd/xmrig.service.template -> $SYSTEMD_DIR/$SERVICE_NAME.service (User=${MINER_USER:-root}), daemon-reload, enable${ENABLE_RUNTIME:+ $ENABLE_RUNTIME} --now" fi case "$AUTOTUNE_MODE" in disabled) _p "configuring autotune" "no periodic timer (autotune disabled) — an installed one would be removed" ;; @@ -3615,7 +3731,7 @@ svc_enable() { mac_enable return } - sudo systemctl enable "$SERVICE_NAME" && log "Enabled $SERVICE_NAME (starts on boot)." + sudo systemctl enable ${ENABLE_RUNTIME:+"$ENABLE_RUNTIME"} "$SERVICE_NAME" && log "Enabled $SERVICE_NAME (starts on boot)." } svc_disable() { [ "$OS_TYPE" = "Linux" ] || { diff --git a/tests/run.sh b/tests/run.sh index 6d028eb..895bb52 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -156,8 +156,10 @@ sed -e "s|\$BUILD_DIR|${BUILD_DIR:-}|g" -e "s|\$CPUPOWER_PATH|${CPUPOWER_PATH:-} -e "s|\$CONTROL_BIND|${CONTROL_BIND:-}|g" -e "s|\$CONTROL_PORT|${CONTROL_PORT:-}|g" EOF # No-op recorders / package managers. dpkg/rpm/pacman exit 0 so "is this dep installed?" is always yes. + # cc: the appliance-mode tool check (pithead#797 R1) probes `command -v cc` — stub it so black-box + # runs don't depend on whether the host has a compiler. local cmd - for cmd in make cmake systemctl modprobe mount umount mountpoint update-grub apt-get apt-cache dpkg dnf rpm pacman brew cpupower journalctl python3 nft useradd; do + for cmd in make cmake cc systemctl modprobe mount umount mountpoint update-grub apt-get apt-cache dpkg dnf rpm pacman brew cpupower journalctl python3 nft useradd; do cat >"$bin/$cmd" <> "\${CALL_LOG:-/dev/null}" @@ -5021,6 +5023,222 @@ SC="$HP/calls6" out="$(run_tk328_nopg "$SC")" assert_contains "fallback (no proposed-grub.sh) is grow-only too (#328)" "$(cat "$SC")" "vm.nr_hugepages=3072" +# --------------------------------------------------------------------------- +# Appliance mode (pithead#797 R1): RIGFORGE_APPLIANCE=1 runs setup on the Pithead appliance image — +# read-only root, volatile /etc overlay, a boot leg re-runs setup every boot. Under the flag setup +# must: never install packages (fail naming missing tools instead), skip the GRUB leg, render units +# into /run and enable them --runtime, mount hugetlbfs at runtime with no fstab/limits.conf writes — +# while runtime tuning (modprobe msr, grow-only sysctl) stays byte-identical. +echo "== black-box: appliance mode (pithead#797 R1) ==" +AP="$(mktemp -d "$SANDBOX/appliance.XXXXXX")" + +# The flag presets SYSTEMD_DIR to /run and flips enablement to --runtime; an explicit override and +# the no-flag defaults are unchanged. +out="$( (unset SYSTEMD_DIR && RIGFORGE_APPLIANCE=1 && source "$SCRIPT" && printf '%s|%s' "$SYSTEMD_DIR" "$ENABLE_RUNTIME"))" +assert_eq "flag presets /run/systemd/system + --runtime (#797)" "$out" "/run/systemd/system|--runtime" +out="$( (unset SYSTEMD_DIR && source "$SCRIPT" && printf '%s|%s' "$SYSTEMD_DIR" "$ENABLE_RUNTIME"))" +assert_eq "no flag: /etc/systemd/system + persistent enable (#797)" "$out" "/etc/systemd/system|" +out="$( (SYSTEMD_DIR="$AP/custom-sd" && RIGFORGE_APPLIANCE=1 && source "$SCRIPT" && printf '%s' "$SYSTEMD_DIR"))" +assert_eq "explicit SYSTEMD_DIR still wins under the flag (#797)" "$out" "$AP/custom-sd" + +# Dependency handling: tools verified (command -v), never installed. The toolchain is only required +# while a build is pending; a prebuilt tree needs envsubst alone (the R0 bench re-ran with a broken +# compiler). PATH is restricted to purpose-built bins so the host's real toolchain can't leak in. +mkbin_ap() { # : a dir of exit-0 fakes + local d="$1" c + shift + mkdir -p "$d" + for c in "$@"; do + printf '#!/bin/sh\nexit 0\n' >"$d/$c" + chmod +x "$d/$c" + done +} +mkbin_ap "$AP/bin-all" git cmake make cc envsubst +mkbin_ap "$AP/bin-envsubst" envsubst +run_apdeps() { # ; echoes output, exits with install_dependencies' rc + ( + RIGFORGE_APPLIANCE=1 + source "$SCRIPT" + OS_TYPE=Linux + XMRIG_REBUILD="$2" + set +e + # PATH is ONLY the purpose-built bin dir — no $STUBS (it fakes the whole toolchain, which + # would mask the missing-tool case) and no real PATH (a host compiler would too). The + # appliance branch itself needs nothing but shell builtins. + PATH="$1" CALL_LOG="$AP/deps-calls.log" install_dependencies &1 + ) +} +: >"$AP/deps-calls.log" +out="$(run_apdeps "$AP/bin-all" true)" +assert_rc "all tools baked -> deps step passes (#797)" "$?" "0" +assert_contains "deps step says baked, skipping install (#797)" "$out" "skipping package install" +assert_absent "no package manager is ever invoked (#797)" "$(cat "$AP/deps-calls.log")" "[apt-get]" +out="$(run_apdeps "$AP/bin-envsubst" true)" +assert_rc "missing toolchain while a build pends -> hard fail (#797)" "$?" "1" +assert_contains "failure names the missing tools (#797)" "$out" "missing from the image: git cmake make cc" +assert_contains "failure points at image build, not runtime install (#797)" "$out" "baked at image build" +out="$(run_apdeps "$AP/bin-envsubst" false)" +assert_rc "prebuilt tree needs no compiler — envsubst alone passes (#797)" "$?" "0" + +# tune_kernel: GRUB file present and update-grub available, yet the appliance skip branch runs — +# no cmdline edit, no backup, no update-grub, no modules-load drop-in. Runtime tuning unchanged: +# modprobe msr still runs and the grow-only sysctl still writes the shortfall. +APK="$AP/kernel" +mkdir -p "$APK/util" "$APK/home/worker" "$APK/mld" "$APK/bin" +cat >"$APK/util/proposed-grub.sh" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in +--runtime) echo 200 ;; +-q) echo "quiet splash default_hugepagesz=2M hugepages=200 msr.allow_writes=on" ;; +esac +EOF +chmod +x "$APK/util/proposed-grub.sh" +cat >"$APK/bin/sysctl" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"$SYSCTL_CALLS" +EOF +chmod +x "$APK/bin/sysctl" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"\n' >"$APK/grub" +printf 'HugePages_Free: 0\n' >"$APK/meminfo" +printf '0\n' >"$APK/nr_hugepages" +out="$( + ( + RIGFORGE_APPLIANCE=1 + source "$SCRIPT" + OS_TYPE=Linux + SCRIPT_DIR="$APK" + WORKER_ROOT="$APK/home/worker" + MODULES_LOAD_DIR="$APK/mld" # exists — the non-appliance path WOULD drop msr.conf here + GRUB_DEFAULT="$APK/grub" # exists — the non-appliance path WOULD edit it + MEMINFO="$APK/meminfo" + NR_HUGEPAGES_FILE="$APK/nr_hugepages" + export SYSCTL_CALLS="$APK/sysctl-calls.log" + set +e + PATH="$APK/bin:$STUBS:$PATH" CALL_LOG="$APK/calls.log" tune_kernel 2>&1 + ) +)" +assert_contains "GRUB leg skipped with the image-owned message (#797)" "$out" "skipping GRUB updates — the kernel cmdline is image-owned" +assert_eq "GRUB file untouched (#797)" "$(cat "$APK/grub")" 'GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"' +assert_eq "no GRUB backup written (#797)" "$([ -e "$APK/grub.bak" ] && echo present || echo absent)" "absent" +assert_absent "update-grub never runs (#797)" "$(cat "$APK/calls.log")" "[update-grub]" +assert_eq "no modules-load drop-in (#797)" "$([ -e "$APK/mld/msr.conf" ] && echo present || echo absent)" "absent" +assert_contains "modprobe msr still runs — runtime tuning unchanged (#797)" "$(cat "$APK/calls.log")" "[modprobe] msr" +assert_contains "grow-only HugePages sysctl still writes (#797/#328)" "$(cat "$APK/sysctl-calls.log")" "vm.nr_hugepages=200" + +# configure_limits: hugetlbfs mounted at runtime (both page sizes), fstab and limits.conf never +# touched. mountpoint is faked not-mounted so the mount calls are observable; the second run fakes +# already-mounted and must mount nothing (idempotent re-run, the every-boot path). +APL="$AP/limits" +mkdir -p "$APL/bin" +printf '#!/bin/sh\nexit 1\n' >"$APL/bin/mountpoint" +chmod +x "$APL/bin/mountpoint" +printf 'seeded\n' >"$APL/fstab" +printf 'seeded\n' >"$APL/limits.conf" +run_aplimits() { # + ( + RIGFORGE_APPLIANCE=1 + source "$SCRIPT" + OS_TYPE=Linux + FSTAB="$APL/fstab" + LIMITS_CONF="$APL/limits.conf" + HUGEPAGES_1G_DIR="$APL/hp1g" + set +e + PATH="$1:$STUBS:$PATH" CALL_LOG="$2" configure_limits 2>&1 + ) +} +out="$(run_aplimits "$APL/bin" "$APL/calls.log")" +assert_contains "2MB hugetlbfs mounted at runtime (#797)" "$(cat "$APL/calls.log")" "[mount] -t hugetlbfs hugetlbfs /dev/hugepages" +assert_contains "1G hugetlbfs mounted at runtime (#797)" "$(cat "$APL/calls.log")" "[mount] -t hugetlbfs -o pagesize=1G hugetlbfs_1g $APL/hp1g" +assert_eq "fstab untouched (#797)" "$(cat "$APL/fstab")" "seeded" +assert_eq "limits.conf untouched (#797)" "$(cat "$APL/limits.conf")" "seeded" +out="$(run_aplimits "$STUBS" "$APL/calls2.log")" # stub mountpoint exits 0 = already mounted +assert_absent "already mounted -> no mount calls (#797)" "$(cat "$APL/calls2.log")" "[mount]" + +# install_service: unit rendered into the (appliance-preset) systemd dir, enabled with --runtime. +APS="$AP/svc" +mkdir -p "$APS/run-systemd" "$APS/xmrig/build" +( + cd "$APS" || exit 1 + RIGFORGE_APPLIANCE=1 + source "$SCRIPT" + OS_TYPE=Linux + SCRIPT_DIR="$ROOT" # real systemd/xmrig.service.template + WORKER_ROOT="$APS" + SYSTEMD_DIR="$APS/run-systemd" + REBOOT_REQUIRED=false + XMRIG_REBUILD=true + set +e + PATH="$STUBS:$PATH" CALL_LOG="$APS/calls.log" install_service >/dev/null 2>&1 +) +assert_eq "unit rendered into the runtime systemd dir (#797)" "$([ -f "$APS/run-systemd/xmrig.service" ] && echo yes || echo no)" "yes" +assert_contains "unit enabled with --runtime (#797)" "$(cat "$APS/calls.log")" "[systemctl] enable --runtime xmrig.service" + +# setup --dry-run previews the SAME appliance decisions (shared logic, #146): baked deps, GRUB skip, +# runtime-only msr and mounts, --runtime enablement — and still covers every main() step. +APDR="$AP/dryrun" +mkdir -p "$APDR/etc" "$APDR/util" +cp "$APK/util/proposed-grub.sh" "$APDR/util/proposed-grub.sh" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"\n' >"$APDR/etc/grub" +printf 'HugePages_Free: 0\n' >"$APDR/etc/meminfo" +cat >"$APDR/config.json" <&1)" +assert_rc "appliance dry-run exits 0 (#797/#146)" "$?" "0" +assert_contains "plan: baked-deps arm (#797)" "$apdr_out" "appliance mode: baked into the image — no package install" +assert_contains "plan: GRUB skip arm (#797)" "$apdr_out" "skipping GRUB updates (appliance: the kernel cmdline is image-owned)" +assert_contains "plan: runtime-only msr arm (#797)" "$apdr_out" "modprobe msr at runtime only (appliance: no modules-load drop-in)" +assert_contains "plan: runtime mounts, no fstab/limits writes (#797)" "$apdr_out" "appliance mode: mount hugetlbfs at runtime" +assert_contains "plan: unit goes to /run with --runtime (#797)" "$apdr_out" "/run/systemd/system/xmrig.service" +assert_contains "plan: enable --runtime wording (#797)" "$apdr_out" "enable --runtime --now" +assert_contains "plan: grow-only preview still renders (#797/#328)" "$apdr_out" "grow the pool so 200 2MB HugePages are available" +for mut in apt-get modprobe tee mount sysctl update-grub; do + assert_absent "appliance dry-run never invokes $mut (#797/#146)" "$(cat "$APDR/calls.log" 2>/dev/null)" "[$mut]" +done +while IFS= read -r step; do + assert_contains "appliance plan covers main() step '$step' (#797/#146)" "$apdr_out" "$step" +done <<<"$main_steps" + +# Full black-box setup with the flag, host-native OS path: proves the flag survives main() wiring +# end to end. Portable asserts here; the Linux-only /etc assertions run on Linux hosts and in the +# Linux CI job (the macOS path skips kernel/limits/service by OS, not by flag). +APW="$(e2e_setup)" +RIGFORGE_APPLIANCE=1 e2e_run "$APW" "$HOST_OS" +rc=$? +assert_rc "appliance full run exits 0 (#797)" "$rc" "0" +assert_absent "appliance full run: no apt-get (#797)" "$(cat "$APW/calls.log")" "[apt-get]" +assert_absent "appliance full run: no brew install (#797)" "$(cat "$APW/calls.log")" "[brew] install" +assert_contains "appliance full run: says deps are baked (#797)" "$E2E_OUT" "dependencies are baked into the image" +if [ "$HOST_OS" = Linux ]; then + assert_contains "appliance full run: GRUB skip taken (#797)" "$E2E_OUT" "the kernel cmdline is image-owned" + assert_contains "appliance full run: GRUB file untouched (#797)" "$(cat "$APW/etc/default/grub")" 'GRUB_CMDLINE_LINUX_DEFAULT="quiet splash memmap=4G&2M"' + assert_absent "appliance full run: no fstab hugetlbfs lines (#797)" "$(cat "$APW/etc/fstab")" "hugetlbfs" + assert_absent "appliance full run: no memlock append (#797)" "$(cat "$APW/etc/security/limits.conf")" "memlock" + assert_eq "appliance full run: no msr.conf drop-in (#797)" "$([ -e "$APW/etc/modules-load.d/msr.conf" ] && echo present || echo absent)" "absent" + assert_contains "appliance full run: unit enabled --runtime (#797)" "$(cat "$APW/calls.log")" "[systemctl] enable --runtime xmrig.service" + # /etc/logrotate.d is volatile on the appliance and the image runs no logrotate — the drop-in + # must not be written (log policy is the integration layer's, pithead#797 R2). + assert_eq "appliance full run: no logrotate drop-in (#797)" "$([ -e "$APW/etc/logrotate.d/xmrig" ] && echo present || echo absent)" "absent" +fi +# check_prerequisites under the flag: a missing jq is a hard, actionable failure — never an install +# (the non-appliance path would apt/brew it; PATH without jq simulates an image that forgot to bake it). +apjq_out="$( ( + source "$SCRIPT" + RIGFORGE_APPLIANCE=1 + OS_TYPE=Linux + set +e + # Sourcing ran jq, so bash hashed its real path — clear the table or `command -v jq` + # ignores the emptied PATH and the missing-tool branch never fires. + hash -r + PATH="/nonexistent" check_prerequisites 2>&1 +))" +apjq_rc=$? +assert_rc "appliance + missing jq fails hard (#797)" "$apjq_rc" "1" +assert_contains "appliance + missing jq names the fix (#797)" "$apjq_out" "bake jq into the image" +assert_absent "appliance + missing jq never installs (#797)" "$apjq_out" "Installing prerequisite" + # tune with no built worker fails clearly. TN2="$(mktemp -d "$SANDBOX/tune2.XXXXXX")" cp "$ROOT/VERSION" "$TN2/" From 256bc71be8446f87d6121a4bf3c4237269e01b53 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:47:19 -0500 Subject: [PATCH 04/32] test(appliance): every systemctl enable must be --runtime (#797) (#331) The appliance full-run asserted --runtime for xmrig.service only; the other enable sites (timers, api, control) share the same ${ENABLE_RUNTIME:+...} mechanism, but a future call site that forgets the expansion would persist its enable into the volatile /etc overlay and silently vanish on reboot. One guard closes the class: no [systemctl] enable line in the appliance run's call log may lack --runtime. Co-authored-by: Claude Fable 5 --- tests/run.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/run.sh b/tests/run.sh index 895bb52..7ee41f6 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -5218,6 +5218,12 @@ if [ "$HOST_OS" = Linux ]; then assert_absent "appliance full run: no memlock append (#797)" "$(cat "$APW/etc/security/limits.conf")" "memlock" assert_eq "appliance full run: no msr.conf drop-in (#797)" "$([ -e "$APW/etc/modules-load.d/msr.conf" ] && echo present || echo absent)" "absent" assert_contains "appliance full run: unit enabled --runtime (#797)" "$(cat "$APW/calls.log")" "[systemctl] enable --runtime xmrig.service" + # Every enable under the flag must be --runtime — a persisted enable writes the volatile + # /etc overlay and silently vanishes on reboot. The xmrig assert above pins one site; this + # guards the other enable sites (timers, api, control) against a future call that forgets + # its ${ENABLE_RUNTIME:+...} expansion. + assert_eq "appliance full run: every systemctl enable is --runtime (#797)" \ + "$(grep -F "[systemctl] enable" "$APW/calls.log" | grep -cv -- --runtime)" "0" # /etc/logrotate.d is volatile on the appliance and the image runs no logrotate — the drop-in # must not be written (log policy is the integration layer's, pithead#797 R2). assert_eq "appliance full run: no logrotate drop-in (#797)" "$([ -e "$APW/etc/logrotate.d/xmrig" ] && echo present || echo absent)" "absent" From 98ef9acf9ca60a96045026c3190f4d9bf72d718c Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 13:35:28 -0500 Subject: [PATCH 05/32] release: v1.13.0 --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21de146..b7ce23f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +## [1.13.0] - 2026-08-01 + +The pithead#797 appliance release: an opt-in setup mode for the read-only appliance image — +baked deps, runtime units, image-owned kernel cmdline — plus a grow-only runtime HugePages +reservation so a co-resident stack's pool is never shrunk out from under it. + ### Added - **Appliance mode: `RIGFORGE_APPLIANCE=1` (pithead#797 R1).** One opt-in env flag for running diff --git a/VERSION b/VERSION index 0eed1a2..feaae22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 +1.13.0 From 6d2689856168f11b5b7ed0c6eb333e52f2b2a622 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 14:27:23 -0500 Subject: [PATCH 06/32] chore(perf): record miner-0 baseline for v1.13.0 Co-Authored-By: Claude Fable 5 --- tests/perf-baselines/miner-0.history.jsonl | 1 + tests/perf-baselines/miner-0.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/perf-baselines/miner-0.history.jsonl b/tests/perf-baselines/miner-0.history.jsonl index 1146e6d..e7de36b 100644 --- a/tests/perf-baselines/miner-0.history.jsonl +++ b/tests/perf-baselines/miner-0.history.jsonl @@ -7,3 +7,4 @@ {"tag":"v1.9.0","recorded":"2026-07-17","bench_1m_hs":10705.8} {"tag":"v1.10.0","recorded":"2026-07-17","bench_1m_hs":10736.5} {"tag":"v1.11.1","recorded":"2026-07-18","bench_1m_hs":10743.9} +{"tag":"v1.13.0","recorded":"2026-08-01","bench_1m_hs":10733.4} diff --git a/tests/perf-baselines/miner-0.json b/tests/perf-baselines/miner-0.json index 0ae7254..8873ca1 100644 --- a/tests/perf-baselines/miner-0.json +++ b/tests/perf-baselines/miner-0.json @@ -1,5 +1,5 @@ { - "bench_1m_hs": 10743.9, + "bench_1m_hs": 10733.4, "cpu": "AMD Ryzen 7 7800X3D 8-Core Processor", - "recorded": "2026-07-18" + "recorded": "2026-08-01" } From 37acb865a96527bd8ae1674a14fb279f1c6e5faf Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 15:35:12 -0500 Subject: [PATCH 07/32] fix(doctor): detect kernel lockdown directly instead of inferring it from MSR failure (#333) (#334) * fix(doctor): detect kernel lockdown directly instead of inferring it from MSR failure (#333) Co-Authored-By: Claude Fable 5 * fix(docs): rewrap line so markdownlint doesn't parse '+ Curve Optimizer' as a list (#333) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 20 ++++++++++ docs/operations.md | 30 ++++++++------ rigforge.sh | 97 ++++++++++++++++++++++++++++++++++++++++++++-- tests/run.sh | 91 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ce23f..6f60de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +### Fixed + +- **`doctor` detects kernel lockdown directly instead of inferring it from MSR failure (#333).** + When kernel lockdown is active (distro kernels — Ubuntu, RHEL, Debian — enable it automatically + under UEFI Secure Boot), every `/dev/cpu/*/msr` write is denied and the MSR prefetcher mod + silently can't apply, costing ~5–15% RandomX. `doctor` only reacted after something downstream had + already failed, and its remedy for the most common cause was wrong: `msr.allow_writes=on` is dead + code under lockdown, because the kernel checks `security_locked_down(LOCKDOWN_MSR)` and returns + before the `allow_writes` filter is ever consulted (`arch/x86/kernel/msr.c`). `doctor` now reads + the actual state from `/sys/kernel/security/lockdown` *before* the MSR probes — cause before + symptom — and flags active lockdown (`integrity` and `confidentiality` both block the write) as a + counted issue naming the level, the cost, and the real fix (disable Secure Boot, with the + board-specific menu path). Reading beats inferring from `mokutil`: Secure-Boot-implies-lockdown is + a distro patch, not upstream behaviour. The three messages that guessed between causes now name + the one that applies, the missing-`msr`-module hint no longer blames Secure Boot (the in-tree + signed module loads fine under it — that's a modprobe/packaging problem), and `secure_boot` joins + the guided `bios` checklist (#80), leading it, verified against the same securityfs probe so + `doctor` and `bios` can't disagree. Unreadable securityfs stays advisory — unknown, never a + manufactured issue. + ## [1.13.0] - 2026-08-01 The pithead#797 appliance release: an opt-in setup mode for the read-only appliance image — diff --git a/docs/operations.md b/docs/operations.md index 85deb8a..d02978d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -56,10 +56,10 @@ Not every verb needs root — the design, in four lines: | `upgrade` | Rebuild and restart only if the pinned XMRig version/commit changed. A no-op when you're already on the pinned build. If periodic autotune is enabled, it also re-tunes the new build (the fastest knobs can shift between versions). `--check` just reports whether a newer RigForge release exists (on-demand GitHub query, always exits 0). | | `apply` | Re-read `config.json`, regenerate the live XMRig config, and restart, without recompiling. The fast path after editing `config.json`. On Linux it also reconciles the periodic-autotune timer with config (so changing the `autotune` target takes effect) and reports it (efficiency / performance / disabled). | | `uninstall` | Remove the service and revert all system changes (fstab, limits, modules, GRUB) and the worker build/logs. Leaves `config.json`. Prompts first; add `--yes` to skip. | -| `doctor` | Read-only health check (run with `sudo` for the deepest checks). Critical findings (counted as issues): the service is active, HugePages are reserved, the `msr` module is loaded, and the MSR mod actually applied, confirmed from XMRig's log and, as root, an `rdmsr` register read-back (see [MSR mod verification](#msr-mod-verification)). Advisory findings (hints, not failures): CPU governor, 1 GB HugePages, HugePages 100%-backed (from the XMRig log), hashrate-capping hardware RigForge can't fix but you can (single-channel or slow RAM via `dmidecode`, and a power/boost-capped CPU clock), and BIOS/firmware recommendations (board/BIOS context, plus enable XMP/EXPO/DOCP or SMT when they're off; manual BIOS changes RigForge can't make from the OS). Prints an actionable hint for anything off. Also binary tamper evidence (#141): the on-disk `xmrig` is compared against the SHA-256 recorded at compile time — a deliberate rebuild refreshes the record, anything else warns and counts as an issue. When the [writable control path](#writable-control-path-opt-in) is enabled, it's also a counted issue: `doctor` checks `rigforge-control` is active and its `/status` endpoint answers (#278), staying silent when control is disabled. Exits non-zero when critical issues are found (cron-friendly, matching Pithead's `status`). | +| `doctor` | Read-only health check (run with `sudo` for the deepest checks). Critical findings (counted as issues): the service is active, HugePages are reserved, kernel lockdown isn't blocking MSR writes (read from `/sys/kernel/security/lockdown` — active lockdown means the MSR mod cannot apply, and `msr.allow_writes=on` can't override it), the `msr` module is loaded, and the MSR mod actually applied, confirmed from XMRig's log and, as root, an `rdmsr` register read-back (see [MSR mod verification](#msr-mod-verification)). Advisory findings (hints, not failures): CPU governor, 1 GB HugePages, HugePages 100%-backed (from the XMRig log), hashrate-capping hardware RigForge can't fix but you can (single-channel or slow RAM via `dmidecode`, and a power/boost-capped CPU clock), and BIOS/firmware recommendations (board/BIOS context, plus enable XMP/EXPO/DOCP or SMT when they're off; manual BIOS changes RigForge can't make from the OS). Prints an actionable hint for anything off. Also binary tamper evidence (#141): the on-disk `xmrig` is compared against the SHA-256 recorded at compile time — a deliberate rebuild refreshes the record, anything else warns and counts as an issue. When the [writable control path](#writable-control-path-opt-in) is enabled, it's also a counted issue: `doctor` checks `rigforge-control` is active and its `/status` endpoint answers (#278), staying silent when control is disabled. Exits non-zero when critical issues are found (cron-friendly, matching Pithead's `status`). | | `bench` | Run a one-off `xmrig --bench` and report the hashrate (a quick perf/health check; set `BENCH=10M` for a longer run). | | `tune` | The single command for tuning. A bare `tune` measures the fastest CPU-specific knobs (prefetch, `cpu.yield`, thread count) offline and keeps them, an optional, one-time step. Live variants: `--now` / `--short` (a quick prefetch re-tune against the running miner, the *run a live tune now* path), `--now --long` (a full live search of every knob, = `--live`), `--confirm` (A/B-check the winner live). Plus `--efficiency` / `--perf`, `--history`, `--clear`. See [Tuning](#tuning). | -| `bios` | Guided, resumable walk-through of the BIOS/UEFI changes for your hardware — the settings `tune` can't reach from the OS (memory profile XMP/EXPO/DOCP, SMT, PBO/Eco-Mode, NUMA-per-socket NPS on EPYC; `--efficiency` picks the low-power set). Detects the current firmware state via the same probes `doctor` uses, hands you a board-specific checklist one item at a time, saves the pending items, and on the next run re-verifies which changes actually took. RigForge never writes BIOS itself; plan for console access (keyboard/KVM) for the reboot-into-BIOS step. Linux-only. See [Guided BIOS tuning](#guided-bios-tuning). | +| `bios` | Guided, resumable walk-through of the BIOS/UEFI changes for your hardware — the settings `tune` can't reach from the OS (Secure Boot when kernel lockdown is blocking the MSR mod, memory profile XMP/EXPO/DOCP, SMT, PBO/Eco-Mode, NUMA-per-socket NPS on EPYC; `--efficiency` picks the low-power set). Detects the current firmware state via the same probes `doctor` uses, hands you a board-specific checklist one item at a time, saves the pending items, and on the next run re-verifies which changes actually took. RigForge never writes BIOS itself; plan for console access (keyboard/KVM) for the reboot-into-BIOS step. Linux-only. See [Guided BIOS tuning](#guided-bios-tuning). | | `autotune` | The scheduled live tuner. You normally don't type it; `tune --now` is the friendlier spelling for an on-demand run, and the periodic schedule is what this verb is really for: set `"autotune": "performance"` (raw H/s) or `"autotune": "efficiency"` (hashrate-per-watt) in `config.json` and setup installs a systemd timer (also re-tuned on `upgrade`). Conservative: it keeps a change only if it beats the baseline by a margin, else rolls back. Linux-only. See [Live auto-tuning](#live-auto-tuning-opt-in). | | `watchdog` | One health check per run — the scheduled recovery verb, like `autotune`. You normally don't type it; set `"watchdog": "enabled"` in `config.json` and setup installs a timer that runs it every `watchdog_interval_min` minutes: two consecutive checks seeing 0 H/s or a dead API restart the wedged miner, and with `max_temp_c` set it stops the miner above that temperature (starting it again 5 °C below). Linux-only. See [Watchdog](#watchdog-opt-in). | | `backup` | Snapshot `config.json` + the tuning files into a timestamped `tar.gz` under `./backups`. See [Backup & restore](#backup--restore). | @@ -136,13 +136,17 @@ power/efficiency and reservation-aware details are all in ### Guided BIOS tuning `sudo ./rigforge.sh bios` walks the detect → guide → reboot → re-verify loop for the firmware -settings with the biggest RandomX impact: the memory profile (XMP/EXPO/DOCP), SMT, the CPU -power/boost posture (`--efficiency` swaps the boost item for Eco-Mode + Curve Optimizer), and — on -EPYC — NUMA nodes per socket (NPS4, so RandomX gets quadrant-local memory). It reads +settings with the biggest RandomX impact: Secure Boot when kernel lockdown is blocking the MSR mod +(it leads the checklist — the cheapest change, and it gates the ~5–15% MSR mod outright; whether to +make it is the operator's call, `bios` just makes the cost visible), the memory profile +(XMP/EXPO/DOCP), SMT, the CPU power/boost posture (`--efficiency` swaps the boost item for +Eco-Mode + Curve Optimizer), and — on EPYC — NUMA nodes per socket (NPS4, so RandomX gets +quadrant-local memory). It reads the same probes `doctor` reports on, so the two never disagree; pending items are saved to `rigforge-bios.json` (included in `backup`/`restore`) and the next `bios` run re-checks exactly those items against fresh probes — an item only counts as applied when its OS-visible fingerprint -flips (memory running at rated speed, SMT on, loaded clock above the boost threshold). The CPU +flips (lockdown reading `none`, memory running at rated speed, SMT on, loaded clock above the boost +threshold). The CPU boost item needs the miner running to measure; with it stopped, `bios` says so and keeps the item pending rather than guessing. After everything took, re-run `tune --live` — the hardware envelope changed. @@ -196,9 +200,13 @@ one-time sweep of every knob, run the offline [`tune`](#tuning) above. Linux onl The MSR "RandomX boost" (writing the CPU's prefetcher MSRs) is one of the biggest levers, worth ~10–15%, so `doctor` verifies it actually took effect, not just that the `msr` module loaded: +- Kernel lockdown first: before any MSR probe, `doctor` reads `/sys/kernel/security/lockdown` — active + lockdown (`integrity` or `confidentiality`, which distro kernels enable automatically under UEFI + Secure Boot) denies every `/dev/cpu/*/msr` write before the `msr.allow_writes` filter is even + consulted, so it is flagged as the cause rather than letting the probes below fail mysteriously. - From XMRig's log (always): the `msr register values for "" preset have been set successfully` - line confirms XMRig wrote the per-family preset (e.g. `ryzen_19h_zen4`). A `FAILED` line is flagged, - usually Secure Boot or a missing `msr.allow_writes=on`. + line confirms XMRig wrote the per-family preset (e.g. `ryzen_19h_zen4`). A `FAILED` line is flagged — + attributed to lockdown when lockdown is active, otherwise to `msr.allow_writes=on` or a hypervisor. - Register read-back via `rdmsr` (run `doctor` as root, with `msr-tools` installed; `setup` installs it): `doctor` reads the prefetcher registers back and checks they hold the preset's values, catching a write a hypervisor or kernel lockdown silently dropped even though XMRig reported success. Run without root, @@ -501,9 +509,9 @@ If you see MSR errors, see Troubleshooting below. | Symptom | Likely cause & fix | |---|---| | Setup fails during the build | The script names the step that failed and tails the build log. Read the full error in `/build.log` (e.g. `data/worker/build.log`). Common causes: a build dependency you declined to install (re-run and accept), or too little RAM during compilation (the build already caps parallelism by RAM; add swap on very low-memory hosts). Re-run `sudo ./rigforge.sh` once resolved; it resumes without redoing finished work. | -| MSR errors in the log | Secure Boot is blocking the `msr` kernel module. Disable Secure Boot in your BIOS/UEFI, then reboot. | -| `doctor`: "MSR registers don't match the preset" | XMRig's log says the write succeeded but the read-back disagrees: the kernel or hypervisor silently dropped it. Common on VMs/cloud instances and under kernel lockdown. Run RigForge on bare metal, and ensure `msr.allow_writes=on` (RigForge sets this) and that lockdown isn't enforced. | -| `doctor`: "couldn't read the MSRs via rdmsr" | The `msr` module isn't loaded (or `doctor` wasn't run as root). Run `sudo ./rigforge.sh doctor`; if it persists, `sudo modprobe msr` (Secure Boot can block it). This is advisory; XMRig's log already confirms the write. | +| MSR errors in the log | Kernel lockdown is denying the MSR writes (distro kernels enable lockdown automatically under UEFI Secure Boot). `msr.allow_writes=on` cannot override it — the kernel checks lockdown before that filter. `doctor` reads `/sys/kernel/security/lockdown` and says which level is active; the fix is disabling Secure Boot in BIOS/UEFI (`bios` gives the board-specific path), then rebooting. | +| `doctor`: "MSR registers don't match the preset" | XMRig's log says the write succeeded but the read-back disagrees: something silently dropped it. When kernel lockdown is active, `doctor` names it as the cause (see the lockdown item it prints first); otherwise a hypervisor is the usual culprit — common on VMs/cloud instances, run RigForge on bare metal. `msr.allow_writes=on` (RigForge sets this) only matters when lockdown is *not* enforced. | +| `doctor`: "couldn't read the MSRs via rdmsr" | The `msr` module isn't loaded (or `doctor` wasn't run as root). Run `sudo ./rigforge.sh doctor`; if it persists, `sudo modprobe msr` and check `/etc/modules-load.d/msr.conf` — a missing module is a modprobe/packaging problem, not Secure Boot (the in-tree signed module loads fine under it). This is advisory; XMRig's log already confirms the write. | | `HugePages_Total` is 0 | The kernel tuning needs a reboot to take effect (GRUB change). Reboot, then re-check `grep Huge /proc/meminfo`. | | HugePages still 0 after reboot | Not enough contiguous memory was reservable, or another tool changed GRUB. Re-run `sudo ./rigforge.sh`; RigForge merges its kernel parameters into `GRUB_CMDLINE_LINUX_DEFAULT` rather than overwriting, so other params are preserved. | | Low hashrate / few threads | RandomX is L3-bound (~2 MB per thread). A CPU with little L3 runs fewer effective threads; this is expected. See [Hardware › L3 cache](hardware.md#a-note-on-l3-cache). | diff --git a/rigforge.sh b/rigforge.sh index 3d7c2b1..f6fa9e0 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -146,6 +146,10 @@ MIN_CLOCK_PCT="${MIN_CLOCK_PCT:-75}" # warn when the loaded clock is below this # BIOS/firmware advisory (#78): board/BIOS identity + SMT state (both world-readable from sysfs). DMI_DIR="${DMI_DIR:-/sys/class/dmi/id}" SMT_CONTROL="${SMT_CONTROL:-/sys/devices/system/cpu/smt/control}" +# Kernel lockdown state (#333): securityfs exposes the active level, world-readable. Distro kernels +# (Ubuntu >= 20.04, RHEL, Debian) turn lockdown on automatically under UEFI Secure Boot, and lockdown +# blocks every /dev/cpu/*/msr write — so the MSR mod silently can't apply. Read, never inferred. +LOCKDOWN_FILE="${LOCKDOWN_FILE:-/sys/kernel/security/lockdown}" # systemd service name for the worker. SERVICE_NAME="${SERVICE_NAME:-xmrig}" @@ -4370,6 +4374,24 @@ _cpu_eff_khz() { if [ "$n" -gt 0 ]; then echo $((sum / n)); fi # always exit 0 (empty output when no data) } +# Kernel lockdown level (#333). securityfs prints every level with the ACTIVE one in square brackets, +# e.g. "none [integrity] confidentiality" (security/lockdown/lockdown.c:lockdown_read). Echoes just the +# bracketed level; empty when the file is absent or unreadable (lockdown LSM not built in, securityfs +# not mounted, or a non-root reader) — "unknown", never "off". Always exits 0: an unreadable probe is +# missing information, not a failure, and must not trip the ERR trap. +_lockdown_state() { # -> none|integrity|confidentiality, or empty when unknown + [ -r "$LOCKDOWN_FILE" ] || return 0 + sed -nE 's/.*\[([a-z]+)\].*/\1/p' "$LOCKDOWN_FILE" 2>/dev/null | head -1 + return 0 +} + +# True when lockdown is at a level that blocks MSR writes (#333). LOCKDOWN_MSR sits below +# LOCKDOWN_INTEGRITY_MAX in enum lockdown_reason (include/linux/security.h), so both `integrity` and +# `confidentiality` deny the write; only `none` permits it. +_lockdown_blocks_msr() { # -> 0 when MSR writes are denied + case "$1" in integrity | confidentiality) return 0 ;; *) return 1 ;; esac +} + # #66 MSR-verification helpers. doctor confirms the prefetcher MSR mod actually took effect — not just # that the `msr` module loaded — in two layers: XMRig's own log line (always available) and an rdmsr # read-back (when msr-tools is installed), which catches a write a hypervisor / kernel-lockdown silently @@ -4842,10 +4864,29 @@ doctor() { # MSR mod applied? (#66) The ~10-15% RandomX gain needs three things, checked in order: the msr # module loadable, XMRig's own log line confirming it WROTE the prefetcher preset, and — when rdmsr # (msr-tools) is present — a register read-back that catches a write a hypervisor/lockdown dropped. + # Kernel lockdown (#333) — checked BEFORE the MSR probes below, because it is the CAUSE and they are + # the symptom. Under lockdown, msr_write()/msr_ioctl() call security_locked_down(LOCKDOWN_MSR) and + # return -EPERM *before* consulting the allow_writes filter (arch/x86/kernel/msr.c), so the + # msr.allow_writes=on we put on the cmdline cannot override it. Reading the state beats inferring it + # from Secure Boot: whether Secure Boot implies lockdown is a distro patch, not upstream behaviour. + local ld + ld=$(_lockdown_state) + if [ -z "$ld" ]; then + _ck_info "kernel lockdown state unknown ($LOCKDOWN_FILE unreadable) — run 'doctor' as root, or the lockdown LSM isn't built in" + elif _lockdown_blocks_msr "$ld"; then + _ck_warn "kernel lockdown is ACTIVE ('$ld') — it denies every /dev/cpu/*/msr write, so the MSR mod cannot apply (worth ~5-15% RandomX). msr.allow_writes=on does NOT override it: disable Secure Boot in BIOS ($(_bios_menu "$(_dmi board_vendor)" secure_boot "${TUNE_TARGET:-perf}")), then re-run 'sudo $0 doctor'." + issues=$((issues + 1)) + else + _ck_ok "kernel lockdown: none — MSR writes are permitted" + fi + if [ -d "$MSR_MODULE_DIR" ]; then _ck_ok "msr kernel module loaded" else - _ck_warn "msr module not loaded — the MSR mod won't apply; if it persists, disable Secure Boot" + # Secure Boot does NOT stop the in-tree, distro-signed `msr` module from loading, so the old + # "disable Secure Boot" hint pointed the wrong way here (#333). A missing module is a modprobe / + # packaging problem; lockdown is reported separately above. + _ck_warn "msr module not loaded — the MSR mod won't apply; check 'sudo modprobe msr' and $MODULES_LOAD_DIR/msr.conf" issues=$((issues + 1)) fi if [ -n "$log_file" ] && [ -f "$log_file" ]; then @@ -4870,7 +4911,13 @@ EOF _ck_ok "MSR registers verified via rdmsr ($_MSR_OK/$_MSR_TOTAL match the $preset preset)" elif [ -n "$_MSR_BAD" ]; then # A genuine value mismatch — the write didn't take. This is the real failure (#66). - _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — a hypervisor/lockdown may have dropped the write, or XMRig changed its preset" + # #333: when lockdown is active we already know which of the three causes it was — + # say so instead of listing all of them. + if _lockdown_blocks_msr "$ld"; then + _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — kernel lockdown ('$ld') dropped the write; see the lockdown item above" + else + _ck_warn "MSR registers don't match the $preset preset ($_MSR_OK/$_MSR_TOTAL ok;${_MSR_BAD}) — a hypervisor may have dropped the write, or XMRig changed its preset" + fi issues=$((issues + 1)) else # No mismatch, but some/all registers were unreadable (e.g. msr module not loaded). Advisory. @@ -4879,7 +4926,13 @@ EOF fi ;; fail) - _ck_warn "XMRig reports the MSR preset FAILED to set — check Secure Boot / msr.allow_writes=on" + # #333: msr.allow_writes=on is only the answer when lockdown is NOT the blocker — under + # lockdown the kernel rejects the write before the filter ever runs. + if _lockdown_blocks_msr "$ld"; then + _ck_warn "XMRig reports the MSR preset FAILED to set — kernel lockdown ('$ld') is denying the write; see the lockdown item above" + else + _ck_warn "XMRig reports the MSR preset FAILED to set — check msr.allow_writes=on and that no hypervisor is masking MSR access" + fi issues=$((issues + 1)) ;; *) : ;; # no msr line yet (the miner may not have started a RandomX job) — stay quiet @@ -5021,6 +5074,21 @@ _bios_detect() { local mem pop nch spd rated smt effk maxk pct cpu_m B_MEM_STATUS=unknown B_MEM_BEFORE="" B_SMT_STATUS=unknown B_SMT_BEFORE="" B_BOOST_STATUS=unknown B_BOOST_BEFORE="" B_NPS_STATUS=unknown B_NPS_BEFORE="" + B_SB_STATUS=unknown B_SB_BEFORE="" + # Secure Boot / lockdown (#333). Same probe doctor uses (the #80 rule: they can never disagree). + # `unknown` when securityfs isn't readable — never guessed from Secure Boot state, which only + # implies lockdown on distro kernels, not upstream. + local ld_state + ld_state=$(_lockdown_state) + if [ -n "$ld_state" ]; then + if _lockdown_blocks_msr "$ld_state"; then + B_SB_STATUS=pending + B_SB_BEFORE="lockdown=$ld_state (MSR writes denied)" + else + B_SB_STATUS=ok + B_SB_BEFORE="lockdown=none" + fi + fi cpu_m=$(lscpu 2>/dev/null | awk -F: '/^Model name:/ {gsub(/^[ \t]+/, "", $2); print $2; exit}' || true) case "$cpu_m" in *EPYC*) @@ -5082,6 +5150,13 @@ _bios_menu() { # -> menu-path line(s) on stdou memory_profile:msi) echo "OC ▸ A-XMP / EXPO ▸ Profile 1" ;; memory_profile:*) echo "look for the memory profile setting (XMP / EXPO / DOCP) and enable profile 1" ;; smt:*) echo "Advanced ▸ CPU Configuration ▸ SMT / Hyper-Threading ▸ Enabled" ;; + # #333: Secure Boot itself is the lever — distro kernels enable lockdown from it, and lockdown is + # what denies the MSR writes. On most boards the toggle only appears once the OS type is "Other OS". + secure_boot:asus) echo "Boot ▸ Secure Boot ▸ OS Type ▸ Other OS (then Secure Boot state reads 'Disabled')" ;; + secure_boot:asrock) echo "Security ▸ Secure Boot ▸ Disabled" ;; + secure_boot:gigabyte) echo "Boot ▸ Secure Boot ▸ Secure Boot Enable ▸ Disabled" ;; + secure_boot:msi) echo "Settings ▸ Advanced ▸ Windows OS Configuration ▸ Secure Boot ▸ Disabled" ;; + secure_boot:*) echo "find the Secure Boot setting (usually under Boot or Security) and disable it" ;; numa_nps:*) echo "Advanced ▸ AMD CBS ▸ DF Common Options ▸ Memory Addressing ▸ NUMA nodes per socket ▸ NPS4" ;; power_boost:*) if [ "$3" = efficiency ]; then @@ -5102,6 +5177,7 @@ _bios_state_write() { # ; reads the B_* globals + TUNE_TARGET if [ "$B_SMT_STATUS" = pending ]; then items=$(jq -c --argjson a "$items" --arg b "$B_SMT_BEFORE" --arg m "$(_bios_menu "$vendor" smt "$TUNE_TARGET")" -n '$a + [{id: "smt", status: "pending", before: $b, menu: $m}]'); fi if [ "$B_BOOST_STATUS" = pending ]; then items=$(jq -c --argjson a "$items" --arg b "$B_BOOST_BEFORE" --arg m "$(_bios_menu "$vendor" power_boost "$TUNE_TARGET")" -n '$a + [{id: "power_boost", status: "pending", before: $b, menu: $m}]'); fi if [ "$B_NPS_STATUS" = pending ]; then items=$(jq -c --argjson a "$items" --arg b "$B_NPS_BEFORE" --arg m "$(_bios_menu "$vendor" numa_nps "$TUNE_TARGET")" -n '$a + [{id: "numa_nps", status: "pending", before: $b, menu: $m}]'); fi + if [ "$B_SB_STATUS" = pending ]; then items=$(jq -c --argjson a "$items" --arg b "$B_SB_BEFORE" --arg m "$(_bios_menu "$vendor" secure_boot "$TUNE_TARGET")" -n '$a + [{id: "secure_boot", status: "pending", before: $b, menu: $m}]'); fi jq -n --arg t "$TUNE_TARGET" --arg when "$(date '+%Y-%m-%d %H:%M')" --argjson items "$items" '{target: $t, saved: $when, items: $items}' >"$f" log "Saved $(jq -r '.items | length' "$f") pending item(s) to $f." } @@ -5112,6 +5188,7 @@ _bios_item_label() { # -> human label smt) printf 'SMT / Hyper-Threading' ;; power_boost) printf 'CPU boost / power' ;; numa_nps) printf 'NUMA per socket (NPS)' ;; + secure_boot) printf 'Secure Boot (kernel lockdown)' ;; esac } @@ -5134,12 +5211,17 @@ _bios_guide() { # if [ "$B_BOOST_STATUS" = unknown ]; then _ck_info "CPU boost not checked — the miner isn't running (start it and re-run bios to include the power/boost item)"; fi if [ "$B_NPS_STATUS" = ok ]; then _ck_ok "NUMA per socket (NPS): $B_NPS_BEFORE"; fi if [ "$B_NPS_STATUS" = pending ]; then _ck_warn "NUMA per socket (NPS): $B_NPS_BEFORE — set NUMA nodes per socket to NPS4 in BIOS so RandomX gets quadrant-local memory."; fi - for id in memory_profile smt power_boost numa_nps; do + if [ "$B_SB_STATUS" = ok ]; then _ck_ok "Secure Boot / kernel lockdown: $B_SB_BEFORE — the MSR mod can apply"; fi + if [ "$B_SB_STATUS" = pending ]; then _ck_warn "Secure Boot / kernel lockdown: $B_SB_BEFORE — the MSR mod (~5-15% RandomX) cannot apply until Secure Boot is off."; fi + if [ "$B_SB_STATUS" = unknown ]; then _ck_info "Secure Boot / kernel lockdown: can't verify — $LOCKDOWN_FILE isn't readable (run as root, or the lockdown LSM isn't built in)"; fi + # Secure Boot leads: it's the cheapest change and it gates the MSR mod outright. + for id in secure_boot memory_profile smt power_boost numa_nps; do case "$id" in memory_profile) if [ "$B_MEM_STATUS" = pending ]; then pending="$pending $id"; fi ;; smt) if [ "$B_SMT_STATUS" = pending ]; then pending="$pending $id"; fi ;; power_boost) if [ "$B_BOOST_STATUS" = pending ]; then pending="$pending $id"; fi ;; numa_nps) if [ "$B_NPS_STATUS" = pending ]; then pending="$pending $id"; fi ;; + secure_boot) if [ "$B_SB_STATUS" = pending ]; then pending="$pending $id"; fi ;; esac done if [ -z "$pending" ]; then @@ -5197,6 +5279,10 @@ _bios_verify() { # fresh_status="$B_NPS_STATUS" fresh_before="$B_NPS_BEFORE" ;; + secure_boot) + fresh_status="$B_SB_STATUS" + fresh_before="$B_SB_BEFORE" + ;; *) continue ;; esac if [ "$fresh_status" = ok ]; then @@ -5213,6 +5299,7 @@ _bios_verify() { # memory_profile) _ck_warn "$(_bios_item_label "$id") — can't verify (run as root so dmidecode can read the RAM state)." ;; smt) _ck_warn "$(_bios_item_label "$id") — can't verify (no SMT control exposed in sysfs on this system)." ;; numa_nps) _ck_warn "$(_bios_item_label "$id") — can't verify (lscpu didn't report an EPYC CPU model, so the NUMA-node count can't be checked)." ;; + secure_boot) _ck_warn "$(_bios_item_label "$id") — can't verify ($LOCKDOWN_FILE isn't readable; re-run as root)." ;; esac kept="$kept $id" fi @@ -5229,10 +5316,12 @@ _bios_verify() { # case " $kept " in *" smt "*) : ;; *) B_SMT_STATUS="done" ;; esac case " $kept " in *" power_boost "*) : ;; *) B_BOOST_STATUS="done" ;; esac case " $kept " in *" numa_nps "*) : ;; *) B_NPS_STATUS="done" ;; esac + case " $kept " in *" secure_boot "*) : ;; *) B_SB_STATUS="done" ;; esac if [ "$B_BOOST_STATUS" = unknown ]; then B_BOOST_STATUS=pending; fi # keep it resumable if [ "$B_MEM_STATUS" = unknown ]; then B_MEM_STATUS=pending; fi if [ "$B_SMT_STATUS" = unknown ]; then B_SMT_STATUS=pending; fi if [ "$B_NPS_STATUS" = unknown ]; then B_NPS_STATUS=pending; fi + if [ "$B_SB_STATUS" = unknown ]; then B_SB_STATUS=pending; fi _bios_state_write "$state" log "$applied of $total applied, $(jq -r '.items | length' "$state") still pending. Reboot into BIOS to finish, then run 'sudo $0 bios' again." fi diff --git a/tests/run.sh b/tests/run.sh index 7ee41f6..10c3d02 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -65,6 +65,8 @@ export CPU_SYSFS="$NOHW/cpu" export RAPL_DIR="$NOHW/powercap" export DMI_DIR="$NOHW/dmi" export SMT_CONTROL="$NOHW/smt" +# #333: absent by default -> lockdown reads "unknown", never the real host's securityfs state. +export LOCKDOWN_FILE="$NOHW/lockdown" export NODE_SYSFS="$NOHW/node" # _nps_suspect's NUMA-node count (#201) export THERMAL_ZONE="$NOHW/thermal" export HWMON_DIR="$NOHW/hwmon" # _read_temp's k10temp/coretemp fallback (#208) @@ -3348,6 +3350,74 @@ out="$(DMI_DIR="/nonexistent-dmi" SMT_CONTROL="/nonexistent-smt" DMIDECODE="$DOC run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" assert_absent "doctor: no firmware context when DMI unreadable (#78)" "$out" "Firmware:" +# #333: kernel lockdown denies /dev/cpu/*/msr writes, so the MSR mod (~5-15% RandomX) silently can't +# apply. doctor must DETECT it from securityfs rather than infer it after a downstream failure, and must +# not offer msr.allow_writes=on as the remedy — the kernel checks lockdown BEFORE that filter +# (arch/x86/kernel/msr.c). Fakes drive every level, so one run covers all of them on any machine. +echo "== unit: doctor kernel-lockdown detection (#333) ==" +printf 'none [integrity] confidentiality\n' >"$DOC/lockdown_integrity" +printf 'none integrity [confidentiality]\n' >"$DOC/lockdown_conf" +printf '[none] integrity confidentiality\n' >"$DOC/lockdown_none" +: >"$DOC/lockdown_empty" + +# --- the two pure helpers, exercised directly --- +ld_state() { (source "$SCRIPT" && LOCKDOWN_FILE="$1" _lockdown_state); } +assert_eq "lockdown: parses the bracketed level (integrity) (#333)" "$(ld_state "$DOC/lockdown_integrity")" "integrity" +assert_eq "lockdown: parses the bracketed level (confidentiality) (#333)" "$(ld_state "$DOC/lockdown_conf")" "confidentiality" +assert_eq "lockdown: parses the bracketed level (none) (#333)" "$(ld_state "$DOC/lockdown_none")" "none" +assert_eq "lockdown: unreadable file -> unknown, not 'none' (#333)" "$(ld_state "/nonexistent-lockdown")" "" +assert_eq "lockdown: empty file -> unknown (#333)" "$(ld_state "$DOC/lockdown_empty")" "" +ld_blocks() { (source "$SCRIPT" && _lockdown_blocks_msr "$1" && echo yes || echo no); } +assert_eq "lockdown: integrity blocks MSR writes (#333)" "$(ld_blocks integrity)" "yes" +assert_eq "lockdown: confidentiality blocks MSR writes (#333)" "$(ld_blocks confidentiality)" "yes" +assert_eq "lockdown: none permits MSR writes (#333)" "$(ld_blocks none)" "no" +assert_eq "lockdown: unknown is not treated as blocking (#333)" "$(ld_blocks "")" "no" + +# --- doctor, one level per run --- +# Active lockdown: named, counted, with the ASUS-specific menu path and the allow_writes correction. +out="$(LOCKDOWN_FILE="$DOC/lockdown_integrity" DMI_DIR="$DOC/dmi" \ + run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: names the active lockdown level (#333)" "$out" "kernel lockdown is ACTIVE ('integrity')" +assert_contains "doctor: says lockdown denies MSR writes (#333)" "$out" "denies every /dev/cpu/*/msr write" +assert_contains "doctor: corrects the allow_writes remedy (#333)" "$out" "msr.allow_writes=on does NOT override it" +assert_contains "doctor: gives the board-specific Secure Boot path (#333)" "$out" "Boot ▸ Secure Boot ▸ OS Type ▸ Other OS" +assert_contains "doctor: active lockdown counts as an issue (#333)" "$out" "issue(s) found" +# Confidentiality blocks MSR writes too — it is strictly above integrity. +out="$(LOCKDOWN_FILE="$DOC/lockdown_conf" DMI_DIR="$DOC/dmi" \ + run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: confidentiality also flagged (#333)" "$out" "kernel lockdown is ACTIVE ('confidentiality')" +# lockdown=none: a clean pass, and no scare text. +out="$(LOCKDOWN_FILE="$DOC/lockdown_none" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: lockdown none passes (#333)" "$out" "kernel lockdown: none" +assert_absent "doctor: no lockdown warning when inactive (#333)" "$out" "lockdown is ACTIVE" +# Unreadable: advisory only. An unverifiable probe must never manufacture an issue (the #67/#78 rule). +out="$(LOCKDOWN_FILE="/nonexistent-lockdown" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: unknown lockdown is advisory (#333)" "$out" "kernel lockdown state unknown" +assert_absent "doctor: unknown lockdown raises no false alarm (#333)" "$out" "lockdown is ACTIVE" + +# The msr-module hint no longer blames Secure Boot: Secure Boot does not stop the in-tree signed `msr` +# module from loading, so that advice sent people to the wrong screen. +out="$(LOCKDOWN_FILE="$DOC/lockdown_none" run_doctor "$DOC/meminfo_ok" "$DOC/nope-missing" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: missing msr module points at modprobe (#333)" "$out" "check 'sudo modprobe msr'" +assert_absent "doctor: missing msr module no longer blames Secure Boot (#333)" "$out" "msr module not loaded — the MSR mod won't apply; if it persists, disable Secure Boot" + +# XMRig's own MSR failure is attributed to lockdown when lockdown is active, and only then. +printf 'net use pool ...\nmsr register values for "ryzen_19h_zen4" preset have FAILED to set\n' >"$DOC/home/worker/xmrig.log" +out="$(LOCKDOWN_FILE="$DOC/lockdown_integrity" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: MSR failure attributed to lockdown (#333)" "$out" "kernel lockdown ('integrity') is denying the write" +out="$(LOCKDOWN_FILE="$DOC/lockdown_none" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: MSR failure without lockdown keeps the allow_writes hint (#333)" "$out" "check msr.allow_writes=on" +assert_absent "doctor: no lockdown blame when lockdown is off (#333)" "$out" "is denying the write" +printf 'net use pool ...\n* HUGE PAGES 100%%\n' >"$DOC/home/worker/xmrig.log" # restore the shared fixture + +# Per-vendor Secure Boot menu paths (#333), mirroring the #80 memory_profile coverage. +bm_sb() { (source "$SCRIPT" && _bios_menu "$1" secure_boot perf); } +assert_contains "bios menu: ASUS Secure Boot path (#333)" "$(bm_sb ASUSTeK)" "OS Type ▸ Other OS" +assert_contains "bios menu: ASRock Secure Boot path (#333)" "$(bm_sb ASRock)" "Security ▸ Secure Boot" +assert_contains "bios menu: Gigabyte Secure Boot path (#333)" "$(bm_sb "Gigabyte Technology")" "Secure Boot Enable" +assert_contains "bios menu: MSI Secure Boot path (#333)" "$(bm_sb "Micro-Star International")" "Windows OS Configuration" +assert_contains "bios menu: unknown vendor falls back generically (#333)" "$(bm_sb "Some OEM")" "usually under Boot or Security" + # #278: doctor reports control receiver health when `control` is enabled. Active + responding (200 or # 503, per util/control-server.py) is ok; enabled-but-down (service inactive, or active but not # answering) warns with a hint and counts as an issue; disabled prints no control-receiver lines at all. @@ -3549,6 +3619,27 @@ out="$(run_bios 'SMT_CONTROL=$DOC/smt_on DMIDECODE=$NOHW/dmidecode-absent CPU_SY assert_contains "bios verify: unreadable RAM keeps the item with the root hint (#80)" "$out" "can't verify (run as root" assert_eq "bios verify: unverifiable memory item stays pending (#80)" "$(jq -c '[.items[].id]' "$BIO/rigforge-bios.json")" '["memory_profile"]' rm -f "$BIO/rigforge-bios.json" + +# #333: Secure Boot joins the guided BIOS walk-through. It leads the checklist (cheapest change, and it +# gates the MSR mod outright), persists like every other item, and — unlike the others — is verifiable +# purely from securityfs, so the reboot loop can confirm it took. +# Guide pass with lockdown active: secure_boot is pending and comes FIRST. +out="$(run_bios 'LOCKDOWN_FILE=$DOC/lockdown_integrity SMT_CONTROL=$DOC/smt_on DMIDECODE=$DOC/dmidecode_xmpon CPU_SYSFS=$DOC/cpu_ok CPUFREQ_MAX=$DOC/cpufreq_max')" +assert_contains "bios: flags active lockdown (#333)" "$out" "lockdown=integrity (MSR writes denied)" +assert_contains "bios: names the Secure Boot item (#333)" "$out" "Secure Boot (kernel lockdown)" +assert_eq "bios: Secure Boot leads the checklist (#333)" "$(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | grep -E '^ 1\. ' | head -1)" " 1. Secure Boot (kernel lockdown)" +assert_eq "bios: Secure Boot persisted as pending (#333)" "$(jq -r '[.items[].id] | index("secure_boot") != null' "$BIO/rigforge-bios.json")" "true" +assert_contains "bios: persisted item carries the vendor menu path (#333)" "$(jq -r '.items[] | select(.id=="secure_boot") | .menu' "$BIO/rigforge-bios.json")" "OS Type ▸ Other OS" +# Verify pass after the operator disabled Secure Boot: lockdown reads none -> the item took. +out="$(run_bios 'LOCKDOWN_FILE=$DOC/lockdown_none SMT_CONTROL=$DOC/smt_on DMIDECODE=$DOC/dmidecode_xmpon CPU_SYSFS=$DOC/cpu_ok CPUFREQ_MAX=$DOC/cpufreq_max')" +assert_contains "bios verify: Secure Boot change took (#333)" "$out" "Secure Boot (kernel lockdown) — now lockdown=none" +rm -f "$BIO/rigforge-bios.json" +# Unverifiable securityfs must keep the item rather than claim success (the #80 honesty rule). +printf '%s\n' '{"target":"perf","saved":"2026-07-10","items":[{"id":"secure_boot","status":"pending","before":"lockdown=integrity","menu":"Boot"}]}' >"$BIO/rigforge-bios.json" +out="$(run_bios 'LOCKDOWN_FILE=/nonexistent-lockdown SMT_CONTROL=$DOC/smt_on DMIDECODE=$DOC/dmidecode_xmpon CPU_SYSFS=$DOC/cpu_ok CPUFREQ_MAX=$DOC/cpufreq_max')" +assert_contains "bios verify: unreadable securityfs is honest, not a pass (#333)" "$out" "isn't readable; re-run as root" +assert_eq "bios verify: unverifiable Secure Boot item stays pending (#333)" "$(jq -c '[.items[].id]' "$BIO/rigforge-bios.json")" '["secure_boot"]' +rm -f "$BIO/rigforge-bios.json" # Dispatch: the case entry shifts and forwards flags (any OS: the rc-1 proves the verb was reached). out="$( (RIGFORGE_HOME="$BIO" bash "$SCRIPT" bios --wat &1 || true)" assert_contains "bios: dispatch forwards to the verb (#80)" "$out" "[ERROR]" From 25abe9b21f21f6fee1ba5cafc3686f464b16dfd9 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 15:38:21 -0500 Subject: [PATCH 08/32] release: v1.13.1 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 ++ VERSION | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f60de1..bd99125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +## [1.13.1] - 2026-08-01 + ### Fixed - **`doctor` detects kernel lockdown directly instead of inferring it from MSR failure (#333).** diff --git a/VERSION b/VERSION index feaae22..b50dd27 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.0 +1.13.1 From fb5ecdb942d6759bca148531180a545e1aecb235 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 16:04:02 -0500 Subject: [PATCH 09/32] chore(perf): record miner-0 baseline for v1.13.1 Co-Authored-By: Claude Fable 5 --- tests/perf-baselines/miner-0.history.jsonl | 1 + tests/perf-baselines/miner-0.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/perf-baselines/miner-0.history.jsonl b/tests/perf-baselines/miner-0.history.jsonl index e7de36b..4c00168 100644 --- a/tests/perf-baselines/miner-0.history.jsonl +++ b/tests/perf-baselines/miner-0.history.jsonl @@ -8,3 +8,4 @@ {"tag":"v1.10.0","recorded":"2026-07-17","bench_1m_hs":10736.5} {"tag":"v1.11.1","recorded":"2026-07-18","bench_1m_hs":10743.9} {"tag":"v1.13.0","recorded":"2026-08-01","bench_1m_hs":10733.4} +{"tag":"v1.13.1","recorded":"2026-08-01","bench_1m_hs":10762.5} diff --git a/tests/perf-baselines/miner-0.json b/tests/perf-baselines/miner-0.json index 8873ca1..339b0f3 100644 --- a/tests/perf-baselines/miner-0.json +++ b/tests/perf-baselines/miner-0.json @@ -1,5 +1,5 @@ { - "bench_1m_hs": 10733.4, + "bench_1m_hs": 10762.5, "cpu": "AMD Ryzen 7 7800X3D 8-Core Processor", "recorded": "2026-08-01" } From 5ee5af9ff18d0e6b5d26876ba5aa65c213c96279 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 2 Aug 2026 10:10:56 -0500 Subject: [PATCH 10/32] fix(setup): probe the distro's cpupower package name so Debian installs don't fail (#327) (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apt dependency list named linux-tools-common unconditionally, but that package is Ubuntu-only — Debian ships cpupower as linux-cpupower — and apt's install transaction is all-or-nothing, so the one unknown name failed the ENTIRE dependency install: git, build-essential and cmake never arrived either. Probe with apt-cache show (the same guard the kernel-versioned linux-tools-$(uname -r) package already had) and add whichever name the distro actually ships; when neither exists, warn and carry on — cpupower is a tuning aid, never worth losing the compiler toolchain over. Closes #327 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++++ rigforge.sh | 15 ++++++++++++++- tests/run.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd99125..b9fe290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +### Fixed + +- **Debian: dependency install no longer fails on `linux-tools-common` (#327).** The apt dependency + list named `linux-tools-common` unconditionally, but that package is Ubuntu-only — Debian ships + cpupower as `linux-cpupower` — and apt's install transaction is all-or-nothing, so the one unknown + name failed the ENTIRE install: git, build-essential and cmake never arrived either. `setup` now + probes with `apt-cache show` (the same guard the kernel-versioned `linux-tools-$(uname -r)` + package already had) and adds whichever name the distro actually ships; when neither exists it + warns and moves on — cpupower is a tuning aid, never worth losing the compiler toolchain over. + ## [1.13.1] - 2026-08-01 ### Fixed diff --git a/rigforge.sh b/rigforge.sh index f6fa9e0..2fefea9 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -738,7 +738,20 @@ _detect_pkg_manager() { DEP_LIST="git build-essential cmake libuv1-dev libssl-dev libhwloc-dev gettext-base python3" if [ "$OS_TYPE" == "Linux" ]; then # msr-tools (rdmsr): lets `doctor` verify the prefetcher MSR mod actually applied (#66). - DEP_LIST="$DEP_LIST linux-tools-common msr-tools" + DEP_LIST="$DEP_LIST msr-tools" + # cpupower's package name differs by distro: linux-tools-common on Ubuntu, + # linux-cpupower on Debian. apt's install transaction is all-or-nothing, so one unknown + # name would fail the ENTIRE dependency install — gcc/cmake included (#327). Probe with + # apt-cache show (same guard as the kernel-versioned package below) and add only the + # name this distro actually ships; when neither exists, warn and carry on — cpupower is + # a tuning aid, never worth losing the compiler toolchain over. + if apt-cache show linux-tools-common &>/dev/null; then + DEP_LIST="$DEP_LIST linux-tools-common" + elif apt-cache show linux-cpupower &>/dev/null; then + DEP_LIST="$DEP_LIST linux-cpupower" + else + warn "No cpupower package found (tried linux-tools-common, linux-cpupower) — skipping it. Frequency tuning via cpupower may be unavailable." + fi if apt-cache show "linux-tools-$(uname -r)" &>/dev/null; then DEP_LIST="$DEP_LIST linux-tools-$(uname -r)" fi diff --git a/tests/run.sh b/tests/run.sh index 10c3d02..b01cec6 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -2534,6 +2534,56 @@ chmod +x "$LT"/* PATH="$LT" CALL_LOG="$LT/calls.log" install_dependencies /dev/null 2>&1 assert_contains "apt install list includes linux-tools- (#74)" "$(cat "$LT/calls.log")" "linux-tools-6.0.0-rig" +# The same run doubles as the Ubuntu cpupower shape (#327): apt-cache says linux-tools-common +# exists, so it's picked and the Debian name never enters the list. +assert_contains "Ubuntu shape: cpupower via linux-tools-common (#327)" "$(cat "$LT/calls.log")" "linux-tools-common" +assert_absent "Ubuntu shape: linux-cpupower stays out (#327)" "$(cat "$LT/calls.log")" "linux-cpupower" + +# #327: cpupower's apt package is distro-dependent — linux-tools-common (Ubuntu) vs linux-cpupower +# (Debian) — and apt's all-or-nothing transaction means one unknown name kills the ENTIRE dependency +# install (gcc/cmake included). The probe must pick the name the distro ships, and a double miss must +# warn without touching the toolchain install. Ubuntu shape is asserted on the #74 run above. +echo "== unit: apt cpupower package probe — Debian / neither exists (#327) ==" +DEB="$(mktemp -d "$SANDBOX/deb327.XXXXXX")" +printf '#!/bin/sh\nexit 1\n' >"$DEB/dpkg" # every dep "missing" -> all go to the install list +cat >"$DEB/apt-cache" <<'EOF' +#!/bin/sh +# Debian trixie shape: linux-cpupower exists; linux-tools-common and linux-tools- do not. +case "$*" in *linux-cpupower*) exit 0 ;; *) exit 1 ;; esac +EOF +printf '#!/bin/sh\necho "[apt-get] $*" >>"$CALL_LOG"\n' >"$DEB/apt-get" +printf '#!/bin/sh\nwhile [ "${1#*=}" != "$1" ]; do export "$1"; shift; done\nexec "$@"\n' >"$DEB/sudo" +printf '#!/bin/sh\necho 6.0.0-rig\n' >"$DEB/uname" +chmod +x "$DEB"/* +: >"$DEB/calls.log" +( + source "$SCRIPT" + OS_TYPE=Linux REAL_USER=test + PATH="$DEB" CALL_LOG="$DEB/calls.log" install_dependencies /dev/null 2>&1 +assert_contains "Debian shape: cpupower via linux-cpupower (#327)" "$(cat "$DEB/calls.log")" "linux-cpupower" +assert_absent "Debian shape: linux-tools-common stays out (#327)" "$(cat "$DEB/calls.log")" "linux-tools-common" + +# Neither name exists (apt-cache always says no): warn, keep going, and the toolchain still installs. +NC="$(mktemp -d "$SANDBOX/nc327.XXXXXX")" +printf '#!/bin/sh\nexit 1\n' >"$NC/dpkg" +printf '#!/bin/sh\nexit 1\n' >"$NC/apt-cache" # no cpupower package under ANY name +printf '#!/bin/sh\necho "[apt-get] $*" >>"$CALL_LOG"\n' >"$NC/apt-get" +printf '#!/bin/sh\nwhile [ "${1#*=}" != "$1" ]; do export "$1"; shift; done\nexec "$@"\n' >"$NC/sudo" +printf '#!/bin/sh\necho 6.0.0-rig\n' >"$NC/uname" +chmod +x "$NC"/* +: >"$NC/calls.log" +nc_out="$( ( + source "$SCRIPT" + OS_TYPE=Linux REAL_USER=test + PATH="$NC" CALL_LOG="$NC/calls.log" install_dependencies &1)" +rc=$? +assert_rc "no cpupower package never fails the install (#327)" "$rc" "0" +assert_contains "warns when no cpupower package exists (#327)" "$nc_out" "No cpupower package found" +assert_contains "toolchain still installs without a cpupower package (#327)" "$(cat "$NC/calls.log")" "build-essential" +assert_absent "no cpupower name reaches apt when neither exists (#327)" "$(cat "$NC/calls.log")" "linux-tools-common" +assert_absent "linux-cpupower also stays out when absent (#327)" "$(cat "$NC/calls.log")" "linux-cpupower" # check_prerequisites (the jq bootstrap) had NO test. jq is deliberately kept OFF the scenario PATH so the # install branch runs; each dir holds ONLY the package manager(s) under test, so `command -v` selects the From c96ff744a939186f9e39228178ed75dc00e42522 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 2 Aug 2026 11:24:23 -0500 Subject: [PATCH 11/32] feat(doctor): surface missing AES-NI / AVX2 instead of mining silently slow (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(doctor): surface missing AES-NI / AVX2 instead of mining silently slow (#338) RandomX without AES-NI falls back to XMRig's soft-AES path, roughly 4x slower, and nothing anywhere said why — the last undelivered acceptance criterion from #1 ("unsupported hardware is surfaced rather than silently failing"). setup/apply now warn at configure time when the CPU flags lack aes (and, advisory, avx2 — that one only slows dataset init), and doctor counts a missing AES-NI as an issue. Judged only when an x86-style flags line exists in /proc/cpuinfo (same CPUINFO override util/proposed-grub.sh already uses); macOS, ARM and stubbed sandboxes read as unknown, and unknown never manufactures an issue — the #333 lockdown stance. Never aborts: a knowingly-old rig is a valid choice. Closes #338 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 * refactor: collapse one-arm case blocks to [[ ]] guards (ponytail-review) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++ rigforge.sh | 41 ++++++++++++++++++++++++++++++ tests/run.sh | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9fe290..0c78524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +### Added + +- **Missing AES-NI / AVX2 is surfaced instead of mining silently slow (#338).** RandomX without + AES-NI falls back to XMRig's soft-AES path, roughly 4x slower, and nothing anywhere said why — the + last undelivered acceptance criterion from #1. `setup`/`apply` now warn at configure time when the + CPU flags lack `aes` (and, advisory, `avx2` — that one only slows dataset init), and `doctor` + counts a missing AES-NI as an issue. Judged only when an x86-style `flags` line exists in + `/proc/cpuinfo`; macOS, ARM and stubbed sandboxes read as unknown, and unknown never manufactures + an issue (the #333 lockdown stance). Never aborts: a knowingly-old rig is a valid choice. + ### Fixed - **Debian: dependency install no longer fails on `linux-tools-common` (#327).** The apt dependency diff --git a/rigforge.sh b/rigforge.sh index 2fefea9..322fa62 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -132,6 +132,9 @@ BIN_DIR="${BIN_DIR:-/usr/local/bin}" # Read-only system paths the `doctor` health check inspects (overridable for tests). MEMINFO="${MEMINFO:-/proc/meminfo}" +# CPU flags source for the ISA preflight (#338). Same override name util/proposed-grub.sh already +# uses for its pdpe1gb probe — one knob, and the test harness already isolates it from the host. +CPUINFO="${CPUINFO:-/proc/cpuinfo}" MSR_MODULE_DIR="${MSR_MODULE_DIR:-/sys/module/msr}" GOVERNOR_FILE="${GOVERNOR_FILE:-/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor}" HUGEPAGES_1G_NR="${HUGEPAGES_1G_NR:-/sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages}" @@ -1001,6 +1004,13 @@ generate_xmrig_config() { # where only one CCD has the V-cache). See issue #44. if [ "$OS_TYPE" != "Darwin" ]; then log "Detected CPU: ${CPU_MODEL:-unknown} — using XMRig auto-tuning (threads, asm, MSR, NUMA auto-detected)." + # ISA preflight (#338): surface missing AES-NI/AVX2 here, at the moment the rig is being + # configured, instead of letting unsupported hardware mine silently slow. Warn, never abort: + # XMRig still runs (soft AES / non-AVX2 init) and a knowingly-old rig is a valid choice. + local _missing_isa + _missing_isa=$(_cpu_missing_isa) + [[ " $_missing_isa " == *" aes "* ]] && warn "This CPU has no AES-NI: RandomX falls back to soft AES, roughly 4x slower. Mining will work, but expect a fraction of a modern CPU's rate." + [[ " $_missing_isa " == *" avx2 "* ]] && warn "This CPU has no AVX2: dataset init will be slower (steady-state hashrate is unaffected)." fi # Rig label for the pool `user` field (#22): any pool entry that didn't set its own `user` gets the @@ -4398,6 +4408,22 @@ _lockdown_state() { # -> none|integrity|confidentiality, or empty when unknown return 0 } +# RandomX ISA preflight (#338, the last acceptance criterion from #1): echo which of aes / avx2 the +# CPU lacks, from the kernel's flags line. RandomX without AES-NI silently falls back to XMRig's +# soft-AES path (~4x slower) and without AVX2 dataset init slows — neither aborts anything, so a rig +# on unsupported hardware "works" at a mysteriously bad rate unless somebody says why. Judged ONLY +# when an x86-style "flags" line exists: no flags line (macOS has no /proc, ARM cpuinfo says +# "Features", sandboxes stub the file) means unknown, and unknown never manufactures an issue — the +# same stance as #333's lockdown probe. `-w` so vaes/avx2_vnni style neighbors can't false-match. +_cpu_missing_isa() { # -> "aes", "avx2", "aes avx2", or empty when all present / undeterminable + grep -q '^flags' "$CPUINFO" 2>/dev/null || return 0 + local missing="" + grep -qw aes "$CPUINFO" || missing="aes" + grep -qw avx2 "$CPUINFO" || missing="$missing${missing:+ }avx2" + printf '%s' "$missing" + return 0 +} + # True when lockdown is at a level that blocks MSR writes (#333). LOCKDOWN_MSR sits below # LOCKDOWN_INTEGRITY_MAX in enum lockdown_reason (include/linux/security.h), so both `integrity` and # `confidentiality` deny the write; only `none` permits it. @@ -4759,6 +4785,21 @@ doctor() { _ck_warn "1GB HugePages not reserved (optional; needs a pdpe1gb CPU + reboot)" fi + # CPU ISA support (#338): a rig without AES-NI mines at soft-AES speed (~4x slower) with no + # error anywhere — exactly the "silently failing" #1's acceptance criterion forbids, so it's a + # counted issue. Missing AVX2 only slows dataset init: advisory. Quiet when there's no x86 + # flags line to judge (unknown, not unsupported). + local miss_isa + miss_isa=$(_cpu_missing_isa) + if [[ " $miss_isa " == *" aes "* ]]; then + _ck_warn "CPU has no AES-NI — RandomX runs soft AES, roughly 4x slower; this hardware cannot mine at a competitive rate" + issues=$((issues + 1)) + else + # The ok line only when there IS a flags line to have judged; no flags line = unknown, say nothing. + grep -q '^flags' "$CPUINFO" 2>/dev/null && _ck_ok "CPU supports AES-NI (hardware RandomX path)" || true + fi + [[ " $miss_isa " == *" avx2 "* ]] && _ck_info "CPU has no AVX2 — dataset init is slower (steady-state hashrate unaffected)" + # Resolve the worker's xmrig.log once — the MSR-applied (#66) and HUGE PAGES checks both read it. local wr="" log_file="" if [ -f "$CONFIG_JSON" ]; then diff --git a/tests/run.sh b/tests/run.sh index b01cec6..4b6599f 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -3468,6 +3468,77 @@ assert_contains "bios menu: Gigabyte Secure Boot path (#333)" "$(bm_sb "Gigabyte assert_contains "bios menu: MSI Secure Boot path (#333)" "$(bm_sb "Micro-Star International")" "Windows OS Configuration" assert_contains "bios menu: unknown vendor falls back generically (#333)" "$(bm_sb "Some OEM")" "usually under Boot or Security" +# #338 (the last #1 acceptance criterion): missing AES-NI/AVX2 must be SURFACED — soft-AES mining is +# ~4x slower with no error anywhere. AES-NI missing = counted doctor issue + setup warn; AVX2 missing = +# advisory only; no x86 "flags" line (macOS, ARM's "Features", stubs) = unknown = silence, never an issue. +echo "== unit: CPU ISA preflight — AES-NI / AVX2 surfaced (#338) ==" +printf 'processor : 0\nflags : fpu vme aes avx avx2 vaes\n' >"$DOC/cpuinfo_full" +# vaes but NOT the standalone aes word: proves the -w match can't be satisfied by a neighbor flag. +printf 'processor : 0\nflags : fpu vme avx avx2 vaes\n' >"$DOC/cpuinfo_noaes" +printf 'processor : 0\nflags : fpu vme aes avx\n' >"$DOC/cpuinfo_noavx2" +printf 'processor : 0\nflags : fpu vme avx\n' >"$DOC/cpuinfo_neither" +printf 'processor : 0\nFeatures : fp asimd aes\n' >"$DOC/cpuinfo_arm" # ARM shape: no "flags" line + +# --- the pure helper, exercised directly --- +isa_miss() { (source "$SCRIPT" && CPUINFO="$1" _cpu_missing_isa); } +assert_eq "isa: full flags -> nothing missing (#338)" "$(isa_miss "$DOC/cpuinfo_full")" "" +assert_eq "isa: vaes does not satisfy the aes word-match (#338)" "$(isa_miss "$DOC/cpuinfo_noaes")" "aes" +assert_eq "isa: missing avx2 reported alone (#338)" "$(isa_miss "$DOC/cpuinfo_noavx2")" "avx2" +assert_eq "isa: both missing, space-separated (#338)" "$(isa_miss "$DOC/cpuinfo_neither")" "aes avx2" +assert_eq "isa: ARM Features line -> unknown, not unsupported (#338)" "$(isa_miss "$DOC/cpuinfo_arm")" "" +assert_eq "isa: absent cpuinfo -> unknown (#338)" "$(isa_miss "/nonexistent-cpuinfo")" "" + +# --- doctor: counted for aes, advisory for avx2, silent on unknown --- +out="$(CPUINFO="$DOC/cpuinfo_noaes" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: missing AES-NI named (#338)" "$out" "CPU has no AES-NI" +assert_contains "doctor: missing AES-NI is a counted issue (#338)" "$out" "issue(s) found" +out="$(CPUINFO="$DOC/cpuinfo_noavx2" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: missing AVX2 is advisory (#338)" "$out" "CPU has no AVX2" +assert_contains "doctor: missing AVX2 alone still passes (#338)" "$out" "all critical checks passed" +out="$(CPUINFO="$DOC/cpuinfo_full" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_contains "doctor: AES-NI present reported ok (#338)" "$out" "CPU supports AES-NI" +out="$(CPUINFO="$DOC/cpuinfo_arm" run_doctor "$DOC/meminfo_ok" "$DOC/msrmod" "$DOC/gov_perf" "$DOC/nr1g")" +assert_absent "doctor: unknown ISA raises no alarm (#338)" "$out" "AES-NI" + +# --- setup path: generate_xmrig_config warns at configure time, and never aborts --- +export STUB_CPU_MODEL="Old Xeon E5405" STUB_NPROC=4 STUB_HOSTNAME=rigbox +ISA338="$(mktemp -d "$SANDBOX/isa338.XXXXXX")" +gen338_out="$( + cd "$ISA338" || exit 1 + source "$SCRIPT" + OS_TYPE=Linux + WORKER_ROOT="$ISA338" + POOL_ADDRESS=myrig.local + POOLS_JSON='[{"url":"myrig.local:3333","user":"","pass":"x","keepalive":true,"tls":false,"enabled":true}]' + ACCESS_TOKEN=tok123 + DONATION=1 + LOGROTATE_DIR="$ISA338" + CPUINFO="$DOC/cpuinfo_neither" + set +e + PATH="$STUBS:$PATH" generate_xmrig_config 2>&1 +)" +assert_rc "config-gen still succeeds on unsupported hardware (#338)" "$?" "0" +assert_contains "config-gen warns about missing AES-NI (#338)" "$gen338_out" "no AES-NI" +assert_contains "config-gen warns about missing AVX2 (#338)" "$gen338_out" "no AVX2" +assert_contains "config-gen: the config was still generated (#338)" "$(J "$ISA338/config.json" '.pools[0].url')" "myrig.local:3333" +# A fully-capable CPU stays quiet — the warn must not become noise on normal rigs. +QUIET338="$(mktemp -d "$SANDBOX/isaq338.XXXXXX")" +genq_out="$( + cd "$QUIET338" || exit 1 + source "$SCRIPT" + OS_TYPE=Linux + WORKER_ROOT="$QUIET338" + POOL_ADDRESS=myrig.local + POOLS_JSON='[{"url":"myrig.local:3333","user":"","pass":"x","keepalive":true,"tls":false,"enabled":true}]' + ACCESS_TOKEN=tok123 + DONATION=1 + LOGROTATE_DIR="$QUIET338" + CPUINFO="$DOC/cpuinfo_full" + set +e + PATH="$STUBS:$PATH" generate_xmrig_config 2>&1 +)" +assert_absent "config-gen: no ISA warning on a capable CPU (#338)" "$genq_out" "AES-NI" + # #278: doctor reports control receiver health when `control` is enabled. Active + responding (200 or # 503, per util/control-server.py) is ok; enabled-but-down (service inactive, or active but not # answering) warns with a hint and counts as an issue; disabled prints no control-receiver lines at all. From ffbf7591ec70920a8180d46bcc1149a15ca658a0 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 2 Aug 2026 17:10:44 -0500 Subject: [PATCH 12/32] release: v1.14.0 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 ++ VERSION | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c78524..e045bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +## [1.14.0] - 2026-08-02 + ### Added - **Missing AES-NI / AVX2 is surfaced instead of mining silently slow (#338).** RandomX without diff --git a/VERSION b/VERSION index b50dd27..850e742 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.1 +1.14.0 From dea38d70037b1ade78377d580fb43a54deb95e0f Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sun, 2 Aug 2026 17:21:06 -0500 Subject: [PATCH 13/32] chore(perf): record miner-0 baseline for v1.14.0 Co-Authored-By: Claude Fable 5 --- tests/perf-baselines/miner-0.history.jsonl | 1 + tests/perf-baselines/miner-0.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/perf-baselines/miner-0.history.jsonl b/tests/perf-baselines/miner-0.history.jsonl index 4c00168..91643a8 100644 --- a/tests/perf-baselines/miner-0.history.jsonl +++ b/tests/perf-baselines/miner-0.history.jsonl @@ -9,3 +9,4 @@ {"tag":"v1.11.1","recorded":"2026-07-18","bench_1m_hs":10743.9} {"tag":"v1.13.0","recorded":"2026-08-01","bench_1m_hs":10733.4} {"tag":"v1.13.1","recorded":"2026-08-01","bench_1m_hs":10762.5} +{"tag":"v1.14.0","recorded":"2026-08-02","bench_1m_hs":10773.6} diff --git a/tests/perf-baselines/miner-0.json b/tests/perf-baselines/miner-0.json index 339b0f3..f1e989b 100644 --- a/tests/perf-baselines/miner-0.json +++ b/tests/perf-baselines/miner-0.json @@ -1,5 +1,5 @@ { - "bench_1m_hs": 10762.5, + "bench_1m_hs": 10773.6, "cpu": "AMD Ryzen 7 7800X3D 8-Core Processor", - "recorded": "2026-08-01" + "recorded": "2026-08-02" } From 0fa4e7bb1d4b5a608ec9c8a97bff46504a8b6bcb Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 14 Aug 2026 19:31:23 -0500 Subject: [PATCH 14/32] docs(#352): align four sites with accepted ADR 0002 D4 semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four comments/docs still described the pre-acceptance design — "the rig re-derives latest and refuses anything else" — contradicting accepted D4: the staged body IS the target (the dashboard re-derives latest host-side, over Tor); the rig makes no version check of its own and only BOUNDS the supplied target with the monotonic anti-rollback + reachable-from-main guards. Reworded util/control-server.py (_handle_upgrade comment), systemd/rigforge-control-upgrade.service.template, the ADR 0002 Alternatives bullet, and the control_upgrade() header in rigforge.sh. Comments and docs only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/adr/0002-remote-worker-upgrade.md | 2 +- rigforge.sh | 7 ++++--- systemd/rigforge-control-upgrade.service.template | 6 +++--- util/control-server.py | 7 ++++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/adr/0002-remote-worker-upgrade.md b/docs/adr/0002-remote-worker-upgrade.md index 044fd6a..254914c 100644 --- a/docs/adr/0002-remote-worker-upgrade.md +++ b/docs/adr/0002-remote-worker-upgrade.md @@ -92,7 +92,7 @@ Low-cost integrity controls that need no standing signing key, shoring up the Gi - **Widen the ADR 0001 D3 allowlist to include a `version`/upgrade key.** Rejected: a code update is not an operationally-mutable config knob; folding it into the tuning path would silently grant every existing remote-tuning user a remote-RCE surface. A second opt-in (D1) is the point. - **Sign releases (cosign, as pithead did) and verify before running.** Considered and rejected for this project (D5): signing only helps against a distribution-point compromise and only with an out-of-band pinned/offline key; the key-custody burden isn't warranted, and GitHub is accepted as the trust root. -- **Let the consumer choose the target version.** Rejected: the receiver/intent must not be able to pin a rig to an arbitrary tag (downgrade/rollback-to-vulnerable). The rig re-derives latest and refuses anything else (D4). +- **Let the consumer choose the target version unbounded.** Rejected in that form: the receiver/intent must not be able to pin a rig to an arbitrary tag (downgrade/rollback-to-vulnerable). D4 does accept the dashboard-supplied target — the rig makes no version check of its own — but bounds it with the monotonic anti-rollback and reachable-from-main guards. - **Receiver fetches and runs directly (scoped-privilege network process).** Rejected for the same reason as ADR 0001 D2: the network-facing process stays unprivileged and stages only. - **No network upgrade path; keep SSH + `rigforge.sh upgrade`.** Lowest surface and still fully supported, but does not give pithead #597 a programmatic per-worker upgrade producer, which is the point of #308. diff --git a/rigforge.sh b/rigforge.sh index 322fa62..29a9e31 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -4151,9 +4151,10 @@ _control_upgrade_do() { # # rigforge-control-upgrade.path unit when the receiver stages an upgrade intent. Fetches the target # RigForge release and applies it, health-gated with rollback to the prior version. Every failure path # returns 0 with a recorded status (served by the receiver's GET /status) — a bad request must not -# wedge the oneshot. The staged version is a CONFIRMATION guard, not a target selector: this verb -# bounds what it will act on (D4/D10) so a compromised trigger can only ever land a real, reachable, -# NEWER release — never an arbitrary tag, a downgrade, or a dangling commit. +# wedge the oneshot. The staged version IS the target (D4) — the dashboard re-derives latest host-side, +# this verb makes no version check of its own: it bounds what it will act on (D4/D10) so a compromised +# trigger can only ever land a real, reachable, NEWER release — never an arbitrary tag, a downgrade, or +# a dangling commit. control_upgrade() { [ "$OS_TYPE" != "Linux" ] && error "control-upgrade is driven by the rigforge-control-upgrade.path unit and is Linux-only." parse_config # need API_PORT etc. so the post-build liveness check can read the miner diff --git a/systemd/rigforge-control-upgrade.service.template b/systemd/rigforge-control-upgrade.service.template index 831d60e..3162a91 100644 --- a/systemd/rigforge-control-upgrade.service.template +++ b/systemd/rigforge-control-upgrade.service.template @@ -11,9 +11,9 @@ Type=oneshot # and systemctl), so ProtectSystem / ProtectHome / NoNewPrivileges / an empty CapabilityBoundingSet # would each break it — the same reason the control-apply oneshot isn't sandboxed. The security for # this path lives in the verb, not in unit flags: it treats the staged file as untrusted (root-owned -# handoff, strict version whitelist), re-derives the real latest release and refuses any non-latest, -# unreachable, or older target, and throttles repeat runs (D4/D6/D8/D10). RIGFORGE_OPERATOR (baked at -# setup) is who the updated checkout and build get handed back to. +# handoff, strict version whitelist), bounds the staged target — refusing anything that is not a real, +# reachable release newer than the one installed — and throttles repeat runs (D4/D6/D8/D10). +# RIGFORGE_OPERATOR (baked at setup) is who the updated checkout and build get handed back to. Environment=RIGFORGE_OPERATOR=$RIGFORGE_OPERATOR ExecStart=$SCRIPT_DIR/rigforge.sh control-upgrade Nice=19 diff --git a/util/control-server.py b/util/control-server.py index 8eef403..14fee3e 100644 --- a/util/control-server.py +++ b/util/control-server.py @@ -212,9 +212,10 @@ def do_POST(self): def _handle_upgrade(self): # #308 (ADR 0002): the remote code-update surface. Gated by control_upgrade — a SECOND opt-in on # top of `control`, so enabling remote tuning does not silently grant remote RCE. The version in - # the body is a CONFIRMATION guard, not a target selector: the applier re-derives the real latest - # release and refuses anything that is not a real, reachable, newer release (D4/D10). We only - # stage the intent here; the unprivileged receiver never fetches or runs anything. + # the body IS the target (D4): the dashboard re-derives latest host-side, the rig makes no version + # check of its own — the applier bounds the supplied target, refusing anything that is not a real, + # reachable release newer than the one installed (D4/D10). We only stage the intent here; the + # unprivileged receiver never fetches or runs anything. if not UPGRADE_ENABLED: return self._send(403, "Forbidden", {"error": "remote upgrade is disabled on this rig; set control_upgrade: \"enabled\" locally to allow it"}) body = self._read_json_body() From 8d15988128689a32766aaf5583aca06863e830ef Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 14 Aug 2026 19:31:53 -0500 Subject: [PATCH 15/32] =?UTF-8?q?docs:=20stack-side=20stratum=20TLS=20ship?= =?UTF-8?q?ped=20=E2=80=94=20rewrite=20the=20TLS=20section=20to=20reality?= =?UTF-8?q?=20(#342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pithead#261 shipped: the stack serves TLS on the SAME :3333 (per-connection detect, mixed fleet migrates one rig at a time), generates a self-signed cert on first apply, and prints the lowercase 64-hex SHA-256 fingerprint (pithead status repeats it). Rotation is delete the proxy-tls files + re-pin. Rewrite docs/pithead-integration.md "Stratum over TLS" accordingly: drop the unshipped claim, the :3334 example port, and the "once pithead#261 fixes its port model" caveat; state that the rig side is config-file-not-prompt; point the troubleshooting row at pithead status as the canonical pin source. Ride-along audit one-liners (2026-08-14): - docs/README.md: ADR 0002 status Proposed -> Accepted (D-OPEN-1 signed off); integration row mentions the optional :8081 sister API like the root README - README.md: platform badge Ubuntu 22.04+ -> Ubuntu 22.04+ / Debian 12 (Debian shipped in v1.14.0, #327) - docs/operations.md: drop the word "simply" Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/README.md | 4 ++-- docs/operations.md | 2 +- docs/pithead-integration.md | 36 +++++++++++++++++++++++------------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d34b766..ec0e3d3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![CI](https://github.com/p2pool-starter-stack/rigforge/actions/workflows/ci.yml/badge.svg)](https://github.com/p2pool-starter-stack/rigforge/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -![Platform: Ubuntu 22.04+](https://img.shields.io/badge/Platform-Ubuntu%2022.04%2B-E95420?logo=ubuntu&logoColor=white) +![Platform: Ubuntu 22.04+ / Debian 12](https://img.shields.io/badge/Platform-Ubuntu%2022.04%2B%20%2F%20Debian%2012-E95420?logo=ubuntu&logoColor=white) [![Miner: XMRig](https://img.shields.io/badge/Miner-XMRig-F26822?logo=monero&logoColor=white)](https://github.com/xmrig/xmrig) [![Companion: Pithead](https://img.shields.io/badge/Companion-Pithead-F26822)](https://github.com/p2pool-starter-stack/pithead) diff --git a/docs/README.md b/docs/README.md index d5a2aa2..3ecd081 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ individual topics once you're up and running. | [Configuration](configuration.md) | Every `config.json` key and default, minimal vs. advanced setups, and how the XMRig config is generated. | | [Operations & Maintenance](operations.md) | The full command reference, service management, logs, upgrades, and troubleshooting. | | [How It Works](how-it-works.md) | What the script actually does: dependencies, compile-from-source, HugePages, MSR, NUMA, the governor, and the systemd service. | -| [Pithead Integration](pithead-integration.md) | The worker ↔ dashboard contract: discovery via `:3333`, the read-only HTTP API on `:8080`, and the token rules. | +| [Pithead Integration](pithead-integration.md) | The worker ↔ dashboard contract: discovery via `:3333`, the read-only API on `:8080` (plus the optional enriched sister API on `:8081`), and the token rules. | | [FAQ](faq.md) | Common questions, plus why RigForge vs. setting XMRig up by hand. | For how RigForge is versioned and released, see [`RELEASING.md`](../RELEASING.md) and @@ -27,7 +27,7 @@ For how RigForge is versioned and released, see [`RELEASING.md`](../RELEASING.md Significant, cross-cutting decisions are recorded as ADRs under [`adr/`](adr/): - [ADR 0001: Writable worker-config control path](adr/0001-writable-worker-config-control-path.md) — *Accepted.* How the stack applies config changes through RigForge without breaking the read-only sister API. -- [ADR 0002: Remote worker-upgrade control path](adr/0002-remote-worker-upgrade.md) — *Proposed.* Opt-in remote RigForge upgrade for the stack (#308), extending ADR 0001 from a tuning surface to a code-update one; hash-only trust, no signing. +- [ADR 0002: Remote worker-upgrade control path](adr/0002-remote-worker-upgrade.md) — *Accepted.* Opt-in remote RigForge upgrade for the stack (#308), extending ADR 0001 from a tuning surface to a code-update one; hash-only trust, no signing. ## Quick links diff --git a/docs/operations.md b/docs/operations.md index d02978d..9371a48 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -258,7 +258,7 @@ Each timer tick runs one check (`journalctl -u rigforge-watchdog.service` shows design (a loaded EPYC sits near 90 °C Tctl in perfectly normal operation), so a damage-avoidance cutoff belongs well above the everyday reading, not near it. Editing `max_temp_c` takes effect on the next tick — the watchdog re-reads `config.json` every run, no `apply` needed. -- A miner that's simply **not running** is left alone — dead-process recovery is systemd's job. +- A miner that's **not running** is left alone — dead-process recovery is systemd's job. Disable it with `"watchdog": "disabled"` (or removing the key) + `apply`; the units are removed cleanly. `uninstall` removes them too. diff --git a/docs/pithead-integration.md b/docs/pithead-integration.md index 68e22dd..a097a40 100644 --- a/docs/pithead-integration.md +++ b/docs/pithead-integration.md @@ -100,36 +100,46 @@ pre-computed state (a request costs microseconds — polling cannot shave hashra ### Stratum over TLS (optional) -Needs stack-side support that hasn't shipped yet (Pithead is tracking it as -[pithead#261](https://github.com/p2pool-starter-stack/pithead/issues/261)) — but everything below -already works today against **any** TLS stratum endpoint, e.g. a public pool's TLS port. +The stack can serve TLS on the **same** `:3333` +([`p2pool.stratum_tls`](https://github.com/p2pool-starter-stack/pithead/blob/main/docs/workers.md#stratum-over-tls), +default off): its proxy detects TLS vs plain per connection, so nothing re-points and a mixed +fleet migrates one rig at a time — rigs still on cleartext keep mining throughout. On the first +`pithead apply` with TLS on, the stack generates a self-signed certificate and prints its SHA-256 +fingerprint (64 lowercase hex chars); `pithead status` repeats it. That fingerprint is what the +rig pins: ```jsonc -// config.json — TLS on, server cert pinned by its SHA-256 fingerprint +// config.json — TLS on, the stack's cert pinned by its SHA-256 fingerprint { "pools": [ - { "url": "your-stack:3334", "tls": true, "tls-fingerprint": "<64 hex chars>" } + { "url": "your-stack:3333", "tls": true, "tls-fingerprint": "" } ] } ``` -Then `sudo ./rigforge.sh apply`. (The `:3334` port is only an example — use whatever port the stack -documents once pithead#261 fixes its port model.) +Then `sudo ./rigforge.sh apply`. The first-run prompt doesn't ask about TLS — these two fields +are edited into `config.json` by hand. The same fields work against **any** TLS stratum endpoint, +e.g. a public pool's TLS port. **The trust model, plainly:** XMRig does no CA validation on stratum TLS. With `"tls": true` and no fingerprint, the link is encrypted but not authenticated — fine against passive snooping, no defense -against an active man-in-the-middle. The fingerprint pin IS the server authentication. Get it with: +against an active man-in-the-middle. The fingerprint pin IS the server authentication: a pinned rig +refuses anything that doesn't hold the stack's exact certificate. `pithead status` is the canonical +source for the pin; to read it off the wire instead (XMRig compares case-insensitively, so the +uppercase openssl output works as-is): ```bash -echo | openssl s_client -connect your-stack:3334 2>/dev/null \ +echo | openssl s_client -connect your-stack:3333 2>/dev/null \ | openssl x509 -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' ``` - TLS is confidentiality; the stratum password (above) is access control. They're orthogonal — set both on an untrusted network. -- Rotation: when the stack rotates its certificate, update `tls-fingerprint` on each rig and run - `apply` (same runbook shape as the password). A stale pin shows up as - `Failed to verify server certificate fingerprint` in the XMRig log. +- Rotation: the operator deletes the two files in the stack's `proxy-tls` data directory and re-runs + `pithead apply` (new certificate, new fingerprint); then update `tls-fingerprint` on each TLS rig + and run `apply` (same runbook shape as the password). A stale pin shows up as + `Failed to verify server certificate fingerprint` in the XMRig log; cleartext rigs are unaffected + throughout. --- @@ -220,7 +230,7 @@ the token in flight, so isolate the mining LAN — see | Symptom | Fix | |---|---| | Rig won't mine / XMRig logs `Permission denied` at login | The stack has stratum authentication on (`p2pool.stratum_password`); set the pool `pass` to that secret. See [Stratum authentication](#stratum-authentication-optional). | -| XMRig logs `Failed to verify server certificate fingerprint` | The `tls-fingerprint` pin doesn't match the server's certificate (rotated cert or a typo). Re-run the openssl one-liner in [Stratum over TLS](#stratum-over-tls-optional) and `apply`. | +| XMRig logs `Failed to verify server certificate fingerprint` | The `tls-fingerprint` pin doesn't match the server's certificate (rotated cert or a typo). Re-pin from `pithead status` (or the openssl one-liner in [Stratum over TLS](#stratum-over-tls-optional)) and `apply`. | | Worker missing from the dashboard | The dashboard discovers rigs from their stratum `user` label; confirm the worker is actually connected to the pool and mining. | | Rig shows as connected but no stats | By default the API is open and the dashboard reads it with no token. If you set an `ACCESS_TOKEN` here, the dashboard must match it (`workers.api_auth: token` + `workers.api_token`, or `name` if the token is the rig name); otherwise clear `ACCESS_TOKEN` and re-run setup. | | Stats unreachable from the stack host | Confirm the worker's `:8080` is reachable from the stack host over the LAN (firewall, correct IP). RigForge binds `0.0.0.0` by default. | From d5c8656f24d6e726f6c9b3a2f098e28cd39bbbac Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 14 Aug 2026 19:34:30 -0500 Subject: [PATCH 16/32] feat(api): mirror the last control outcome into the enriched feed (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pithead's host runner caps its synchronous /status poll after a worker-apply at 20s, but control-apply's auto-rollback can take minutes — the change-history row froze at 'accepted' with no way to catch up short of a new authenticated dial to the rig's control port (a token the dashboard container never holds). The rig now mirrors its own last outcome into the already-open read feed: _api_control_json reads {change_id, status, reason} from the status.json _control_status already writes 0644, and _api_rigforge_block serves it as rigforge.control. Missing, unreadable, or malformed status.json degrades to control: null — the mirror can never break the feed — and pithead's parse_worker_control_status filters non-terminal records itself. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++++ docs/operations.md | 5 +++-- docs/pithead-integration.md | 5 ++++- rigforge.sh | 11 ++++++++++- tests/run.sh | 14 ++++++++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e045bf9..9c331de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to RigForge are documented here. The format is based on ## [Unreleased] +### Added + +- **The enriched feed mirrors the last control outcome (#346).** Pithead's host runner caps its + synchronous `/status` poll after a worker-apply, so a slow auto-rollback outruns it and the change + history row froze at `accepted`. The sister API's `rigforge` block now carries `control` — + `{change_id, status, reason}` read from the control status file — so the next routine poll of the + open read feed catches the terminal outcome without a new authenticated dial to the control port. + A rig that never took a control change, or an unreadable/malformed status file, serves + `control: null`; the mirror never breaks the feed. + ## [1.14.0] - 2026-08-02 ### Added diff --git a/docs/operations.md b/docs/operations.md index d02978d..e3a661a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -534,8 +534,9 @@ second read-only HTTP endpoint (default `:8081`, keys `api_port`/`api_bind`) wit `:8080` XMRig API has **plus** the data only RigForge knows: applied tune knobs and the last tune run (`/tune`), hashrate-per-watt from RAPL, the doctor probes — HugePages, MSR state, governor, RAM channels/speeds, memory-profile and SMT state, throttling — the watchdog's armed/thermal state, -the effective **writable** config (`config`, pool secrets masked; #253), and a config revision + -last-change provenance (`config_meta`; #254) — as JSON (`/health`, or nested under +the effective **writable** config (`config`, pool secrets masked; #253), a config revision + +last-change provenance (`config_meta`; #254), and the last control-path outcome (`control`, +`{change_id, status, reason}` or `null`; #346) — as JSON (`/health`, or nested under `rigforge` in `/1/summary` and `/2/summary`). It follows XMRig's own architecture: one tiny persistent server (python3 stdlib, ~10 MB idle) ships pre-computed bytes, so a request costs microseconds and cannot touch mining performance; a systemd timer recomputes the state every 15 diff --git a/docs/pithead-integration.md b/docs/pithead-integration.md index 68e22dd..dfb7ad1 100644 --- a/docs/pithead-integration.md +++ b/docs/pithead-integration.md @@ -82,7 +82,10 @@ pithead#235): config — exactly the control-path allowlist, pool secrets masked; see the prefill note in §3), and `config_meta` (`{revision, changed_at, source, last_change_id}` — `revision` is a content hash of the writable config that changes iff that config changes, so a poller can detect a change made directly - on the rig; `source` is `control`/`local`/`restore`; see §3). + on the rig; `source` is `control`/`local`/`restore`; see §3), and `control` (the last control-path + outcome, `{change_id, status, reason}` mirrored from the control status file, so a poller that + missed a slow rollback catches the terminal outcome here without dialing the control port; `null` + when the rig has never taken a control change or the status file is unreadable). - `GET /health` and `GET /tune` — the `rigforge.health` / `rigforge.tune` objects bare. - When XMRig's own API is unreachable the response is still `200` with `"rigforge": {..., "xmrig_api": "unreachable"}` — a down miner is exactly when the health data diff --git a/rigforge.sh b/rigforge.sh index 322fa62..086cd7c 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -4721,8 +4721,17 @@ _api_config_meta_json() { fi } +# #346: the last control outcome mirrored into the feed as `rigforge.control`. Pithead's host runner +# caps its synchronous /status poll after a worker-apply, so a slow rollback outruns it; rather than a +# new authenticated dial to the control port, the rig mirrors {change_id, status, reason} from the +# status.json _control_status already writes 644, and the next routine poll catches up. Missing, +# unreadable, or malformed status.json -> null — the mirror must never break the feed. +_api_control_json() { + jq -c '{change_id, status, reason}' "${RIGFORGE_CONTROL_STATE:-/var/lib/rigforge-control}/status.json" 2>/dev/null || echo null +} + _api_rigforge_block() { # - jq -n --arg v "$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo unknown)" --arg xv "$XMRIG_VERSION" --arg xc "$XMRIG_COMMIT" --argjson tune "$(_api_tune_json)" --argjson power "$(_api_power_json "$1")" --argjson health "$(_health_json)" --argjson watchdog "$(_watchdog_json)" --argjson config "$(_api_config_json)" --argjson config_meta "$(_api_config_meta_json)" '{version: $v, xmrig_version: $xv, xmrig_commit: $xc, tune: $tune, power: $power, health: $health, watchdog: $watchdog, config: $config, config_meta: $config_meta}' + jq -n --arg v "$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo unknown)" --arg xv "$XMRIG_VERSION" --arg xc "$XMRIG_COMMIT" --argjson tune "$(_api_tune_json)" --argjson power "$(_api_power_json "$1")" --argjson health "$(_health_json)" --argjson watchdog "$(_watchdog_json)" --argjson config "$(_api_config_json)" --argjson config_meta "$(_api_config_meta_json)" --argjson control "$(_api_control_json)" '{version: $v, xmrig_version: $xv, xmrig_commit: $xc, tune: $tune, power: $power, health: $health, watchdog: $watchdog, config: $config, config_meta: $config_meta, control: $control}' } # Produce the sister API's response bodies: compute once, write atomically (tmp + rename, the diff --git a/tests/run.sh b/tests/run.sh index 4b6599f..e84f9ea 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -5979,6 +5979,20 @@ printf '{broken' >"$APIQ/home/worker/tune-overrides.json" run_refresh assert_eq "corrupt tune-overrides -> applied null, not a crash" "$(jq -r '.applied' "$APIQ/data/tune.json")" "null" rm -f "$APIQ/home/worker/tune-overrides.json" +# #346: the last control outcome mirrored into the feed as rigforge.control — pithead's poller catches +# a late terminal outcome on the open read feed instead of a new authenticated dial to the control port. +# A realistic full status.json (the _control_status shape): the mirror picks exactly the three keys. +CTL346="$(mktemp -d "$SANDBOX/ctl346.XXXXXX")" +printf '%s' '{"status":"rolled_back","change_id":"abc0123456789def","source":"control","applied_at":"2026-01-01T00:00:00Z","changed_keys":["DONATION"],"reason":"miner did not return to a live hashrate; rolled back and live","backup":"/b","warnings":[]}' >"$CTL346/status.json" +run_refresh "RIGFORGE_CONTROL_STATE=$CTL346" +assert_eq "control mirror: exactly {change_id, status, reason} on the feed (#346)" "$(jq -cS '.rigforge.control' "$APIQ/data/summary.json")" '{"change_id":"abc0123456789def","reason":"miner did not return to a live hashrate; rolled back and live","status":"rolled_back"}' +# No status.json (a rig that never took a control change, or control disabled) -> null, feed intact. +run_refresh "RIGFORGE_CONTROL_STATE=$CTL346/absent" +assert_eq "control mirror: no status.json -> null (#346)" "$(jq -c '.rigforge.control' "$APIQ/data/summary.json")" "null" +# Malformed status.json -> null, and the refresh still writes the feed. +printf '{broken' >"$CTL346/status.json" +run_refresh "RIGFORGE_CONTROL_STATE=$CTL346" +assert_eq "control mirror: malformed status.json -> null, refresh survives (#346)" "$(jq -c '.rigforge.control' "$APIQ/data/summary.json")" "null" # #276 (item 5): each `printf | jq ... && mv` (rigforge.sh:4233-4235) is independently atomic — a jq # failure on ONE file must not corrupt or block the others. Break _api_rigforge_block so specifically # health.json's own extraction (`.health + {watchdog: .watchdog}`) fails (health is a string, not an From f28020eab305cee3aaba64deb7c4f57af00cc3a6 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 14 Aug 2026 19:39:32 -0500 Subject: [PATCH 17/32] fix(status): serialize the hugepages array so the stats row renders (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMRig's /2/summary reports "hugepages" as a [loaded, total] pages array; jq's @tsv refuses nested arrays (exit 5), so _status_api_summary's single stats-row fork died on every healthy rig — the || true swallowed the exit but the ERR trap still printed an abort, and the Hashrate/Pool/Uptime/ Shares block never rendered. Serialize the array to "loaded/total" before @tsv; scalar and absent shapes pass through unchanged. Tests: the shared curl stub now emits the real array shape (the old bool-shaped fixture is how this slipped past the suite), the #143 assertion tracks it, and a new #341 section renders status against array, scalar, and absent hugepages bodies. Co-Authored-By: Claude Fable 5 --- rigforge.sh | 7 +++++-- tests/run.sh | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/rigforge.sh b/rigforge.sh index 322fa62..061d6a1 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -3688,10 +3688,13 @@ _status_api_summary() { echo "RigForge: worker API not reachable at 127.0.0.1:8080 (miner stopped or still starting)." return 0 fi - # One jq fork for every field, tab-separated (bash-3.2-safe read into locals). + # One jq fork for every field, tab-separated (bash-3.2-safe read into locals). /2/summary reports + # hugepages as a [loaded, total] pages array — @tsv rejects nested arrays (exit 5, killing the whole + # row on every healthy rig, #341), so serialize it; scalar/absent shapes pass through untouched. IFS=$(printf '\t') read -r hs pool up acc rej hp < <(printf '%s' "$body" | jq -r '[(.hashrate.total[0] // 0), (.connection.pool // "?"), (.uptime // 0), - (.connection.accepted // 0), (.connection.rejected // 0), (.hugepages // "")] | @tsv' 2>/dev/null) || true + (.connection.accepted // 0), (.connection.rejected // 0), + (.hugepages // "" | if type == "array" then join("/") else . end)] | @tsv' 2>/dev/null) || true [ -n "${hs:-}" ] || return 0 # half-up API / unparseable body: stay quiet, platform block follows printf ' %-10s %s H/s\n' "Hashrate:" "$hs" printf ' %-10s %s\n' "Pool:" "$pool" diff --git a/tests/run.sh b/tests/run.sh index 4b6599f..ec2c701 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -184,10 +184,12 @@ exit 0 EOF # curl stub for the worker-API probe: record the invocation (so a test can assert whether an # Authorization header was passed) and emit an XMRig-style /2/summary body. Exits 0 like a real 200. + # hugepages is the real /2/summary shape — a [loaded, total] pages ARRAY, not a bool; the old + # bool-shaped fixture is exactly how the @tsv array crash (#341) slipped past this suite. cat >"$bin/curl" <<'EOF' #!/usr/bin/env bash echo "[curl] $*" >> "${CURL_LOG:-/dev/null}" -printf '{"hashrate":{"total":[%s,0,0]},"connection":{"pool":"poolbox.lan:3333","accepted":42,"rejected":1},"uptime":93780,"hugepages":true}\n' "${STUB_API_HR:-1234.5}" +printf '{"hashrate":{"total":[%s,0,0]},"connection":{"pool":"poolbox.lan:3333","accepted":42,"rejected":1},"uptime":93780,"hugepages":[1248,1248]}\n' "${STUB_API_HR:-1234.5}" EOF chmod +x "$bin"/* @@ -2439,7 +2441,7 @@ assert_contains "status: hashrate line (#143)" "$out" "Hashrate: 1234.5 H/s" assert_contains "status: pool line (#143)" "$out" "Pool: poolbox.lan:3333" assert_contains "status: uptime rendered as d/h/m (#143)" "$out" "Uptime: 1d 2h 3m" assert_contains "status: shares line (#143)" "$out" "42 accepted / 1 rejected" -assert_contains "status: hugepages line when the field exists (#143)" "$out" "HugePages: true" +assert_contains "status: hugepages line when the field exists (#143)" "$out" "HugePages: 1248/1248" assert_contains "status: platform block still follows (#143)" "$(cat "$ST/calls.log")" "[systemctl] status xmrig" : >"$ST/calls.log" out="$(run_status fail)" @@ -2460,6 +2462,34 @@ out="$( assert_contains "status: missing config degrades to the platform block (#143)" "$(cat "$ST/calls.log")" "[systemctl] status xmrig" assert_contains "status: missing config still exits 0 (#143)" "$out" "rc=0" +# #341: XMRig's /2/summary reports hugepages as a [loaded, total] pages ARRAY; @tsv refuses nested +# arrays (jq exit 5), which killed the whole stats row — and with it every line above — on every +# healthy rig. The render must serialize the array, and keep the scalar/absent shapes working. +echo "== unit: status hugepages shapes (#341) ==" +run_status_body() { # : svc_status with curl faked to return exactly this body + ( + source "$SCRIPT" + OS_TYPE=Linux + SERVICE_NAME=xmrig + CONFIG_JSON="$ST/config.json" + unset API_CMD + # Distinct name on purpose: _status_api_summary's own `local body` would shadow a `body` + # here through bash's dynamic scoping, and the fake would print the empty local instead. + STUB_BODY_341="$1" + curl() { printf '%s' "$STUB_BODY_341"; } + set +e + PATH="$STUBS:$PATH" CALL_LOG="$ST/calls.log" svc_status 2>&1 + ) +} +out="$(run_status_body '{"hashrate":{"total":[321.0,0,0]},"connection":{"pool":"h:3333","accepted":7,"rejected":0},"uptime":60,"hugepages":[0,1280]}')" +assert_contains "status: array hugepages render loaded/total (#341)" "$out" "HugePages: 0/1280" +assert_contains "status: stats row survives the array (#341)" "$out" "Hashrate: 321.0 H/s" +out="$(run_status_body '{"hashrate":{"total":[321.0,0,0]},"uptime":60,"hugepages":true}')" +assert_contains "status: scalar hugepages still renders (#341)" "$out" "HugePages: true" +out="$(run_status_body '{"hashrate":{"total":[321.0,0,0]},"uptime":60}')" +assert_absent "status: absent hugepages -> no line (#341)" "$out" "HugePages:" +assert_contains "status: stats row renders without hugepages (#341)" "$out" "Hashrate: 321.0 H/s" + # #reown: REAL_USER is who root-written files are handed back to. The systemd autotune runs as root with # no SUDO_USER, so its unit's RIGFORGE_OPERATOR must drive the re-own; interactive SUDO_USER still wins. ru_op="$( ( From a0396d3dba38a9f7ba1ec2dbe31f9ef84b1df4b3 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 14 Aug 2026 19:49:52 -0500 Subject: [PATCH 18/32] test(e2e): prove the appliance contracts against a real /etc in-container (#348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/e2e/linux.sh now runs TWO passes, a fresh disposable container each: the standard deploy, then RIGFORGE_APPLIANCE=1. The appliance branch in in-container.sh reuses the shared harness setup (real apt prereqs, /etc seed, stubs, config) and asserts the mode's contracts on the container's real filesystem across a first run and an every-boot re-run: - units render into /run/systemd/system (real envsubst|tee), and nothing lands under /etc/systemd/system; - no package installs: install_dependencies reads the toolchain as baked (new cc stub feeds the command -v probe) and a logging apt-get stub proves apt is never invoked; - /etc/fstab, /etc/security/limits.conf and /etc/default/grub stay byte-identical (grepping for "memlock" would false-fail: the stock ubuntu limits.conf documents it in comments), no modules-load or logrotate drop-ins; - every systemctl enable carries --runtime, pinned non-vacuous by the xmrig enable line and a zero count of enables without it; - hugetlbfs mounted at runtime (argument-level: mount is stubbed, the real mountpoint probe reports not-mounted in a fresh container). Honesty boundary stated in the harness comment: systemctl/mount stay stubbed (no pid-1 systemd, no privileges in the container), so those contracts are proven at the argument level; the filesystem effects are real. To make argument-level assertions readable at all, the container stubs now log "[cmd] args" to $CALL_LOG (run.sh's idiom) — this also turns run 3's previously vacuous "no git clone" assert into real evidence, since the git stub never logged before. CI wiring is the existing e2e job's existing step: make test-e2e runs both passes, so local runs and CI cover appliance identically with no matrix plumbing. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 ++ tests/README.md | 2 +- tests/e2e/in-container.sh | 104 ++++++++++++++++++++++++++++++++++---- tests/e2e/linux.sh | 32 ++++++++---- 4 files changed, 119 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c61427..2246002 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,9 @@ jobs: persist-credentials: false # zizmor: artipacked # Runs the real script end-to-end inside a disposable Ubuntu container (RigForge's documented # Linux target), exercising the genuine Linux deploy path and /etc idempotency with real tools. + # linux.sh runs TWO passes (a fresh container each): the standard deploy, then + # RIGFORGE_APPLIANCE=1 (#348) proving the appliance contracts against a real /etc — units in + # /run/systemd/system, no package installs, no fstab/limits/logrotate writes, --runtime enables. - name: Run end-to-end suite run: make test-e2e diff --git a/tests/README.md b/tests/README.md index 0f6d9ee..716b8ae 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,7 +13,7 @@ compiles XMRig and mines. Each layer covers what the one below it has to stub. | Layer | File | Runs | What it proves | How to run | |---|---|---|---|---| | **Unit + black-box suite** | [`run.sh`](run.sh) | Any host (macOS/Linux), no Docker. **In CI.** | Config parsing, the XMRig-config generation matrix (every CPU/OS profile, simulated via PATH stubs), GRUB/HugePages math, the command surface, tune search, doctor: everything that doesn't need a real `/etc` or real hardware. The bulk of coverage. | `make test` (lint + suite) or `bash tests/run.sh` | -| **Linux container e2e** | [`e2e/linux.sh`](e2e/linux.sh) → [`e2e/in-container.sh`](e2e/in-container.sh) | Disposable Ubuntu container, **needs Docker**. **In CI.** | The genuine Linux deploy path against a real (throwaway) `/etc` with real GNU tools (`sed -i`, `tee`, `envsubst`) + idempotency on re-run. Only the heavy/privileged bits (compile, package install, `systemctl`/`mount`) are stubbed. | `make test-e2e` | +| **Linux container e2e** | [`e2e/linux.sh`](e2e/linux.sh) → [`e2e/in-container.sh`](e2e/in-container.sh) | Disposable Ubuntu container, **needs Docker**. **In CI.** | The genuine Linux deploy path against a real (throwaway) `/etc` with real GNU tools (`sed -i`, `tee`, `envsubst`) + idempotency on re-run. Only the heavy/privileged bits (compile, package install, `systemctl`/`mount`) are stubbed. A second pass re-runs the container with `RIGFORGE_APPLIANCE=1` (#348) and proves the appliance contracts on that real `/etc`: units land in `/run/systemd/system`, no package installs, no fstab/limits.conf/logrotate writes, every enable carries `--runtime`. | `make test-e2e` | | **Native macOS e2e** | [`e2e/macos.sh`](e2e/macos.sh) | A real Mac, **CI-only** (runs as a step in the macOS job). | The macOS deploy path with genuine BSD tools the Linux CI can only stub: BSD `sed`, the macOS config profile, `mac_*` process control (real `nohup` + PID file), the launchd login agent, `backup`/`restore`. | `make test-e2e-macos` | | **Coverage gate** | [`coverage.sh`](coverage.sh) | kcov in **Docker**. **In CI.** | Line coverage of `rigforge.sh` + `util/proposed-grub.sh` by running `run.sh` under kcov; enforces the committed floor ([`coverage-floor.txt`](coverage-floor.txt)) plus a patch-coverage gate (diff-cover) on changed lines. | `make coverage` | | **Release smoke (quick)** | [`smoke.sh`](smoke.sh) | Real Linux rig, **manual**. Not in CI. | The compiled binary actually starts and hashes (`xmrig --bench`, fully offline). Fast pre-tag confidence that the worker we ship runs. | `make smoke` | diff --git a/tests/e2e/in-container.sh b/tests/e2e/in-container.sh index b935255..ea23f3d 100755 --- a/tests/e2e/in-container.sh +++ b/tests/e2e/in-container.sh @@ -2,7 +2,10 @@ # # Runs INSIDE a disposable Linux container (invoked by tests/e2e/linux.sh). Provisions a writable copy # of the repo, runs the real rigforge.sh twice against the container's real /etc, and asserts the -# Linux deploy path + idempotency. Exits non-zero on any failed assertion. +# Linux deploy path + idempotency. With RIGFORGE_APPLIANCE=1 in the environment (linux.sh's second +# pass, #348) it instead asserts the appliance contracts against the same real /etc: units in +# /run/systemd/system, no package installs, no fstab/limits.conf/logrotate writes, --runtime enables. +# Exits non-zero on any failed assertion. # set -uo pipefail @@ -20,6 +23,16 @@ assert_rc() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected rc $3, g assert_eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3], got [$2]"; fi; } assert_contains() { case "$2" in *"$3"*) ok "$1" ;; *) bad "$1" "[$2] missing [$3]" ;; esac } assert_absent() { case "$2" in *"$3"*) bad "$1" "[$2] unexpectedly contains [$3]" ;; *) ok "$1" ;; esac } +summarize() { # print the tally and exit (non-zero if any assertion failed) + echo "" + printf 'in-container: \033[1;32m%d passed\033[0m, ' "$PASS" + if [ "$FAIL" -gt 0 ]; then + printf '\033[1;31m%d failed\033[0m\n' "$FAIL" + exit 1 + fi + printf '0 failed\n' + exit 0 +} # 1. Real prerequisites: jq + envsubst (gettext). Installed with the REAL apt before stubs go on # PATH, so the script's own dependency step is the only thing we stub out. @@ -61,6 +74,7 @@ exec "$@" X cat >"$STUBS/git" <<'X' #!/usr/bin/env bash +echo "[git] $*" >>"${CALL_LOG:-/dev/null}" case "$*" in *rev-parse*) echo "${XMRIG_COMMIT:-}" ;; # #18 verifies the cloned commit *clone*) mkdir -p xmrig/src; printf 'static int DonateLevel = 1;\n' > xmrig/src/donate.h ;; @@ -75,9 +89,11 @@ echo "Socket(s): 2" X printf '#!/usr/bin/env bash\necho 8\n' >"$STUBS/nproc" printf '#!/usr/bin/env bash\necho poolbox\n' >"$STUBS/hostname" -# No-op the rest (sysctl -w / mount / systemctl etc. cannot run unprivileged in a container). -for c in cmake make systemctl modprobe mount cpupower update-grub sysctl dpkg nft; do - printf '#!/usr/bin/env bash\nexit 0\n' >"$STUBS/$c" +# No-op the rest (sysctl -w / mount / systemctl etc. cannot run unprivileged in a container). Each +# stub logs "[cmd] args" to $CALL_LOG (run.sh's idiom) so call-shape assertions read real evidence +# instead of an always-empty file. cc exists only for the appliance pass's baked-toolchain probe. +for c in cmake make systemctl modprobe mount cpupower update-grub sysctl dpkg nft cc; do + printf '#!/usr/bin/env bash\necho "[%s] $*" >>"${CALL_LOG:-/dev/null}"\nexit 0\n' "$c" >"$STUBS/$c" done chmod +x "$STUBS"/* export PATH="$STUBS:$PATH" @@ -94,6 +110,78 @@ EOF BUILD="$WORK/data-home/worker/xmrig/build" ARCH="$(uname -m)" +# Appliance pass (#348): RIGFORGE_APPLIANCE=1 in the environment (linux.sh's second pass) asserts +# the appliance contracts against the same real container /etc, then exits — tests/run.sh already +# covers the mode's per-function branches with PATH stubs; this proves the /etc side for real. +# What is REAL here: the filesystem (/etc and /run), the unit renders (envsubst | tee), sed, jq, +# useradd, and the mountpoint probe. What stays STUBBED — so those contracts are proven at the +# argument level only: systemctl (no pid-1 systemd in a container; --runtime is asserted on the +# logged args), mount (needs privileges), the compile toolchain, and apt-get (stubbed to LOG so a +# wrongful install attempt becomes assertion evidence instead of a real package install). +if [ "${RIGFORGE_APPLIANCE:-0}" = 1 ]; then + cat >"$STUBS/apt-get" <<'X' +#!/usr/bin/env bash +echo "[apt-get] $*" >>"${CALL_LOG:-/dev/null}" +exit 0 +X + chmod +x "$STUBS/apt-get" + # systemd owns /run/systemd/system on the real appliance; the container has no pid-1 systemd. + mkdir -p /run/systemd/system + # Byte-identical before/after is the contract: appliance mode writes NOTHING to these files. + # (Grepping for e.g. "memlock" would false-fail — the stock limits.conf documents it in comments.) + grub_before="$(cat /etc/default/grub)" + fstab_before="$(cat /etc/fstab)" + limits_before="$(cat /etc/security/limits.conf)" + + echo "== appliance run (the real /etc must stay untouched) ==" + aout="$(CALL_LOG="$WORK/appliance-calls.log" ./rigforge.sh &1)" + arc=$? + assert_rc "appliance run exits 0" "$arc" "0" + [ "$arc" = 0 ] || printf '%s\n' "$aout" | tail -20 + acalls="$(cat "$WORK/appliance-calls.log" 2>/dev/null)" + # Units land in /run/systemd/system, never on the volatile /etc overlay. + assert_eq "appliance: xmrig unit rendered into /run/systemd/system" "$([ -f /run/systemd/system/xmrig.service ] && echo y || echo n)" "y" + assert_eq "appliance: no xmrig unit in /etc/systemd/system" "$([ -e /etc/systemd/system/xmrig.service ] && echo present || echo absent)" "absent" + assert_eq "appliance: sister API server unit in /run (#99)" "$([ -f /run/systemd/system/rigforge-api.service ] && echo y || echo n)" "y" + assert_eq "appliance: API refresh timer in /run (#99)" "$([ -f /run/systemd/system/rigforge-api-refresh.timer ] && echo y || echo n)" "y" + assert_eq "appliance: no rigforge/xmrig unit anywhere under /etc/systemd/system" "$(find /etc/systemd/system \( -name '*.service' -o -name '*.timer' -o -name '*.path' \) 2>/dev/null | grep -c 'xmrig\|rigforge')" "0" + assert_contains "appliance: unit rendered by real envsubst" "$(cat /run/systemd/system/xmrig.service 2>/dev/null)" "ReadWritePaths=$WORK/data-home/worker" + assert_absent "appliance: server unit fully rendered (no unexpanded vars)" "$(cat /run/systemd/system/rigforge-api.service 2>/dev/null)" '$SCRIPT_DIR' + # Never installs packages: the toolchain reads baked (stub cc + git/cmake/make on PATH). + assert_contains "appliance: deps declared baked, no install" "$aout" "dependencies are baked into the image" + assert_absent "appliance: apt-get never invoked" "$acalls" "[apt-get]" + # /etc stays byte-identical: no fstab/limits/GRUB/modules-load/logrotate writes. + assert_eq "appliance: fstab byte-identical" "$(cat /etc/fstab)" "$fstab_before" + assert_eq "appliance: limits.conf byte-identical" "$(cat /etc/security/limits.conf)" "$limits_before" + assert_eq "appliance: GRUB byte-identical" "$(cat /etc/default/grub)" "$grub_before" + assert_eq "appliance: no GRUB backup written" "$([ -e /etc/default/grub.bak ] && echo present || echo absent)" "absent" + assert_contains "appliance: GRUB skip is deliberate (image-owned cmdline)" "$aout" "the kernel cmdline is image-owned" + assert_eq "appliance: no modules-load drop-in" "$([ -e /etc/modules-load.d/msr.conf ] && echo present || echo absent)" "absent" + assert_eq "appliance: no logrotate drop-in" "$([ -e /etc/logrotate.d/xmrig ] && echo present || echo absent)" "absent" + # Enablement is transient. The xmrig assert pins one real site (non-vacuous), the count guards + # every other enable site (timers, api, control) against a forgotten ${ENABLE_RUNTIME:+...}. + assert_contains "appliance: xmrig enable carries --runtime" "$acalls" "[systemctl] enable --runtime xmrig.service" + assert_eq "appliance: every systemctl enable is --runtime" "$(grep -F "[systemctl] enable" "$WORK/appliance-calls.log" | grep -cv -- --runtime)" "0" + # hugetlbfs is mounted at runtime instead of via fstab (mount is stubbed: argument-level proof; + # the real mountpoint probe reports not-mounted in a fresh container, so both mounts must fire). + assert_contains "appliance: runtime 2MB hugetlbfs mount" "$acalls" "[mount] -t hugetlbfs hugetlbfs /dev/hugepages" + assert_contains "appliance: runtime 1G hugetlbfs mount" "$acalls" "[mount] -t hugetlbfs -o pagesize=1G hugetlbfs_1g /dev/hugepages1G" + assert_eq "appliance: 1G mountpoint dir created for real" "$([ -d /dev/hugepages1G ] && echo y || echo n)" "y" + + echo "== appliance second run (the every-boot path accretes no /etc state) ==" + aout2="$(CALL_LOG="$WORK/appliance-calls2.log" ./rigforge.sh &1)" + arc2=$? + assert_rc "appliance re-run exits 0" "$arc2" "0" + [ "$arc2" = 0 ] || printf '%s\n' "$aout2" | tail -20 + assert_eq "appliance re-run: fstab still byte-identical" "$(cat /etc/fstab)" "$fstab_before" + assert_eq "appliance re-run: limits.conf still byte-identical" "$(cat /etc/security/limits.conf)" "$limits_before" + assert_eq "appliance re-run: GRUB still byte-identical" "$(cat /etc/default/grub)" "$grub_before" + assert_contains "appliance re-run: xmrig re-enabled --runtime" "$(cat "$WORK/appliance-calls2.log" 2>/dev/null)" "[systemctl] enable --runtime xmrig.service" + assert_eq "appliance re-run: every systemctl enable is --runtime" "$(grep -F "[systemctl] enable" "$WORK/appliance-calls2.log" | grep -cv -- --runtime)" "0" + + summarize +fi + # #146: the dry-run plan, against the REAL container: real dpkg probe, real /proc for the # HugePages count, real proposed-grub.sh for the exact GRUB before -> after diff. Run BEFORE the # real setup so the plan shows the fresh-box actions — and prove it changed nothing. @@ -273,10 +361,4 @@ assert_eq "uninstall: removed the 'rigforge' command from PATH" "$([ -L /usr/loc ./rigforge.sh uninstall --yes /dev/null 2>&1 assert_rc "uninstall is idempotent" "$?" "0" -echo "" -printf 'in-container: \033[1;32m%d passed\033[0m, ' "$PASS" -if [ "$FAIL" -gt 0 ]; then - printf '\033[1;31m%d failed\033[0m\n' "$FAIL" - exit 1 -fi -printf '0 failed\n' +summarize diff --git a/tests/e2e/linux.sh b/tests/e2e/linux.sh index 451dc96..4bc5dfc 100644 --- a/tests/e2e/linux.sh +++ b/tests/e2e/linux.sh @@ -9,7 +9,9 @@ # compile (git/cmake/make), the package install (dpkg reports "already present"), and the host-only # bits (systemctl/modprobe/mount/sysctl). Hardware detection is stubbed so the CPU profile is # deterministic. We force linux/amd64 so the x86-only MSR path actually fires (emulated on Apple -# Silicon). Run: tests/e2e/linux.sh (or: make test-e2e) +# Silicon). Two passes, a fresh container each: the standard deploy, then RIGFORGE_APPLIANCE=1 +# (#348) — appliance mode against a real /etc, which must stay untouched while units land in /run. +# Run: tests/e2e/linux.sh (or: make test-e2e) # set -uo pipefail @@ -28,14 +30,24 @@ fi # multi-arch index digest; --platform below selects linux/amd64 from it. Refresh with: # docker buildx imagetools inspect ubuntu:24.04 --format '{{.Manifest.Digest}}' IMAGE="ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54" -echo "=================== E2E: $IMAGE (linux/amd64) ===================" -if docker run --rm --platform linux/amd64 \ - -v "$ROOT:/src:ro" \ - "$IMAGE" bash /src/tests/e2e/in-container.sh; then - echo "" - echo "rigforge e2e: $IMAGE passed" + +run_pass() { #