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