diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 00f3823..306cbf0 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -38,6 +38,15 @@ on: node_version: description: quantus-node version (overrides the pin) required: false + chainspec_url: + description: "chain spec JSON to run from (overrides CHAINSPEC_URL); empty = the built-in CHAIN name" + required: false + expected_genesis: + description: "0x genesis hash the started node must report (overrides EXPECTED_GENESIS); required with a chain spec" + required: false + bootnodes: + description: "space-separated multiaddrs to dial and hold as reserved peers (overrides BOOTNODES)" + required: false concurrency: # never half-apply two deploys at once group: deploy @@ -52,6 +61,21 @@ env: # Pinned; bump deliberately to upgrade. An unattended upgrade of a validator # is how you find out at 3am that a release changed a consensus rule. NODE_VERSION: "0.11.1" + # Mainnet launch (2026-09-10 09:09 UTC): the chain spec is published as a + # file before a binary embeds it, so the node can run from a JSON at + # /etc/quantus-node/chainspec.json instead of a built-in name. When set, the + # spec's `id` (not CHAIN above) names /chains//, which is + # where quantus/miner's deploy reads the miner credentials from โ€” pass it + # there as the `chain` input. EXPECTED_GENESIS is asserted against the + # started node's block-0 hash, which is the whole defence against running + # a look-alike spec. BOOTNODES are dialled and held as reserved peers: + # launch is a propagation race, and a direct link to the foundation's nodes + # is the one latency knob we have (quantus/chain#1). Dispatch inputs + # override each of these for the cutover; commit the values afterwards so + # push-triggered deploys keep them. + CHAINSPEC_URL: "" + EXPECTED_GENESIS: "" + BOOTNODES: "" # The miner is deployed by quantus/miner's own workflow, following its main # branch (quantus/miner#8, lair/quantus#4). This repo no longer ships it; # SCRAPE_MINERS below must still agree with that repo's deploy matrix. @@ -87,6 +111,10 @@ jobs: runs-on: infra outputs: node_version: ${{ steps.v.outputs.node }} + chainspec_url: ${{ steps.v.outputs.chainspec_url }} + expected_genesis: ${{ steps.v.outputs.expected_genesis }} + bootnodes: ${{ steps.v.outputs.bootnodes }} + chain_id: ${{ steps.cs.outputs.chain_id }} steps: - id: v run: | @@ -94,6 +122,46 @@ jobs: n="${{ github.event.inputs.node_version || env.NODE_VERSION }}" echo "node=${n}" >> "$GITHUB_OUTPUT" echo "quantus-node ${n}" + cs="${{ github.event.inputs.chainspec_url || env.CHAINSPEC_URL }}" + eg="${{ github.event.inputs.expected_genesis || env.EXPECTED_GENESIS }}" + bn="${{ github.event.inputs.bootnodes || env.BOOTNODES }}" + if [ -n "$cs" ] && [ -z "$eg" ]; then + echo "a chain spec needs expected_genesis: without it a look-alike spec would pass validation" >&2 + exit 1 + fi + case "$eg" in ""|0x[0-9a-f]*) ;; *) echo "expected_genesis must be a 0x hex hash" >&2; exit 1 ;; esac + for b in $bn; do + case "$b" in /*/p2p/*) ;; *) echo "bootnode '$b' is not a multiaddr ending in /p2p/" >&2; exit 1 ;; esac + done + echo "chainspec_url=${cs}" >> "$GITHUB_OUTPUT" + echo "expected_genesis=${eg}" >> "$GITHUB_OUTPUT" + echo "bootnodes=${bn}" >> "$GITHUB_OUTPUT" + echo "chain spec: ${cs:-built-in ${{ env.CHAIN }}}; expected genesis: ${eg:-not asserted}; bootnodes: ${bn:-none}" + + - name: download chain spec + id: cs + run: | + set -euo pipefail + cs="${{ steps.v.outputs.chainspec_url }}" + if [ -z "$cs" ]; then + echo "chain_id=${{ env.CHAIN }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + mkdir -p _bin + curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \ + -o _bin/chainspec.json "$cs" + # The spec must parse, and its id is the directory the node keeps + # this chain's state and miner credentials under. + id=$(python3 -c " + import json, sys + d = json.load(open('_bin/chainspec.json')) + for k in ('id', 'name', 'chainType'): + assert k in d, f'chain spec has no {k}' + print(f\"name={d['name']} id={d['id']} type={d['chainType']} bootNodes={len(d.get('bootNodes') or [])} telemetry={d.get('telemetryEndpoints')}\", file=sys.stderr) + print(d['id'])") + echo "chain_id=${id}" >> "$GITHUB_OUTPUT" + sha256sum _bin/chainspec.json + { echo "chain spec: $cs"; echo "id: $id"; echo "sha256: $(sha256sum _bin/chainspec.json | cut -d' ' -f1)"; } >> "$GITHUB_STEP_SUMMARY" - name: download quantus-node run: | @@ -221,7 +289,8 @@ jobs: env: QUANTUS_INNER_HASH: ${{ matrix.inner_hash }} QUANTUS_PUBLIC_ADDR: ${{ matrix.public_addr }} - QUANTUS_CHAIN: ${{ env.CHAIN }} + QUANTUS_CHAIN: ${{ needs.fetch.outputs.chainspec_url != '' && '/etc/quantus-node/chainspec.json' || env.CHAIN }} + QUANTUS_BOOTNODES: ${{ needs.fetch.outputs.bootnodes }} QUANTUS_RPC_EXPOSE: ${{ matrix.rpc_expose }} QUANTUS_TELEMETRY_URL: ${{ env.TELEMETRY_URL }} run: | @@ -248,6 +317,10 @@ jobs: out = out.replace("{{QUANTUS_PUBLIC_ADDR}}", os.environ.get("QUANTUS_PUBLIC_ADDR", "")) out = out.replace("{{QUANTUS_CHAIN}}", os.environ["QUANTUS_CHAIN"]) out = out.replace("{{QUANTUS_RPC_EXPOSE}}", os.environ.get("QUANTUS_RPC_EXPOSE", "")) + # Each bootnode is dialled AND held as a reserved peer (not + # --reserved-only: the rest of the peer set still fills normally). + flags = " ".join(f"--bootnodes {b} --reserved-nodes {b}" for b in os.environ.get("QUANTUS_BOOTNODES", "").split()) + out = out.replace("{{QUANTUS_BOOTNODES}}", flags) pathlib.Path("node.env").write_text(out) a = pathlib.Path("asset/config/arena.env.tmpl").read_text() a = a.replace("{{QUANTUS_INNER_HASH}}", os.environ["QUANTUS_INNER_HASH"]) @@ -301,6 +374,13 @@ jobs: gitea_ci@"$HOST":/etc/systemd/system/quantus-node.service push --chown=root:quantus-node --chmod=F0640 node.env \ gitea_ci@"$HOST":/etc/quantus-node/node.env + # 3a. Chain spec file, when running from one. Left in place otherwise: + # node.env decides what --chain points at, and an unreferenced + # file is harmless while deleting it would need one more grant. + if [ -s _bin/chainspec.json ]; then + push --chown=root:quantus-node --chmod=F0640 _bin/chainspec.json \ + gitea_ci@"$HOST":/etc/quantus-node/chainspec.json + fi pushfw --mkpath --chmod=F0644 asset/firewalld/quantus-node.xml \ gitea_ci@"$HOST":/etc/firewalld/services/quantus-node.xml pushfw --mkpath --chmod=F0644 asset/firewalld/quantus-node-miner.xml \ @@ -517,6 +597,32 @@ jobs: fail=1 fi + echo "--- chain ---" + # Which chain the node actually started, from its own RPC on loopback. + # The genesis hash is the identity of the chain; asserting it is what + # stops a look-alike spec (or a stale built-in name) from mining the + # wrong network unnoticed. + rpc() { run "curl -sS -m 10 -H content-type:application/json -d '{\"id\":1,\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":$2}' http://127.0.0.1:9944" \ + | python3 -c 'import sys, json; print(json.load(sys.stdin).get("result", ""))'; } + chain_name=$(rpc system_chain '[]' || true) + genesis=$(rpc chain_getBlockHash '[0]' || true) + echo " chain: ${chain_name:-?} genesis: ${genesis:-?} id: ${{ needs.fetch.outputs.chain_id }}" + if [ -z "$genesis" ]; then + echo " FAIL node RPC did not answer chain_getBlockHash" >&2; fail=1 + elif [ -n "${{ needs.fetch.outputs.expected_genesis }}" ] && [ "$genesis" != "${{ needs.fetch.outputs.expected_genesis }}" ]; then + echo " FAIL genesis ${genesis} is not the expected ${{ needs.fetch.outputs.expected_genesis }} โ€” WRONG CHAIN" >&2; fail=1 + elif [ -n "${{ needs.fetch.outputs.expected_genesis }}" ]; then + echo " genesis matches expected" + fi + # The miner credential directory follows the spec id; quantus/miner's + # deploy must be pointed at exactly this name. + if run "sudo cat /var/lib/quantus-node/chains/${{ needs.fetch.outputs.chain_id }}/miner-auth-token" | grep -q .; then + echo " miner credentials present under chains/${{ needs.fetch.outputs.chain_id }}/ (use chain=${{ needs.fetch.outputs.chain_id }} on the miner deploy)" + else + echo " FAIL no miner credentials under chains/${{ needs.fetch.outputs.chain_id }}/ โ€” the chain id the miner deploy needs is not this" >&2; fail=1 + fi + { echo "chain: ${chain_name:-?}"; echo "genesis: ${genesis:-?}"; echo "chain id for the miner deploy: ${{ needs.fetch.outputs.chain_id }}"; } >> "$GITHUB_STEP_SUMMARY" + echo "--- listeners ---" # P2P must be listening; the miner link must be listening or the node # is authoring nothing (--miner-listen-port disables local mining). diff --git a/asset/config/node.env.tmpl b/asset/config/node.env.tmpl index d7481be..5fe5620 100644 --- a/asset/config/node.env.tmpl +++ b/asset/config/node.env.tmpl @@ -21,6 +21,11 @@ QUANTUS_PUBLIC_ADDR={{QUANTUS_PUBLIC_ADDR}} # (/chains//) follows from the same value. QUANTUS_CHAIN={{QUANTUS_CHAIN}} +# Bootnodes, rendered as complete flags: each address is dialled (--bootnodes) +# and held as a reserved peer (--reserved-nodes) so the connection to the +# foundation's nodes is never evicted. Empty = the spec's own bootNodes only. +QUANTUS_BOOTNODES={{QUANTUS_BOOTNODES}} + # JSON-RPC exposure. Empty = loopback only. A complete flag string opens it: # --rpc-external --rpc-methods safe --rpc-cors all --rpc-rate-limit 300 \ # --rpc-rate-limit-whitelisted-ips 127.0.0.1/32 diff --git a/asset/systemd/quantus-node.service b/asset/systemd/quantus-node.service index f72610d..6e6016c 100644 --- a/asset/systemd/quantus-node.service +++ b/asset/systemd/quantus-node.service @@ -1,4 +1,4 @@ -# Quantus Planck node, validator role, external-miner mode. +# Quantus node, validator role, external-miner mode. # # Hardened per ~/git/architecture/generic.md ยง8. Two knobs are deliberately # relaxed; both are load-bearing, do not "tidy" them back: @@ -39,7 +39,7 @@ # gets packets in but never tells anyone to send them. [Unit] -Description=Quantus node (Planck, validator, external-miner mode) +Description=Quantus node (validator, external-miner mode) Documentation=https://github.com/Quantus-Network/chain After=network-online.target Wants=network-online.target @@ -62,7 +62,10 @@ ExecStart=/usr/local/bin/quantus-node \ --miner-listen-port 9833 \ --port 30333 \ $QUANTUS_PUBLIC_ADDR \ + $QUANTUS_BOOTNODES \ $QUANTUS_RPC_EXPOSE \ + --out-peers 32 \ + --in-peers 64 \ --prometheus-port 9615 \ --prometheus-external \ --max-blocks-per-request 64 \ diff --git a/script/infra-setup.sh b/script/infra-setup.sh index ac8d5c8..5655ae3 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -162,6 +162,7 @@ gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-node gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-node.service gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-node.conf gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-node/node.env +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-node/chainspec.json gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-node.xml gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-node-miner.xml gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-node-metrics.xml