diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6f0382..24822d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ Pithead ships as **one product, one version** — the version lives in the top-l [`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md). +## [1.10.2] - 2026-07-21 + +### Fixed + +- **The payout wallet no longer reports the whole stack unhealthy during its first scan (#718).** + With the genesis-scan default, `wallet-rpc`'s initial full-chain scan takes hours, and + monero-wallet-rpc doesn't answer its RPC while scanning — so the healthcheck flipped `unhealthy` + and could fire stack-health alerts for the entire scan, even though the wallet was working. The + healthcheck now tolerates an unreachable RPC while a first-scan marker is present (armed on wallet + creation, cleared the first time the RPC answers), so it stays healthy through the initial scan + and turns strict once caught up. +- **Control-gate refusal messages no longer cite a closed, unrelated issue (#713).** The + "security-sensitive setting" and "destructive change" refusals pointed operators at #338 (a + closed Telegram-control issue) for "out-of-band approval"; they now just name the real path — + edit `config.json` on the host and run `./pithead apply`. + ## [1.10.1] - 2026-07-21 ### Fixed diff --git a/VERSION b/VERSION index 4dae2985..5ad2491c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.1 +1.10.2 diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index 2d80a85e..f56d129d 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -7,7 +7,7 @@ name = "mining-dashboard" # Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version # (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version # from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only. -version = "1.10.1" +version = "1.10.2" description = "Monitoring dashboard and XvB switching engine for Pithead" readme = "README.md" requires-python = ">=3.11" diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock index 56dc42c5..5c768638 100644 --- a/build/dashboard/uv.lock +++ b/build/dashboard/uv.lock @@ -782,7 +782,7 @@ wheels = [ [[package]] name = "mining-dashboard" -version = "1.10.1" +version = "1.10.2" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/build/monero/wallet-entrypoint.sh b/build/monero/wallet-entrypoint.sh index cff963dd..2c9f00d5 100644 --- a/build/monero/wallet-entrypoint.sh +++ b/build/monero/wallet-entrypoint.sh @@ -11,6 +11,11 @@ set -eu WALLET_DIR="${WALLET_DIR:-/home/ubuntu/wallets}" WALLET_FILE="$WALLET_DIR/payout-wallet" +# Marker (#718): touched when a wallet is first created, cleared by the healthcheck on the first +# successful RPC. While it exists, an unreachable RPC means "still on the initial scan" — which for +# the genesis default is HOURS, during which monero-wallet-rpc is single-threaded and won't answer. +# It lives in the volume so it persists across container recreates until the scan actually finishes. +SCAN_MARKER="$WALLET_DIR/.payout-scanning" GEN_JSON="${GEN_JSON:-/tmp/gen.json}" # tmpfs; holds the view key for the create-from-keys step only DAEMON_ADDRESS="${MONERO_NODE_HOST:-127.0.0.1}:${MONERO_RPC_PORT:-18081}" @@ -76,6 +81,9 @@ if [ ! -f "$WALLET_FILE" ]; then height="$(resolve_scan_height)" [ -n "$height" ] || height=0 echo "Creating view-only payout wallet at restore height $height (#381)..." + # Mark the initial scan (#718): the healthcheck tolerates an unreachable RPC while this exists + # (genesis scan is hours) and clears it on the first successful RPC. + touch "$SCAN_MARKER" 2>/dev/null || true # The view key lives ONLY in this tmpfs file, never on argv. write_gen_json "$height" # --generate-from-json creates + opens the wallet, then keeps serving the RPC. diff --git a/build/monero/wallet-healthcheck.sh b/build/monero/wallet-healthcheck.sh index 118a6b4f..d6381084 100644 --- a/build/monero/wallet-healthcheck.sh +++ b/build/monero/wallet-healthcheck.sh @@ -1,16 +1,37 @@ #!/bin/sh -# monero-wallet-rpc liveness check (#381). +# monero-wallet-rpc health (#381, #718). # -# Reads the RPC credentials from the container's environment instead of taking them as arguments, -# so they are NOT baked into the compose `healthcheck.test` — where `docker inspect` could read -# them (#90). A JSON-RPC `get_version` proves the server is up and answering; it succeeds during -# the initial scan too, so the long start_period in compose tolerates first-run blockchain scanning -# without flapping unhealthy. -set -eu +# RPC-up is the normal signal: a JSON-RPC get_version proves the server is answering. Credentials +# come from the environment, not argv, so `docker inspect` can't read them (#90). +# +# But during the INITIAL payout scan (#718) monero-wallet-rpc is single-threaded and heads-down for +# the whole scan — with the genesis default it is HOURS, and it refuses the RPC the entire time. A +# plain RPC check flaps unhealthy after the 2m start_period and spams stack-health alerts for the +# whole first scan. So: a marker file (`.payout-scanning`, written by the entrypoint on wallet +# creation, living in the volume so it survives recreates) means "still on the first scan" — while +# it exists, an unreachable RPC is treated as healthy. The first time the RPC answers, the scan has +# caught up, so we clear the marker and are strict from then on (a later RPC failure is a real +# fault, not scan tolerance). +set -u + +WALLET_DIR="${WALLET_DIR:-/home/ubuntu/wallets}" +SCAN_MARKER="$WALLET_DIR/.payout-scanning" + +rpc_up() { + curl -fsS --digest \ + -u "${WALLET_RPC_USERNAME:-wallet}:${WALLET_RPC_PASSWORD:-}" \ + -o /dev/null \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"0","method":"get_version"}' \ + http://localhost:18082/json_rpc +} + +if rpc_up; then + # Caught up and answering — retire the initial-scan grace so future RPC failures read as real. + rm -f "$SCAN_MARKER" 2>/dev/null || true + exit 0 +fi -curl -fsS --digest \ - -u "${WALLET_RPC_USERNAME:-wallet}:${WALLET_RPC_PASSWORD:-}" \ - -o /dev/null \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":"0","method":"get_version"}' \ - http://localhost:18082/json_rpc +# RPC silent. Healthy only if we're still on the first scan (marker present); else it's a fault. +[ -f "$SCAN_MARKER" ] && exit 0 +exit 1 diff --git a/pithead b/pithead index 7252e10b..096d1187 100755 --- a/pithead +++ b/pithead @@ -5075,11 +5075,11 @@ control_approval_gate() { # bad=$(printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -cvxE "$editable_re" || true) if [ "${bad:-0}" -gt 0 ]; then hit=$(printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -m1 -vxE "$editable_re" || true) - printf 'this change alters a security-sensitive setting (%s) that is not committable from the dashboard — out-of-band approval is tracked in #338. Apply it from the host with `%s apply`.' "${hit:-unparseable change row}" "$0" + printf 'this change alters a security-sensitive setting (%s) that is not committable from the dashboard. Edit config.json on the host and run `%s apply`.' "${hit:-unparseable change row}" "$0" return 1 fi if printf '%s\n' "$porcelain" | grep -qE $'^DEST\t'; then - printf 'this change is destructive and cannot be committed from the dashboard — out-of-band approval is tracked in #338. Apply it from the host with `%s apply`.' "$0" + printf 'this change is destructive and cannot be committed from the dashboard. Edit config.json on the host and run `%s apply`.' "$0" return 1 fi # Approved: echo the changed key NAMES so the commit's audit entry can record WHAT changed diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 3fcd6cad..68071c0e 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -1105,6 +1105,34 @@ assert_eq "scan height: auto -> genesis 0 (full payout history)" "$(rsh auto)" " assert_eq "scan height: empty -> genesis 0" "$(rsh '')" "0" assert_eq "scan height: explicit block kept verbatim" "$(rsh 2500000)" "2500000" +# Wallet healthcheck (#718): during the multi-hour genesis scan monero-wallet-rpc refuses the RPC, +# so the check must tolerate an unreachable RPC WHILE the initial-scan marker is present, and turn +# strict once the RPC first answers. Stub `curl` on PATH to be the RPC up/down control. +HCBIN="$SANDBOX/hc-bin" +HCDIR="$SANDBOX/hc-wallet" +mkdir -p "$HCBIN" "$HCDIR" +mk_curl() { + printf '#!/bin/sh\nexit %s\n' "$1" >"$HCBIN/curl" + chmod +x "$HCBIN/curl" +} +run_hc() { ( + PATH="$HCBIN:$PATH" WALLET_DIR="$HCDIR" sh "$ROOT/build/monero/wallet-healthcheck.sh" >/dev/null 2>&1 + echo $? +); } +# RPC down + marker present (mid initial scan) -> healthy (the whole point of #718). +mk_curl 7 +: >"$HCDIR/.payout-scanning" +assert_eq "healthcheck: RPC down but scanning -> healthy (#718)" "$(run_hc)" "0" +# RPC up -> healthy AND the marker is retired (scan caught up; strict from now on). +mk_curl 0 +assert_eq "healthcheck: RPC up -> healthy (#718)" "$(run_hc)" "0" +if [ -f "$HCDIR/.payout-scanning" ]; then bad "healthcheck: RPC up clears the scan marker (#718)" "marker still present"; else ok "healthcheck: RPC up clears the scan marker (#718)"; fi +# RPC down + NO marker (scan already finished once) -> unhealthy: a real fault, not scan tolerance. +mk_curl 7 +assert_eq "healthcheck: RPC down after scan done -> unhealthy (#718)" "$(run_hc)" "1" +# The entrypoint arms the marker on wallet creation so the grace applies from first boot. +assert_contains "wallet-entrypoint touches the scan marker on create (#718)" "$(cat "$ROOT/build/monero/wallet-entrypoint.sh")" 'touch "$SCAN_MARKER"' + echo "== unit: clock_sync_status (mining is time-sensitive) ==" # doctor's NTP check classifies timedatectl's NTPSynchronized: yes→synced, no→unsynced, else unknown. CLKBIN="$SANDBOX/clk-bin" @@ -4814,7 +4842,7 @@ assert_eq "destructive candidate previews destructive:true" "$(jq -r '.destructi printf '{"id":"%s","action":"commit","actor":"admin"}\n' "$UUID3" >"$REQS/$UUID3.json" run_pending >/dev/null assert_eq "destructive commit is refused" "$(jq -r '.status' "$RESULTS/$UUID3.json" 2>/dev/null)" "rejected" -assert_contains "destructive refusal points at #338" "$(jq -r '.error' "$RESULTS/$UUID3.json" 2>/dev/null)" "#338" +assert_contains "destructive refusal names the host apply path, not stale #338 (#713)" "$(jq -r '.error' "$RESULTS/$UUID3.json" 2>/dev/null)" "Edit config.json on the host" assert_eq "refused destructive commit did not touch config.json" "$(jq -r '.monero.clearnet_initial_sync // false' "$C/config.json")" "false" [ ! -f "$STAGED/$UUID3.json" ] && ok "refused destructive intent cleared from staged" || bad "refused destructive intent cleared from staged" "still staged" # A NON-destructive commit still proceeds (pool switch mini -> nano is INFO, not DEST).