From 8bfbb4b62afa1879c8aa40b5e2f5c39b3f9c0e79 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 12:30:41 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(appliance):=20the=20built-in=20RigForg?= =?UTF-8?q?e=20miner=20=E2=80=94=20'Both'=20on=20the=20current=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appliance now honours "Mine on this machine too?" itself instead of telling a shell-less machine's operator to install RigForge by hand: - image bake: rigforge's Debian toolchain (trixie: linux-cpupower), a pinned rigforge tree (ARG RIGFORGE_REF, recorded in the image) and a prebuilt XMRig at rigforge's own pin — a Tor-only box can never run rigforge's github clone, so the clone happens at build time; - pithead-sync: rigforge program tree replaced wholesale on /data, config.json + workspace preserved, the prebuilt seeded only when the pinned commit changed (a same-pin native rebuild is left alone); - render: rigforge's config.json becomes a derived file — loopback pool url, stratum password when set, and the stack's HugePages budget declared as hugepages_reserve_extra_mb so rigforge's grow-only sysctl sizes the shared pool as the single writer; pithead's own vm.nr_hugepages write is now grow-only for the same reason; - boot path: pithead-boot runs `pithead local-miner` LAST, after the health-gated slot commit — the miner needs the stratum listening and must never delay or block the commit; setup and apply run the same leg so first provisioning and dashboard toggles converge without a reboot; - wizard: the Yes answer promises the built-in worker, with no install-it-yourself instruction anywhere. Coverage: stack tests for the render/boot/sync legs + grow-only sysctl (1878 pass), a frontend probe pinning the wizard promise (292 pass), verify-image checks for everything baked, and the KVM provision phase gains the enable -> xmrig up -> share accepted -> back-after-reboot leg (needs the KVM host to run). Closes #796 Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/web/static/wizard.mjs | 10 +- .../dashboard/tests/frontend/wizard.test.mjs | 12 + docs/appliance.md | 2 +- docs/dev/appliance-release.md | 2 +- docs/dev/appliance-wizard.md | 17 +- docs/workers.md | 14 +- os/overlay/pithead-boot | 6 + os/overlay/pithead-sync | 39 +++- os/rootfs/Containerfile | 45 ++++ pithead | 133 ++++++++++- pithead-completion.bash | 2 +- tests/os/run.sh | 56 ++++- tests/os/verify-image.sh | 11 + tests/stack/run.sh | 207 ++++++++++++++++++ 14 files changed, 534 insertions(+), 22 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/static/wizard.mjs b/build/dashboard/mining_dashboard/web/static/wizard.mjs index 1cdfef97..fc349f9b 100644 --- a/build/dashboard/mining_dashboard/web/static/wizard.mjs +++ b/build/dashboard/mining_dashboard/web/static/wizard.mjs @@ -477,14 +477,16 @@ export class WizardApp extends Component { <${Field} label="Mine on this machine too?"> ${ v("localMiner") === true && - html`<${Note}>Pithead serves the pool; RigForge does the mining. After setup, - install RigForge on this machine and point it at the stratum address the final - page shows — it owns all CPU tuning.` + html`<${Note}>Nothing to install: the machine carries its own RigForge miner, + pointed at its own pool. It starts by itself once the stack is up and appears + in the dashboard's Workers view. Mining tunes the whole box for hashrate — + CPU governor, memory reservations — which is exactly what a dedicated + appliance is for.` }

First sync

diff --git a/build/dashboard/tests/frontend/wizard.test.mjs b/build/dashboard/tests/frontend/wizard.test.mjs index 00a0757a..30fdb0f0 100644 --- a/build/dashboard/tests/frontend/wizard.test.mjs +++ b/build/dashboard/tests/frontend/wizard.test.mjs @@ -391,6 +391,18 @@ test("the mine-on-this-box choice is a labeled select naming RigForge, default N restore(); }); +test("saying yes promises the built-in miner, never a manual install", async () => { + // The appliance honours the choice itself now: an operator who answers Yes must not be + // told to install anything — that instruction pointed at a rig that could never exist + // on a machine with no shell. + const { inst, restore } = await appOn([stateFor("setup")]); + inst.editJson({ target: { value: JSON.stringify({ local_miner: { enabled: true } }) } }); + const out = renderToString(inst.render()); + assert.match(out, /Nothing to install/); + assert.doesNotMatch(out, /install RigForge/i); + restore(); +}); + test("before a disk is chosen, the page asks ONLY that", async () => { const { inst, restore } = await appOn([ stateFor("installer", { diff --git a/docs/appliance.md b/docs/appliance.md index be8dab3b..9d006159 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -166,7 +166,7 @@ Then a handful of choices, all with sensible defaults: | Telegram bot | — | Optional. Alerts and status commands; needs both the token and the chat id. | | Monero node | run it here | Point at a node you already run. | | Tari node | run it here | Same, over a network you trust. | -| Mine with this machine's CPU | off | On if this box should mine as well as coordinate. | +| Mine with this machine's CPU | off | On if this box should mine as well as coordinate. Nothing to install: the image carries its own [RigForge](https://github.com/p2pool-starter-stack/rigforge) miner, pointed at this machine's own pool. It starts by itself once the stack is up, comes back on every boot, and appears in the dashboard's Workers view. Mining tunes the whole box for hashrate (CPU governor, memory reservations) — what a dedicated appliance is for. | | First sync | private over Tor | Faster over the open internet if days of syncing is too slow; it uses Tor afterwards either way. | | Time zone | UTC | For dashboard timestamps. | | Dashboard login | generate one for me | Or choose your own password. "No login" is offered but leaves the dashboard — payout addresses, hashrate — open to anyone on your network; never combine it with the Tor onion. | diff --git a/docs/dev/appliance-release.md b/docs/dev/appliance-release.md index c777b74f..845653a2 100644 --- a/docs/dev/appliance-release.md +++ b/docs/dev/appliance-release.md @@ -67,7 +67,7 @@ is the only thing standing between the hand-written boot path and a fleet. |---|---|---| | `boot` | EFI boot to userspace; first-boot wizard announces itself with a console token; wizard serves the token gate on `:80` | 3 | | `update` | `/data` grew to the disk and slots did not (#784); bundle installs into the spare; spare boots; **an uncommitted update reverts on reboot**; a committed update persists; **after the commit, the page served comes from the NEW dashboard image** (marker baked into the image and read back over HTTP — the tag never changes, so "containers run" proves nothing about staleness, #798); an operator can roll back off a committed version | 13 | -| `provision` | a config submitted through the wizard's real HTTP flow (token read from the console, exactly as a human would) provisions the stack: validation, cosign-verified image pulls, containers running under podman, dashboard served through caddy. Then a **reboot with no hands on it** — the stack must return unaided through `pithead-boot` (load baked images, render the derived layer, compose up, health-gated slot commit), the failure mode being a miner that sits dark after every power blip. The Caddyfile is corrupted and the archive digest records dropped before the reboot on purpose: derived things are regenerated every boot, and a stale one killed TLS on hardware. Catches an appliance whose engine cannot run the product — which happened, invisibly to every other phase | 10 | +| `provision` | a config submitted through the wizard's real HTTP flow (token read from the console, exactly as a human would) provisions the stack: validation, cosign-verified image pulls, containers running under podman, dashboard served through caddy. The submitted config also enables the on-box miner, so the **built-in RigForge worker** must come up on its own and get a share accepted upstream — the proxy logs `accepted` only for shares p2pool took. Then a **reboot with no hands on it** — the stack must return unaided through `pithead-boot` (load baked images, render the derived layer, compose up, health-gated slot commit, then the local-miner leg), the failure mode being a miner that sits dark after every power blip. The Caddyfile is corrupted and the archive digest records dropped before the reboot on purpose: derived things are regenerated every boot, and a stale one killed TLS on hardware. Catches an appliance whose engine cannot run the product — which happened, invisibly to every other phase | 13 | | `install` | the image boots as **removable** media (usb bus — the gate keys on it); the inventory offers the internal disk and never the boot medium; the real installer runs; the machine then boots from the target alone with a **complete** copy (`/var/lib/dpkg` — the overlay made an incomplete copy easy and invisible), a fresh machine-id, `/data` sized to the target, and the wizard serving. Then the **reinstall leg**: a sentinel planted in `/data`, a second install over the same disk, and the sentinel required afterwards — the chain-preserving promise, tested. The keep leg reinstalls from a **newer stick** over a `/data` that already holds the old dashboard image and its digest record: the image ID must change and the page served must come from the newer image (#798) | 21 | | `fault` | three power cuts mid-write; a deliberately corrupted bundle is refused without crashing and without bricking; a power cut inside the commit window; operator rollback after all of it; the box is still updatable afterwards | 11 | diff --git a/docs/dev/appliance-wizard.md b/docs/dev/appliance-wizard.md index d20becc7..c67b5770 100644 --- a/docs/dev/appliance-wizard.md +++ b/docs/dev/appliance-wizard.md @@ -110,7 +110,7 @@ certificate beats no dashboard. ## The boot contract (provisioned machines) -After provisioning, every boot runs one unit — `pithead-boot` — whose four steps each answer +After provisioning, every boot runs one unit — `pithead-boot` — whose five steps each answer a hardware-validated failure: 1. **`pithead load-images`** — load the baked container-image archives when their content @@ -136,6 +136,17 @@ a hardware-validated failure: site and proves nothing). A slot that boots but cannot serve stays uncommitted on purpose: that is the state A/B fallback exists for. Unprovisioned machines never commit — GRUB's clear-and-retry keeps them booting, and a bad update before provisioning reverts. +5. **`pithead local-miner`** — converge the built-in RigForge worker to `local_miner.enabled`, + deliberately LAST: the miner needs the stack's stratum listening, and it must never delay + or block the slot commit — the stack serving is the product's health, the miner is a + passenger (`|| true`). When enabled, this runs RigForge's setup in appliance mode from the + tree `pithead-sync` keeps on `/data/rigforge`: its unit renders into `/run` with + `--runtime` enablement (gone every boot, recreated here, like the control-runner units), + and the cached XMRig build on `/data` makes the run a re-render rather than a recompile. + The miner's config is derived by `render` (step 2's family): the stack's own stratum over + loopback, the stratum password, and the stack's HugePages budget declared as + `hugepages_reserve_extra_mb` — RigForge's grow-only sysctl then sizes the shared pool as + the single writer, and pithead's own HugePages write never shrinks a grown pool back. **Rule for changes:** anything generated from `config.json` or the program is derived and must be rebuilt by `render` — adding one anywhere else recreates the staleness bug. The container @@ -157,8 +168,8 @@ had a gap between it and the next one. | pure logic | `tests/frontend/configsync.test.mjs` | path access, typed coercion, address/pair guidance | | view rendering | `tests/frontend/wizard.test.mjs` (probes) | each view given its props | | **app orchestration** | `tests/frontend/wizard.test.mjs` (stubbed server) | **stage mapping, the handoff arriving through the poll, refresh-mid-provision, rejection round-trip, request bodies** | -| host logic | `tests/stack/run.sh` | cert minting + idempotence, remote-node preflight, pre-seed, install requests, the digest-keyed image loader, reinstall pre-fill (secret strip + fail-open) | -| the real thing | `tests/os/run.sh --phase provision` | token from the console → submit → handoff → ack → running stack → reboot through a corrupted Caddyfile → no failed units → slot self-commit | +| host logic | `tests/stack/run.sh` | cert minting + idempotence, remote-node preflight, pre-seed, install requests, the digest-keyed image loader, reinstall pre-fill (secret strip + fail-open), the local-miner legs (derived config, sync seeding, boot-leg wiring) | +| the real thing | `tests/os/run.sh --phase provision` | token from the console → submit → handoff → ack → running stack → built-in miner up and its shares accepted → reboot through a corrupted Caddyfile → no failed units → slot self-commit → miner back | The orchestration row is the one that was missing. pytest proved the endpoint published the credentials; a render probe proved the card renders given them; nothing proved the app *asked*. diff --git a/docs/workers.md b/docs/workers.md index a0747a67..d0833462 100644 --- a/docs/workers.md +++ b/docs/workers.md @@ -391,6 +391,15 @@ A box that runs the stack 24/7 has spare CPU between syncs. You can put that CPU co-locating a RigForge worker on the stack host, pointed at the stack's own stratum over loopback — no LAN exposure, no firewall rule, no Tor hop. +**On the appliance, this is built in.** The image carries a pinned RigForge tree and a prebuilt +XMRig, so answering yes to "Mine on this machine too?" in the setup wizard is the whole job: +pithead renders the miner's configuration (pool, stratum password, and the stack's HugePages +headroom), runs RigForge's setup in its appliance mode after the stack is up, and repeats that on +every boot — the miner's service lives in `/run` and is re-created each time, like every other +derived thing on the appliance. The worker appears in the dashboard's Workers Alive table; toggle +`local_miner.enabled` from the dashboard's configuration view to turn it off or on again. The +rest of this section is the DIY flow. + Opt in during `./pithead setup` (the prompt "Also mine on this machine with its spare CPU?", off by default), or set it in `config.json`: @@ -409,8 +418,9 @@ and the stratum password (the `PROXY_STRATUM_PASSWORD` already in `.env`, shown on the same host and enter those two values when it asks; the worker self-registers and appears in the dashboard's Workers Alive table like any other rig. -Pithead does not install, run, or tune the miner. RigForge owns all host-level tuning — HugePages, -GRUB, MSR, the CPU governor, and the miner service. Two things to know before enabling it: +On DIY, Pithead does not install, run, or tune the miner. RigForge owns all host-level tuning — +HugePages, GRUB, MSR, the CPU governor, and the miner service. Two things to know before enabling +it: - **RigForge's tuning is host-global.** It sets a `performance` CPU governor and may reserve HugePages box-wide, which affects every workload on the machine, not just the miner. That is fine diff --git a/os/overlay/pithead-boot b/os/overlay/pithead-boot index 5fb7ba66..2aba6f65 100755 --- a/os/overlay/pithead-boot +++ b/os/overlay/pithead-boot @@ -34,6 +34,12 @@ for _ in $(seq 90); do if [ "${code:-000}" != "000" ]; then command -v rauc >/dev/null 2>&1 && rauc status mark-good echo "pithead-boot: stack is serving (HTTP $code) — booted slot committed" + # The on-box miner comes LAST, after the slot commit: it needs the stack's stratum + # listening, and a miner that cannot start must never delay or block the commit — the + # stack serving is the product's health, the miner is a passenger. Idempotent every + # boot: RigForge's appliance mode re-renders its /run unit and re-tunes from the tree + # pithead-sync keeps on /data. No-op unless local_miner.enabled. + ./pithead local-miner || true exit 0 fi sleep 10 diff --git a/os/overlay/pithead-sync b/os/overlay/pithead-sync index bd0cbeff..726b43c4 100755 --- a/os/overlay/pithead-sync +++ b/os/overlay/pithead-sync @@ -2,8 +2,11 @@ # Copy the program tree from the system slot onto the data partition, leaving state untouched. # Runs every boot, so an A/B update delivers its new program the first time the new slot boots. set -euo pipefail -SRC=/opt/pithead -DST=/data/pithead +# Env-overridable so the stack tests can run this script against a sandbox. +SRC="${PITHEAD_SYNC_SRC:-/opt/pithead}" +DST="${PITHEAD_SYNC_DST:-/data/pithead}" +RF_SRC="${PITHEAD_SYNC_RIGFORGE_SRC:-/opt/rigforge}" +RF_DST="${PITHEAD_SYNC_RIGFORGE_DST:-/data/rigforge}" mkdir -p "$DST" for f in pithead pithead-completion.bash VERSION docker-compose.yml \ @@ -23,6 +26,38 @@ if [ -d "$SRC/build" ]; then fi # An images/ copy from an older layout is dead weight on the data partition — reclaim it. rm -rf "${DST:?}/images" + +# The RigForge leg: the image bakes a pinned RigForge tree, and the miner must run from /data — +# RigForge roots its workspace under its own script dir (DYNAMIC_HOME), so tree placement alone +# puts the XMRig build on writable storage. Program files are replaced wholesale like pithead's; +# state the baked tree never carries (config.json — pithead-rendered — and the data/ workspace) +# is untouched by construction, because only names present in the source are replaced. +if [ -d "$RF_SRC" ]; then + mkdir -p "$RF_DST" + for f in "$RF_SRC"/* "$RF_SRC"/.[!.]*; do + [ -e "$f" ] || continue + base=$(basename "$f") + # prebuilt/ is a seed, not a program file — handled below, never synced as a tree. + [ "$base" = "prebuilt" ] && continue + rm -rf "${RF_DST:?}/$base" + cp -a "$f" "$RF_DST/$base" + done + # Seed the image's prebuilt XMRig into the workspace when the pinned commit changed (or + # nothing is built yet). Same-commit builds are LEFT ALONE: a native recompile of the same + # pin is the operator's better binary, and RigForge's own integrity record covers it. A new + # pin arrives with a new image and its new prebuilt, so the on-box clone path (github over + # clearnet — unreachable from a Tor-only appliance) never needs to run. + if [ -d "$RF_SRC/prebuilt/xmrig" ]; then + baked_commit=$(cat "$RF_SRC/prebuilt/xmrig/.rigforge-commit" 2>/dev/null || true) + have_commit=$(cat "$RF_DST/data/worker/xmrig/.rigforge-commit" 2>/dev/null || true) + if [ -n "$baked_commit" ] && [ "$baked_commit" != "$have_commit" ]; then + mkdir -p "$RF_DST/data/worker" + rm -rf "$RF_DST/data/worker/xmrig" + cp -a "$RF_SRC/prebuilt/xmrig" "$RF_DST/data/worker/xmrig" + fi + fi + echo "pithead-sync: rigforge tree synced to $RF_DST (state preserved)" +fi # repart formats /data with ext4's default 5% root reserve — ~50 GB lost on a 1 TB disk that # exists to hold chain data. 0.5% keeps a real safety margin (the pre-repart layout used the # same figure). tune2fs is idempotent and instant, so every boot is the simplest correct hook. diff --git a/os/rootfs/Containerfile b/os/rootfs/Containerfile index 2a3d504d..7cba537b 100644 --- a/os/rootfs/Containerfile +++ b/os/rootfs/Containerfile @@ -65,6 +65,51 @@ RUN curl -fsSL -o /usr/local/bin/docker-compose \ && chmod +x /usr/local/bin/docker-compose /usr/local/bin/cosign \ && touch /etc/containers/nodocker +# The built-in miner (#796): a pinned RigForge tree plus everything it needs, baked at image +# build because nothing can be installed later — apt cannot run on the read-only root, and even +# a rw-remount install leaves its /etc state (alternatives, ld.so.cache) on the volatile overlay +# where the first reboot breaks the compiler. RIGFORGE_REF pins a commit on rigforge's develop +# (the first ref with appliance mode); move it to the next tagged rigforge release when one +# ships. pithead-sync copies the tree to /data/rigforge every boot; `pithead local-miner` runs +# its setup with RIGFORGE_APPLIANCE=1 when local_miner.enabled. +# +# The toolchain is RigForge's Debian dependency list, with one naming fix: trixie ships the +# cpupower CLI as linux-cpupower (linux-tools-common is Ubuntu's name and does not exist here). +# msr-tools gives doctor its rdmsr probe; gettext-base is envsubst, which renders the unit on +# every boot and is the ONLY tool a prebuilt tree strictly needs. +RUN apt-get install -y --no-install-recommends \ + git build-essential cmake libuv1-dev libssl-dev libhwloc-dev gettext-base python3 \ + linux-cpupower msr-tools +ARG RIGFORGE_REF=32be3a433b261110dde371f0ed5dab88acafd6f4 +RUN curl -fsSL -o /tmp/rigforge.tar.gz \ + "https://github.com/p2pool-starter-stack/rigforge/archive/${RIGFORGE_REF}.tar.gz" \ + && mkdir -p /opt/rigforge \ + && tar -xzf /tmp/rigforge.tar.gz -C /opt/rigforge --strip-components=1 \ + && rm /tmp/rigforge.tar.gz \ + && chmod +x /opt/rigforge/rigforge.sh \ + && printf 'ref=%s version=%s\n' "$RIGFORGE_REF" "$(tr -d ' \t\r\n' /opt/rigforge/RIGFORGE_REF +# Prebuild XMRig at RigForge's own pin, read from the baked tree so the two cannot drift. The +# appliance may be Tor-only, and RigForge's build step clones github over clearnet — so the +# clone happens HERE, on the build host, and pithead-sync seeds the result into the workspace +# where RigForge's "already built" check accepts it (binary + commit marker + sha record, the +# exact format rigforge.sh writes). The donate-level source patch is skipped on purpose: the +# default (1%) equals XMRig's own default, and the runtime config carries the value regardless. +RUN set -e \ + && XMRIG_VERSION=$(sed -n 's/^XMRIG_VERSION="${XMRIG_VERSION:-\(.*\)}"$/\1/p' /opt/rigforge/rigforge.sh) \ + && XMRIG_COMMIT=$(sed -n 's/^XMRIG_COMMIT="${XMRIG_COMMIT:-\(.*\)}"$/\1/p' /opt/rigforge/rigforge.sh) \ + && test -n "$XMRIG_VERSION" && test -n "$XMRIG_COMMIT" \ + && git clone --quiet --branch "$XMRIG_VERSION" --depth 1 https://github.com/xmrig/xmrig.git /tmp/xmrig \ + && test "$(git -C /tmp/xmrig rev-parse HEAD)" = "$XMRIG_COMMIT" \ + && cmake -S /tmp/xmrig -B /tmp/xmrig/build -DWITH_HWLOC=ON >/dev/null \ + && make -C /tmp/xmrig/build -j"$(nproc)" >/dev/null \ + && mkdir -p /opt/rigforge/prebuilt/xmrig/build \ + && install -m 755 /tmp/xmrig/build/xmrig /opt/rigforge/prebuilt/xmrig/build/xmrig \ + && printf '%s\n' "$XMRIG_COMMIT" >/opt/rigforge/prebuilt/xmrig/.rigforge-commit \ + && sha256sum /opt/rigforge/prebuilt/xmrig/build/xmrig | awk '{print $1}' \ + >/opt/rigforge/prebuilt/xmrig/.rigforge-sha256 \ + && rm -rf /tmp/xmrig + # Appliance identity: the engine pin doctor and the wizard read, hugepages (load-bearing for the # memory caps — the RandomX dataset must land in hugetlbfs, not the cgroup), persistent journald # (a headless box that loses its logs on reboot is undebuggable). diff --git a/pithead b/pithead index c027ef8f..bc8b80ed 100755 --- a/pithead +++ b/pithead @@ -1882,6 +1882,12 @@ announce_dashboard_url() { # local_miner.enabled is on. announce_local_miner() { [ "$(config_bool '.local_miner.enabled' false 2>/dev/null || echo false)" = "true" ] || return 0 + # On the appliance the miner is built in: pithead renders RigForge's config and the boot path + # runs its setup, so there is nothing to install and no values to copy. + if is_appliance; then + log "Local mining is ON: this machine also mines with its own CPU. The built-in RigForge worker points at the stack's own stratum and appears in the dashboard's Workers view." + return 0 + fi local bind port host secret bind=$(env_get STRATUM_BIND 2>/dev/null || true) [ -n "$bind" ] || bind="0.0.0.0" @@ -1914,6 +1920,80 @@ announce_stratum_auth() { log "Stratum authentication is ON: every rig must connect with pass \"$sp\" (set it as the xmrig/RigForge stratum password). See docs/workers.md#authentication." } +# --- local miner on the appliance (#796) ---------------------------------------------------- +# The appliance honours "Mine on this machine too?" itself: the image bakes a pinned RigForge +# tree (plus a prebuilt XMRig), pithead-sync delivers it to /data/rigforge, render below writes +# its config, and the boot path runs its setup in appliance mode after the stack is up. On DIY, +# none of this fires — the operator installs RigForge by hand (announce_local_miner's values). + +rigforge_dir() { printf '%s' "${PITHEAD_RIGFORGE_DIR:-/data/rigforge}"; } + +# RigForge's config.json is a DERIVED file on the appliance — a pure function of pithead's +# config.json + .env, rebuilt on every render like the Caddyfile. It carries the three values +# the miner needs: the stack's own stratum over loopback, the stratum password when one is set, +# and the stack's HugePages budget as hugepages_reserve_extra_mb — the hand-off that makes +# RigForge the pool's single writer. Its grow-only sysctl sizes the whole pool (miner need + +# this headroom); pithead's own write never shrinks it back (optimize_kernel). Declaring the +# budget beats maxing the two reservations: both sides hold pages at the same time, so only the +# sum survives one side growing into its own reservation. +render_local_miner_config() { + is_appliance || return 0 + local dir + dir=$(rigforge_dir) + if [ "$(config_bool '.local_miner.enabled' false 2>/dev/null || echo false)" != "true" ]; then + # Derived means derived: switched off, the config goes away — the boot leg then has + # nothing to start, and a stale stratum password does not linger on /data. + rm -f "$dir/config.json" + return 0 + fi + if [ ! -d "$dir" ]; then + warn "Local mining is on, but there is no RigForge tree at $dir — this image does not carry the built-in miner." + return 0 + fi + local port secret + port=$(stratum_port_effective) + secret=$(env_get PROXY_STRATUM_PASSWORD 2>/dev/null || true) + jq -n --arg url "127.0.0.1:$port" --arg pass "$secret" --argjson reserve "$((PITHEAD_HUGEPAGES * 2))" \ + '{pools: [({url: $url} + (if $pass != "" then {pass: $pass} else {} end))], hugepages_reserve_extra_mb: $reserve}' \ + >"$dir/config.json" + log "Local miner config rendered to $dir/config.json (pool 127.0.0.1:$port)." +} + +# The run leg: converge the on-box miner to what config.json says. Runs after the stack is up +# (the miner needs the stratum listening; RigForge's setup restarts the service it installs), +# from pithead-boot on every boot and from setup on first provisioning. RigForge's appliance +# mode makes the whole run idempotent on the read-only root: units in /run with --runtime +# enablement, no package installs, GRUB untouched, grow-only HugePages. +provision_local_miner() { + is_appliance || return 0 + local dir + dir=$(rigforge_dir) + if [ "$(config_bool '.local_miner.enabled' false 2>/dev/null || echo false)" != "true" ]; then + # Switched off: stop a still-running miner now rather than waiting for the reboot that + # would drop its runtime unit anyway. Best-effort — absent unit, absent systemctl (tests) + # and DIY hosts all land in the same quiet no-op. + systemctl stop xmrig.service >/dev/null 2>&1 || true + return 0 + fi + if [ ! -x "$dir/rigforge.sh" ]; then + warn "Local mining is on, but there is no RigForge tree at $dir — this image does not carry the built-in miner." + return 1 + fi + [ -f "$dir/config.json" ] || render_local_miner_config + # The prebuilt XMRig baked into the image normally makes this instant; a compile only + # happens when the cached build is missing or fails its integrity check. That is minutes of + # silence on the console, so say what the machine is doing. + if [ ! -x "$dir/data/worker/xmrig/build/xmrig" ]; then + _console "Preparing the on-box miner — building it once. This can take several minutes." + fi + if (cd "$dir" && RIGFORGE_APPLIANCE=1 ./rigforge.sh setup); then + log "Local miner is up — it appears in the dashboard's Workers view." + else + warn "Local miner setup failed — the stack itself is unaffected. Details are in the log above." + return 1 + fi +} + reset_dashboard() { local assume_yes=0 arg for arg in "$@"; do @@ -2367,6 +2447,12 @@ Maintenance: The boot path runs this on every boot — a reinstall or update that ships new images converges without any wizard involvement. + local-miner Converge the appliance's built-in RigForge worker to what + local_miner.enabled says: run RigForge's setup in appliance mode + when it is on (the miner points at this machine's own stratum), + stop the miner when it is off. The boot path runs this after the + stack is up; a no-op outside the appliance. + onion-client-key Print the Tor client-auth line for the dashboard onion — the client PRIVATE key, kept out of 'status'. Add it to your Tor client's ClientOnionAuthDir to reach a client-auth'd onion. @@ -5824,13 +5910,19 @@ EOF log "Rendered Quadlet units to $outdir ($(find "$outdir" -maxdepth 1 \( -name '*.container' -o -name '*.network' \) | wc -l | tr -d ' ') files)." } +# The stack's own HugePages budget: 3072 pages of 2 MiB (~6 GiB) for RandomX — p2pool's dataset +# plus monerod's verify cache. ONE definition: the sysctl write, the GRUB params and the local +# miner's declared headroom (hugepages_reserve_extra_mb in RigForge's config) all derive from it, +# so the reservation and the declaration cannot drift apart. +readonly PITHEAD_HUGEPAGES=3072 + # Kernel boot params pithead appends to GRUB_CMDLINE_LINUX_DEFAULT for RandomX: reserve 6 GiB of # 2 MiB HugePages and disable Transparent HugePages. NOTE the THP param is SINGULAR # (transparent_hugepage) — the plural form is an unrecognized param the kernel silently ignores, # so THP would never actually be disabled (#176). Kept as a function so it has one definition and # can be unit-tested for valid kernel param names. randomx_boot_params() { - echo "hugepagesz=2M hugepages=3072 transparent_hugepage=never" + echo "hugepagesz=2M hugepages=$PITHEAD_HUGEPAGES transparent_hugepage=never" } # Re-generate the bootloader config after a /etc/default/grub edit and flag that a reboot is needed. @@ -5875,7 +5967,18 @@ optimize_kernel() { fi log "Applying RandomX optimizations (HugePages)..." if [ "$OS_TYPE" == "Linux" ]; then - sudo sysctl -w vm.nr_hugepages=3072 + # Grow-only, never shrink: with a co-located RigForge miner the HugePages pool is shared, + # and whoever writes an absolute value last steals the other side's pages — the kernel + # shrinks the pool to the in-use floor and leaves zero headroom for a restart on either + # side. RigForge's own write is grow-only for the same reason, so write ordering between + # the two products stays safe regardless of who runs first. + local current_hugepages + current_hugepages=$(cat "${PITHEAD_NR_HUGEPAGES_FILE:-/proc/sys/vm/nr_hugepages}" 2>/dev/null || echo 0) + if [ "${current_hugepages:-0}" -ge "$PITHEAD_HUGEPAGES" ] 2>/dev/null; then + log "HugePages pool already holds $current_hugepages pages (>= $PITHEAD_HUGEPAGES) — leaving it as is." + else + sudo sysctl -w vm.nr_hugepages="$PITHEAD_HUGEPAGES" + fi if [ -f "/etc/default/grub" ]; then # Heal an earlier release's invalid plural THP param if present (#176). Runs regardless of @@ -5889,7 +5992,7 @@ optimize_kernel() { warn "Persistent HugePages requires editing /etc/default/grub and a reboot." read -r -p "Modify GRUB for persistent HugePages now? (y/N): " GRUB_OK || true if [[ ! "$GRUB_OK" =~ ^[Yy] ]]; then - log "Skipped GRUB edit. HugePages set for this boot only (vm.nr_hugepages=3072)." + log "Skipped GRUB edit. HugePages set for this boot only (vm.nr_hugepages=$PITHEAD_HUGEPAGES)." return 0 fi log "Updating GRUB configuration for persistent HugePages..." @@ -6092,8 +6195,9 @@ setup() { inject_service_configs optimize_kernel generate_caddyfile - provision_control_runner # #33: install/remove the dashboard-control systemd trigger - update_current_symlink # #455: versioned deploy dir -> maintain the `current ->` pointer + provision_control_runner # #33: install/remove the dashboard-control systemd trigger + render_local_miner_config # #796: the appliance's built-in RigForge worker reads a derived config + update_current_symlink # #455: versioned deploy dir -> maintain the `current ->` pointer log "Deployment preparation complete!" if [ "$REBOOT_REQUIRED" = true ]; then @@ -6102,6 +6206,10 @@ setup() { echo "After reboot, start the stack with: '$0 up'" else prompt_start_stack + # The miner leg comes AFTER the stack: it points at the stack's own stratum, and + # RigForge starts the service it installs. Appliance-only inside; best-effort — a + # miner that cannot start must not fail provisioning of the stack that just did. + provision_local_miner || true fi } @@ -6591,6 +6699,7 @@ render_derived() { provision_control_runner provision_ssh_access provision_console_login + render_local_miner_config log "Derived configuration regenerated from config.json." } @@ -6720,7 +6829,8 @@ apply() { provision_onion_client_auth provision_control_runner provision_ssh_access - provision_console_login # #33: converge the control-runner units on the (new) toggle + provision_console_login # #33: converge the control-runner units on the (new) toggle + render_local_miner_config # #796: the built-in miner's config is derived — keep it current log "Updating containers..." migrate_compose_project @@ -6762,6 +6872,10 @@ apply() { fi fi rm -f "$apply_marker" + # Converge the built-in miner on a toggle without waiting for a reboot (#796): start it when + # local_miner just turned on, stop it when it turned off. After the recreate above so the + # stratum the miner dials is the freshly-applied one. Best-effort, same posture as setup. + provision_local_miner || true log "Configuration applied." announce_dashboard_url } @@ -6771,7 +6885,7 @@ apply() { # Every dispatchable subcommand, in help order. main's dispatch, the chain validator, and the # tab-completion script (pithead-completion.bash) key off this one list; tests/stack/run.sh fails # if any of the three drift apart. -readonly PITHEAD_COMMANDS="setup apply render up down restart upgrade logs status doctor support-bundle reset-dashboard backup restore uninstall firstboot-wizard load-images control-run-pending onion-client-key rotate-dashboard-onion rotate-secrets render-quadlet version help" +readonly PITHEAD_COMMANDS="setup apply render up down restart upgrade logs status doctor support-bundle reset-dashboard backup restore uninstall firstboot-wizard load-images local-miner control-run-pending onion-client-key rotate-dashboard-onion rotate-secrets render-quadlet version help" # The subset allowed in a chain: commands that take no positional argument and terminate on their # own. Excluded: setup (interactive first-run), logs (follows until Ctrl+C), restore (needs an # archive path), reset-dashboard (destructive — run it deliberately, alone), and the one-shot @@ -8233,6 +8347,11 @@ main() { _reject_options load-images "$@" load_baked_images ;; + local-miner) + _reject_options local-miner "$@" + require_env + provision_local_miner + ;; control-run-pending) _reject_options control-run-pending "$@" require_deployed diff --git a/pithead-completion.bash b/pithead-completion.bash index b2f1a71e..56cff17a 100644 --- a/pithead-completion.bash +++ b/pithead-completion.bash @@ -13,7 +13,7 @@ # Keep in sync with PITHEAD_COMMANDS in the pithead script — tests/stack/run.sh fails if the # two lists drift. -_pithead_commands="setup apply render up down restart upgrade logs status doctor support-bundle reset-dashboard backup restore uninstall firstboot-wizard load-images control-run-pending onion-client-key rotate-dashboard-onion rotate-secrets render-quadlet version help" +_pithead_commands="setup apply render up down restart upgrade logs status doctor support-bundle reset-dashboard backup restore uninstall firstboot-wizard load-images local-miner control-run-pending onion-client-key rotate-dashboard-onion rotate-secrets render-quadlet version help" # Service names = the top-level keys under `services:` in the docker-compose.yml next to the # pithead being completed. $1 = path to that compose file. diff --git a/tests/os/run.sh b/tests/os/run.sh index 9d77da0b..b6d37f80 100755 --- a/tests/os/run.sh +++ b/tests/os/run.sh @@ -1066,7 +1066,9 @@ phase_provision() { # Everything else keeps its default — which is itself part of what this proves. # Both addresses are required — Monero's has a format gate (95 chars, leading 4), Tari's is # deliberately format-free host-side, so a labelled dummy passes and stays obviously fake. - body="monero_wallet=4$(printf 'A%.0s' $(seq 1 94))&tari_wallet=harness-dummy-tari-address&pool=mini" + # local_miner=true: the Both role (#796) — the same submit must also light the built-in + # RigForge worker, asserted in the local-miner leg below. + body="monero_wallet=4$(printf 'A%.0s' $(seq 1 94))&tari_wallet=harness-dummy-tari-address&pool=mini&local_miner=true" scode=$(curl -sSk -b "$jar" --data "$body" "https://$ip/submit" -o /dev/null -w '%{http_code}' 2>/dev/null) [ "$scode" = "200" ] || { bad "config submit did not return 200 (got ${scode:-none} — a 30x means the session was not accepted)" @@ -1156,6 +1158,41 @@ phase_provision() { return fi + # ---- local-miner leg (#796): enable -> xmrig up -> shares reach p2pool --------------- + # The submit above asked to mine on the box itself, so the built-in RigForge worker must + # come up without any hands: setup renders its config, runs its appliance-mode setup, and + # the miner dials the machine's own stratum. The share assertion is the honest end of the + # chain: xmrig-proxy logs "accepted" only for shares the upstream — p2pool — took. + local mtries=0 miner_up=0 + while [ "$mtries" -lt 36 ]; do + if _ssh "systemctl is-active --quiet xmrig && pgrep -x xmrig >/dev/null"; then + miner_up=1 + break + fi + sleep 10 + mtries=$((mtries + 1)) + done + if [ "$miner_up" -eq 1 ]; then + ok "built-in miner is up (xmrig unit active, process running)" + else + bad "the built-in miner never came up (unit: $(_ssh 'systemctl is-active xmrig' 2>/dev/null || echo unknown))" + info " local-miner journal tail: $(_ssh "journalctl -u pithead-firstboot -n 5 --no-pager -o cat" 2>/dev/null | tr '\n' ' ' | cut -c1-200)" + fi + # A KVM guest hashes slowly; give the first share a generous window. + local mdeadline=$(($(date +%s) + 900)) miner_share=0 + while [ "$(date +%s)" -lt "$mdeadline" ]; do + if _ssh "podman logs \$(podman ps -q --filter name=xmrig-proxy | head -1) 2>&1 | grep -qi accepted"; then + miner_share=1 + break + fi + sleep 15 + done + if [ "$miner_share" -eq 1 ]; then + ok "a share from the built-in miner was accepted upstream (reached p2pool)" + else + bad "no accepted share within 15m — the miner runs but its work never reached p2pool" + fi + # ---- reboot leg: the provisioned stack must return UNAIDED --------------------------- # pithead-boot owns recovery (#792): render the derived layer, compose up, health-gated slot # commit. Nothing may drive it here: no pithead command, no wizard. The failure mode this @@ -1252,6 +1289,23 @@ phase_provision() { bad "slot never self-committed — grubenv: ${genv:-unreadable}" ;; esac + # The miner must return too (#796): its unit lives in /run and died with the reboot, so + # only pithead-boot's local-miner leg — which runs after the slot commit above — can have + # brought it back. The cached build makes this a re-render, not a recompile. + local mtries2=0 miner_back=0 + while [ "$mtries2" -lt 24 ]; do + if _ssh "systemctl is-active --quiet xmrig && pgrep -x xmrig >/dev/null"; then + miner_back=1 + break + fi + sleep 10 + mtries2=$((mtries2 + 1)) + done + if [ "$miner_back" -eq 1 ]; then + ok "built-in miner returned after the reboot (boot path re-ran its setup)" + else + bad "the miner did not return after the reboot — its runtime unit was never re-rendered" + fi } phase_fault() { diff --git a/tests/os/verify-image.sh b/tests/os/verify-image.sh index 7de14f2e..0b826430 100755 --- a/tests/os/verify-image.sh +++ b/tests/os/verify-image.sh @@ -114,6 +114,17 @@ chk "rauc keyring baked" '[ -s "$ROOT/etc/rauc/keyring.pem" ]' chk "firstboot + sync units enabled" 'ls "$ROOT"/etc/systemd/system/multi-user.target.wants/pithead-firstboot.service "$ROOT"/etc/systemd/system/multi-user.target.wants/pithead-sync.service' chk "program tree at /opt/pithead" '[ -x "$ROOT/opt/pithead/pithead" ] && [ -s "$ROOT/opt/pithead/VERSION" ]' +echo "==> the built-in miner (local_miner on the appliance)" +# The whole point of baking: nothing here can be installed after the image ships. A missing +# piece surfaces as a miner that silently never starts — on a machine with no shell. +chk "rigforge tree baked" '[ -x "$ROOT/opt/rigforge/rigforge.sh" ]' +chk "rigforge ref recorded" 'grep -q "^ref=" "$ROOT/opt/rigforge/RIGFORGE_REF"' +chk "prebuilt xmrig baked (Tor-only box cannot clone)" '[ -x "$ROOT/opt/rigforge/prebuilt/xmrig/build/xmrig" ]' +chk "prebuilt commit marker matches rigforge's pin" '[ "$(cat "$ROOT/opt/rigforge/prebuilt/xmrig/.rigforge-commit")" = "$(sed -n "s/^XMRIG_COMMIT=\"\${XMRIG_COMMIT:-\(.*\)}\"$/\1/p" "$ROOT/opt/rigforge/rigforge.sh")" ]' +chk "prebuilt sha record present (integrity check input)" '[ -s "$ROOT/opt/rigforge/prebuilt/xmrig/.rigforge-sha256" ]' +chk "miner toolchain baked (compiler chain)" '[ -e "$ROOT/usr/bin/gcc" ] && [ -e "$ROOT/usr/bin/cmake" ] && [ -e "$ROOT/usr/bin/make" ] && [ -e "$ROOT/usr/bin/git" ]' +chk "miner runtime tools baked (envsubst/cpupower/rdmsr)" '[ -e "$ROOT/usr/bin/envsubst" ] && [ -e "$ROOT/usr/bin/cpupower" ] && [ -e "$ROOT/usr/sbin/rdmsr" ]' + echo "==> provenance" BUILT=$(cat "$ROOT/opt/pithead/BUILD_COMMIT" 2>/dev/null || echo missing) echo " image built from: $BUILT" diff --git a/tests/stack/run.sh b/tests/stack/run.sh index cba35129..86be9e0f 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -3909,6 +3909,11 @@ assert_contains "LAN bind targets the bound address:port" "$lm_out" "192.168.1.9 # No stratum password set: say so rather than printing a blank pass. printf 'STRATUM_BIND=0.0.0.0\nSTRATUM_PORT=3333\nPROXY_STRATUM_PASSWORD=\n' >"$LM/.env" assert_contains "no-password case is stated explicitly" "$(run_sourced "$LM" announce_local_miner 2>&1)" "none set" +# On the appliance the miner is BUILT IN: the announcement must promise the machine handles it, +# never instruct an install on a box with no shell — the exact gap that was filed as a defect. +lm_out="$(PITHEAD_APPLIANCE=1 run_sourced "$LM" announce_local_miner 2>&1)" +assert_contains "appliance: announces the built-in worker" "$lm_out" "built-in RigForge worker" +assert_not_contains "appliance: no install-it-yourself instruction" "$lm_out" "point a RigForge install" # Re-apply: an "auto" password must be STABLE (reused, not rotated) — like the proxy token. out="$(cd "$V" && DOCKER_LOG="$DOCKER_LOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" @@ -8073,6 +8078,208 @@ assert_rc "no install on any disk -> rc 1" "$?" "1" rm -rf "$PFSB" unset PFSB PF_TREE +echo "== unit: render_local_miner_config — the built-in miner's config is DERIVED (#796) ==" +# On the appliance, RigForge's config.json is a pure function of pithead's config.json + .env, +# rebuilt on every render like the Caddyfile: the stack's own stratum over loopback, the stratum +# password when one is set, and the stack's HugePages budget declared as headroom — the hand-off +# that makes RigForge the pool's single (grow-only) writer. +LMR=$(mktemp -d) +mkdir -p "$LMR/rigforge" +printf '{"local_miner":{"enabled":true}}' >"$LMR/config.json" +printf 'STRATUM_PORT=3333\n' >"$LMR/.env" +export PITHEAD_RIGFORGE_DIR="$LMR/rigforge" +PITHEAD_APPLIANCE=0 run_sourced "$LMR" render_local_miner_config >/dev/null 2>&1 +[ -f "$LMR/rigforge/config.json" ] && bad "DIY host -> nothing written" "file exists" || + ok "DIY host -> nothing written" +PITHEAD_APPLIANCE=1 run_sourced "$LMR" render_local_miner_config >/dev/null 2>&1 +assert_eq "pool url is the stack's own stratum over loopback" \ + "$(jq -r '.pools[0].url' "$LMR/rigforge/config.json")" "127.0.0.1:3333" +assert_eq "no stratum password -> no pass key at all" \ + "$(jq -r '.pools[0] | has("pass")' "$LMR/rigforge/config.json")" "false" +assert_eq "the stack's HugePages budget is declared as headroom (3072 pages -> 6144 MB)" \ + "$(jq -r '.hugepages_reserve_extra_mb' "$LMR/rigforge/config.json")" "6144" +printf 'STRATUM_PORT=13333\nPROXY_STRATUM_PASSWORD=s3cret\n' >"$LMR/.env" +PITHEAD_APPLIANCE=1 run_sourced "$LMR" render_local_miner_config >/dev/null 2>&1 +assert_eq "custom stratum port lands in the pool url" \ + "$(jq -r '.pools[0].url' "$LMR/rigforge/config.json")" "127.0.0.1:13333" +assert_eq "stratum password lands as the pool pass" \ + "$(jq -r '.pools[0].pass' "$LMR/rigforge/config.json")" "s3cret" +# Derived means derived: switched off, the config goes away with the toggle. +printf '{"local_miner":{"enabled":false}}' >"$LMR/config.json" +PITHEAD_APPLIANCE=1 run_sourced "$LMR" render_local_miner_config >/dev/null 2>&1 +[ -f "$LMR/rigforge/config.json" ] && bad "disabled -> the derived config is removed" "still there" || + ok "disabled -> the derived config is removed" +# Enabled on an image that never baked the tree: warn and carry on — render must not fail. +printf '{"local_miner":{"enabled":true}}' >"$LMR/config.json" +rm -rf "$LMR/rigforge" +lmr_out=$(PITHEAD_APPLIANCE=1 run_sourced "$LMR" render_local_miner_config 2>&1) +assert_rc "missing tree -> rc 0 (render survives)" "$?" "0" +assert_contains "missing tree is named" "$lmr_out" "no RigForge tree" +unset PITHEAD_RIGFORGE_DIR +rm -rf "$LMR" +unset LMR lmr_out + +echo "== unit: provision_local_miner — the boot leg converges the miner (#796) ==" +# Driven against a fake rigforge.sh: the real one compiles miners and tunes kernels. What this +# owns: the invocation contract (appliance flag set, run from the tree on /data, config rendered +# first) and both convergence directions (enabled -> setup, disabled -> stop). +LMP=$(mktemp -d) +mkdir -p "$LMP/rigforge" "$LMP/bin" +cat >"$LMP/rigforge/rigforge.sh" <<'EOF' +#!/usr/bin/env bash +echo "rigforge:$1 appliance=${RIGFORGE_APPLIANCE:-unset} cwd=$PWD" >>"${RF_LOG:?}" +exit "${RF_RC:-0}" +EOF +chmod +x "$LMP/rigforge/rigforge.sh" +printf '#!/usr/bin/env bash\necho "systemctl:$*" >>"${RF_LOG:?}"\n' >"$LMP/bin/systemctl" +chmod +x "$LMP/bin/systemctl" +export RF_LOG="$LMP/calls" PITHEAD_RIGFORGE_DIR="$LMP/rigforge" +printf '{"local_miner":{"enabled":true}}' >"$LMP/config.json" +printf 'STRATUM_PORT=3333\n' >"$LMP/.env" +: >"$RF_LOG" +PITHEAD_APPLIANCE=1 PATH="$LMP/bin:$PATH" run_sourced "$LMP" provision_local_miner >/dev/null 2>&1 +assert_rc "enabled -> rc 0" "$?" "0" +assert_contains "runs rigforge setup in appliance mode" "$(cat "$RF_LOG")" "rigforge:setup appliance=1" +assert_contains "runs it from the synced tree" "$(cat "$RF_LOG")" "cwd=$LMP/rigforge" +[ -s "$LMP/rigforge/config.json" ] && ok "a missing miner config is rendered before setup" || + bad "a missing miner config is rendered before setup" "not written" +# Disabled: stop the service, never run setup — a dashboard toggle must not wait for a reboot. +printf '{"local_miner":{"enabled":false}}' >"$LMP/config.json" +: >"$RF_LOG" +PITHEAD_APPLIANCE=1 PATH="$LMP/bin:$PATH" run_sourced "$LMP" provision_local_miner >/dev/null 2>&1 +assert_rc "disabled -> rc 0" "$?" "0" +assert_contains "disabled -> the miner service is stopped" "$(cat "$RF_LOG")" "systemctl:stop xmrig.service" +assert_not_contains "disabled -> rigforge never runs" "$(cat "$RF_LOG")" "rigforge:" +# DIY host: a no-op in both directions — RigForge there is the operator's own install. +printf '{"local_miner":{"enabled":true}}' >"$LMP/config.json" +: >"$RF_LOG" +PITHEAD_APPLIANCE=0 PATH="$LMP/bin:$PATH" run_sourced "$LMP" provision_local_miner >/dev/null 2>&1 +assert_rc "DIY host -> rc 0" "$?" "0" +assert_eq "DIY host -> touches nothing" "$(cat "$RF_LOG")" "" +# A failing setup is contained: warned, rc 1, and the caller treats the stack as unaffected. +: >"$RF_LOG" +lmp_out=$(PITHEAD_APPLIANCE=1 RF_RC=1 PATH="$LMP/bin:$PATH" run_sourced "$LMP" provision_local_miner 2>&1) +assert_rc "failed setup -> rc 1" "$?" "1" +assert_contains "failed setup names the containment" "$lmp_out" "stack itself is unaffected" +# An image that never baked the tree: enabled is a promise the image cannot keep — warn, rc 1. +rm -rf "$LMP/rigforge" +lmp_out=$(PITHEAD_APPLIANCE=1 PATH="$LMP/bin:$PATH" run_sourced "$LMP" provision_local_miner 2>&1) +assert_rc "missing tree -> rc 1" "$?" "1" +assert_contains "missing tree is named" "$lmp_out" "no RigForge tree" +unset RF_LOG PITHEAD_RIGFORGE_DIR +rm -rf "$LMP" +unset LMP lmp_out + +echo "== unit: pithead-boot wiring — the miner leg rides AFTER the slot commit ==" +# Ordering is the contract: the stack serving is the product's health and gates the A/B commit; +# the miner is a passenger that needs the stratum listening and must never delay the commit. +BOOTSCRIPT="$ROOT/os/overlay/pithead-boot" +mg_line=$(grep -n "mark-good" "$BOOTSCRIPT" | head -1 | cut -d: -f1) +lm_line=$(grep -n "pithead local-miner" "$BOOTSCRIPT" | head -1 | cut -d: -f1) +if [ -n "$mg_line" ] && [ -n "$lm_line" ] && [ "$lm_line" -gt "$mg_line" ]; then + ok "pithead-boot runs 'pithead local-miner' after the health-gated commit" +else + bad "pithead-boot runs 'pithead local-miner' after the health-gated commit" \ + "mark-good@${mg_line:-none} local-miner@${lm_line:-none}" +fi +unset BOOTSCRIPT mg_line lm_line + +echo "== unit: pithead-sync's rigforge leg — program replaced, state preserved, prebuilt seeded ==" +# The baked tree is program; config.json (pithead-rendered) and the data/ workspace (the XMRig +# build cache) are state. The prebuilt binary seeds the workspace so the appliance never needs +# RigForge's clone path — github over clearnet, unreachable from a Tor-only box. +SYNCSCRIPT="$ROOT/os/overlay/pithead-sync" +SSB=$(mktemp -d) +mkdir -p "$SSB/opt-pithead" "$SSB/opt-rigforge/util" "$SSB/opt-rigforge/prebuilt/xmrig/build" +for f in pithead pithead-completion.bash VERSION docker-compose.yml \ + config.reference.json config.core-keys.json config.minimal.json cosign.pub; do + printf 'pithead-program' >"$SSB/opt-pithead/$f" +done +printf 'program-v2' >"$SSB/opt-rigforge/rigforge.sh" +chmod +x "$SSB/opt-rigforge/rigforge.sh" +printf 'helper' >"$SSB/opt-rigforge/util/proposed-grub.sh" +printf 'bin-v2' >"$SSB/opt-rigforge/prebuilt/xmrig/build/xmrig" +printf 'commit-B\n' >"$SSB/opt-rigforge/prebuilt/xmrig/.rigforge-commit" +printf 'sha-B\n' >"$SSB/opt-rigforge/prebuilt/xmrig/.rigforge-sha256" +run_sync() { + PITHEAD_SYNC_SRC="$SSB/opt-pithead" PITHEAD_SYNC_DST="$SSB/data/pithead" \ + PITHEAD_SYNC_RIGFORGE_SRC="$SSB/opt-rigforge" PITHEAD_SYNC_RIGFORGE_DST="$SSB/data/rigforge" \ + bash "$SYNCSCRIPT" +} +run_sync >/dev/null 2>&1 +assert_rc "sync runs clean" "$?" "0" +[ -x "$SSB/data/rigforge/rigforge.sh" ] && ok "rigforge program delivered beside pithead's" || + bad "rigforge program delivered beside pithead's" "missing" +assert_eq "prebuilt seeded into the workspace where 'already built' finds it" \ + "$(cat "$SSB/data/rigforge/data/worker/xmrig/build/xmrig" 2>/dev/null)" "bin-v2" +assert_eq "the commit marker rides with the seed" \ + "$(cat "$SSB/data/rigforge/data/worker/xmrig/.rigforge-commit" 2>/dev/null)" "commit-B" +[ -e "$SSB/data/rigforge/prebuilt" ] && bad "prebuilt/ is a seed, never a synced tree" "synced" || + ok "prebuilt/ is a seed, never a synced tree" +# State survives a re-run: the rendered config and a native rebuild of the SAME pin stay put. +printf '{"pools":[{"url":"127.0.0.1:3333"}]}' >"$SSB/data/rigforge/config.json" +printf 'native-rebuild' >"$SSB/data/rigforge/data/worker/xmrig/build/xmrig" +run_sync >/dev/null 2>&1 +assert_eq "config.json (state) survives the resync" \ + "$(cat "$SSB/data/rigforge/config.json")" '{"pools":[{"url":"127.0.0.1:3333"}]}' +assert_eq "a same-pin native rebuild is left alone" \ + "$(cat "$SSB/data/rigforge/data/worker/xmrig/build/xmrig")" "native-rebuild" +# A new pin arrives with a new image AND its new prebuilt: the cached build is replaced, so the +# on-box clone path never needs to run. +printf 'commit-C\n' >"$SSB/opt-rigforge/prebuilt/xmrig/.rigforge-commit" +printf 'bin-v3' >"$SSB/opt-rigforge/prebuilt/xmrig/build/xmrig" +printf 'program-v3' >"$SSB/opt-rigforge/rigforge.sh" +run_sync >/dev/null 2>&1 +assert_eq "a new pin replaces the cached build with the new prebuilt" \ + "$(cat "$SSB/data/rigforge/data/worker/xmrig/build/xmrig")" "bin-v3" +assert_eq "program files are replaced wholesale" "$(cat "$SSB/data/rigforge/rigforge.sh")" "program-v3" +# An image without the bake (downgrade, older layout): the leg skips cleanly. +rm -rf "$SSB/opt-rigforge" +run_sync >/dev/null 2>&1 +assert_rc "no baked tree -> the leg is skipped, sync still clean" "$?" "0" +unset -f run_sync +rm -rf "$SSB" +unset SSB SYNCSCRIPT + +echo "== unit: optimize_kernel's HugePages write is grow-only ==" +# With a co-located miner the pool is shared and RigForge (grow-only by design) sizes it as the +# single writer — pithead writing its absolute 3072 on top would shrink a grown pool to the +# in-use floor and starve whichever side restarts next. +OKSB=$(mktemp -d) +mkdir -p "$OKSB/bin" +printf '#!/usr/bin/env bash\necho "sudo:$*" >>"${OKLOG:?}"\n' >"$OKSB/bin/sudo" +# OS_TYPE is readonly once sourced, so the Linux arm is selected the way pcr791 does it: a +# stubbed uname on PATH before the source. +printf '#!/usr/bin/env bash\n[ "$1" = "-s" ] && { echo Linux; exit 0; }\nexec /usr/bin/uname "$@"\n' >"$OKSB/bin/uname" +chmod +x "$OKSB/bin/sudo" "$OKSB/bin/uname" +export OKLOG="$OKSB/calls" +okrun() { # + printf '%s\n' "$1" >"$OKSB/nr" + ( + cd "$OKSB" || exit + PATH="$OKSB/bin:$PATH" + # shellcheck disable=SC1090 + source "$STACK" + set +e + log() { :; } + warn() { :; } + PITHEAD_NR_HUGEPAGES_FILE="$OKSB/nr" optimize_kernel "$OKLOG" +okrun 100 >/dev/null 2>&1 +assert_contains "a small pool is grown to the stack's budget" "$(cat "$OKLOG")" "sudo:sysctl -w vm.nr_hugepages=3072" +: >"$OKLOG" +okrun 4000 >/dev/null 2>&1 +assert_not_contains "a larger pool (the miner's merged budget) is never shrunk" "$(cat "$OKLOG")" "vm.nr_hugepages" +: >"$OKLOG" +okrun 3072 >/dev/null 2>&1 +assert_not_contains "an exact pool is left alone" "$(cat "$OKLOG")" "vm.nr_hugepages" +unset OKLOG +unset -f okrun +rm -rf "$OKSB" +unset OKSB + # --------------------------------------------------------------------------- echo "" printf 'pithead tests: \033[1;32m%d passed\033[0m, ' "$PASS" From 8f636f912629343c3277c0e59f704ad270caf87f Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 12:49:47 -0500 Subject: [PATCH 2/2] fix(boot): bound the miner leg with its own timeout; cover the re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review: pithead-boot runs with TimeoutStartSec=infinity, so '|| true' alone cannot save the unit from a HUNG miner setup — a stalled native rebuild would leave the boot unit activating forever. The leg now runs under timeout 1800 (clears a worst-case first native recompile; the prebuilt path finishes in seconds). Tests pin the timeout's presence beside the ordering assert, and the provision leg gains the second-boot re-run case. Co-Authored-By: Claude Fable 5 --- os/overlay/pithead-boot | 6 +++++- tests/stack/run.sh | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/os/overlay/pithead-boot b/os/overlay/pithead-boot index 2aba6f65..685495a6 100755 --- a/os/overlay/pithead-boot +++ b/os/overlay/pithead-boot @@ -39,7 +39,11 @@ for _ in $(seq 90); do # stack serving is the product's health, the miner is a passenger. Idempotent every # boot: RigForge's appliance mode re-renders its /run unit and re-tunes from the tree # pithead-sync keeps on /data. No-op unless local_miner.enabled. - ./pithead local-miner || true + # timeout, not just || true: the boot unit runs with TimeoutStartSec=infinity, so a + # hung miner setup (a stalled native rebuild, a wedged mount) would otherwise leave the + # unit activating forever. 30 minutes clears a worst-case first native recompile; the + # prebuilt path finishes in seconds. + timeout 1800 ./pithead local-miner || true exit 0 fi sleep 10 diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 86be9e0f..e09425dc 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -8143,6 +8143,12 @@ assert_contains "runs rigforge setup in appliance mode" "$(cat "$RF_LOG")" "rigf assert_contains "runs it from the synced tree" "$(cat "$RF_LOG")" "cwd=$LMP/rigforge" [ -s "$LMP/rigforge/config.json" ] && ok "a missing miner config is rendered before setup" || bad "a missing miner config is rendered before setup" "not written" +# Idempotent re-run (the boot leg fires EVERY boot): same config, run again — same outcome, +# setup invoked again (rigforge's own appliance mode is the idempotency owner), no error. +: >"$RF_LOG" +PITHEAD_APPLIANCE=1 PATH="$LMP/bin:$PATH" run_sourced "$LMP" provision_local_miner >/dev/null 2>&1 +assert_rc "enabled re-run (second boot) -> rc 0" "$?" "0" +assert_contains "re-run invokes setup again, appliance mode" "$(cat "$RF_LOG")" "rigforge:setup appliance=1" # Disabled: stop the service, never run setup — a dashboard toggle must not wait for a reboot. printf '{"local_miner":{"enabled":false}}' >"$LMP/config.json" : >"$RF_LOG" @@ -8182,6 +8188,12 @@ else bad "pithead-boot runs 'pithead local-miner' after the health-gated commit" \ "mark-good@${mg_line:-none} local-miner@${lm_line:-none}" fi +# A hung miner setup must not wedge the boot unit either — TimeoutStartSec=infinity on +# pithead-boot means || true alone cannot save it; the leg needs its own bounded clock. +grep -qE "timeout [0-9]+ \./pithead local-miner" "$BOOTSCRIPT" && + ok "the miner leg runs under its own timeout (boot unit has no clock of its own)" || + bad "the miner leg runs under its own timeout (boot unit has no clock of its own)" \ + "no 'timeout N ./pithead local-miner' in pithead-boot" unset BOOTSCRIPT mg_line lm_line echo "== unit: pithead-sync's rigforge leg — program replaced, state preserved, prebuilt seeded =="