Files
Rob ThijssenandClaude Opus 5.5 74e86e6c04 deploy: node 1.0.4 from the quantus/chain mirror
Fetch node release binaries from the releases of quantus/chain, our private
mirror on this Gitea, instead of GitHub. The runner authenticates with the
QUANTUS_CHAIN_READ_TOKEN secret (read-only, agent-bot app:quantus-deploy),
addresses the server by github.server_url since it answers to several names,
and checks the tarball against a pinned NODE_SHA256. A node_version dispatch
override now needs a matching node_sha256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5HxbVRq4fPMGdx3JzVEpC
2026-10-23 09:15:11 +03:00

11 KiB
Raw Permalink Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this repo is

Deployment automation only — systemd units, firewalld definitions, config templates, one Gitea Actions workflow and one provisioning script, for a Quantus Planck node and its external GPU miner. There is no application code, no build, and no test suite: the deployed artifacts are release binaries the workflow fetches from the releases of quantus/chain, our private mirror on this Gitea. Because there is nothing to compile, generic.md §1–§2 (Rust workspace layout, crate naming) do not apply here.

No secrets live in this repo. QUANTUS_INNER_HASH is a Gitea Actions secret; the miner's auth token and TLS pin are generated on the node host and copied host-to-host by the deploy.

Read readme.md first — it carries the topology, the measured hashrate numbers the design rests on, the port rationale, and the list of deliberate deviations from house convention.

The one thing that will surprise you

Setting --miner-listen-port on the node disables its built-in CPU mining. A node with that flag and no external miner attached is a node that authors nothing, while systemctl is-active reports it perfectly healthy. The same trap exists on the miner side: a quantus-miner that enumerated no GPU adapter also looks active. This is why the workflow's validate mode checks miner_hashes_total is advancing and that the GPU is drawing power, rather than trusting unit state. Do not "simplify" those checks away.

Facts established by measurement, not assumption

These came from benchmarking the real fleet; don't re-derive or contradict them without re-measuring:

  • GPU beats CPU by ~200×. One RTX 5090 at its 400 W floor out-hashes every CPU in the fleet combined by ~24×. CPU mining is disabled everywhere on purpose.
  • RTX 5090: 368 MH/s @ 600 W; 281 MH/s @ 400 W (18% better MH/s per watt). RTX 4090: 183 MH/s @ 450 W.
  • The PoW is Poseidon2 over Goldilocks — register-resident, no memory hardness. VRAM size and memory bandwidth are irrelevant to hashrate.
  • quantus-node has no environment-variable form for any flag (its --help carries no [env:] annotations), so --rewards-inner-hash must be a CLI argument and is visible in ps on the node host. The EnvironmentFile keeps it out of the world-readable unit; it does not hide it from a local user.
  • The node will not start without a pre-generated node key — it exits with NetworkKeyNotFound(".../network/secret_dilithium") rather than generating one. infra-setup.sh creates it; a deploy must never rotate it, because it is the node's stable network identity.
  • Node Prometheus is on 9615, not 9616. Upstream's MINING.md parameter table is wrong about this; the running binary is the authority.

gitea_ci needs a real shell

Create it with /bin/bash, never /usr/sbin/nologin. The deploy runs ssh gitea_ci@host <command>; a nologin shell authenticates the key and then refuses the command with "This account is currently not available". Every pre-existing gitea_ci on the fleet has bash — infra-setup.sh repairs one that does not, because a host provisioned with nologin can never be deployed to and the failure looks like an auth problem rather than a shell one.

Adding an asset means re-running infra-setup

Any new file the deploy rsyncs needs a matching line in the role's sudoers block in script/infra-setup.sh, and that script must then be re-run against the hosts. Without it the deploy fails partway through with a bare

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

after some files have already landed. Each deploy job now preflights this: it extracts the granted paths from infra-setup.sh itself and compares them to sudo -n -l on the target, so the two cannot drift and the error names the missing paths instead of making you read an rsync exit code.

Exposing the JSON-RPC

Use --unsafe-rpc-external, never --rpc-external: Substrate refuses the latter on a validator and exits immediately, regardless of --rpc-methods. Both bind identically; the name is a forced acknowledgement. --rpc-methods safe is what actually keeps it safe and is mandatory whenever the RPC is exposed. Do not rely on auto downgrading by itself, and never use unsafe on a validator. Access is bounded by a firewalld rich rule scoped to the node's own /16 — derived from the host's address at deploy time, never a literal in this repo. Whitelist 127.0.0.1/32 from rate limiting or the arena exporter throttles itself.

Hardening knobs that are load-bearing

RestrictAddressFamilies on the node must include AF_NETLINK. libp2p calls getifaddrs(), which opens a NETLINK_ROUTE socket; without it the node starts, reports active, and never binds its P2P port — an undialable peer that looks healthy. Verified live. The same class of failure is why the validate step checks listeners rather than trusting systemctl is-active.

If the miner ever fails to reach the node, netlink is the first thing to suspect there too — it is deliberately left out of the miner unit (least privilege, and a QUIC client to a known address should not need interface enumeration), but that is an assumption, not a verified fact.

Deploys must be no-ops when nothing changed

push() uses rsync -ic — checksum, not size+mtime. The fetch job downloads fresh binaries every run, so mtimes always differ and the default quick check reports a spurious change on every deploy. Restart is gated on RESTART, set only when rsync itemizes an actual content difference. Do not restore an unconditional systemctl restart: this workflow doubles as the health check for an untouched deployment, and bouncing a syncing node drops every peer.

--node-addr takes an IP, not a name

quantus-miner --node-addr parses as a Rust SocketAddr: no DNS resolution, so a hostname fails at argument parsing with "invalid socket address syntax". The deploy resolves the node host on the MINER host at deploy time and renders the literal into miner.env — which also keeps 10.x out of the repo. Do not "simplify" it back to ${{ matrix.node }}:$PORT.

Prometheus targets report "unknown" before their first scrape

Any run that ADDS a scrape target will see it as health unknown, not up — Prometheus has not scraped it yet, and validate runs seconds after the config reload. validate metrics waits for unknowns to resolve before judging. A genuinely dead target reports down, so the wait does not hide real failures.

Validate runs seconds after a restart

systemctl restart returns as soon as the process is exec'd, not when it is serving. The node opens RocksDB and initialises litep2p before binding its P2P port, which takes appreciably longer than the deploy step's own teardown — so an immediate assertion reads a starting node as a broken one. Validate waits for readiness on a bounded poll. Do not replace that with a fixed sleep, and do not remove it because "it passed locally".

Inline Python in workflow steps

Use a quoted heredoc (python3 - <<'PY') reading a file or an env var. Do NOT use python3 -c '...' with escaped quotes: \" inside an f-string expression is read by Python as a line continuation and fails with "unexpected character after line continuation character". A quoted heredoc needs no shell escaping at all.

Grafana checks cannot use its API

/api/search and most of Grafana's API require auth (401 unauthenticated), and this workflow deliberately holds no Grafana credentials. Assert provisioning from journalctl -u grafana.service instead — gitea_ci is in systemd-journal, and a dashboard that fails to parse logs logger=provisioning.dashboard level=error and is then silently absent from the UI. /api/health is unauthenticated and is fine for liveness.

Pipes in validate steps

The workflow shell is bash -e -o pipefail. Any pipe whose reader exits early — awk '...; exit', grep -q, grep -m1, head — SIGPIPEs the writer, and pipefail turns that into exit 141. On a large body (a metrics scrape) it fires reliably; on a small one it hides, so it passes review and fails in CI. Capture into a variable and parse with a here-string instead:

m=$(run "curl -fsS $url")
v=$(awk '/^metric/{print $2; exit}' <<<"$m")   # correct
v=$(run "curl -fsS $url" | awk '/^metric/{print $2; exit}')   # 141

ssh argument quoting

run() in the deploy is ssh ... "$@". ssh joins its argument vector with spaces and the REMOTE shell re-splits it, so any argument containing a space must be passed as one pre-quoted string:

run "sudo firewall-cmd --zone=$zone --add-rich-rule='$rich'"   # correct
run sudo firewall-cmd --zone="$zone" --add-rich-rule="$rich"   # word-splits remotely

This bit the firewalld rich rules on run 1 and nothing else, because no other argument in the deploy contains a space. Check this before adding any command with a quoted multi-word argument.

Working on the deploy

  • The workflow is the source of infra truth (deployment-gitea-actions.md). Hosts, ports, versions and paths live in .gitea/workflows/deploy.yaml's env: block — never introduce a separate manifest.
  • Anything one-time, or anything involving key material, belongs in script/infra-setup.sh, not the workflow.
  • infra-setup.sh must stay idempotent and must skip unreachable hosts rather than aborting the run.
  • Never suppress errors (generic.md §7): no 2>/dev/null, no || true. If a command may legitimately fail, handle it with a visible message.
  • Never print a secret (agent-credentials.md §6). --mint-reward-key emits only a "value withheld" acknowledgement. Keep it that way; do not add a debug echo.
  • The inner_hash is not a secret and must not be turned back into one. The node embeds it verbatim in the PreRuntime digest of every block it authors, so it is public on-chain from the first block. Nothing here needs a seed phrase either: the node wants only the preimage, and the miner has no knowledge of the payout address at all.
  • One inner_hash per node host, in the deploy-node matrix. Never collapse them to a shared value "for simplicity" — the value is static, so sharing one publicly ties those nodes to a single operator. doc/wormhole-rewards.md §5.
  • Version pins are deliberate. Do not add auto-update to a validator.

Further reading

doc/wormhole-rewards.md — what the inner_hash is cryptographically, why the node takes a preimage rather than an address, what publishing it does and does not expose, and why the seed-phrase derivation step is a design flaw rather than an operator error. Read it before changing anything about reward configuration.

Before enabling a miner on any host

A miner host may already be serving helexa neuron from the same GPU. The miner will saturate it. readme.md documents the options; the deploy deliberately does not arbitrate. This is an open decision, not an oversight.

Keep the fleet's shape in the workflow

Host assignments, hardware inventories, site or geographic names, ISPs and addressing belong in the deploy-node matrix in .gitea/workflows/deploy.yaml (and, for the miners, the deploy matrix in quantus/miner) — the source of infra truth — and nowhere else. Do not restate them in readme.md, CLAUDE.md, doc/, or unit-file comments, even as helpful context. Prose should describe roles and measured hardware classes, never which box is where.