diff --git a/build/dashboard/mining_dashboard/web/static/wizard.mjs b/build/dashboard/mining_dashboard/web/static/wizard.mjs
index ecea3722..49f6183e 100644
--- a/build/dashboard/mining_dashboard/web/static/wizard.mjs
+++ b/build/dashboard/mining_dashboard/web/static/wizard.mjs
@@ -208,9 +208,9 @@ export const Done = ({ status, handoff, installer, stick, rig, onAck }) => html`
page goes dark while the machine builds itself.`
}/>`
: rig
- ? html`
Saved. This machine now carries the rig role — the worker
- name and pool address are stored on it. Rig provisioning lands with the next phase
- of the system; nothing mines yet, and the machine's console says the same.
+ ? html`Saved. The miner is starting on this machine now. It has no
+ dashboard of its own, so this page is the last one it shows — the rig appears in your
+ Pithead's Workers view once it connects, and its console narrates until then.
${status || ""}
`
: html`Provisioning. The machine is pulling and starting the stack —
10 to 30 minutes on a home connection. This page will stop responding
diff --git a/build/dashboard/mining_dashboard/wizard.py b/build/dashboard/mining_dashboard/wizard.py
index d27a2f65..23ccb6f9 100644
--- a/build/dashboard/mining_dashboard/wizard.py
+++ b/build/dashboard/mining_dashboard/wizard.py
@@ -469,10 +469,11 @@ async def status(request: web.Request) -> web.Response:
return web.Response(text="Copying the system to the disk…")
if _spool_read("applied") is not None:
if _spool_read("role") == "rig":
- # Honest by design: the rig's boot leg arrives with the next phase of the system.
+ # A rig serves no dashboard, so this page is the last thing it will ever show:
+ # say where the machine went rather than promising a link that does not exist.
return web.Response(
- text="Rig settings saved on this machine. Rig provisioning lands with the "
- "next phase of the system — nothing mines yet."
+ text="Rig settings saved. The miner is starting on this machine now — "
+ "it appears in your Pithead's Workers view once it connects."
)
return web.Response(text="Provisioned — the dashboard is coming up now.")
err = _spool_read("error.txt")
diff --git a/build/dashboard/tests/frontend/wizard.test.mjs b/build/dashboard/tests/frontend/wizard.test.mjs
index a37d248c..1dad79e8 100644
--- a/build/dashboard/tests/frontend/wizard.test.mjs
+++ b/build/dashboard/tests/frontend/wizard.test.mjs
@@ -526,12 +526,14 @@ test("the rig card shows the worker and where it points — no credentials, no l
assert.doesNotMatch(stick, /erase the disk/); // run-from-stick erases nothing
});
-test("after the ack, the rig's done view is honest: saved, not mining yet", async () => {
+test("after the ack, the rig's done view sends the operator to the coordinator, not a link here", async () => {
const { inst, restore } = await appOn([stateFor("setup")]);
inst.setRole({ target: { value: "rig" } });
Object.assign(inst.state, { stage: "done", handoff: null });
const out = renderToString(inst.render());
- assert.match(out, /nothing mines yet/);
+ assert.match(out, /miner is starting/);
+ assert.match(out, /Workers view/); // a rig has no dashboard of its own to point at
+ assert.doesNotMatch(out, /nothing mines yet/); // the boot leg is real now
assert.doesNotMatch(out, /pulling and starting the stack/);
restore();
});
diff --git a/build/dashboard/tests/web/test_wizard.py b/build/dashboard/tests/web/test_wizard.py
index ab20bfca..8468e9c8 100644
--- a/build/dashboard/tests/web/test_wizard.py
+++ b/build/dashboard/tests/web/test_wizard.py
@@ -750,7 +750,11 @@ async def test_status_narrates_the_rig_save_without_promising_a_dashboard(client
spool.joinpath("applied").write_text("1")
body = await (await client.get("/status")).text()
assert "Rig settings saved" in body
- assert "dashboard" not in body.lower()
+ assert "dashboard" not in body.lower() # a rig serves none — never point at one
+ # The boot leg is real: the last page this machine ever shows says the miner is starting,
+ # and names where the operator will actually see it.
+ assert "miner is starting" in body
+ assert "Workers view" in body
async def test_rig_submit_clears_a_previous_error(client, seeded):
diff --git a/docs/dev/appliance-wizard.md b/docs/dev/appliance-wizard.md
index 8198bfc9..1a13238a 100644
--- a/docs/dev/appliance-wizard.md
+++ b/docs/dev/appliance-wizard.md
@@ -107,10 +107,10 @@ way the config pre-seed is scrubbed. The stick keeps neither copy after a disk i
stick whose own `/data` carries the rig marker IS a rig (run-from-USB), and that marker
outranks installer mode on every later boot.
-**The rig boot leg belongs to the next phase.** Today a machine carrying `machine-role: rig`
-states its role on the console and stops — nothing mines yet, and the message says exactly
-that. The Both role is fully live end to end: the boot contract's step 5 already honours
-`local_miner.enabled`.
+**Getting a machine back out of the rig role** is the installer, not a setting: a rig serves no
+dashboard and answers on no port, so there is nothing to log into and change. Boot the stick
+beside it and install with the wipe, and it is a blank machine that can pick any role again. A
+*keep* reinstall deliberately leaves it a rig — keep means keep whatever the role says.
## The certificate lifecycle
@@ -151,8 +151,52 @@ certificate beats no dashboard.
## The boot contract (provisioned machines)
-After provisioning, every boot runs one unit — `pithead-boot` — whose five steps each answer
-a hardware-validated failure:
+After provisioning, every boot runs one unit — `pithead-boot`. **Provisioned has two shapes**,
+and the unit's condition names both: a coordinator states it with `config.json` (every machine
+provisioned before the role contract has only that), a rig with `machine-role` — it has no
+`config.json` and never will. Both are *triggering* conditions — the `|` prefix — so either one
+admits the unit while the `/data` mount check still has to hold. The mirror image guards the
+wizard: `pithead-firstboot` is excluded by **either** file, or a rig would re-open a setup page
+on every boot while the unit that owns its miner sat skipped beside it.
+
+The marker and not `rig.json`, deliberately: a fleet stick holds a rig's answers *in flight*
+while installing that rig onto a disk, and must stay an installer through it. Only an accepted
+role writes the marker, and only onto the machine that IS that role.
+
+The condition only decides *whether* the unit runs — existence is all a systemd condition can
+test. Which leg runs is the marker's VALUE, read by the script.
+
+### The rig leg
+
+A rig is not a small coordinator. It runs no containers, so the image loader is skipped before
+it costs anything; it has no `config.json` to render from and no stack to bring up. `pithead-boot`
+forks first thing and does two steps:
+
+1. **`pithead local-miner`**, which reads the marker and takes the rig branch: `rig.json` →
+ RigForge's `config.json` (pool, worker name as `pools[].user`, stratum password when one was
+ set, and *no* HugePages headroom — there is no stack here to leave room for) → the same
+ appliance-mode setup the Both role runs, from the same tree on `/data`, against the same
+ prebuilt XMRig the image baked. Nothing on this path clones or compiles; a Tor-only box could
+ not. A native rebuild is the operator's option, cached on `/data`, never a requirement.
+2. **The same health-gated slot commit**, on the miner running. The gate is deliberately
+ *pool-independent*: a rig whose coordinator is late still starts, still retries, and still
+ commits. Rolling a slot back cannot fix a switch nobody plugged in, and a gate that punished
+ it would flap the A/B pair every boot the LAN was slow.
+
+The leg also does the removable-root minimization the run-from-USB rig needs, because that
+machine's root IS the stick it mines from. The image ships journald persistent with a 200 MB
+cap, whose files land on that same medium; the rig leg flips it to volatile — logs in memory,
+no rotating writes on the stick — and converges it on every boot, since `/etc` and `/run` are
+both volatile here and no drop-in survives a reboot. Swap needs no code at all: the appliance
+declares no swap partition and creates none, in any role. And `/data` needs no rig-sized repart
+rule, because it is sized to the **medium** rather than to the role — a 16 GB stick leaves a rig
+roughly 6 GiB after the ESP and both slots, which is plenty with no chains. It could not be
+role-conditional in any case: `systemd-repart` runs at first boot, before the wizard has asked
+what the machine is (`os/rootfs/repart.d/40-data.conf` carries the full reasoning).
+
+### The coordinator leg
+
+Five steps, each answering a hardware-validated failure:
1. **`pithead load-images`** — load the baked container-image archives when their content
changed. The archives ship in the read-only slot, the engine's storage lives on `/data`,
@@ -218,8 +262,10 @@ 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 local-miner legs (derived config, sync seeding, boot-leg wiring), the rig-role legs (pool discovery publisher, rig request consumption, the role marker + boot stub) |
+| 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 rig-role legs (pool discovery publisher, rig request consumption, the role marker, the rig boot leg's derived config + prebuilt-first + volatile journal + refusals, and both unit conditions) |
+| the artifact | `tests/os/verify-image.sh` | both role paths present in the shipped image: the boot script's fork, the unit conditions that admit each role, the baked prebuilt, no swap anywhere |
| 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 other real thing | `tests/os/run.sh --phase rig` | the same page answered `RigForge` → rig card with no login → mining from the byte-identical baked binary → **no containers at all** → reboot owned by `pithead-boot`, wizard closed → slot self-commit on an unanswered pool → A/B install, uncommitted rollback, self-commit, persistence |
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/os/overlay/pithead-boot b/os/overlay/pithead-boot
index 83956330..26d31b4d 100755
--- a/os/overlay/pithead-boot
+++ b/os/overlay/pithead-boot
@@ -22,6 +22,36 @@
set -uo pipefail
cd /data/pithead || exit 1
+# Role fork (#797): a rig is not a small coordinator, it is a different machine. No compose
+# stack, no nodes, no dashboard — and no container images worth loading, which is why this comes
+# before the loader. Its whole boot is the miner, and its slot commits on the miner running, the
+# same shape the coordinator's commit takes on the stack serving.
+#
+# The gate is deliberately pool-INDEPENDENT: a rig whose pool is unreachable still starts, still
+# retries, and must still commit. Rolling a slot back cannot fix a switch nobody plugged in, and
+# a gate that punished it would flap the A/B pair every boot the LAN was late.
+# Existence first, deliberately: `/dev/null` on the inner command cannot suppress
+# it. Harmless (this script runs without errexit, and the coordinator path is still reached),
+# but it would put a "No such file or directory" line in the journal of every coordinator boot,
+# which is the most common boot there is. An error nobody should read is an error nobody will.
+if [ -f machine-role ] && [ "$(tr -d '[:space:]' /dev/null 2>&1 && rauc status mark-good
+ echo "pithead-boot: the rig's miner is running — booted slot committed"
+ exit 0
+ fi
+ sleep 5
+ done
+ echo "pithead-boot: the rig's miner never came up — slot left uncommitted so A/B fallback stays armed" >&2
+ exit 1
+fi
+
# Containers are DERIVED too (#798): the baked archives ship in the slot, but podman's store
# lives on /data and the image tag is constant across releases — a keep-reinstall or A/B
# update ran RC-old containers under an RC-new OS until this loaded them. ONE loader, shared
diff --git a/os/overlay/pithead-boot.service b/os/overlay/pithead-boot.service
index ffe8191c..7b3a7e14 100644
--- a/os/overlay/pithead-boot.service
+++ b/os/overlay/pithead-boot.service
@@ -1,7 +1,16 @@
[Unit]
Description=Pithead stack at boot — render, up, health-gated A/B commit
-# Provisioned machines only: before config.json exists, pithead-firstboot owns the boot.
-ConditionPathExists=/data/pithead/config.json
+# Provisioned machines only: before either file exists, pithead-firstboot owns the boot. Two
+# TRIGGERING conditions (the `|` prefix — any one satisfied is enough, while the non-triggering
+# mount check below must still hold), because "provisioned" has two shapes. A coordinator states
+# it with config.json, and every machine provisioned before the role contract existed has only
+# that. A rig has no config.json and never will, so it states it with machine-role — written at
+# the moment a role is ACCEPTED, which is what makes it safe here: a fleet stick holds a rig's
+# answers only in flight and never writes the marker for a machine it is merely installing.
+# Existence is all a systemd condition can test; the script reads the marker's VALUE to pick the
+# leg.
+ConditionPathExists=|/data/pithead/config.json
+ConditionPathExists=|/data/pithead/machine-role
# Same guard as firstboot: container storage lives on /data — a missing mount must refuse, not
# silently rebuild storage on the discarded root overlay.
ConditionPathIsMountPoint=/data
diff --git a/os/overlay/pithead-firstboot.service b/os/overlay/pithead-firstboot.service
index ec8102c6..37ea2c0a 100644
--- a/os/overlay/pithead-firstboot.service
+++ b/os/overlay/pithead-firstboot.service
@@ -1,7 +1,13 @@
[Unit]
Description=Pithead first-boot setup wizard (#77 phase 3)
-# Only while unprovisioned — the wizard window closes permanently at handoff.
+# Only while unprovisioned — the wizard window closes permanently at handoff. Both shapes of
+# provisioned close it: a coordinator's config.json and a rig's accepted role marker. Without
+# the second, a rig would re-open a setup page on every boot forever (it has no config.json to
+# close the window) while pithead-boot, which owns its miner and its A/B commit, sat skipped
+# beside it. The marker, not rig.json: a fleet stick holds a rig's answers in flight while
+# installing one, and must stay an installer through it.
ConditionPathExists=!/data/pithead/config.json
+ConditionPathExists=!/data/pithead/machine-role
# Guard: container storage lives on /data. If it is not a real mount, podman would silently build
# storage on the discarded root overlay and fill it — refuse to start instead.
ConditionPathIsMountPoint=/data
diff --git a/os/rootfs/repart.d/40-data.conf b/os/rootfs/repart.d/40-data.conf
index 53bbc83e..2682e36b 100644
--- a/os/rootfs/repart.d/40-data.conf
+++ b/os/rootfs/repart.d/40-data.conf
@@ -4,6 +4,21 @@
#
# No SizeMaxBytes and all the weight, so it takes everything the fixed partitions leave.
# GrowFileSystem extends the filesystem after the partition, which is the half people forget.
+#
+# Why there is no rig-sized variant of this file, and does not need to be. A rig holds no chains
+# — a pool address, a worker name and one compiled miner — so the obvious ask is a small /data
+# for that role. It is already what happens: /data is sized to the MEDIUM, not to the role and
+# not to the image, so a 16 GB stick leaves a rig ~6 GiB after the ESP and both 4 GiB slots, and
+# a 1 TB disk leaves a coordinator its chains. There is nothing a rig-conditional rule would
+# make smaller that the medium has not already decided.
+#
+# And it could not be conditional even if it wanted to be: systemd-repart runs at first boot,
+# before /data is mounted and long before the wizard asks what this machine IS — the role does
+# not exist yet when the partition table is written. Making it role-aware would mean carrying
+# the answer from BEFORE first boot: a role staged on the ESP (a disk install already stages
+# pithead-rig.json there) read by a repart drop-in generator ahead of the first pass. That still
+# could not cover the run-from-USB rig, whose role is chosen on a medium repart already
+# partitioned. Sized to the medium is the rule that holds in every case.
[Partition]
Type=linux-generic
Label=data
diff --git a/pithead b/pithead
index ab7a0803..e195c4bf 100755
--- a/pithead
+++ b/pithead
@@ -1409,6 +1409,16 @@ record_machine_role() { #
printf '%s\n' "$1" >"$PWD/machine-role" 2>/dev/null || true
}
+# The marker, read back. Anything unrecognised (or absent) is a coordinator: every machine
+# provisioned before this contract existed had no marker and was one.
+machine_role() { # echoes pithead|both|rig
+ local r=""
+ if [ -f "$PWD/machine-role" ]; then
+ r=$(tr -d '[:space:]' <"$PWD/machine-role" 2>/dev/null) || r=""
+ fi
+ case "$r" in rig | both | pithead) printf '%s' "$r" ;; *) printf 'pithead' ;; esac
+}
+
machine_role_from_config() { #
if [ "$(jq -r '.local_miner.enabled // false' "$1" 2>/dev/null)" = "true" ]; then
printf 'both'
@@ -1735,19 +1745,11 @@ firstboot_wizard() {
*) error "Unknown option for firstboot-wizard: $arg. Run '$0 help'." ;;
esac
done
- # A machine already carrying the rig role: state it and stop — even on a stick that could
- # offer the installer, because a run-from-USB rig's stick IS that rig's system, not a
- # fleet tool. The rig boot leg lands with the next phase; until it does, an honest console
- # line beats a coordinator's setup page collecting answers a rig can never use.
- if [ "$(tr -d '[:space:]' <"$PWD/machine-role" 2>/dev/null)" = "rig" ]; then
- _console "This machine carries the RigForge rig role ($(jq -r '.worker // "unnamed"' "$PWD/rig.json" 2>/dev/null) -> $(jq -r '.pool // "no pool recorded"' "$PWD/rig.json" 2>/dev/null))." \
- "Rig provisioning lands with the next phase of the system — nothing mines yet."
- return 0
- fi
# A rig install staged its answers on this ESP (the disk-install leg below): land them on
- # /data and stop, the same one-move consumption as the config pre-seed — never on the
- # installation medium, where staged files are cleaned up by the installer itself.
- if [ -f "$PRESEED_DIR/pithead-rig.json" ] && ! installer_mode_available; then
+ # /data, the same one-move consumption as the config pre-seed — never on the installation
+ # medium, where staged files are cleaned up by the installer itself. Then fall through to
+ # the rig leg below, exactly as a pre-seeded coordinator falls through to setup.
+ if [ -f "$PRESEED_DIR/pithead-rig.json" ] && ! installer_mode_available && [ ! -f "$PWD/rig.json" ]; then
if jq -e 'type == "object" and ((.pool // "") | length > 0)' "$PRESEED_DIR/pithead-rig.json" >/dev/null 2>&1 &&
install -m 600 "$PRESEED_DIR/pithead-rig.json" "$PWD/rig.json" 2>/dev/null; then
record_machine_role rig
@@ -1757,11 +1759,21 @@ firstboot_wizard() {
rm -f "$PRESEED_DIR/pithead-rig.json" 2>/dev/null ||
warn "Could not remove the consumed rig settings from $PRESEED_DIR — they may hold a password; delete the file."
fi
- _console "This machine is now a RigForge rig ($(jq -r '.worker // "unnamed"' "$PWD/rig.json" 2>/dev/null))." \
- "Rig provisioning lands with the next phase of the system — nothing mines yet."
- return 0
+ _console "This machine is now a RigForge rig ($(jq -r '.worker // "unnamed"' "$PWD/rig.json" 2>/dev/null))."
+ else
+ warn "The staged rig settings at $PRESEED_DIR/pithead-rig.json are unusable — opening the setup page."
fi
- warn "The staged rig settings at $PRESEED_DIR/pithead-rig.json are unusable — opening the setup page."
+ fi
+ # A machine already carrying the rig role mines, and asks nothing — not even on a stick that
+ # could offer the installer, because a run-from-USB rig's stick IS that rig's system, not a
+ # fleet tool. Reached only on the boot that ACCEPTS the role (a disk install's first boot,
+ # just above): once the marker exists, pithead-boot owns every later boot and this unit's
+ # own condition skips it. The miner is best-effort here for the same reason it is in pithead-boot
+ # — a rig whose pool moved must still come up and keep retrying, not brick its own boot.
+ if [ "$(machine_role)" = "rig" ]; then
+ _console "This machine is a RigForge rig ($(jq -r '.worker // "unnamed"' "$PWD/rig.json" 2>/dev/null) -> $(jq -r '.pool // "no pool recorded"' "$PWD/rig.json" 2>/dev/null))."
+ provision_rig_miner || true
+ return 0
fi
# A configuration dropped on the medium beats opening a browser at all — but never on the
# INSTALLATION medium: pre-seeding covers configuration, not the erase decision (the docs'
@@ -2008,8 +2020,11 @@ firstboot_wizard() {
chown 1000:1000 "$spool/applied" 2>/dev/null || true
sleep 8 # long enough for the page's poll to show the saved state
"$engine" rm -f pithead-wizard >/dev/null 2>&1 || true
- _console "Rig settings saved: $rig_worker -> stratum+tcp://$rig_pool." \
- "Rig provisioning lands with the next phase of the system — nothing mines yet."
+ _console "Rig settings saved: $rig_worker -> stratum+tcp://$rig_pool."
+ # Mine now, on this boot. Every later boot goes through pithead-boot, whose own
+ # condition now covers the marker just written — there is no second wizard and no
+ # reboot to wait for, the same way an accepted coordinator config runs setup here.
+ provision_rig_miner || true
return 0
fi
if firstboot_consume_spool "$spool"; then
@@ -2235,6 +2250,10 @@ rigforge_dir() { printf '%s' "${PITHEAD_RIGFORGE_DIR:-/data/rigforge}"; }
# sum survives one side growing into its own reservation.
render_local_miner_config() {
is_appliance || return 0
+ # Not on a rig, ever. This function's "switched off" branch DELETES the miner's config, and
+ # a rig with no config.json reads as switched off — so a stray render there would take out
+ # the very file the rig mines from. render_rig_miner_config owns that machine's copy.
+ if [ "$(machine_role)" = "rig" ]; then return 0; fi
local dir
dir=$(rigforge_dir)
if [ "$(config_bool '.local_miner.enabled' false 2>/dev/null || echo false)" != "true" ]; then
@@ -2256,13 +2275,43 @@ render_local_miner_config() {
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.
+# The one place RigForge's setup is ever invoked, shared by both roles that run a miner. Same
+# appliance flag, same tree on /data, same first-run narration — a rig and a coordinator that
+# also mines differ in where the miner's config came from, never in how it is started.
+#
+# Prebuilt-first is the whole reason this is instant: pithead-sync seeds the image's baked XMRig
+# into the workspace, so the binary already exists and RigForge's setup re-renders rather than
+# compiles. A build here means the operator's own native rebuild replaced it or the cached one
+# failed its integrity check — minutes of silence on a console with nothing else to look at, so
+# say what the machine is doing. Nothing on this path clones: a Tor-only box could not.
+rigforge_setup_run() {
+ local dir
+ dir=$(rigforge_dir)
+ if [ ! -x "$dir/rigforge.sh" ]; then
+ warn "There is no RigForge tree at $dir — this image does not carry the built-in miner."
+ return 1
+ fi
+ if [ ! -x "$dir/data/worker/xmrig/build/xmrig" ]; then
+ _console "Preparing the miner — building it once. This can take several minutes."
+ fi
+ (cd "$dir" && RIGFORGE_APPLIANCE=1 ./rigforge.sh setup)
+}
+
+# The run leg: converge the on-box miner to what this machine says it is. 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.
+#
+# The role forks here rather than in the boot path, so both boot owners — pithead-boot on a
+# provisioned machine, the first-boot wizard on the boot that accepts a role — get the right leg
+# from the one command.
provision_local_miner() {
is_appliance || return 0
+ if [ "$(machine_role)" = "rig" ]; then
+ provision_rig_miner
+ return
+ fi
local dir
dir=$(rigforge_dir)
if [ "$(config_bool '.local_miner.enabled' false 2>/dev/null || echo false)" != "true" ]; then
@@ -2272,18 +2321,8 @@ provision_local_miner() {
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
+ if rigforge_setup_run; 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."
@@ -2291,6 +2330,71 @@ provision_local_miner() {
fi
}
+# --- the rig role's boot leg (one stick, three machines) -------------------------------------
+# A rig has no config.json, no containers, no dashboard and no chains: its entire product is the
+# miner. So it rides the SAME leg the Both role rides, sourced from rig.json instead of
+# config.json — one invocation contract, one prebuilt, one appliance mode.
+
+# RigForge's config for a rig, derived from rig.json exactly the way the Both role's is derived
+# from config.json — rebuilt every boot, never repaired. Three values and no more: the pool the
+# operator gave, the worker name that labels this rig at that pool (RigForge's pools[].user,
+# which falls back to the hostname when empty), and the stratum password when one was set.
+# No hugepages_reserve_extra_mb: there is no stack on this machine to leave headroom for, so
+# RigForge sizes the HugePages pool for the miner alone.
+render_rig_miner_config() {
+ local dir
+ dir=$(rigforge_dir)
+ if [ ! -d "$dir" ]; then
+ warn "This machine is a rig, but there is no RigForge tree at $dir — this image does not carry the miner."
+ return 1
+ fi
+ jq '{pools: [({url: .pool, user: (.worker // "")}
+ + (if (.stratum_password // "") == "" then {} else {pass: .stratum_password} end))]}' \
+ "$PWD/rig.json" >"$dir/config.json" 2>/dev/null || return 1
+ chmod 600 "$dir/config.json" 2>/dev/null || true
+}
+
+# Write minimization for a removable root. The rig role's stick can BE the system it runs from —
+# that is the point of the run-from-USB choice — and the image's journald ships
+# Storage=persistent with a 200 MB cap, whose files land on the /var overlay, whose upper lives
+# on that same medium. A rig holds almost no state and its logs are read within the boot that
+# produced them, so volatile is the honest setting: the journal lives in RAM and the stick sees
+# no rotating writes at all.
+#
+# Converged every boot rather than baked, because it cannot be baked: /etc and /run are BOTH
+# volatile on this appliance, so no drop-in survives a reboot and journald always starts
+# persistent again. The existing-directory guard makes this one restart per boot, not a loop.
+# Swap needs no code at all: the appliance declares no swap partition and creates none, so
+# "no swap" is already true in every role — see os/rootfs/repart.d.
+rig_minimize_writes() {
+ is_appliance || return 0
+ local dropin="${PITHEAD_JOURNALD_DROPIN_DIR:-/run/systemd/journald.conf.d}"
+ local journal="${PITHEAD_JOURNAL_DIR:-/var/log/journal}"
+ mkdir -p "$dropin" 2>/dev/null || return 0
+ # Sorts after the image's own pithead.conf, and later file wins: that is how a drop-in
+ # overrides a drop-in.
+ printf '[Journal]\nStorage=volatile\nRuntimeMaxUse=32M\n' >"$dropin/zz-rig-volatile.conf" 2>/dev/null || return 0
+ [ -d "$journal" ] || return 0
+ rm -rf "${journal:?}"
+ systemctl restart systemd-journald >/dev/null 2>&1 || true
+ log "Rig write minimization: the journal is in memory for this boot — the root may be the stick the miner runs from."
+}
+
+provision_rig_miner() {
+ if [ ! -f "$PWD/rig.json" ]; then
+ warn "This machine is marked as a rig, but its settings are missing — install it again from the stick to choose a role."
+ return 1
+ fi
+ rig_minimize_writes
+ render_rig_miner_config || return 1
+ if rigforge_setup_run; then
+ log "The rig is mining: $(jq -r '.worker // "this machine"' "$PWD/rig.json" 2>/dev/null) -> $(jq -r '.pool // "no pool recorded"' "$PWD/rig.json" 2>/dev/null)."
+ return 0
+ fi
+ warn "The rig's miner did not start. Details are in the log above."
+ return 1
+}
+
# --- OS update (appliance A/B slots) ---
# The build variant stamp: debug images bake an SSH key (often the box's only management
# channel), release images are shell-less by design. The running system carries the stamp at
@@ -2836,11 +2940,13 @@ 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.
+ local-miner Converge this machine's RigForge worker to what the machine is.
+ On a coordinator that follows local_miner.enabled: RigForge's
+ setup in appliance mode when it is on (the miner points at this
+ machine's own stratum), the miner stopped when it is off. On a rig
+ it follows rig.json instead — that machine has no stack, and the
+ miner is the whole of it. The boot path runs this on every boot;
+ a no-op outside the appliance.
os-update BUNDLE [-y|--yes]
Install an OS update bundle into the spare A/B slot (appliance
@@ -9010,7 +9116,8 @@ main() {
;;
local-miner)
_reject_options local-miner "$@"
- require_env
+ # A rig has no .env and never will — there is no stack on it to render one.
+ [ "$(machine_role)" = "rig" ] || require_env
provision_local_miner
;;
os-update) os_update "$@" ;;
diff --git a/tests/os/run.sh b/tests/os/run.sh
index b625830b..275cbd19 100755
--- a/tests/os/run.sh
+++ b/tests/os/run.sh
@@ -5,7 +5,7 @@
# os-image sibling of tests/integration/run.sh; it needs a Linux host with KVM + libvirt + the
# built image, so it runs on the bench, not in CI.
#
-# tests/os/run.sh --image PATH [--keep] [--phase boot|update|install|provision|fault|all]
+# tests/os/run.sh --image PATH [--keep] [--phase boot|update|install|provision|rig|fault|all]
#
# Phases:
# boot flash the image to a scratch disk, boot it, assert EFI boot + firstboot wizard up
@@ -20,8 +20,11 @@
# dashboard served. This is the phase that catches an appliance whose engine cannot
# actually run the product (it happened: pithead speaks docker, the image had only
# podman, and every other phase was green).
+# rig answer "RigForge" on the same page and prove the OTHER machine this image installs:
+# mines from the baked binary with no compile and no stack at all, and takes an A/B
+# update — install, uncommitted rollback, self-commit — exactly like a coordinator.
# fault power cuts mid-write and mid-commit, plus a corrupt bundle. A brick is disqualifying.
-# all all four (default)
+# all all five (default)
#
# Exit non-zero on the first failed assertion. --keep leaves the VM + disks for inspection.
set -uo pipefail
@@ -1441,6 +1444,279 @@ phase_provision() {
unset -f _gate
}
+phase_rig() {
+ info "phase: rig (the OTHER machine this image installs — mines instead of coordinating)"
+ # One image, two machines. Every other phase proves the coordinator; this one proves that
+ # answering "RigForge" on the same page produces a box with no stack at all, that it mines
+ # from the baked binary without compiling or reaching the network, and — the part that makes
+ # it a fleet member rather than a toy — that it takes an A/B update exactly like a
+ # coordinator does. A rig has no dashboard to complain through, so a rig that silently never
+ # starts is invisible to everything except an assertion like this one.
+ local img token jar body scode marker
+
+ img=$(_build_image v1) || {
+ bad "image build failed (/tmp/os-fault-build.log)"
+ return
+ }
+ _vm_boot_disk "$img" && _wait_ssh 240 || {
+ bad "guest never answered SSH (ip: ${ip:-none})"
+ return
+ }
+ ok "image boots ($ip)"
+
+ local tries=0
+ token=""
+ while [ -z "$token" ] && [ "$tries" -lt 40 ]; do
+ token=$(tr -d '\r' <"$SERIAL" | grep -oE 'pit-[A-Z0-9]{6}' | tail -1)
+ [ -n "$token" ] || sleep 3
+ tries=$((tries + 1))
+ done
+ [ -n "$token" ] || {
+ bad "no one-time token ever appeared on the console"
+ return
+ }
+ tries=0
+ while ! curl -fsSk -m 5 "https://$ip/" 2>/dev/null | grep -qi "Pithead setup"; do
+ sleep 5
+ tries=$((tries + 1))
+ [ "$tries" -lt 24 ] || {
+ bad "wizard gate never served"
+ return
+ }
+ done
+ jar=$(mktemp)
+ curl -fsSk -c "$jar" -d "token=$token" "https://$ip/auth" -o /dev/null 2>/dev/null || {
+ bad "token was not accepted"
+ rm -f "$jar"
+ return
+ }
+ grep -q "wizard_session" "$jar" || {
+ bad "auth returned no session cookie — the submit below would be unauthenticated"
+ rm -f "$jar"
+ return
+ }
+
+ # The pool: the guest's OWN sshd. The host-side gate dials the address before it commits
+ # anything, and a KVM guest has no Pithead on its LAN to dial — so this stands in for one.
+ # It is a real TCP listener and nothing more, which is exactly what the gate checks; what it
+ # deliberately does NOT prove is an accepted share, the same limit the coordinator's
+ # local-miner leg documents. XMRig will dial it, get no stratum and retry forever, and that
+ # is the point: the miner must come up and STAY up on a pool that does not answer, or a rig
+ # whose coordinator is late would fail its own boot and roll its slot back.
+ body="role=rig&rig_pool=127.0.0.1:22&rig_worker=kvm-rig"
+ scode=$(curl -sSk -b "$jar" --data "$body" "https://$ip/submit" -o /dev/null -w '%{http_code}' 2>/dev/null)
+ [ "$scode" = "200" ] || {
+ bad "rig submit did not return 200 (got ${scode:-none})"
+ rm -f "$jar"
+ return
+ }
+ ok "rig role submitted through the wizard"
+ tries=0
+ while [ "$tries" -lt 24 ]; do
+ if curl -sSk -b "$jar" -m 5 "https://$ip/api/handoff" 2>/dev/null | grep -q '"worker"'; then
+ break
+ fi
+ sleep 5
+ tries=$((tries + 1))
+ done
+ [ "$tries" -lt 24 ] || {
+ bad "no rig card appeared on the page"
+ rm -f "$jar"
+ return
+ }
+ # A rig's card carries the worker and where it points — and NO login, because a rig has none.
+ if curl -sSk -b "$jar" -m 5 "https://$ip/api/handoff" 2>/dev/null | grep -q '"password"'; then
+ bad "the rig card published a dashboard password — a rig serves no dashboard"
+ else
+ ok "the rig card is worker + pool, with no login (a rig has none)"
+ fi
+ curl -sSk -b "$jar" -X POST "https://$ip/handoff-ack" -o /dev/null 2>/dev/null || true
+ rm -f "$jar"
+
+ # ---- the machine that came out: a rig, not a small coordinator ------------------------
+ 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 "the rig mines (xmrig unit active, process running) with no reboot in between"
+ else
+ bad "the rig never started mining (unit: $(_ssh 'systemctl is-active xmrig' 2>/dev/null || echo unknown))"
+ info " firstboot journal tail: $(_ssh "journalctl -u pithead-firstboot -n 8 --no-pager -o cat" 2>/dev/null | tr '\n' ' ' | cut -c1-300)"
+ fi
+ [ "$(_ssh 'cat /data/pithead/machine-role' | tr -d '\r\n')" = "rig" ] &&
+ ok "the role marker says rig" || bad "the role marker is not rig"
+ [ -z "$(_ssh 'ls /data/pithead/config.json 2>/dev/null')" ] &&
+ ok "no coordinator config was ever written (a rig has none)" ||
+ bad "a config.json appeared on a rig — the coordinator contract leaked into the rig role"
+ if _ssh "jq -e '.pools[0].url == \"127.0.0.1:22\" and .pools[0].user == \"kvm-rig\"' /data/rigforge/config.json >/dev/null"; then
+ ok "the miner's config is derived from rig.json (pool + worker name)"
+ else
+ bad "the rig's miner config does not match its answers ($(_ssh "jq -c '.pools' /data/rigforge/config.json 2>/dev/null" | cut -c1-100))"
+ fi
+ # THE assertion of this phase: no stack. Not a stopped stack, not a held one — none started.
+ local names
+ names=$(_ssh "podman ps -a --format '{{.Names}}'" 2>/dev/null | tr -d '\r' | tr '\n' ' ')
+ if [ -z "${names// /}" ]; then
+ ok "no compose stack was started — no containers exist at all on a rig"
+ else
+ bad "a rig started containers: '$names'"
+ fi
+ # Prebuilt-first, proven by identity: a native recompile produces a DIFFERENT binary, and a
+ # clone could not have happened at all (this guest has no path to github).
+ if _ssh "cmp -s /data/rigforge/data/worker/xmrig/build/xmrig /opt/rigforge/prebuilt/xmrig/build/xmrig"; then
+ ok "the rig mines the BAKED binary byte for byte — no compile, no clone, no clearnet"
+ else
+ bad "the running miner is not the baked prebuilt — something compiled or fetched on first boot"
+ fi
+ # Removable-root tolerance: the journal is in memory, so a stick root takes no rotating
+ # writes. (This guest's root is virtual, but the setting is the role's, not the medium's.)
+ [ "$(_ssh 'systemd-analyze cat-config systemd/journald.conf 2>/dev/null | grep -c "^Storage=volatile"')" != "0" ] &&
+ ok "journald is volatile on a rig (a rig's root may be the stick it mines from)" ||
+ bad "journald is still persistent on a rig — a USB root would take rotating writes"
+
+ # ---- reboot: pithead-boot owns a rig now, and commits its slot -------------------------
+ info "reboot leg — the rig must come back mining, and commit its own slot"
+ _ssh reboot 2>/dev/null || true
+ sleep 10
+ _wait_ssh 300 || {
+ bad "the rig never returned from the reboot"
+ return
+ }
+ 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
+ [ "$miner_back" -eq 1 ] &&
+ ok "the rig returned mining with no hands on it (its unit lives in /run and died with the reboot)" ||
+ bad "the rig did not return after the reboot — its runtime unit was never re-rendered"
+ # WHICH unit owns the boot is the whole R4 fork: the wizard's window is closed by rig.json,
+ # and pithead-boot — skipped on a rig before this phase existed — is what runs.
+ [ "$(_ssh 'systemctl is-active pithead-boot' | tr -d '\r\n')" = "active" ] &&
+ ok "pithead-boot owns a provisioned rig's boot" ||
+ bad "pithead-boot did not run on the rig (its condition still excludes a machine with no config.json)"
+ _ssh "systemctl is-active --quiet pithead-firstboot" &&
+ bad "the first-boot wizard ran again on a provisioned rig" ||
+ ok "the wizard window is closed on a provisioned rig (no setup page on every boot)"
+ local failed_units
+ failed_units=$(_ssh "systemctl --failed --no-legend --no-pager --plain" 2>/dev/null |
+ awk '$1 !~ /^[0-9a-f]{64}-[0-9a-f]+\.service$/' | tr -s ' ' | tr '\n' ';')
+ [ -z "${failed_units//[; ]/}" ] && ok "no failed systemd units on the rig after the reboot" ||
+ bad "failed units on the rig after the reboot: $failed_units"
+ # The commit gate, rig-shaped: a rig that could not commit would roll back every A/B update
+ # it ever received. Note the pool here answers nothing — the commit must not depend on it.
+ local genv tries3=0
+ while [ "$tries3" -lt 18 ]; do
+ genv=$(_ssh "grub-editenv /boot/efi/grub/grubenv list" 2>/dev/null | tr '\n' ' ')
+ case "$genv" in *A_OK=1*A_TRY=0* | *A_TRY=0*A_OK=1*) break ;; esac
+ sleep 10
+ tries3=$((tries3 + 1))
+ done
+ case "$genv" in
+ *A_OK=1*A_TRY=0* | *A_TRY=0*A_OK=1*)
+ ok "the rig committed its own slot on the miner running (A_OK=1 A_TRY=0), pool unanswered"
+ ;;
+ *) bad "the rig never self-committed — grubenv: ${genv:-unreadable}" ;;
+ esac
+
+ # ---- A/B update: identical pipeline, identical outcome --------------------------------
+ info "update leg — a rig takes a bundle exactly like a coordinator"
+ local bundle
+ bundle=$(_build_bundle v2) || {
+ bad "v2 bundle build failed (/tmp/os-fault-bundle.log)"
+ return
+ }
+ _stage_bundle "$bundle" || {
+ bad "staging the bundle on the rig failed"
+ return
+ }
+ _ssh "$(_install_cmd /data/update.bundle)" || {
+ bad "the v2 install failed on the rig"
+ return
+ }
+ ok "v2 installed into the rig's spare slot"
+ _ssh "$(_boot_spare_cmd)" || true
+ sleep 10
+ _wait_ssh 300 || {
+ bad "the rig never returned after booting the spare slot"
+ return
+ }
+ marker=$(_ssh cat /etc/pithead-test-marker | tr -d '\r\n')
+ [ "$marker" = "v2" ] && ok "the rig's spare slot booted with v2" || {
+ bad "expected v2 in the rig's spare slot, got '$marker'"
+ return
+ }
+ # The state that must survive a whole-slot replacement: the role and its answers live on
+ # /data, so the new slot has to come up as the SAME rig.
+ [ "$(_ssh 'cat /data/pithead/machine-role' | tr -d '\r\n')" = "rig" ] &&
+ ok "the role survived the slot swap (it lives on /data, not in the image)" ||
+ bad "the updated slot lost the rig role"
+ local mtries3=0 miner_v2=0
+ while [ "$mtries3" -lt 24 ]; do
+ if _ssh "systemctl is-active --quiet xmrig && pgrep -x xmrig >/dev/null"; then
+ miner_v2=1
+ break
+ fi
+ sleep 10
+ mtries3=$((mtries3 + 1))
+ done
+ [ "$miner_v2" -eq 1 ] && ok "the rig mines again on the updated slot" ||
+ bad "the rig stopped mining after the A/B update"
+ # An uncommitted update must revert here for the same reason it does on a coordinator.
+ _ssh reboot || true
+ sleep 10
+ _wait_ssh 300 || {
+ bad "the rig never returned after the no-commit reboot"
+ return
+ }
+ marker=$(_ssh cat /etc/pithead-test-marker | tr -d '\r\n')
+ [ "$marker" = "v1" ] && ok "ROLLBACK: an uncommitted update reverts on a rig too" ||
+ bad "expected v1 after the rig's uncommitted reboot, got '$marker'"
+ _ssh "$(_install_cmd /data/update.bundle)" || {
+ bad "the second v2 install failed on the rig"
+ return
+ }
+ _ssh "$(_boot_spare_cmd)" || true
+ sleep 10
+ _wait_ssh 300 || {
+ bad "the rig never returned after the second install"
+ return
+ }
+ # No harness mark-good: the rig's own boot path must commit, the same way it did on v1.
+ local genv2 tries4=0
+ while [ "$tries4" -lt 24 ]; do
+ genv2=$(_ssh "grub-editenv /boot/efi/grub/grubenv list" 2>/dev/null | tr '\n' ' ')
+ case "$genv2" in *B_OK=1*B_TRY=0* | *B_TRY=0*B_OK=1*) break ;; esac
+ sleep 10
+ tries4=$((tries4 + 1))
+ done
+ case "$genv2" in
+ *B_OK=1*B_TRY=0* | *B_TRY=0*B_OK=1*)
+ ok "the rig self-committed the UPDATED slot (B_OK=1 B_TRY=0) — no harness hands"
+ ;;
+ *) bad "the rig never self-committed the updated slot — grubenv: ${genv2:-unreadable}" ;;
+ esac
+ _ssh reboot || true
+ sleep 10
+ _wait_ssh 300 || {
+ bad "the rig never returned after the post-commit reboot"
+ return
+ }
+ marker=$(_ssh cat /etc/pithead-test-marker | tr -d '\r\n')
+ [ "$marker" = "v2" ] && ok "COMMIT: the update persists on the rig across reboot" ||
+ bad "expected v2 on the rig after commit, got '$marker'"
+}
+
phase_fault() {
info "phase: fault injection — a brick is disqualifying, not deducted"
local img bundle marker i out
@@ -1584,12 +1860,14 @@ boot) phase_boot ;;
update) phase_update ;;
install) phase_install ;;
provision) phase_provision ;;
+rig) phase_rig ;;
fault) phase_fault ;;
all)
phase_boot
phase_update
phase_install
phase_provision
+ phase_rig
;;
*)
echo "unknown phase: $PHASE" >&2
diff --git a/tests/os/verify-image.sh b/tests/os/verify-image.sh
index a4291692..208b388f 100755
--- a/tests/os/verify-image.sh
+++ b/tests/os/verify-image.sh
@@ -150,6 +150,38 @@ chk "prebuilt sha record present (integrity check input)" '[ -s "$ROOT/opt/rigfo
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 "==> both role paths (a rig boots differently, updates identically)"
+# One image installs two machines, and which one a box becomes is decided by a marker on /data
+# that no static check can see. What CAN be checked here is that the shipped artifact carries
+# both legs — because the failure mode is silent on the machine that has no dashboard to say so:
+# a rig whose boot unit never fires just sits there, dark, mining nothing.
+# shellcheck disable=SC2034 # read inside chk's eval'd conditions
+RIGB="$ROOT/usr/local/sbin/pithead-boot"
+# The guard, not just the mention: ` 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 6e9ec0d7..f9686367 100755
--- a/tests/stack/run.sh
+++ b/tests/stack/run.sh
@@ -8372,41 +8372,140 @@ assert_eq "no password -> the key is omitted, not written empty" "$(jq -r 'has("
rm -rf "$RCSB"
unset RCSB
-echo "== unit: the machine-role marker + the rig boot stub (#797 R3; the boot leg is R4's) =="
+echo "== unit: the machine-role marker, written and read back (#797 R3/R4) =="
MRSB=$(mktemp -d)
printf '{"local_miner":{"enabled":true}}' >"$MRSB/config.json"
assert_eq "local_miner on -> both (the role IS the switch)" "$(run_sourced "$MRSB" machine_role_from_config "$MRSB/config.json")" "both"
printf '{}' >"$MRSB/config.json"
assert_eq "no local_miner -> pithead" "$(run_sourced "$MRSB" machine_role_from_config "$MRSB/config.json")" "pithead"
rm -f "$MRSB/config.json"
+assert_eq "no marker at all -> pithead (every pre-contract machine)" "$(run_sourced "$MRSB" machine_role)" "pithead"
run_sourced "$MRSB" record_machine_role rig >/dev/null 2>&1
assert_eq "the marker lands where the boot path reads it" "$(cat "$MRSB/machine-role")" "rig"
-
-# A machine already marked rig: firstboot states the role and STOPS — no wizard container, no
-# coordinator questions, an honest console line until the rig boot leg lands with R4.
-printf '{"pool":"10.0.0.5:3333","worker":"shed-3"}' >"$MRSB/rig.json"
-out=$(PITHEAD_INSTALL_BIN=/nonexistent run_sourced "$MRSB" firstboot_wizard 2>&1)
-assert_rc "rig-marked machine -> firstboot returns 0, no wizard" "$?" "0"
-assert_contains "the console states the role and the worker" "$out" "RigForge rig role"
-assert_contains "the stub is honest about what does not run yet" "$out" "nothing mines yet"
-assert_not_contains "no wizard container is started" "$out" "Setup wizard is up"
+assert_eq "the boot path reads back what was written" "$(run_sourced "$MRSB" machine_role)" "rig"
+printf 'nonsense\n' >"$MRSB/machine-role"
+assert_eq "an unreadable marker degrades to pithead, never to rig" "$(run_sourced "$MRSB" machine_role)" "pithead"
rm -rf "$MRSB"
-unset MRSB out
-
-echo "== unit: a staged rig install lands on /data at the target's first boot (#797 R3) =="
+unset MRSB
+
+echo "== unit: the rig boot leg — a role=rig machine mines instead of coordinating (#797 R4) =="
+# A rig has no config.json, no .env, no containers and no dashboard: rig.json IS its whole
+# contract. Driven against a fake rigforge.sh, like the Both role's leg — the real one compiles
+# miners and tunes kernels. What this owns: the derived config (pool + worker + password), the
+# invocation contract, and the refusals.
+RIGL=$(mktemp -d)
+mkdir -p "$RIGL/rigforge" "$RIGL/bin" "$RIGL/run" "$RIGL/journal"
+cat >"$RIGL/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 "$RIGL/rigforge/rigforge.sh"
+printf '#!/usr/bin/env bash\necho "systemctl:$*" >>"${RF_LOG:?}"\n' >"$RIGL/bin/systemctl"
+chmod +x "$RIGL/bin/systemctl"
+# The prebuilt XMRig the image bakes and pithead-sync seeds: present means no compile, which is
+# the whole no-clearnet-on-first-boot promise. Its absence is what narrates a build.
+mkdir -p "$RIGL/rigforge/data/worker/xmrig/build"
+: >"$RIGL/rigforge/data/worker/xmrig/build/xmrig"
+chmod +x "$RIGL/rigforge/data/worker/xmrig/build/xmrig"
+export RF_LOG="$RIGL/calls" PITHEAD_RIGFORGE_DIR="$RIGL/rigforge"
+export PITHEAD_JOURNALD_DROPIN_DIR="$RIGL/run" PITHEAD_JOURNAL_DIR="$RIGL/journal"
+run_rig() { PITHEAD_APPLIANCE=1 PATH="$RIGL/bin:$PATH" run_sourced "$RIGL" "$@"; }
+
+printf '{"pool":"10.0.0.5:3333","worker":"shed-3"}' >"$RIGL/rig.json"
+: >"$RF_LOG"
+rigl_out=$(run_rig provision_local_miner 2>&1)
+assert_rc "role=rig without the marker -> still the coordinator leg" "$?" "0"
+# rig.json alone means nothing: the MARKER is what the boot path forks on. Unmarked, this is a
+# coordinator with local_miner off, and the coordinator leg's job there is to stop the miner.
+assert_not_contains "no marker means no rig leg ran" "$(cat "$RF_LOG")" "rigforge:"
+assert_contains "unmarked -> the coordinator leg, which stops a miner it does not own" "$(cat "$RF_LOG")" "systemctl:stop xmrig.service"
+run_sourced "$RIGL" record_machine_role rig >/dev/null 2>&1
+: >"$RF_LOG"
+rigl_out=$(run_rig provision_local_miner 2>&1)
+assert_rc "marked rig -> rc 0" "$?" "0"
+assert_contains "the marked machine runs rigforge setup in appliance mode" "$(cat "$RF_LOG")" "rigforge:setup appliance=1"
+assert_contains "it runs from the synced tree on /data" "$(cat "$RF_LOG")" "cwd=$RIGL/rigforge"
+assert_contains "the console names the worker and its pool" "$rigl_out" "shed-3 -> 10.0.0.5:3333"
+# The derived config: rig.json's three values and nothing else. No hugepages headroom — there is
+# no stack on this machine to leave room for.
+assert_eq "the pool is the address the operator gave" "$(jq -r '.pools[0].url' "$RIGL/rigforge/config.json")" "10.0.0.5:3333"
+assert_eq "the worker name labels the rig at the pool" "$(jq -r '.pools[0].user' "$RIGL/rigforge/config.json")" "shed-3"
+assert_eq "no stratum password -> no pass key at all" "$(jq -r '.pools[0] | has("pass")' "$RIGL/rigforge/config.json")" "false"
+assert_eq "no stack here -> no hugepages headroom declared" "$(jq -r 'has("hugepages_reserve_extra_mb")' "$RIGL/rigforge/config.json")" "false"
+# Prebuilt-first: the seeded binary means the first boot renders, it never compiles or clones.
+assert_not_contains "a seeded prebuilt narrates no build" "$rigl_out" "building it once"
+# Removable-root tolerance: the journal goes to memory, because the root may be the stick the
+# miner runs from — and journald has to be restarted for the setting to take.
+assert_contains "journald is flipped to volatile" "$(cat "$RIGL/run/zz-rig-volatile.conf")" "Storage=volatile"
+[ -d "$RIGL/journal" ] && bad "the persistent journal directory is reclaimed" "still there" ||
+ ok "the persistent journal directory is reclaimed"
+assert_contains "journald is restarted so the setting takes" "$(cat "$RF_LOG")" "systemctl:restart systemd-journald"
+# A stratum password lands as the pool pass, and the config is re-derived every boot. Idempotent
+# on the second boot: nothing left to reclaim, so journald is not restarted again.
+printf '{"pool":"pithead.local:3333","worker":"shed-4","stratum_password":"s3cret"}' >"$RIGL/rig.json"
+: >"$RF_LOG"
+rigl_out=$(run_rig provision_local_miner 2>&1)
+assert_rc "re-run (the leg fires every boot) -> rc 0" "$?" "0"
+assert_eq "the config is re-derived, not repaired" "$(jq -r '.pools[0].url' "$RIGL/rigforge/config.json")" "pithead.local:3333"
+assert_eq "the stratum password lands as the pool pass" "$(jq -r '.pools[0].pass' "$RIGL/rigforge/config.json")" "s3cret"
+assert_not_contains "already volatile -> journald is not restarted again" "$(cat "$RF_LOG")" "restart systemd-journald"
+# No prebuilt (a wiped workspace): the operator gets told why the console is silent for minutes.
+rm -rf "$RIGL/rigforge/data/worker/xmrig"
+rigl_out=$(run_rig provision_local_miner 2>&1)
+assert_contains "a missing prebuilt narrates the one-time build" "$rigl_out" "building it once"
+# A failing setup is a failing boot leg: the caller leaves the slot uncommitted on it.
+rigl_out=$(PITHEAD_APPLIANCE=1 RF_RC=1 PATH="$RIGL/bin:$PATH" run_sourced "$RIGL" provision_local_miner 2>&1)
+assert_rc "failed setup -> rc 1" "$?" "1"
+assert_contains "failed setup is named on the console" "$rigl_out" "did not start"
+# A marker with no settings beside it: refuse, and say how to get the machine back.
+rm -f "$RIGL/rig.json"
+rigl_out=$(run_rig provision_local_miner 2>&1)
+assert_rc "marked rig with no settings -> rc 1" "$?" "1"
+assert_contains "the refusal names the way back (install again from the stick)" "$rigl_out" "install it again from the stick"
+# DIY host: a no-op, marker or not — RigForge there is the operator's own install.
+: >"$RF_LOG"
+printf '{"pool":"10.0.0.5:3333","worker":"shed-3"}' >"$RIGL/rig.json"
+PITHEAD_APPLIANCE=0 PATH="$RIGL/bin:$PATH" run_sourced "$RIGL" provision_local_miner >/dev/null 2>&1
+assert_eq "DIY host -> touches nothing" "$(cat "$RF_LOG")" ""
+unset RF_LOG PITHEAD_RIGFORGE_DIR PITHEAD_JOURNALD_DROPIN_DIR PITHEAD_JOURNAL_DIR
+unset -f run_rig
+rm -rf "$RIGL"
+unset RIGL rigl_out
+
+echo "== unit: a rig's first boot mines — wizard side and staged-install side (#797 R4) =="
+# Both boots that ACCEPT a role end mining on that same boot: no second wizard, no reboot to
+# wait for. Every LATER boot skips this unit entirely (its condition now excludes rig.json) and
+# goes through pithead-boot instead.
RPSB=$(mktemp -d)
RPESP=$(mktemp -d)
-export PITHEAD_PRESEED_DIR="$RPESP"
+mkdir -p "$RPSB/rigforge"
+cat >"$RPSB/rigforge/rigforge.sh" <<'EOF'
+#!/usr/bin/env bash
+echo "rigforge:$1 appliance=${RIGFORGE_APPLIANCE:-unset}" >>"${RF_LOG:?}"
+EOF
+chmod +x "$RPSB/rigforge/rigforge.sh"
+export PITHEAD_PRESEED_DIR="$RPESP" PITHEAD_RIGFORGE_DIR="$RPSB/rigforge" RF_LOG="$RPSB/calls"
+: >"$RF_LOG"
printf '{"pool":"10.0.0.5:3333","worker":"shed-3"}' >"$RPESP/pithead-rig.json"
out=$(PITHEAD_INSTALL_BIN=/nonexistent run_sourced "$RPSB" firstboot_wizard 2>&1)
assert_rc "staged rig settings -> consumed, rc 0" "$?" "0"
assert_eq "the answers land beside the program" "$(jq -r '.worker' "$RPSB/rig.json")" "shed-3"
assert_eq "the role marker is written" "$(cat "$RPSB/machine-role")" "rig"
-assert_contains "the stub message narrates the wait" "$out" "nothing mines yet"
+assert_contains "the console states the role and the worker" "$out" "RigForge rig"
+assert_contains "the staged install mines on that first boot" "$(cat "$RF_LOG")" "rigforge:setup appliance=1"
+assert_not_contains "no wizard container is started" "$out" "Setup wizard is up"
# Spent, like the config pre-seed: settings (possibly a password) must not sit on the ESP.
[ -f "$RPESP/pithead-rig.json" ] && bad "the consumed settings leave the ESP" "still there" || ok "the consumed settings leave the ESP"
+# An already-marked machine reaching this unit by hand takes the same leg, and asks nothing.
+: >"$RF_LOG"
+out=$(PITHEAD_INSTALL_BIN=/nonexistent run_sourced "$RPSB" firstboot_wizard 2>&1)
+assert_rc "already-marked rig -> rc 0, no coordinator questions" "$?" "0"
+assert_contains "the marked machine takes the rig leg too" "$(cat "$RF_LOG")" "rigforge:setup appliance=1"
+assert_not_contains "the R3 stub message is gone" "$out" "nothing mines yet"
+unset PITHEAD_PRESEED_DIR PITHEAD_RIGFORGE_DIR RF_LOG
rm -rf "$RPSB" "$RPESP"
-unset PITHEAD_PRESEED_DIR RPSB RPESP out
+unset RPSB RPESP out
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,
@@ -8524,7 +8623,50 @@ 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
+
+# The rig fork (#797 R4): a rig has no stack, so the role branch must come BEFORE the loader and
+# must never reach render/up. It still commits its own slot — one image, one update pipeline.
+rig_line=$(grep -n '^if .*machine-role' "$BOOTSCRIPT" | head -1 | cut -d: -f1)
+li_line=$(grep -n 'pithead load-images' "$BOOTSCRIPT" | head -1 | cut -d: -f1)
+if [ -n "$rig_line" ] && [ -n "$li_line" ] && [ "$rig_line" -lt "$li_line" ]; then
+ ok "the role fork precedes the container-image loader (a rig loads none)"
+else
+ bad "the role fork precedes the container-image loader (a rig loads none)" \
+ "role@${rig_line:-none} load-images@${li_line:-none}"
+fi
+# A coordinator has no marker, and reading a file that is not there is a REDIRECTION failure the
+# shell reports itself — `2>/dev/null` on the inner command cannot reach it. Harmless to control
+# flow, but it would print "No such file or directory" into the journal of every coordinator
+# boot. Existence has to be tested before the read.
+sed -n "${rig_line:-1}p" "$BOOTSCRIPT" | grep -q '\[ -f machine-role \]' &&
+ ok "the marker is tested for existence before it is read (no error on every coordinator boot)" ||
+ bad "the marker is tested for existence before it is read (no error on every coordinator boot)" \
+ "$(sed -n "${rig_line:-1}p" "$BOOTSCRIPT")"
+rig_branch=$(sed -n "${rig_line:-1},/^fi\$/p" "$BOOTSCRIPT")
+printf '%s' "$rig_branch" | grep -qE '\./pithead (up|render|load-images)' &&
+ bad "the rig branch starts nothing container-shaped" "it calls the stack's own commands" ||
+ ok "the rig branch starts nothing container-shaped"
+printf '%s' "$rig_branch" | grep -q 'mark-good' &&
+ ok "a rig commits its A/B slot exactly like a coordinator" ||
+ bad "a rig commits its A/B slot exactly like a coordinator" "no mark-good in the rig branch"
+# The units are the other half of the fork: without the triggering condition a rig never runs
+# the boot unit, and without the firstboot exclusion it re-runs the WIZARD every boot.
+BOOTUNIT="$ROOT/os/overlay/pithead-boot.service"
+FBUNIT="$ROOT/os/overlay/pithead-firstboot.service"
+grep -q '^ConditionPathExists=|/data/pithead/machine-role' "$BOOTUNIT" &&
+ grep -q '^ConditionPathExists=|/data/pithead/config.json' "$BOOTUNIT" &&
+ ok "the boot unit triggers on either shape of provisioned (config.json or the role marker)" ||
+ bad "the boot unit triggers on either shape of provisioned (config.json or the role marker)" \
+ "$(grep -c '^ConditionPathExists=|' "$BOOTUNIT") triggering conditions"
+grep -q '^ConditionPathExists=!/data/pithead/machine-role' "$FBUNIT" &&
+ ok "the wizard window is closed by the role marker too (no wizard on a provisioned rig)" ||
+ bad "the wizard window is closed by the role marker too (no wizard on a provisioned rig)" "missing"
+# The marker, not rig.json: a fleet stick writes a rig's ANSWERS in flight while installing one
+# onto a disk, and must stay an installer through it — only an ACCEPTED role writes the marker.
+grep -h '^ConditionPathExists=' "$BOOTUNIT" "$FBUNIT" | grep -q 'rig\.json' &&
+ bad "neither unit keys on the in-flight rig.json (a stick would stop being an installer)" "it does" ||
+ ok "neither unit keys on the in-flight rig.json (a stick stays an installer)"
+unset BOOTSCRIPT BOOTUNIT FBUNIT mg_line lm_line rig_line li_line rig_branch
echo "== unit: the A/B commit gate consumes doctor --json, not just the curl (#852) =="
# The gate that used to be a bare curl to https://localhost/ committed any slot whose dashboard