Commit Graph
33 Commits
Author SHA1 Message Date
Rob Thijssen ebd6fd2b32 chore: pin quantus-miner 4.0.2
deploy / fetch (push) Successful in 18s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 36s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-metrics (push) Successful in 12s
2026-09-03 09:37:37 +03:00
Rob Thijssen e178bcbdf9 feat: boot-time GPU power limits, clamped to hardware floors
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 34s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 43s
deploy / deploy-metrics (push) Successful in 8s
nvidia-smi -pl survives neither a reboot nor a driver unload, and both
failures are silent -- the card returns to its default limit and nobody
notices until a connector runs warm. Hence a boot-time unit, which also
enables persistence mode so the limit is not lost when the last GPU
client detaches.

Per-host limits configured as host=watts in infra-setup.sh, clamped to
each card's own min/max. Verified: a 300W request on a 5090 whose floor
is 400W is corrected with an explicit log line rather than failing or
being ignored.

Measured on a 4090 mining Poseidon2:
  450W (stock)  185.5 MH/s  70C  0.413 MH/s/W
  300W          183.2 MH/s  61C  0.611
  250W          180.3 MH/s  58C  0.721

44% less power and 12C cooler for 2.8% less hashrate -- this workload is
register-resident integer math and does not need the top of the clock
curve. It also cuts current through the 12VHPWR connector, which is the
one component on these cards with a documented melting failure mode.

Ordered Before=quantus-miner.service so a GPU is never briefly at its
default limit under full mining load.
2026-09-01 06:42:32 +03:00
Rob Thijssen 298dff9343 feat: at-a-glance GPU gauges
deploy / fetch (push) Successful in 16s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 32s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 43s
deploy / deploy-metrics (push) Successful in 8s
Adds a Now row of gauge panels at the top of the GPU dashboard,
mirroring node-exporter's CPU Busy: threshold markers, amber then red,
one gauge per GPU series rather than one aggregate.

GPU Busy (utilisation), Temperature with amber at 80C and red at 87C
where consumer NVIDIA cards start thermal throttling, and Power as a
fraction of the card's enforced limit rather than raw watts -- a 4090
at 450W and a 5090 at 600W are both fully loaded, and only the ratio
says so on one scale.

Labels are shortened to host + gpu index for the gauges; the timeseries
below keep the full name.
2026-09-01 06:19:28 +03:00
Rob Thijssen 598aea28f8 feat: GPU metrics via the node-exporter textfile collector
deploy / fetch (push) Successful in 16s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 33s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-metrics (push) Successful in 9s
node-exporter already runs on every host, is already scraped (job
'node', targets from cichlid's inventory, labelled by host), and already
has the textfile collector enabled with a spool at
/var/lib/prometheus/node-exporter. So a textfile drop needs no new
exporter, no port allocation, no firewalld service and no scrape config
-- which is why it is one-time provisioning rather than a deploy job.

DCGM was the alternative and is the wrong tool: it targets datacenter
cards and its GeForce support is partial. nvidia-smi reports everything
wanted.

The script writes to a temp file and renames atomically, because the
collector reads whole files and a half-written one surfaces as
node_textfile_scrape_error. When nvidia-smi fails it emits an explicit
nvidia_gpu_scrape_ok 0 rather than leaving a stale file, so a broken
driver reads as broken instead of the graphs quietly flatlining.

Verified on the dual-GPU host: both cards reporting temperature, power,
utilisation, memory, clocks and fan through node-exporter with
node_textfile_scrape_error 0.

Fleet-general rather than quantus-specific -- it lives here because
there is no monitoring repo yet, and the dashboard says so.
2026-09-01 06:10:10 +03:00
Rob Thijssen d784db8e3a feat: peer occupancy panels for the out-peers experiment
opened MINUS closed per direction, plotted against the caps. Occupancy
is the measurement that matters and cumulative opens actively mislead:
inbound churns constantly (170 opened, 156 closed) while outbound is
deliberately maintained (36 opened, 27 closed), so a high inbound open
count says nothing about whether its cap is full. Reading the ratio of
cumulative opens is how issue #1 came to recommend the wrong knob.

Measured now: outbound 9 held against a cap of 8 -- saturated -- while
inbound sits at 14 of 32 and the node has discovered 300 peers. So
--out-peers binds and --in-peers does not, the reverse of what #1 said.

No exporter change needed; both series are already scraped from the
node. Adds the discovered-peers count and rounds-per-minute alongside,
since those are the other two the experiment compares.
2026-09-01 06:05:01 +03:00
Rob Thijssen 867a71f514 feat: telemetry census — node population vs distinct authors
deploy / fetch (push) Successful in 16s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 34s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy-metrics (push) Successful in 9s
The chain can only see miners that WIN blocks. A miner too small to take
a block in any given window is invisible in the authorship leaderboard
while still being connected and still burning electricity. Telemetry
sees every node, so the two series together answer a question neither
can alone: nodes flat while distinct authors falls means small miners
are being out-competed, not quitting.

Measured on the live feed: 32 nodes against 9-21 distinct authors, 229
aggregate CPU cores, 15 VMs, and five client versions in the wild --
half the network is still on 0.6.x/0.7.x/0.8.x, which is a fork risk at
a runtime upgrade and worth knowing before 2026-09-09.

python3-websockets is a SOFT dependency, packaged not pip: without it
the exporter serves every chain metric and reports
quantus_telemetry_up 0, so a missing package degrades rather than
breaks. infra-setup.sh installs it and treats failure as non-fatal.

The genesis hash is discovered from the node rather than configured --
one less value to get wrong when the chain changes for mainnet.
2026-09-01 05:34:50 +03:00
Rob Thijssen d60173f0ca feat: wss://quantus.internal via the site edge proxy
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 34s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy-metrics (push) Successful in 8s
subxt refuses plain ws:// to any host but localhost (InsecureUrl), so
quantus-cli against a remote node needs either an ssh tunnel or TLS.
This adds the vhost and an edge role in infra-setup.sh to mint the cert,
install it, arm step@quantus.timer and reload nginx.

Operator-run rather than CI-deployed, matching lair/mail: the runner's
rights never extend to reading certificate keys or reloading nginx on a
shared edge proxy.

Two things found while writing it. The vhost must listen on
127.0.0.1:14443 with proxy_protocol, not 443, because a stream SNI
router owns 443 on the edge proxies. And the sites-available copy of an
existing vhost is NOT a safe template: 11 of 19 enabled vhosts on
hanzalova are regular files rather than symlinks and have drifted from
sites-available since the SNI migration -- agent0's enabled copy listens
on 14443 while its sites-available copy still says 443. This vhost is
installed as a symlink so it cannot drift the same way.

Verified: nginx -t parses the config on the real host, failing only on
the not-yet-minted certificate.
2026-09-01 05:21:10 +03:00
Rob Thijssen 71bbc2e826 fix: --rpc-external is refused on a validator
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 31s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-metrics (push) Successful in 9s
The node crash-looped 7 times and was down ~100s, not mining:

  Error: --rpc-external option shouldn't be used if the node is running
  as a validator. Use `--unsafe-rpc-external` or `--rpc-methods=unsafe`
  if you understand the risks.

Substrate rejects --rpc-external on a validator outright and exits,
whatever --rpc-methods says. --unsafe-rpc-external binds identically
('Same as --rpc-external' per --help) and differs only in demanding an
explicit acknowledgement. The safety still comes from --rpc-methods
safe, which is unchanged.

Restored on the host immediately; this makes CI converge on the same
thing rather than reverting it.

Also: validate opened with a bare , which exits 3
the moment a unit is 'activating' and under set -e aborted the step
before printing anything. A crash-looping unit sits in
activating/auto-restart forever, so a validator refusing its own flags
surfaced as an opaque 'exit code 3'. It now waits for a terminal state
and, on failure, reports NRestarts and the actual error lines from the
journal.
2026-09-01 04:50:01 +03:00
Rob Thijssen add26303f2 feat: preflight the sudoers before deploying
Run 11 failed with

  sudo: a password is required
  rsync error: error in rsync protocol data stream (code 12)

because quantus-node-rpc.xml was added to the deploy and to
infra-setup.sh's grants, but infra-setup had not been re-run against the
host. The failure landed forty lines into deploy-node, after several
files had already been written, and said nothing about the actual cause.

Each deploy job now compares the paths infra-setup.sh grants for its
role against sudo -n -l on the target, before touching anything. The two
cannot drift because the expected list is extracted from
infra-setup.sh itself rather than duplicated, so an asset added in
future is covered automatically.

Verified against the live host: it correctly reported
/etc/firewalld/services/quantus-node-rpc.xml as missing before
infra-setup was re-run, and reports all 26 paths permitted after.
2026-09-01 04:47:24 +03:00
Rob Thijssen aa506b963c feat: optionally expose the JSON-RPC to the site LAN
deploy / fetch (push) Successful in 17s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Has been skipped
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --rpc-external --rpc-methods safe --rpc-cors all… (push) Failing after 31s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Has been skipped
deploy / deploy-metrics (push) Has been skipped
Substrate binds JSON-RPC to loopback and makes opening it deliberately
awkward, which is correct for a validator. rpc_expose in the node matrix
takes the complete flag string; empty keeps it on loopback.

--rpc-methods safe is the load-bearing flag. It permits every state read
and author_submitExtrinsic (signed, so no key exposure) while blocking
author_rotateKeys, system_addReservedPeer and the rest. --rpc-methods
auto happens to downgrade once listening externally, but relying on that
makes the safety depend on a flag nobody wrote down.

The firewalld rich rule is scoped to the node's own /16, derived from
its own resolved address at deploy time -- so no subnet literal enters
this repo and the rule follows the host between sites.

Loopback is whitelisted from rate limiting because the arena exporter
runs beside the node and makes ~4 calls every 2s, which is 120/min --
enough to throttle itself against a naive limit.

validate now asserts the bind matches intent in both directions: nothing
listening while rpc_expose is set is a failure, and loopback-only is
reported as configuration rather than silence.
2026-09-01 04:43:27 +03:00
Rob Thijssen ca94e3a6c0 feat: track accrued rewards in the arena exporter
deploy / fetch (push) Successful in 16s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 31s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-metrics (push) Successful in 8s
quantus-node has no balance subcommand, and the faucet_getAccountInfo
RPC that MINING.md documents is absent from this build -- rpc_methods
returns only account_nextIndex and system_accountNextIndex. So the
exporter builds the System.Account storage key itself: twox128(System)
++ twox128(Account) ++ blake2_128_concat(account).

XXH64 is inlined because python3-xxhash is not installed on the node
hosts and this exporter stays dependency-free. It asserts the two
universally-known prefixes at import, so a broken implementation fails
loudly instead of silently querying the wrong key.

The reward address is Poseidon2(inner_hash). Rather than reimplement
Poseidon2 over Goldilocks, the deploy lifts it from the node's own
startup log; empty is tolerated and the exporter simply omits balance
metrics, which is what a genuinely first deploy will see.

Verified live: 158.153404340522 PLK, 12 decimals, zero RPC errors.

Also: str.replace fails silently on a missing anchor, which is how the
first attempt shipped an exporter calling a method that was never
inserted. Edits to this file now assert their anchors.
2026-09-01 04:07:03 +03:00
Rob Thijssen adc04f5bc5 fix: wait for first scrape before judging target health
deploy / fetch (push) Successful in 16s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 29s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy-metrics (push) Successful in 11s
Run 8's validate reported

  FAIL quantus-arena    bob:25033       unknown
  FAIL quantus-miner    quadbrat:9900   unknown
  ok   quantus-miner    benjy:9900      up
  ok   quantus-node     bob:9615        up

which is not a failure: a target Prometheus has only just learned about
reports 'unknown' until its first scrape completes, and this step runs
seconds after the config reload with a 15s scrape interval. Both were up
moments later.

The old retry loop broke as soon as ANY quantus target existed, which
was immediately true because the node target predated this run. It now
waits for the unknowns to resolve, bounded at 120s. A genuinely dead
target reports 'down' rather than 'unknown', so this does not paper over
real failures -- the same distinction the readiness wait on the node
makes.

Third race of this shape: deploy finishes before the thing it deployed
is observable.
2026-09-01 03:43:03 +03:00
Rob Thijssen c748da08cb feat: arena exporter — network hashrate, difficulty, authorship leaderboard
deploy / fetch (push) Successful in 14s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 40s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy-metrics (push) Failing after 15s
Closes the observability gap in #1 and builds the chain-scraper half of
#2. Every block header carries its miner's reward preimage in a
PreRuntime digest (engine id pow_), so authorship for EVERY miner on the
network is derivable from headers alone -- no indexer, no cooperation,
no event decoding. Difficulty comes from QPoWApi_get_difficulty via
state_call, verified working against the live node.

That makes expected share computable for the first time: our hashrate
over difficulty/interval, plotted against the fraction of blocks we
actually author. The gap between those two lines is orphan and
stale-work loss, which is the only honest test of whether tuning
anything helped.

Pure stdlib Python, deliberately: no pip, no venv, no build step, and it
deploys like every other asset here. Runs as its own service account
against the node's loopback RPC -- it has no business being able to
touch the validator's state or reward config.

Two things learned testing it live. The window is a BLOCK COUNT, not a
duration: a time window is meaningless while the node catches up, since
it ingests history at import speed -- the first version reported a 9ms
block interval and 102 GH/s. And per-preimage series are capped at top-N
plus our own, because an unbounded label set would be a cardinality bomb
if the network grows.

Prior art in Quantus-Network/qsafe.af derived miners from balance events
instead; the digest is lighter and unambiguous, and drops the GraphQL
indexer dependency entirely.
2026-08-31 19:58:02 +03:00
Rob Thijssen 938941b887 feat: second miner on quadbrat, and make the chain configurable
One node fans the same job out to every connected miner
(node/src/miner_server.rs holds a HashMap of MinerHandle and
broadcast_job's to all of them) and each miner picks its own random
starting nonce, so a second miner needs no coordination and no second
node. quadbrat joins as the test of that: RTX 3060, idle, gitea_ci
already present.

Separately, --chain was hardcoded in the unit while the workflow already
carried CHAIN in env for the credential path -- so switching chains
would have meant editing a static asset. With mainnet expected
2026-09-09 that is the wrong shape: the chain now comes from config, and
the switch is one line plus the version pins.

readme gains a mainnet-readiness section listing what the deploy cannot
decide: a mainnet reward wallet, the unpublished chain spec name, disk
for a second chains/ directory, and the GPU contention question.
2026-08-31 19:12:37 +03:00
Rob Thijssen bbe29caafe feat: mining efficiency panels, and say what they cannot measure
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 31s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-metrics (push) Successful in 9s
Adds a Mining efficiency row: mining rounds started per minute, hashes
per mining round, and active jobs over time.

These are proxies, not the answer. Neither binary exports what the
question actually needs -- quantus-node has no difficulty metric and no
authored-block counter (substrate_proposer_block_constructed counts
proposals constructed, not rounds won), and quantus-miner has no
solutions-found counter. So authored-blocks-vs-expected-share cannot be
plotted, and a panel implying otherwise would be worse than none.

A text panel on the row states that plainly rather than leaving someone
to infer a conclusion the data does not support. lair/quantus#1 tracks
closing the gap -- a chain-query exporter counting blocks credited to
the reward address is the cheapest route -- and the experiments that
depend on it.
2026-08-31 19:05:35 +03:00
Rob Thijssen 2cd478646f fix: validate metrics without shell-escaped python or grafana auth
Two bugs, both in validation only -- the deploy half worked and both
scrape targets are up.

The targets check used python3 -c with \" escapes inside an f-string,
which Python reads as a line continuation:

  SyntaxError: unexpected character after line continuation character

Rewritten as a quoted heredoc reading a file, so nothing is escaped
through the shell. It now also reports per-target health rather than
grepping for ' up '.

The Grafana check called /api/search, which returns 401 -- the API
requires auth and this job holds no Grafana credentials by design.
Replaced with the unauthenticated /api/health, a file presence check for
both dashboard and provider, and a scan of the grafana journal for
dashboard provisioning errors, which is what actually distinguishes a
dashboard that loaded from one that failed to parse.
2026-08-31 18:55:58 +03:00
Rob Thijssen 76fd8073c0 fix: gitea_ci needs a login shell
deploy-metrics failed on the metrics host with

  This account is currently not available.

which is /usr/sbin/nologin refusing 'ssh gitea_ci@host <command>' after
the key has already authenticated.

The --shell /usr/sbin/nologin came in with the rest of the provisioning
block copied from lair/mail. It had never surfaced because gitea_ci
already existed with /bin/bash on every host touched so far -- bob,
benjy, oolon, slartibartfast all have bash. The metrics host is the
first account this script actually created, so it is the first to have
the bug.

Creates with /bin/bash and repairs an existing nologin account, since
otherwise a re-run leaves the host permanently undeployable. Applied to
all three roles, not just the one that failed.

Note lair/mail carries the same line and would hit this on any genuinely
new host.
2026-08-31 18:51:54 +03:00
Rob Thijssen 2c896ed618 fix: ship the grafana dashboard provider, and assert it loaded
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 30s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 43s
deploy / deploy-metrics (push) Failing after 10s
The dashboard JSON was deployed but its provider config never was, so
Grafana had no reason to read the directory -- the dashboard would have
been a file on disk and an empty folder in the UI.

Tracked separately from the dashboard because Grafana reads providers
only at container startup while dashboards are re-read on the
provisioning interval: a provider change restarts grafana, a dashboard
change does not.

validate now queries Grafana's search API for the dashboard uid rather
than testing that a file exists, since a provisioned dashboard that
fails to parse leaves the JSON looking perfectly fine on disk.
2026-08-31 18:46:14 +03:00
Rob Thijssen 6e931842dd feat: scrape node and miner metrics, and a grafana dashboard
Adds a deploy-metrics job shipping a Prometheus scrape drop-in and a
provisioned Grafana dashboard to the fleet metrics host, plus the
exposure work the exporters needed to be reachable at all.

The node's exporter was loopback-only, so --prometheus-external. The
miner's already bound 0.0.0.0 unconditionally (crates/metrics builds the
listener from [0,0,0,0], no bind option) -- I had recorded it as loopback
in the port registry, which was wrong and is corrected. Both exporters
are unauthenticated, so each gets a firewalld service opened by a rich
rule scoped to the scrape host; that rule, not the bind, is the access
boundary.

Dashboard panels are built on metric names read off the running node and
the miner's source, not guessed. It leads with the states that look
healthy but are not: peers at zero, hash rate at zero while the unit is
active, GPU devices at zero, jobs never arriving.

Two shared-infra changes are in infra-setup.sh rather than the workflow,
since other projects depend on both files: prometheus.yml gains
scrape_config_files so projects stop sharing one file, and
grafana.container gains a dashboards mount it did not have.
2026-08-31 18:45:41 +03:00
Rob Thijssen 2855bd96a4 fix: do not restart when nothing changed
The deploy restarted the node and miner unconditionally, and rsync's
default size+mtime check reported a change on every run anyway because
fetch re-downloads the binaries each time (verified: '<f..T......' on an
identical file, empty under -c).

So every run bounced a node that is 6% through a million-block sync,
dropping every peer and reopening RocksDB for no change at all.

push() now compares by checksum and itemizes; restart is gated on a real
content difference in the binary, unit or config. firewalld definitions
go through a separate helper since --reload picks them up and they are
never a reason to bounce the daemon. An unchanged but stopped unit is
still started.

This matters beyond tidiness: the workflow's validate mode exists to
check an untouched deployment, which it cannot honestly do while
restarting it first.
2026-08-31 18:38:33 +03:00
Rob Thijssen d198ae03f7 fix: resolve the node address for the miner at deploy time
deploy / fetch (push) Successful in 18s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 27s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
quantus-miner --node-addr parses as a Rust SocketAddr -- an IP and a
port, with no DNS resolution -- so passing the node's hostname failed at
argument parsing:

  error: invalid value 'bob.hanzalova.internal:9833' for
  '--node-addr <NODE_ADDR>': invalid socket address syntax

Resolve on the miner host, which is the one dialling and therefore has
the authoritative view, and guard that the result is a mesh address.
Keeps the literal out of the repo, same as the firewall rich rule.

Verified on the host: the hostname is rejected at parse, an IP gets
through to the next check.
2026-08-31 18:35:25 +03:00
Rob Thijssen 36a9868443 fix: wait for the node to bind before asserting it has
deploy / fetch (push) Successful in 27s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 28s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Failing after 21s
Run 3's validate ran ~3s after systemctl restart, while the journal was
still at 'Using litep2p network backend'. The node had not yet opened
tcp/30333, so a healthy node starting normally was reported as not
listening -- and the failure skipped deploy-miner entirely.

Bounded poll (120s) for the P2P socket before the listener assertions,
rather than a guessed sleep. Verified out of band that the node does
bind and is dialable from outside the site.

systemctl restart returns on exec, not on ready; the node's startup is
dominated by opening RocksDB, so this gap is wide and reproducible.
2026-08-31 18:31:07 +03:00
Rob Thijssen 83f3994658 feat: advertise the office indirection name for P2P
/dns4/ rather than the literal WAN address so an IP change needs no
redeploy, matching the per-site indirection convention in public-dns.md.

Paired with a 'bob' host alias on the site router (10.6.0.193, created
via opn-cli to match the existing hanzalova/oci/quadbrat convention).
The inbound 30333/tcp forward that makes both meaningful is still
outstanding and has to be a console change: opn-cli exposes firewall
alias and rule but no NAT subcommand.
2026-08-31 18:27:14 +03:00
Rob Thijssen 3740bfb520 feat: advertise a public address, optionally
Opening 30333 on the host does not make a node dialable. Behind NAT
libp2p advertises the address it can see -- a 10.x mesh address -- so an
edge port forward delivers packets that no peer ever knows to send.
--public-addr is what closes that gap.

Plumbed as a per-host matrix field rather than a constant, since a
mesh-only node should not advertise anything. The unit takes it as a
bare $VAR so systemd word-splits it: a complete flag becomes two
arguments, empty becomes zero, and one unit serves both cases.

Validate now reports which state a node is in. An undialable validator
mines happily and reports active while contributing nothing back to the
network, which is exactly the class of silent failure the rest of the
validate step exists to catch.
2026-08-31 18:25:39 +03:00
Rob Thijssen 08d284287a fix: node needs AF_NETLINK, and validate needs honest pipes
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal) (push) Failing after 23s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Has been skipped
Three bugs from run 2.

The real one: RestrictAddressFamilies omitted AF_NETLINK. libp2p calls
getifaddrs(), which opens a NETLINK_ROUTE socket, so the node started,
reported active, logged

  failed to fetch network interfaces error=GetIfAddrsError("getifaddrs", -1)
  litep2p started with no listen addresses, cannot accept inbound connections

and never bound --port. A silently undialable peer. Verified on the live
host: adding AF_NETLINK binds 30333 on both stacks, removing it unbinds.
The validate listener check caught this correctly — it was not a false
positive.

Two in validate itself: ss was invoked with no -t/-u (the protocol was
captured into $2 and only ever echoed), which errors with RTNETLINK
answers: Invalid argument; and 'awk ... exit' / 'grep -q' on a pipe
SIGPIPEs the writer, which pipefail reports as 141 — that was the exit
code that killed the sync check. Both now capture and parse via
here-string. Also fixed the same latent pattern in the getent lookup.
2026-08-31 18:22:15 +03:00
Rob Thijssen 5b6fb431ff fix: quote firewalld rich rules for the remote shell
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal) (push) Failing after 23s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Has been skipped
run() is `ssh ... "$@"`, and ssh concatenates its argument vector with
spaces for the remote shell to re-split, so local quoting is lost. The
rich rule arrived as a dozen bare words:

  firewall-cmd: error: unrecognized arguments: family=ipv4 source
  address=10.6.0.146/32 service name=quantus-node-miner accept

Pass the three rich-rule invocations as one pre-quoted string each.
Verified against the real host as gitea_ci: the unquoted form reproduces
the CI error, the quoted form returns 'no' — so it parses and the scoped
sudoers rule matches it.

Every other command in the deploy survived only because no other
argument contains a space; CLAUDE.md now records the trap.
2026-08-31 18:14:07 +03:00
Rob Thijssen 4a9b09f442 feat(deploy): update node service name to baba-gorchitsa
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal) (push) Failing after 21s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Has been skipped
This change updates the `--name` parameter in the quantus-node systemd
service
to use "baba-gorchitsa" instead of the hostname. This ensures a
consistent
node identifier across deployments.
2026-08-31 18:10:39 +03:00
Rob Thijssen cdfebedc62 feat(deploy): update inner_hash for bob.hanzalova.internal
Update the inner_hash value for the node host bob.hanzalova.internal to
a new value derived from its own wallet. This ensures the node uses the
correct preimage for reward calculations and maintains proper
cryptographic integrity.
2026-08-31 18:05:47 +03:00
Rob Thijssen 942bbb2417 docs: keep the fleet's shape out of prose
Site names, host assignments and hardware inventories were scattered
through readme.md, CLAUDE.md and a unit-file comment. They belong in the
deploy matrices — the single source of infra truth — and nowhere else.

readme.md now describes roles and measured hardware classes rather than
which box is where; the GPU-contention section is written as a check to
perform rather than a named host. CLAUDE.md gains a standing rule so this
does not creep back.
2026-08-31 17:56:43 +03:00
Rob Thijssen 57a3705de4 feat: per-host inner_hashes, and document the wormhole scheme
The reward preimage is static and published in every block header, so two
nodes sharing one are publicly and permanently identifiable as the same
operator. That silently defeats the point of running nodes at separate
sites, so the inner_hash moves out of a single env: value and into the
deploy-node matrix, one per host, alongside the miners each node serves.

doc/wormhole-rewards.md records the derivation chain
(secret -> Poseidon2(salt|secret) = inner_hash -> Poseidon2(inner_hash) =
address) with source citations, and the analysis: publishing the
inner_hash is not a theft risk because spending needs a plonky2 proof of
knowledge of the secret, but it does make mining income permanently
attributable. The privacy in this design is at the exit, not at receipt,
which is not what MINING.md's wording suggests.

It also records the operational flaw: the inner_hash cannot be derived
from any public material and the Android wallet does not expose it, so a
seed phrase must reach some machine to produce a value that is public
anyway. Mitigation is a dedicated mining wallet, not carefulness.
2026-08-31 17:53:30 +03:00
Rob Thijssen e936597806 fix: the inner_hash is public config, not a secret
The node embeds the reward preimage verbatim in the PreRuntime digest of
every block it authors (client/consensus/qpow/src/lib.rs:726), and the
mining-rewards pallet reads that digest back to derive the payout
address on-chain. It is public from the first mined block.

So the Gitea secret, the 0640 handling and the ps-visibility caveat were
all theatre over a value published on a blockchain. It moves to the
workflow env: block with the rest of the infra truth, and
infra-setup.sh loses --mint-reward-key entirely along with its pass and
Gitea-API dependencies.

This also answers why the node takes a preimage and not an address: the
runtime does the derivation, which is what proves the payout target is a
canonical wormhole address rather than an arbitrary account. An address
would give it nothing to verify.
2026-08-31 17:37:31 +03:00
Rob Thijssen 4b88d55449 fix: take the derived inner_hash, not a seed phrase
Neither the node nor the miner needs a mnemonic. The node needs only the
32-byte preimage, from which it derives the reward address; the mining
protocol carries no reward address at all, so the miner cannot know it.

Deriving an inner_hash from a wallet is a one-time offline step and has
no business happening on the deploy workstation. infra-setup.sh now
accepts only the inner_hash, validates it is 0x + 64 hex, and refuses a
mnemonic outright. quantus-node itself refuses --words/--seed as argv
for the same reason: argv is world-readable and recorded in shell
history and audit logs.

This drops the seed phrase, the temporary binary download and the local
key derivation from the provisioning path entirely.
2026-08-31 17:01:48 +03:00
Rob Thijssen e3dfd73f2f feat: deploy a quantus planck node and external gpu miner
Node on bob, external GPU miner on benjy, split because
--miner-listen-port disables the node's built-in CPU mining.

The PoW is Poseidon2 over Goldilocks: register-resident, no memory
hardness, dominated by 64-bit modular multiplication. Measured on the
fleet, GPU beats CPU by ~200x (RTX 5090 368 MH/s @ 600W, RTX 4090
183 MH/s @ 450W, Ryzen 9 9950X3D 4.15 MH/s), so CPU mining is disabled
everywhere including on the GPU hosts.

The workflow deploys or validates. Validate mode asserts the hash
counter is advancing and the GPU is drawing power, because a miner that
enumerated no adapter and a node authoring nothing both look healthy to
systemctl is-active.

infra-setup.sh provisions the scoped gitea_ci accounts, generates the
node's libp2p key (the node refuses to start without one and it must
never be rotated by a deploy), and derives the reward preimage from a
seed phrase on stdin. It deliberately will not mint a fresh wallet: that
phrase is escalation-grade and fails agent-credentials.md §1.
2026-08-31 16:55:05 +03:00