diff --git a/tests/README.md b/tests/README.md index 716b8ae..7b06e57 100644 --- a/tests/README.md +++ b/tests/README.md @@ -42,6 +42,9 @@ holds it from off-box: `ssh miner-0 'flock -n -x /var/lock/rig-e2e.lock true || - macOS-specific behaviour (BSD tools, launchd, the mac process control) → [`e2e/macos.sh`](e2e/macos.sh). - Something only provable on real hardware (it actually hashes, MSRs really applied, HugePages really reserved) → [`e2e-real.sh`](e2e-real.sh). +- A change to the sister-API feed or the control `/status` wire shape (a new field, a new `status` + string) → update the fixture in [`contract/v1/`](contract/v1/) in the same PR; `run.sh`'s + `== contract guard ==` section byte-compares against it and fails otherwise (#351). ## Conventions diff --git a/tests/contract/v1/README.md b/tests/contract/v1/README.md new file mode 100644 index 0000000..811e205 --- /dev/null +++ b/tests/contract/v1/README.md @@ -0,0 +1,111 @@ +# Wire contract v1 + +Plain-JSON fixtures pinning the two wire shapes pithead's fakes consume from a RigForge worker +(#351). Nothing outside this repo enforced these before — drift was only ever caught by a manual +live run against a real stack (the confirmed case: pithead's poller didn't know about #320's +`noop`/`throttled` statuses until someone hit them live). + +## What's covered + +| Fixture | Pins | Produced by | +|---|---|---| +| [`feed.json`](feed.json) | The sister-API feed (`GET :8081/1\|2/summary`) — the XMRig-passthrough envelope plus the full `rigforge` block RigForge adds. | `api_refresh` (`rigforge.sh`) | +| [`control-status.json`](control-status.json) | The control path's `GET :8082/status` vocabulary — every `status` string the code can emit, one example record per status, the 503 body served before any change has ever landed, and the `age_seconds` every served body carries. | `_control_status` (`rigforge.sh`) for the terminal/`started` record shapes; `do_GET` / `stage_pending` / `_with_age` (`util/control-server.py`) for `pending`, the no-history 503, and `age_seconds` — see below. | + +Both are generated by `tests/run.sh` from the **real** code paths (not hand-written), then +normalized and byte-compared against the committed file on every run — see +[`tests/run.sh`](../../run.sh), section `== contract guard: wire-shape fixtures (#351) ==`. + +`control-status.json` has two producers because it has two servers of truth. `_control_status()` is +the single choke point `control_apply`/`control_upgrade` write terminal outcomes through — the right +source for those record shapes, and cheap to call directly (source `rigforge.sh`, call the function, +read the file it wrote). But it is **not** the thing pithead's poller actually talks to: that's +`util/control-server.py`'s `GET /status`, which layers three things on top that only exist in the +Python receiver and are invisible to a guard that only ever calls `_control_status()` directly — the +503 no-history body (`do_GET`, when `status.json` has never been written), the `pending` status +(`stage_pending()`, written the instant `POST /apply` accepts a change, before any oneshot has run), +and a derived `age_seconds` (`_with_age()`) injected into *every* served body, pending or terminal. +The guard's `== contract guard ==` section spins up the real `util/control-server.py` over HTTP for +exactly those three (same harness as the `== black-box: the control server (#236) ==` section above +it) and checks the results against this fixture too — so a change in either file trips it. + +## Normalization + +A few fields are wall-clock-, release-version-, or server-random-derived, not part of the wire +*shape*, and would make every fixture regen a false-positive diff. They're replaced with a fixed +placeholder before compare — everything else (keys, nesting, types, and every enum literal, above +all `status`) is byte-strict: + +| File | Key(s) normalized | Why | +|---|---|---| +| `feed.json` | `rigforge.version`, `rigforge.xmrig_version`, `rigforge.xmrig_commit` | Installed-software provenance — bumps on every RigForge release / xmrig pin update, unrelated to the shape of the object they sit in. | +| `control-status.json` | `applied_at` (every terminal/`started` example), `accepted_at` (the `pending` example) | `date -u` at write/accept time — different on every run by construction. | +| `control-status.json` | `age_seconds` (every example) | Computed at SERVE time from `applied_at`/`accepted_at` (`_with_age()`) and never persisted to disk — by definition different on every run, same reason as the timestamp it derives from. | +| `control-status.json` | `change_id` (the `pending` example only) | Server-generated (`os.urandom(8).hex()` in `stage_change()`) — the guard drives this one through a real `POST /apply`, which does not take a caller-supplied id. The seven terminal/`started` examples keep a real, byte-pinned `change_id` because `_control_status()` takes it as an explicit argument the guard controls. | + +`change_id` (the seven terminal/`started` examples), `reason`, `backup`, `changed_keys`, `warnings`, +and `config_meta.revision` are real output from fixed, hand-picked inputs (the guard supplies the +arguments), so they're fully reproducible and stay byte-pinned — a change to any of them (e.g. a +reworded `reason` string, or the revision hash algorithm) is a real wire-visible change and should +fail the guard. The no-history 503 body is likewise byte-pinned in full, not normalized at all — it +is a fixed literal (`{"status": "no change applied yet"}`) with no volatile field, so any wording +change is exactly the kind of drift this guard exists to catch. + +## Health values pinned by construction (feed.json) + +`feed.json`'s `rigforge.health` block is not normalized — every field there is meant to be +byte-strict. That only works because the guard forces the inputs `_health_json` reads to a fixed, +hardware-independent state (this file's own `MEMINFO`/`GOVERNOR_FILE`/`RAPL_DIR`/... exports, plus +`API_CMD` replacing the worker-API curl). `service_active` needed the same treatment: `_health_json` +calls the REAL `systemctl is-active "$SERVICE_NAME"` (default `xmrig`), so on any Linux box that +genuinely has an active `xmrig` unit — every fleet rig — that call would return `active` there and +nowhere else, byte-diffing the fixture only on rigs. The guard overrides `SERVICE_NAME` to a unit +name no rig will ever have, so `is-active` reports inactive everywhere: a dev Mac (no `systemctl` +at all), a CI runner (no such unit), and a mining rig (a real `xmrig` unit, but not under this +fabricated name) all agree. This pins `service_active: false` and, since both are gated on `sa` +being true in `_health_json`, `clock_pct_of_boost: null` and `throttling: null` as well. + +## The status vocabulary + +`control-status.json`'s `statuses` array is cross-checked against the UNION of every literal status +word `rigforge.sh` actually passes to `_control_status` AND every `"status": ""` literal +`util/control-server.py` itself writes to a file `GET /status` can serve (both read straight off the +source, `grep` over call sites — not a hand-maintained list, and not just one side of the wire). A +new status word landing in *either* file can't land without the guard noticing, fixture or not. +Current vocabulary: `applied`, `failed`, `noop`, `pending`, `rejected`, `rolled_back`, `started`, +`throttled`. `noop`/`throttled`/`started` are the non-terminal / idempotent additions from #320; +`pending` (#344) is `stage_pending()`'s own non-terminal status for a change accepted but not yet +run — it never comes from `_control_status`, only from the receiver itself. + +The control-server.py grep intentionally excludes any `"status": ""` on a line also matching +`_send(202, ...)` — that's the synchronous accept acknowledgment `POST /apply`/`POST /upgrade` +return (`"status": "accepted"`), a different value from anything a later `GET /status` call returns, +and not part of this vocabulary. + +## The no-history response + +Before any change has ever been accepted on a rig (fresh install, or `state/status.json` never +written), `GET /status` returns `503 Service Unavailable` with the body documented in +`no_history` — `{"status": "no change applied yet"}`. It is part of what pithead's poller can +receive on a genuinely quiet rig, so it is pinned the same as every other example, not just an +implementation detail. + +## The change rule + +**Changing either wire shape means updating its fixture in the same PR.** The guard fails loudly +(a diff, not a silent pass) the moment `rigforge.sh` starts emitting something these files don't +already describe. Regenerate by hand — copy the relevant block out of the guard's failure output, +`jq -S` it, and paste it in — or run the guard, read the diff, and edit the fixture to match if the +new shape is intentional. + +Bumping the fixture here does **not** by itself update pithead — that's a paired change, filed +against pithead as its own PR (or done by whoever owns that side), same as any other cross-repo +contract change. This directory is the RigForge-side half only. + +## Versioning + +This is `v1`. A backward-incompatible change to either shape (a key removed, a type changed, a +status renamed rather than added) gets a new `v2/` directory alongside this one rather than an +in-place edit, so a sibling repo pinned to `v1` keeps working until it moves. A purely additive +change (a new key, a new status word) stays in `v1` — see `feed.json`'s own history for a real +example: `config`/`config_meta` (#253/#254) and `control` (#346) all landed as additive keys. diff --git a/tests/contract/v1/control-status.json b/tests/contract/v1/control-status.json new file mode 100644 index 0000000..ec3ba6e --- /dev/null +++ b/tests/contract/v1/control-status.json @@ -0,0 +1,121 @@ +{ + "examples": { + "applied": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": null, + "change_id": "2222222222222222", + "changed_keys": [ + "version" + ], + "reason": "upgraded to v9.9.9", + "source": "control", + "status": "applied", + "warnings": [] + }, + "failed": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": null, + "change_id": "5555555555555555", + "changed_keys": [ + "version" + ], + "reason": "staged upgrade target malformed (want vX.Y.Z)", + "source": "control", + "status": "failed", + "warnings": [] + }, + "noop": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": null, + "change_id": "3333333333333333", + "changed_keys": [ + "version" + ], + "reason": "already on v9.9.9 — nothing to upgrade", + "source": "control", + "status": "noop", + "warnings": [] + }, + "pending": { + "accepted_at": "NORMALIZED", + "age_seconds": "NORMALIZED", + "change_id": "NORMALIZED", + "status": "pending" + }, + "rejected": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": null, + "change_id": "6666666666666666", + "changed_keys": [ + "DONATION" + ], + "reason": "rejected merge-failed", + "source": "control", + "status": "rejected", + "warnings": [] + }, + "rolled_back": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": "/b", + "change_id": "7777777777777777", + "changed_keys": [ + "watchdog", + "DONATION" + ], + "reason": "miner did not return to a live hashrate; rolled back and live", + "source": "control", + "status": "rolled_back", + "warnings": [ + "thermal protection changed: watchdog" + ] + }, + "started": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": null, + "change_id": "1111111111111111", + "changed_keys": [ + "version" + ], + "reason": null, + "source": "control", + "status": "started", + "warnings": [] + }, + "throttled": { + "age_seconds": "NORMALIZED", + "applied_at": "NORMALIZED", + "backup": null, + "change_id": "4444444444444444", + "changed_keys": [ + "version" + ], + "reason": "throttled — too soon since the last upgrade attempt", + "source": "control", + "status": "throttled", + "warnings": [] + } + }, + "no_history": { + "body": { + "status": "no change applied yet" + }, + "http_status": 503 + }, + "statuses": [ + "applied", + "failed", + "noop", + "pending", + "rejected", + "rolled_back", + "started", + "throttled" + ], + "version": 1 +} diff --git a/tests/contract/v1/feed.json b/tests/contract/v1/feed.json new file mode 100644 index 0000000..7ce0df5 --- /dev/null +++ b/tests/contract/v1/feed.json @@ -0,0 +1,94 @@ +{ + "connection": { + "accepted": 42, + "failures": 0, + "pool": "poolbox.lan:3333", + "rejected": 1, + "uptime": 93700 + }, + "hashrate": { + "total": [ + 1234.5, + 0, + 0 + ] + }, + "hugepages": [ + 1248, + 1248 + ], + "rigforge": { + "config": { + "DONATION": 1, + "autotune": "disabled", + "max_temp_c": 85, + "pools": [ + { + "url": "h:3333" + } + ], + "watchdog": "enabled", + "watchdog_interval_min": 5 + }, + "config_meta": { + "changed_at": null, + "last_change_id": null, + "revision": "50c51399833d2a28", + "source": null + }, + "control": { + "change_id": "7777777777777777", + "reason": "miner did not return to a live hashrate; rolled back and live", + "status": "rolled_back" + }, + "health": { + "clock_pct_of_boost": null, + "firmware": { + "board": null, + "vendor": null + }, + "governor": null, + "hugepages_1g": null, + "hugepages_total": null, + "msr": "none", + "ram": { + "channels": 0, + "modules": 0, + "mts": 0, + "rated_mts": 0 + }, + "service_active": false, + "smt": null, + "throttling": null, + "xmp": null + }, + "power": { + "hs_per_watt": null, + "watts": null + }, + "tune": { + "applied": null, + "autotune": { + "enabled": false, + "next": null, + "schedule": null, + "target": null + }, + "candidates_tried": null, + "last_best_hs": null, + "target": null + }, + "version": "NORMALIZED", + "watchdog": { + "max_temp_c": 85, + "mode": "enabled", + "resumes_below_c": 80, + "strikes": 0, + "temp_c": null, + "thermal_hold": false + }, + "xmrig_commit": "NORMALIZED", + "xmrig_version": "NORMALIZED" + }, + "uptime": 93780 +} diff --git a/tests/run.sh b/tests/run.sh index 4d1e93c..2785bf8 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -7894,6 +7894,165 @@ else unset RIG_LOCK_FILE RIG_LOCK_HOLDER fi +# --------------------------------------------------------------------------- +echo "== contract guard: wire-shape fixtures (#351) ==" +# Cross-repo tripwire: pithead's fakes fetch and byte-compare tests/contract/v1/*.json against a +# live worker. Regenerate the SAME shapes here through the real code (api_refresh / _control_status, +# not hand-written JSON), normalize the couple of fields that legitimately vary release-to-release +# or run-to-run, and diff against the committed fixture — see tests/contract/v1/README.md for the +# normalization list and the change rule. This is what makes a wire-shape drift fail HERE instead of +# only ever surfacing as a manual live run against a real pithead stack. +# +# _control_status (rigforge.sh) is only HALF the wire: util/control-server.py's own do_GET serves a +# 503 no-history body of its own, stage_pending() writes a `pending` record neither rigforge.sh nor +# _control_status ever produces, and _with_age() injects a derived age_seconds into EVERY served body +# — none of that exists on disk for _control_status()-side generation to find. Regenerating only +# through rigforge.sh made the guard structurally blind to anything the Python receiver adds or emits +# itself. The vocabulary check below is unioned across both sources, and the record-shape check below +# that drives the REAL util/control-server.py over HTTP (same harness as the "#236 black-box: the +# control server" section above) for exactly the three things only IT can produce. +CONTRACT_DIR="$ROOT/tests/contract/v1" + +# --- vocabulary: every literal status word rigforge.sh hands to _control_status (the call sites, not +# a hand-maintained list) UNIONED with every `"status": ""` literal control-server.py itself +# writes to a file GET /status can serve (stage_pending's `pending`; a same-line `_send(202, ...)` is +# excluded — that's the synchronous POST /apply|/upgrade accept ack, a different value than anything +# a later GET /status call returns, so it does not belong in this vocabulary). A new status word from +# EITHER side can't land without this noticing, fixture update or not. +live_statuses_bash="$(grep -oE '_control_status "\$status" [a-z_]+' "$SCRIPT" | awk '{print $3}')" +live_statuses_py="$(grep -v '_send(202' "$ROOT/util/control-server.py" | grep -oE '"status": "[a-z_]+"' | sed -E 's/.*"([a-z_]+)"$/\1/')" +live_statuses="$(printf '%s\n%s\n' "$live_statuses_bash" "$live_statuses_py" | sort -u | jq -R . | jq -cs .)" +fixture_statuses="$(jq -c '.statuses' "$CONTRACT_DIR/control-status.json")" +assert_eq "control status vocabulary matches the fixture, rigforge.sh + control-server.py union (#351/#320/#344)" "$live_statuses" "$fixture_statuses" + +# --- shape: one example record per TERMINAL/started status, produced by the actual _control_status +# writer (the single choke point control_apply and control_upgrade both go through) with fixed +# inputs, so applied_at is the only volatile field (normalized below). age_seconds is additionally +# normalized here though _control_status never writes it — it's serve-time-derived by +# control-server.py's _with_age() on every real GET /status, so it's part of the wire shape a poller +# actually sees; the server-driven leg below proves _with_age genuinely adds it rather than just +# asserting the fixture says so. Full orchestration through control_apply/control_upgrade is already +# exercised elsewhere in this file; this re-derives the exact wire record for each status the same +# way those call sites do. +CTLFX="$(mktemp -d "$SANDBOX/ctlfx.XXXXXX")" +mk_status_record() { # + local d f + d=$(mktemp -d "$CTLFX/s.XXXXXX") + f="$d/status.json" + ( + source "$SCRIPT" + set +e + _control_status "$f" "$1" "$2" "$3" "$4" "$5" + ) >/dev/null 2>&1 + jq -S '.applied_at = "NORMALIZED" | .age_seconds = "NORMALIZED"' "$f" +} + +# --- server-driven: the `pending` example, the 503 no-history body, and proof that a served terminal +# record really does carry age_seconds — all three exist only in util/control-server.py, so only the +# real server (not _control_status) can produce them. Same spin-up/curl/kill harness as "#236 +# black-box: the control server" above, a dedicated instance so this doesn't depend on that section's +# mutated state. +if ! command -v python3 >/dev/null 2>&1; then + echo " SKIP: python3 not present (kcov container) — the server-driven contract legs run in the other CI jobs" +else + CGSRV="$(mktemp -d "$SANDBOX/cgsrv.XXXXXX")" + mkdir -p "$CGSRV/state" + CGTOK="tok-cg1" + printf '{ "pools":[{"url":"h:3333"}], "ACCESS_TOKEN":"%s" }\n' "$CGTOK" >"$CGSRV/config.json" + CGPORT=$((20000 + RANDOM % 20000)) + python3 "$ROOT/util/control-server.py" 127.0.0.1 "$CGPORT" "$CGSRV/state" "$CGSRV/config.json" & + CGSRV_PID=$! + CGU="http://127.0.0.1:$CGPORT" + cgup=0 + for _ in 1 2 3 4 5 6 7 8 9 10; do + curl -s -o /dev/null --max-time 2 -H "Authorization: Bearer $CGTOK" "$CGU/status" 2>/dev/null && { + cgup=1 + break + } + sleep 0.3 + done + assert_eq "contract-guard control server comes up (#351)" "$cgup" "1" + + # 503 no-history: do_GET's OWN fallback when state/status.json has never been written — invisible + # to _control_status()-side generation by construction (that function is never called with "no + # history yet"; only a fresh do_GET can produce this body). + live_no_history="$(jq -Sn --argjson code "$(hc "$CGU/status" -H "Authorization: Bearer $CGTOK")" \ + --argjson body "$(curl -sS --max-time 5 -H "Authorization: Bearer $CGTOK" "$CGU/status" 2>/dev/null)" \ + '{body: $body, http_status: $code}')" + fixture_no_history="$(jq -S '.no_history' "$CONTRACT_DIR/control-status.json")" + + # pending: staged by the REAL /apply accept path (stage_pending()) — the only source for this + # status word; _control_status never writes it. change_id is server-random + # (os.urandom(8).hex() in stage_change), unlike the fixed cid's mk_status_record supplies above, + # so it's normalized here same as accepted_at/age_seconds — see contract/v1/README.md. + pcid="$(curl -sS --max-time 5 -X POST "$CGU/apply" -H "Authorization: Bearer $CGTOK" -H 'Content-Type: application/json' -d '{"DONATION":2}' 2>/dev/null | jq -r .change_id)" + live_pending="$(curl -sS --max-time 5 -H "Authorization: Bearer $CGTOK" "$CGU/status?change_id=$pcid" 2>/dev/null | + jq -S '.accepted_at = "NORMALIZED" | .age_seconds = "NORMALIZED" | .change_id = "NORMALIZED"')" + + # age_seconds on a served TERMINAL record: _with_age derives it from applied_at for status.json + # too, not just the pending path — assert this against the real serving code, not just the fixture. + printf '{"status":"applied","change_id":"9999999999999999","applied_at":"2020-01-01T00:00:00Z"}' >"$CGSRV/state/status.json" + tbody="$(curl -sS --max-time 5 -H "Authorization: Bearer $CGTOK" "$CGU/status" 2>/dev/null)" + assert_eq "age_seconds present on a served terminal record (#351/#344)" "$(printf '%s' "$tbody" | jq 'has("age_seconds")')" "true" + + kill "$CGSRV_PID" 2>/dev/null || true + wait "$CGSRV_PID" 2>/dev/null || true + + assert_eq "control server's no-history 503 matches the fixture (#351)" "$live_no_history" "$fixture_no_history" + + live_examples="$(jq -Sn \ + --argjson started "$(mk_status_record started 1111111111111111 version '' '')" \ + --argjson applied "$(mk_status_record applied 2222222222222222 version 'upgraded to v9.9.9' '')" \ + --argjson noop "$(mk_status_record noop 3333333333333333 version 'already on v9.9.9 — nothing to upgrade' '')" \ + --argjson throttled "$(mk_status_record throttled 4444444444444444 version 'throttled — too soon since the last upgrade attempt' '')" \ + --argjson failed "$(mk_status_record failed 5555555555555555 version 'staged upgrade target malformed (want vX.Y.Z)' '')" \ + --argjson rejected "$(mk_status_record rejected 6666666666666666 DONATION 'rejected merge-failed' '')" \ + --argjson rolled_back "$(mk_status_record rolled_back 7777777777777777 watchdog,DONATION 'miner did not return to a live hashrate; rolled back and live' /b)" \ + --argjson pending "$live_pending" \ + '{applied: $applied, failed: $failed, noop: $noop, pending: $pending, rejected: $rejected, rolled_back: $rolled_back, started: $started, throttled: $throttled}')" + fixture_examples="$(jq -S '.examples' "$CONTRACT_DIR/control-status.json")" + assert_eq "control status record shape matches the fixture (#351)" "$live_examples" "$fixture_examples" +fi + +# --- feed: the rigforge block's shape, produced by the real api_refresh path. This file's own +# HARDWARE INDEPENDENCE exports (top of file: MEMINFO/GOVERNOR_FILE/RAPL_DIR/...) already make +# health/tune/watchdog fully deterministic on any machine; only installed-software provenance +# varies release to release, so that's all that needs normalizing. +FEEDFX="$(mktemp -d "$SANDBOX/feedfx.XXXXXX")" +mkdir -p "$FEEDFX/home" "$FEEDFX/data" "$FEEDFX/control" +printf 'v0.0.0-fixture\n' >"$FEEDFX/VERSION" +printf '{ "HOME_DIR": "%s/home", "pools": [{"url": "h:3333", "pass": "secret"}], "watchdog": "enabled", "max_temp_c": 85 }\n' "$FEEDFX" >"$FEEDFX/config.json" +printf '%s' '{"status":"rolled_back","change_id":"7777777777777777","source":"control","applied_at":"2026-01-01T00:00:00Z","changed_keys":["watchdog","DONATION"],"reason":"miner did not return to a live hashrate; rolled back and live","backup":"/b","warnings":["thermal protection changed: watchdog"]}' >"$FEEDFX/control/status.json" +printf '%s' '{"hashrate":{"total":[1234.5,0,0]},"connection":{"pool":"poolbox.lan:3333","uptime":93700,"failures":0,"accepted":42,"rejected":1},"uptime":93780,"hugepages":[1248,1248]}' >"$FEEDFX/xmrig-body.json" +( + source "$SCRIPT" + OS_TYPE=Linux + SCRIPT_DIR="$FEEDFX" + CONFIG_JSON="$FEEDFX/config.json" + # source-time CONFIG_META_FILE was derived from the REAL $SCRIPT_DIR (above), not this sandbox — + # override it explicitly (same reason the #253/#254 contract-pin test above does) or this reads + # whatever `apply()` left at the real repo root from an earlier test in this same run. + CONFIG_META_FILE="$FEEDFX/meta.json" + RIGFORGE_API_DATA="$FEEDFX/data" + RIGFORGE_CONTROL_STATE="$FEEDFX/control" + API_CMD="cat '$FEEDFX/xmrig-body.json'" + # _health_json shells out to the REAL `systemctl is-active` for service_active — API_CMD only + # replaces the worker-API curl, not that. On any Linux box genuinely running the xmrig unit (i.e. + # every fleet rig) the untouched default SERVICE_NAME=xmrig would read "active" there and byte- + # diff this fixture on rigs while passing everywhere else. Point it at a unit name no rig will + # ever have instead of putting $STUBS on PATH: the generic systemctl stub there always exits 0 + # (fine for the tests that use it, which never assert exact service_active/autotune values), which + # would make this read "active" unconditionally instead of fixing anything. Pins service_active: + # false and, since both are gated on it, clock_pct_of_boost/throttling: null too — see + # contract/v1/README.md. + SERVICE_NAME="rigforge-contract-fixture-nonexistent" + set +e + api_refresh 2>/dev/null +) +live_feed="$(jq -S '.rigforge.version = "NORMALIZED" | .rigforge.xmrig_version = "NORMALIZED" | .rigforge.xmrig_commit = "NORMALIZED"' "$FEEDFX/data/summary.json")" +fixture_feed="$(jq -S . "$CONTRACT_DIR/feed.json")" +assert_eq "sister-API feed shape matches the fixture (#351)" "$live_feed" "$fixture_feed" + # --------------------------------------------------------------------------- echo "" printf 'rigforge tests: \033[1;32m%d passed\033[0m, ' "$PASS"