Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions build/dashboard/mining_dashboard/web/static/wizard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,9 @@ export const Done = ({ status, handoff, installer, stick, rig, onAck }) => html`
page goes dark while the machine builds itself.`
}<//>`
: rig
? html`<p><strong>Saved.</strong> 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.</p>
? html`<p><strong>Saved.</strong> 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.</p>
<p class="text-muted">${status || ""}</p>`
: html`<p><strong>Provisioning.</strong> The machine is pulling and starting the stack —
10 to 30 minutes on a home connection. <strong>This page will stop responding</strong>
Expand Down
7 changes: 4 additions & 3 deletions build/dashboard/mining_dashboard/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 4 additions & 2 deletions build/dashboard/tests/frontend/wizard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
6 changes: 5 additions & 1 deletion build/dashboard/tests/web/test_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
60 changes: 53 additions & 7 deletions docs/dev/appliance-wizard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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*.
Expand Down
30 changes: 30 additions & 0 deletions os/overlay/pithead-boot
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<machine-role` on a machine that has none is a REDIRECTION
# failure, and the shell prints that itself — `2>/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:]' <machine-role)" = "rig" ]; then
# Same bounded clock as the coordinator's miner leg, for the same reason: the boot unit runs
# with TimeoutStartSec=infinity, so a stalled native rebuild would leave it activating
# forever. The prebuilt path finishes in seconds.
timeout 1800 ./pithead local-miner || exit 1
for _ in $(seq 12); do
if systemctl is-active --quiet xmrig.service; then
command -v rauc >/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
Expand Down
13 changes: 11 additions & 2 deletions os/overlay/pithead-boot.service
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 7 additions & 1 deletion os/overlay/pithead-firstboot.service
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions os/rootfs/repart.d/40-data.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading