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.
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
name: deploy
|
||||
# Deploy — or validate — the Quantus Planck node and its external GPU miner.
|
||||
#
|
||||
# Topology (the workflow is the source of infra truth,
|
||||
# ~/git/architecture/deployment-gitea-actions.md):
|
||||
#
|
||||
# bob.hanzalova.internal quantus-node consensus, P2P, rewards
|
||||
# ▲ QUIC/9833 (mesh only, rich-rule scoped to the miner host)
|
||||
# benjy.hanzalova.internal quantus-miner 1× RTX 4090
|
||||
#
|
||||
# The node and the miner are separate processes on separate hosts by design: the
|
||||
# node is disk+network bound and belongs on an always-on box, the miner is pure
|
||||
# GPU compute. Setting --miner-listen-port DISABLES the node's built-in CPU
|
||||
# mining, so the node authors nothing if the miner is absent — the validate mode
|
||||
# below checks exactly that rather than trusting `is-active`.
|
||||
#
|
||||
# Runs on `infra`. gitea-runners.md §4 says a deploy needs only ssh+rsync and so
|
||||
# fits `fedora-43`; that is about tooling, not routing. These targets are
|
||||
# mesh-only `.internal` names and lair/mail's two working deploys both use
|
||||
# `infra` for that reason. Deliberate deviation, noted per readme.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- asset/**
|
||||
- .gitea/workflows/deploy.yaml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "deploy (apply) or validate (check only, no changes)"
|
||||
required: false
|
||||
default: deploy
|
||||
type: choice
|
||||
options: [deploy, validate]
|
||||
node_version:
|
||||
description: quantus-node version (overrides the pin)
|
||||
required: false
|
||||
miner_version:
|
||||
description: quantus-miner version (overrides the pin)
|
||||
required: false
|
||||
|
||||
concurrency: # never half-apply two deploys at once
|
||||
group: deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# --- infra truth ---
|
||||
NODE_HOST: bob.hanzalova.internal
|
||||
MINER_HOST: benjy.hanzalova.internal
|
||||
CHAIN: planck
|
||||
# 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.10.0"
|
||||
MINER_VERSION: "4.0.0"
|
||||
# Ports — port-allocations.md §5 registry. These are upstream protocol
|
||||
# defaults rather than derived numbers; see readme.md "Ports".
|
||||
P2P_PORT: "30333"
|
||||
MINER_LINK_PORT: "9833"
|
||||
NODE_METRICS_PORT: "9615"
|
||||
MINER_METRICS_PORT: "9900"
|
||||
# benjy carries one 4090. Measured 183 MH/s at ~450 W.
|
||||
GPU_DEVICES: "1"
|
||||
|
||||
jobs:
|
||||
fetch:
|
||||
runs-on: infra
|
||||
outputs:
|
||||
node_version: ${{ steps.v.outputs.node }}
|
||||
miner_version: ${{ steps.v.outputs.miner }}
|
||||
steps:
|
||||
- id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
n="${{ github.event.inputs.node_version || env.NODE_VERSION }}"
|
||||
m="${{ github.event.inputs.miner_version || env.MINER_VERSION }}"
|
||||
echo "node=${n}" >> "$GITHUB_OUTPUT"
|
||||
echo "miner=${m}" >> "$GITHUB_OUTPUT"
|
||||
echo "quantus-node ${n} / quantus-miner ${m}"
|
||||
|
||||
- name: download quantus-node
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.v.outputs.node }}"
|
||||
curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \
|
||||
-o node.tar.gz \
|
||||
"https://github.com/Quantus-Network/chain/releases/download/v${v}/quantus-node-v${v}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
tar xzf node.tar.gz
|
||||
install -D -m 0755 "$(find . -name quantus-node -type f | head -1)" _bin/quantus-node
|
||||
_bin/quantus-node --version
|
||||
|
||||
- name: download quantus-miner
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.v.outputs.miner }}"
|
||||
curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \
|
||||
-o _bin/quantus-miner \
|
||||
"https://github.com/Quantus-Network/quantus-miner/releases/download/v${v}/quantus-miner-linux-x86_64"
|
||||
chmod 0755 _bin/quantus-miner
|
||||
_bin/quantus-miner --version
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: quantus-bin
|
||||
path: _bin
|
||||
|
||||
deploy-node:
|
||||
runs-on: infra
|
||||
needs: fetch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with: { name: quantus-bin, path: _bin }
|
||||
|
||||
- name: write ssh key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 ~/.ssh
|
||||
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
|
||||
chmod 0600 ~/.ssh/id_gitea_ci
|
||||
|
||||
- name: reachability
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
|
||||
gitea_ci@${{ env.NODE_HOST }} hostname -f
|
||||
|
||||
- name: render node config
|
||||
if: ${{ github.event.inputs.mode != 'validate' }}
|
||||
env:
|
||||
QUANTUS_INNER_HASH: ${{ secrets.QUANTUS_INNER_HASH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$QUANTUS_INNER_HASH" || {
|
||||
echo "QUANTUS_INNER_HASH is unset — mint one with:" >&2
|
||||
echo " ./script/infra-setup.sh --mint-reward-key" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$QUANTUS_INNER_HASH" in
|
||||
0x*) ;;
|
||||
*) echo "QUANTUS_INNER_HASH must be 0x-prefixed (the node rejects it otherwise)" >&2; exit 1 ;;
|
||||
esac
|
||||
# Literal substitution — never a shell/sed expansion, so a value with
|
||||
# regex or shell metacharacters survives intact.
|
||||
python3 - <<'PY'
|
||||
import os, pathlib
|
||||
tmpl = pathlib.Path("asset/config/node.env.tmpl").read_text()
|
||||
out = tmpl.replace("{{QUANTUS_INNER_HASH}}", os.environ["QUANTUS_INNER_HASH"])
|
||||
pathlib.Path("node.env").write_text(out)
|
||||
PY
|
||||
|
||||
- name: deploy node
|
||||
if: ${{ github.event.inputs.mode != 'validate' }}
|
||||
env:
|
||||
HOST: ${{ env.NODE_HOST }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
|
||||
run() { ssh $SSHOPTS gitea_ci@"$HOST" "$@"; }
|
||||
push() { rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' "$@"; }
|
||||
|
||||
# 1. service account
|
||||
push --mkpath --chmod=F0644 \
|
||||
asset/systemd/quantus-node.sysusers.conf \
|
||||
gitea_ci@"$HOST":/etc/sysusers.d/quantus-node.conf
|
||||
run sudo systemd-sysusers
|
||||
|
||||
# 2. directories. /etc is root-owned so the daemon can read but not
|
||||
# rewrite its own config (generic.md §8).
|
||||
run sudo install -d -o root -g quantus-node -m 0750 /etc/quantus-node
|
||||
run sudo install -d -o quantus-node -g quantus-node -m 0750 /var/lib/quantus-node
|
||||
|
||||
# 3. binary, unit, config, firewalld defs
|
||||
push --chmod=F0755 _bin/quantus-node gitea_ci@"$HOST":/usr/local/bin/quantus-node
|
||||
push --chmod=F0644 asset/systemd/quantus-node.service \
|
||||
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
|
||||
push --mkpath --chmod=F0644 asset/firewalld/quantus-node.xml \
|
||||
gitea_ci@"$HOST":/etc/firewalld/services/quantus-node.xml
|
||||
push --mkpath --chmod=F0644 asset/firewalld/quantus-node-miner.xml \
|
||||
gitea_ci@"$HOST":/etc/firewalld/services/quantus-node-miner.xml
|
||||
|
||||
# 4. SELinux relabel. No `semanage port` needed: the unit runs under
|
||||
# init_t, which may bind any port.
|
||||
run sudo restorecon -R /usr/local/bin/quantus-node /etc/quantus-node /var/lib/quantus-node
|
||||
|
||||
# 5. firewalld. Reload FIRST so the freshly-shipped definitions exist,
|
||||
# or --query-service fails INVALID_SERVICE
|
||||
# (deployment-gitea-actions.md §6).
|
||||
run sudo firewall-cmd --reload
|
||||
zone=$(run sudo firewall-cmd --get-default-zone)
|
||||
echo "default zone: ${zone}"
|
||||
|
||||
# 5a. P2P: public, plain named service.
|
||||
if run sudo firewall-cmd --zone="$zone" --query-service=quantus-node; then
|
||||
echo "firewalld: quantus-node already enabled in ${zone}"
|
||||
else
|
||||
run sudo firewall-cmd --permanent --zone="$zone" --add-service=quantus-node
|
||||
run sudo firewall-cmd --zone="$zone" --add-service=quantus-node
|
||||
fi
|
||||
|
||||
# 5b. Miner link: a rich rule scoped to the miner host, NOT a plain
|
||||
# --add-service. With a single default zone (generic.md §9) adding
|
||||
# the service outright would publish 9833/udp on every address the
|
||||
# host carries. Resolve the miner's mesh address ON THE NODE — this
|
||||
# repo carries no 10.x literals, and bob's own resolution is the
|
||||
# address benjy will actually present.
|
||||
miner_ip=$(run "getent ahostsv4 ${{ env.MINER_HOST }}" | awk '{print $1; exit}')
|
||||
case "$miner_ip" in
|
||||
10.*) echo "miner source: ${miner_ip}" ;;
|
||||
*) echo "refusing to open ${{ env.MINER_LINK_PORT }}/udp to non-mesh address '${miner_ip}'" >&2; exit 1 ;;
|
||||
esac
|
||||
rich="rule family=ipv4 source address=${miner_ip}/32 service name=quantus-node-miner accept"
|
||||
if run sudo firewall-cmd --zone="$zone" --query-rich-rule="$rich"; then
|
||||
echo "firewalld: miner rich rule already present in ${zone}"
|
||||
else
|
||||
run sudo firewall-cmd --permanent --zone="$zone" --add-rich-rule="$rich"
|
||||
run sudo firewall-cmd --zone="$zone" --add-rich-rule="$rich"
|
||||
fi
|
||||
|
||||
# 6. (re)start
|
||||
run sudo systemctl daemon-reload
|
||||
run sudo systemctl enable quantus-node.service
|
||||
run sudo systemctl restart quantus-node.service
|
||||
|
||||
- name: validate node
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
|
||||
run() { ssh $SSHOPTS gitea_ci@"${{ env.NODE_HOST }}" "$@"; }
|
||||
fail=0
|
||||
|
||||
echo "--- unit ---"
|
||||
run systemctl is-active quantus-node.service
|
||||
|
||||
echo "--- version ---"
|
||||
got=$(run /usr/local/bin/quantus-node --version)
|
||||
echo "installed: ${got}"
|
||||
case "$got" in
|
||||
*"${{ needs.fetch.outputs.node_version }}"*) echo "version matches pin" ;;
|
||||
*) echo "version does NOT match pin ${{ needs.fetch.outputs.node_version }}" >&2; fail=1 ;;
|
||||
esac
|
||||
|
||||
echo "--- listeners ---"
|
||||
# P2P must be listening; the miner link must be listening or the node
|
||||
# is authoring nothing (--miner-listen-port disables local mining).
|
||||
for spec in "${{ env.P2P_PORT }} tcp" "${{ env.MINER_LINK_PORT }} udp"; do
|
||||
set -- $spec
|
||||
if run "ss -Hln sport = :$1" | grep -q .; then
|
||||
echo " ok $2/$1 listening"
|
||||
else
|
||||
echo " FAIL $2/$1 not listening" >&2; fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "--- sync ---"
|
||||
# best_number stalling at 0 while peers>0 means the freshness gate or a
|
||||
# stalled import, not a healthy new node.
|
||||
m=$(run "curl -fsS http://127.0.0.1:${{ env.NODE_METRICS_PORT }}/metrics")
|
||||
peers=$(printf '%s' "$m" | awk '/^substrate_sub_libp2p_peers_count/{print $2; exit}')
|
||||
best=$(printf '%s' "$m" | awk '/^substrate_block_height\{status="best"/{print $2; exit}')
|
||||
echo " peers=${peers:-unknown} best_block=${best:-unknown}"
|
||||
if [ "${peers:-0}" = "0" ]; then echo " WARN no peers" >&2; fi
|
||||
|
||||
exit $fail
|
||||
|
||||
- name: journal
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
|
||||
gitea_ci@${{ env.NODE_HOST }} journalctl -u quantus-node.service -n 80 --no-pager
|
||||
|
||||
deploy-miner:
|
||||
runs-on: infra
|
||||
needs: [fetch, deploy-node]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with: { name: quantus-bin, path: _bin }
|
||||
|
||||
- name: write ssh key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 ~/.ssh
|
||||
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
|
||||
chmod 0600 ~/.ssh/id_gitea_ci
|
||||
|
||||
- name: reachability
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
|
||||
gitea_ci@${{ env.MINER_HOST }} hostname -f
|
||||
|
||||
- name: deploy miner
|
||||
if: ${{ github.event.inputs.mode != 'validate' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
|
||||
nrun() { ssh $SSHOPTS gitea_ci@"${{ env.NODE_HOST }}" "$@"; }
|
||||
run() { ssh $SSHOPTS gitea_ci@"${{ env.MINER_HOST }}" "$@"; }
|
||||
push() { rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' "$@"; }
|
||||
|
||||
# 1. service account + dirs
|
||||
push --mkpath --chmod=F0644 \
|
||||
asset/systemd/quantus-miner.sysusers.conf \
|
||||
gitea_ci@"${{ env.MINER_HOST }}":/etc/sysusers.d/quantus-miner.conf
|
||||
run sudo systemd-sysusers
|
||||
run sudo install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
|
||||
run sudo install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
|
||||
|
||||
# 2. The miner's credentials are GENERATED BY THE NODE on first start
|
||||
# and regenerate if the node's base-path is ever wiped. Copying them
|
||||
# on every deploy — rather than once in infra-setup.sh — is what
|
||||
# makes that self-healing instead of a silent auth failure.
|
||||
# They pass through the runner in memory and are never written to
|
||||
# the workspace.
|
||||
umask 077
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
nrun sudo cat /var/lib/quantus-node/chains/${{ env.CHAIN }}/miner-auth-token \
|
||||
> "$tmp/miner-auth-token"
|
||||
nrun sudo cat /var/lib/quantus-node/chains/${{ env.CHAIN }}/miner-tls-cert-sha256 \
|
||||
> "$tmp/miner-tls-cert-sha256"
|
||||
test -s "$tmp/miner-auth-token" || { echo "node auth token empty — has the node started?" >&2; exit 1; }
|
||||
test -s "$tmp/miner-tls-cert-sha256" || { echo "node TLS pin empty — has the node started?" >&2; exit 1; }
|
||||
push --chown=root:quantus-miner --chmod=F0640 \
|
||||
"$tmp/miner-auth-token" gitea_ci@"${{ env.MINER_HOST }}":/etc/quantus-miner/miner-auth-token
|
||||
push --chown=root:quantus-miner --chmod=F0640 \
|
||||
"$tmp/miner-tls-cert-sha256" gitea_ci@"${{ env.MINER_HOST }}":/etc/quantus-miner/miner-tls-cert-sha256
|
||||
|
||||
# 3. non-secret runtime config
|
||||
python3 - <<'PY'
|
||||
import os, pathlib
|
||||
t = pathlib.Path("asset/config/miner.env.tmpl").read_text()
|
||||
t = t.replace("{{QUANTUS_NODE_ADDR}}", os.environ["NODE_ADDR"])
|
||||
t = t.replace("{{QUANTUS_GPU_DEVICES}}", os.environ["GPU_DEVICES"])
|
||||
pathlib.Path("miner.env").write_text(t)
|
||||
PY
|
||||
push --chown=root:quantus-miner --chmod=F0640 \
|
||||
miner.env gitea_ci@"${{ env.MINER_HOST }}":/etc/quantus-miner/miner.env
|
||||
|
||||
# 4. binary + unit
|
||||
push --chmod=F0755 _bin/quantus-miner gitea_ci@"${{ env.MINER_HOST }}":/usr/local/bin/quantus-miner
|
||||
push --chmod=F0644 asset/systemd/quantus-miner.service \
|
||||
gitea_ci@"${{ env.MINER_HOST }}":/etc/systemd/system/quantus-miner.service
|
||||
|
||||
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
|
||||
|
||||
run sudo systemctl daemon-reload
|
||||
run sudo systemctl enable quantus-miner.service
|
||||
run sudo systemctl restart quantus-miner.service
|
||||
env:
|
||||
NODE_ADDR: ${{ env.NODE_HOST }}:${{ env.MINER_LINK_PORT }}
|
||||
GPU_DEVICES: ${{ env.GPU_DEVICES }}
|
||||
|
||||
- name: validate miner
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
|
||||
run() { ssh $SSHOPTS gitea_ci@"${{ env.MINER_HOST }}" "$@"; }
|
||||
fail=0
|
||||
|
||||
echo "--- unit ---"
|
||||
run systemctl is-active quantus-miner.service
|
||||
|
||||
echo "--- version ---"
|
||||
got=$(run /usr/local/bin/quantus-miner --version)
|
||||
echo "installed: ${got}"
|
||||
case "$got" in
|
||||
*"${{ needs.fetch.outputs.miner_version }}"*) echo "version matches pin" ;;
|
||||
*) echo "version does NOT match pin ${{ needs.fetch.outputs.miner_version }}" >&2; fail=1 ;;
|
||||
esac
|
||||
|
||||
echo "--- gpu ---"
|
||||
# An `active` miner that found no adapter still looks healthy to
|
||||
# systemd; assert the GPU is actually enumerated and busy.
|
||||
run "nvidia-smi --query-gpu=name,power.draw,utilization.gpu --format=csv,noheader"
|
||||
|
||||
echo "--- hashing ---"
|
||||
# The metrics counter is the only honest evidence that this process is
|
||||
# doing work rather than idling on a failed connection.
|
||||
h1=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" \
|
||||
| awk '/^miner_hashes_total/{print $2; exit}')
|
||||
sleep 20
|
||||
h2=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" \
|
||||
| awk '/^miner_hashes_total/{print $2; exit}')
|
||||
echo " miner_hashes_total ${h1:-?} -> ${h2:-?}"
|
||||
if [ -n "${h1:-}" ] && [ -n "${h2:-}" ] && [ "${h2%.*}" -gt "${h1%.*}" ]; then
|
||||
echo " ok hash counter advancing"
|
||||
else
|
||||
echo " WARN counter not advancing — the node may still be syncing," >&2
|
||||
echo " which is expected and not itself a deploy failure." >&2
|
||||
fi
|
||||
|
||||
exit $fail
|
||||
|
||||
- name: journal
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
|
||||
gitea_ci@${{ env.MINER_HOST }} journalctl -u quantus-miner.service -n 80 --no-pager
|
||||
@@ -0,0 +1,5 @@
|
||||
# no rendered config, ever (architecture generic.md §6)
|
||||
asset/config/*.env
|
||||
!asset/config/*.tmpl
|
||||
_bin/
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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 upstream release binaries fetched
|
||||
by the workflow. 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.
|
||||
|
||||
## 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`
|
||||
reads a seed phrase on stdin and emits only a "value withheld" acknowledgement.
|
||||
Keep it that way; do not add a debug echo.
|
||||
- Version pins are deliberate. Do not add auto-update to a validator.
|
||||
|
||||
## Before enabling the miner on benjy
|
||||
|
||||
`benjy` already runs helexa `neuron` on `:13131` against the same RTX 4090. The
|
||||
miner will saturate that GPU. `readme.md` documents the options; the deploy
|
||||
deliberately does not arbitrate. This is an open decision, not an oversight.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Rendered by .gitea/workflows/deploy.yaml and rsynced to
|
||||
# /etc/quantus-miner/miner.env (0640 root:quantus-miner). Not secret — the
|
||||
# miner's actual credentials are the auth token and TLS pin, which are copied
|
||||
# from the node host as separate 0640 files.
|
||||
QUANTUS_NODE_ADDR={{QUANTUS_NODE_ADDR}}
|
||||
QUANTUS_GPU_DEVICES={{QUANTUS_GPU_DEVICES}}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Rendered by .gitea/workflows/deploy.yaml from the QUANTUS_INNER_HASH Gitea
|
||||
# secret and rsynced to /etc/quantus-node/node.env (0640 root:quantus-node).
|
||||
# The rendered file is never committed (generic.md §6).
|
||||
#
|
||||
# 32-byte wormhole preimage, 0x-prefixed — the node derives the reward address
|
||||
# from it and logs that address on startup. Generated by
|
||||
# script/infra-setup.sh --mint-reward-key.
|
||||
QUANTUS_INNER_HASH={{QUANTUS_INNER_HASH}}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>quantus-node-miner</short>
|
||||
<description>Quantus node external-miner control channel (QUIC/UDP). NOT a
|
||||
public service: shared-secret auth plus a pinned self-signed TLS cert, and the
|
||||
upstream docs are explicit that it must never be reachable from the open
|
||||
internet. The deploy binds this to the miner host's mesh address with a
|
||||
firewalld rich rule rather than adding it to the zone wholesale.</description>
|
||||
<port protocol="udp" port="9833"/>
|
||||
</service>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>quantus-node</short>
|
||||
<description>Quantus Planck P2P (libp2p). Public: this is the peer-to-peer
|
||||
gossip port and must be reachable from the internet for the node to be a
|
||||
useful network participant.</description>
|
||||
<port protocol="tcp" port="30333"/>
|
||||
</service>
|
||||
@@ -0,0 +1,64 @@
|
||||
# Quantus external miner. Runs on the GPU host and connects OUT to the node's
|
||||
# QUIC control channel; it listens on nothing but its loopback metrics port, so
|
||||
# it ships no firewalld service of its own.
|
||||
#
|
||||
# Hardened per ~/git/architecture/generic.md §8, with one deliberate relaxation:
|
||||
#
|
||||
# PrivateDevices=false
|
||||
# The miner needs /dev/nvidia*; PrivateDevices=true hides them and wgpu
|
||||
# finds no adapter, silently falling back to nothing.
|
||||
#
|
||||
# Poseidon2-over-Goldilocks is compute-bound in registers, not memory-bound, so
|
||||
# there is no tuning knob here worth more than the GPU power limit — which is a
|
||||
# host-level concern (nvidia-smi -pl), not a unit-file one.
|
||||
#
|
||||
# --cpu-workers 0 is deliberate: benjy's i9-13900KF contributes ~3.9 MH/s next
|
||||
# to the 4090's ~183 MH/s, for a couple hundred watts. Measured, not assumed.
|
||||
|
||||
[Unit]
|
||||
Description=Quantus external miner (GPU)
|
||||
Documentation=https://github.com/Quantus-Network/quantus-miner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=quantus-miner
|
||||
Group=quantus-miner
|
||||
Environment=RUST_LOG=info
|
||||
WorkingDirectory=/var/lib/quantus-miner
|
||||
|
||||
ExecStart=/usr/local/bin/quantus-miner serve \
|
||||
--node-addr ${QUANTUS_NODE_ADDR} \
|
||||
--auth-token-file /etc/quantus-miner/miner-auth-token \
|
||||
--tls-cert-sha256-file /etc/quantus-miner/miner-tls-cert-sha256 \
|
||||
--gpu-devices ${QUANTUS_GPU_DEVICES} \
|
||||
--cpu-workers 0 \
|
||||
--metrics-port 9900
|
||||
EnvironmentFile=/etc/quantus-miner/miner.env
|
||||
|
||||
# The node may be down, still syncing, or mid-redeploy; reconnecting is normal
|
||||
# operation, not an error condition.
|
||||
Restart=always
|
||||
RestartSec=15s
|
||||
LimitNOFILE=65536
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=false
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=false
|
||||
|
||||
ReadWritePaths=/var/lib/quantus-miner
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,4 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u quantus-miner - "Quantus miner service account" /var/lib/quantus-miner /usr/sbin/nologin
|
||||
m quantus-miner video
|
||||
m quantus-miner render
|
||||
@@ -0,0 +1,74 @@
|
||||
# Quantus Planck 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:
|
||||
#
|
||||
# MemoryDenyWriteExecute=false
|
||||
# Substrate JITs the on-chain wasm runtime through wasmtime and needs W|X
|
||||
# pages. With this true the node aborts during block import.
|
||||
#
|
||||
# Type=simple (not notify)
|
||||
# quantus-node does not sd_notify(READY=1). Type=notify would block
|
||||
# `systemctl restart` until TimeoutStartSec expires
|
||||
# (deployment-gitea-actions.md §6).
|
||||
#
|
||||
# The reward preimage arrives via EnvironmentFile because the node exposes no
|
||||
# environment-variable form for any flag (`--help` carries no `[env:]`
|
||||
# annotations), so it must be a CLI argument. The EnvironmentFile keeps it out
|
||||
# of this world-readable unit; it is still visible in `ps` to a local user on
|
||||
# the host, which is accepted on a single-tenant box.
|
||||
|
||||
[Unit]
|
||||
Description=Quantus node (Planck, validator, external-miner mode)
|
||||
Documentation=https://github.com/Quantus-Network/chain
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=quantus-node
|
||||
Group=quantus-node
|
||||
Environment=RUST_LOG=info
|
||||
EnvironmentFile=/etc/quantus-node/node.env
|
||||
WorkingDirectory=/var/lib/quantus-node
|
||||
|
||||
ExecStart=/usr/local/bin/quantus-node \
|
||||
--validator \
|
||||
--chain planck \
|
||||
--name %H \
|
||||
--base-path /var/lib/quantus-node \
|
||||
--node-key-file /var/lib/quantus-node/node_key.p2p \
|
||||
--rewards-inner-hash ${QUANTUS_INNER_HASH} \
|
||||
--miner-listen-port 9833 \
|
||||
--port 30333 \
|
||||
--prometheus-port 9615 \
|
||||
--max-blocks-per-request 64 \
|
||||
--sync full
|
||||
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
# RocksDB needs time to flush cleanly; a SIGKILL mid-compaction means a slow
|
||||
# (or corrupt) recovery on next start.
|
||||
TimeoutStopSec=300
|
||||
KillSignal=SIGINT
|
||||
LimitNOFILE=65536
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=false
|
||||
|
||||
ReadWritePaths=/var/lib/quantus-node
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,2 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u quantus-node - "Quantus node service account" /var/lib/quantus-node /usr/sbin/nologin
|
||||
@@ -0,0 +1,161 @@
|
||||
# quantus
|
||||
|
||||
Deployment for a [Quantus Network](https://quantus.com) Planck node and its
|
||||
external GPU miner.
|
||||
|
||||
Two components, two hosts:
|
||||
|
||||
| Component | Host | Role |
|
||||
| --- | --- | --- |
|
||||
| `quantus-node` | `bob.hanzalova.internal` | consensus, P2P, reward address. Disk + network bound. |
|
||||
| `quantus-miner` | `benjy.hanzalova.internal` | Poseidon2-over-Goldilocks search on 1× RTX 4090. Pure GPU compute. |
|
||||
|
||||
They are split deliberately. Setting `--miner-listen-port` on the node
|
||||
**disables its built-in CPU mining**, so the node authors nothing unless an
|
||||
external miner is connected — the split is not optional once that flag is set.
|
||||
The miner connects *out* to the node over QUIC/9833 and listens on nothing but a
|
||||
loopback metrics port.
|
||||
|
||||
Conventions: `~/git/architecture` — `generic.md` for the on-host shape,
|
||||
`deployment-gitea-actions.md` for the deploy mechanism. Deviations are listed
|
||||
below.
|
||||
|
||||
## Why GPU, and why only these hosts
|
||||
|
||||
The proof-of-work is Poseidon2 over the Goldilocks field (`p = 2⁶⁴ − 2³² + 1`),
|
||||
width 12, 4 external + 22 internal rounds — an arithmetic hash, not a bit-mixing
|
||||
one. Cost is dominated by 64-bit modular multiplication, the state lives entirely
|
||||
in registers, and there is no memory hardness at all. Measured on this fleet:
|
||||
|
||||
| Hardware | Hashrate | Power | Efficiency |
|
||||
| --- | --- | --- | --- |
|
||||
| RTX 5090 (`beast`, ×2) | 368 MH/s each | 600 W | 0.60 MH/s/W |
|
||||
| RTX 5090 @ 400 W floor | 281 MH/s | 400 W | 0.70 MH/s/W |
|
||||
| RTX 4090 (`benjy`) | 183 MH/s | 450 W | 0.41 MH/s/W |
|
||||
| Ryzen 9 9950X3D, 32t | 4.15 MH/s | — | — |
|
||||
| Xeon E5-2620 v3, 12t (`bob`) | 0.50 MH/s | — | — |
|
||||
|
||||
A single 5090 at its minimum power limit out-hashes every CPU in the fleet
|
||||
combined by roughly 24×. CPU mining is therefore disabled everywhere
|
||||
(`--cpu-workers 0`), including on the GPU hosts, where it would add ~1% hashrate
|
||||
for a couple hundred watts. The old Xeons are node-capable and mining-worthless.
|
||||
|
||||
GPU/CPU parity is verified upstream (`engine-gpu`'s `gpu_cpu_parity` example,
|
||||
25/25 jobs) — the GPU is doing real work, not advancing a lying counter.
|
||||
|
||||
## Ports
|
||||
|
||||
Registered in `~/git/architecture/port-allocations.md` §5.
|
||||
|
||||
| Port | Proto | Component | Exposure |
|
||||
| --- | --- | --- | --- |
|
||||
| `30333` | tcp | node P2P (libp2p) | **public** — named firewalld service |
|
||||
| `9833` | udp | node ← miner control channel | mesh, firewalld **rich rule** scoped to the miner host only |
|
||||
| `9615` | tcp | node Prometheus | loopback |
|
||||
| `9900` | tcp | miner Prometheus | loopback |
|
||||
|
||||
**Deviation from `port-allocations.md` §3:** these are upstream protocol
|
||||
defaults, not numbers derived from the service name. `30333` is the chain's
|
||||
well-known P2P port and changing it buys nothing; `9833` is the miner protocol's
|
||||
default and both ends must agree. They are recorded in the registry, which is
|
||||
what the doc says actually prevents collisions. Neither lands in the crowded
|
||||
alt-HTTP cluster.
|
||||
|
||||
The miner link gets a rich rule rather than a plain `--add-service` because the
|
||||
fleet uses a single default zone (`generic.md` §9): adding the service outright
|
||||
would publish `9833/udp` on every address the host carries, and upstream is
|
||||
explicit that this port must never face the internet.
|
||||
|
||||
## Secrets
|
||||
|
||||
| Name | What | Set by |
|
||||
| --- | --- | --- |
|
||||
| `RSYNC_SSH_KEY` | the shared fleet runner key | already exists — do **not** rotate |
|
||||
| `QUANTUS_INNER_HASH` | 32-byte wormhole preimage, `0x`-prefixed | `script/infra-setup.sh --mint-reward-key` |
|
||||
|
||||
Mining rewards accrue to a wormhole address derived from `QUANTUS_INNER_HASH`.
|
||||
The script derives it from a wallet seed phrase read on **stdin**, so rewards
|
||||
land in a wallet you already control and appear with no claiming step.
|
||||
|
||||
It will not generate a fresh wallet for you. A newly minted wormhole key's
|
||||
24-word phrase is the only way to ever spend what it accumulates, and it fails
|
||||
`agent-credentials.md` §1 outright — a seed controlling real funds is
|
||||
escalation-grade and must not enter `~/.agents`. Create the wallet in the
|
||||
Quantus wallet app, back the phrase up there, then feed it in.
|
||||
|
||||
The miner's own credentials — `miner-auth-token` and `miner-tls-cert-sha256` —
|
||||
are **not** secrets in this repo. The node generates them on first start and
|
||||
regenerates them if its base-path is ever wiped, so the deploy copies them from
|
||||
the node to the miner on every run. That makes the pairing self-healing instead
|
||||
of a silent auth failure six months later.
|
||||
|
||||
## Usage
|
||||
|
||||
One-time, from a workstation with sudo ssh to the targets:
|
||||
|
||||
```sh
|
||||
./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
|
||||
./script/infra-setup.sh --mint-reward-key # reads a seed phrase on stdin
|
||||
```
|
||||
|
||||
Then push to `main`, or run the **deploy** workflow from the Actions UI. It has
|
||||
two modes:
|
||||
|
||||
- **deploy** (default, and what a push does) — apply, then validate.
|
||||
- **validate** — check only, change nothing. Asserts each unit is active, the
|
||||
installed binary matches the pinned version, the node's P2P and miner ports
|
||||
are listening, peers are connected, the GPU is enumerated and drawing power,
|
||||
and `miner_hashes_total` is advancing.
|
||||
|
||||
`systemctl is-active` alone is not evidence this stack works: a miner that found
|
||||
no GPU adapter, and a node that is authoring nothing because no miner is
|
||||
attached, both look perfectly healthy to systemd. Hence the counter check.
|
||||
|
||||
Upgrades are deliberate — bump `NODE_VERSION` / `MINER_VERSION` in the workflow.
|
||||
There is no auto-update on a validator on purpose.
|
||||
|
||||
## Known conflict: helexa on `benjy` ⚠️
|
||||
|
||||
`benjy` already runs **helexa `neuron`** on `:13131` (`architecture/generic.md`
|
||||
§11, `inference.md`), serving inference from the same RTX 4090 this miner will
|
||||
saturate. They will contend directly: the miner drives the GPU to ~95%
|
||||
utilisation and ~450 W continuously, which will make inference latency on benjy
|
||||
by turns awful and unpredictable.
|
||||
|
||||
Nothing here resolves that, and the deploy does not try to arbitrate. Options,
|
||||
in rough order of preference:
|
||||
|
||||
1. Move mining to `beast` (2× 5090, and 4× the hashrate) and leave benjy to
|
||||
inference. Requires checking what beast's neuron is doing first.
|
||||
2. Make the miner yield — `--gpu-throttle-ms`, or an `ExecCondition` that
|
||||
refuses to start while a model is loaded.
|
||||
3. Accept the contention on benjy and treat inference there as best-effort.
|
||||
|
||||
Decide before enabling `quantus-miner.service` on benjy, not after.
|
||||
|
||||
## Deviations from house convention
|
||||
|
||||
- **`runs-on: infra`, not `fedora-43`.** `gitea-runners.md` §4 says a deploy
|
||||
needs only ssh + rsync and so fits `fedora-43`. That is about tooling, not
|
||||
routing — these targets are mesh-only `.internal` names, and `lair/mail`'s two
|
||||
working deploys both use `infra` for that reason. Revisit if the fedora runners
|
||||
gain mesh reachability.
|
||||
- **Upstream protocol ports** rather than derived ones — see Ports above.
|
||||
- **`MemoryDenyWriteExecute=false`** on the node (Substrate JITs its wasm runtime
|
||||
through wasmtime) and **`PrivateDevices=false`** on the miner (needs
|
||||
`/dev/nvidia*`). Both are annotated in the unit files; neither is tidy-up-able.
|
||||
- **`Type=simple`, not `notify`** — `quantus-node` does not `sd_notify(READY=1)`.
|
||||
- **No Rust workspace.** This repo deploys upstream release binaries; there is
|
||||
nothing to build, so `generic.md` §1–§2 do not apply.
|
||||
|
||||
## Upstream
|
||||
|
||||
- node — <https://github.com/Quantus-Network/chain> (`MINING.md` is the real doc)
|
||||
- miner — <https://github.com/Quantus-Network/quantus-miner>
|
||||
- guide — <https://docs.quantus.com/guides/mining>
|
||||
|
||||
Note that Planck is a **testnet**: "tokens have no monetary value". Mainnet
|
||||
difficulty and reward schedule are unpublished, so no profitability claim here is
|
||||
possible yet. Also worth tracking: this PoW has no memory hardness and a small
|
||||
fixed state, which makes it an unusually clean FPGA/ASIC target — relevant to any
|
||||
decision to buy hardware for it.
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time, operator-run provisioning so the Gitea Actions runner can deploy as
|
||||
# the scoped `gitea_ci` user, plus the key material that cannot live in the
|
||||
# workflow. Two roles, each with its own narrow sudoers:
|
||||
#
|
||||
# node (--node-hosts) quantus-node binary + unit + config + firewalld,
|
||||
# and read access to the two miner credential files
|
||||
# the node generates -> deploy.yaml deploy-node
|
||||
# miner (--miner-hosts) quantus-miner binary + unit + credentials
|
||||
# -> deploy.yaml deploy-miner
|
||||
#
|
||||
# The roles are separate deliberately: the miner host holds a GPU and an
|
||||
# outbound-only process, and a compromise of the miner deploy must not be able
|
||||
# to rewrite the validator's binary or read its reward config. Hence
|
||||
# `quantus-miner_gitea_ci` as a SEPARATE sudoers file from
|
||||
# `quantus-node_gitea_ci` (deployment-gitea-actions.md §1).
|
||||
#
|
||||
# Convention: ~/git/architecture/deployment-gitea-actions.md §1–§2, generic.md
|
||||
# §8–§10, agent-credentials.md §1 and §6.
|
||||
#
|
||||
# Run from a workstation with admin (sudo) ssh access to the targets — NOT the
|
||||
# gitea_ci account. Idempotent; re-running is a no-op. Skips unreachable hosts
|
||||
# so one offline node doesn't block the rest.
|
||||
#
|
||||
# ./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
|
||||
# ./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub --node-hosts "" \
|
||||
# --miner-hosts benjy.hanzalova.internal
|
||||
# ./script/infra-setup.sh --mint-reward-key # reads a seed phrase on stdin
|
||||
#
|
||||
# The runner keypair is NOT generated here. It already exists at
|
||||
# ~/.ssh/id_gitea_ci and is shared by every project's RSYNC_SSH_KEY secret and
|
||||
# every host's gitea_ci authorized_keys — regenerating it would silently break
|
||||
# every other deploy on the fleet (deployment-gitea-actions.md §2).
|
||||
set -euo pipefail
|
||||
|
||||
ADMIN_USER="${ADMIN_USER:-$USER}"
|
||||
# `${X-default}` not `${X:-default}`: an explicitly EMPTY list means "none of
|
||||
# this role", which is how you provision only the other one. With `:-` the empty
|
||||
# value would silently fall back to the full default list.
|
||||
NODE_HOSTS="${NODE_HOSTS-bob.hanzalova.internal}"
|
||||
MINER_HOSTS="${MINER_HOSTS-benjy.hanzalova.internal}"
|
||||
PUBKEY="${PUBKEY:-${HOME}/.ssh/id_gitea_ci.pub}"
|
||||
CHAIN="${CHAIN:-planck}"
|
||||
NODE_VERSION="${NODE_VERSION:-0.10.0}"
|
||||
GITEA_API="${GITEA_API:-https://git.lair.cafe/api/v1}"
|
||||
GITEA_REPO="${GITEA_REPO:-lair/quantus}"
|
||||
MINT_REWARD_KEY=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pubkey) PUBKEY="$2"; shift 2 ;;
|
||||
--node-hosts) NODE_HOSTS="$2"; shift 2 ;;
|
||||
--miner-hosts) MINER_HOSTS="$2"; shift 2 ;;
|
||||
--admin) ADMIN_USER="$2"; shift 2 ;;
|
||||
--node-version) NODE_VERSION="$2"; shift 2 ;;
|
||||
--mint-reward-key) MINT_REWARD_KEY=1; shift ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
NODE_URL="https://github.com/Quantus-Network/chain/releases/download/v${NODE_VERSION}/quantus-node-v${NODE_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
|
||||
# ---------------------------------------------------------------- reward key --
|
||||
#
|
||||
# The reward preimage (`inner_hash`) is DERIVED from a wallet seed phrase, so
|
||||
# that mining rewards land in a wallet the operator already controls and appear
|
||||
# there with no claiming step.
|
||||
#
|
||||
# The seed phrase is read on stdin and never touches argv (visible in `ps`), a
|
||||
# file, or stdout (agent-credentials.md §6). Only the derived inner_hash leaves
|
||||
# this function, and it goes straight into the Gitea Actions secret.
|
||||
#
|
||||
# This deliberately will NOT generate a fresh wallet for you. A freshly minted
|
||||
# wormhole key has a 24-word recovery phrase that is the only way to ever spend
|
||||
# what it accumulates, and a provisioning script is the wrong place to be
|
||||
# responsible for that. It also fails agent-credentials.md §1 outright: a seed
|
||||
# controlling real funds is escalation-grade, so it must not land in ~/.agents.
|
||||
# Create the wallet in the Quantus wallet app, back the phrase up there, then
|
||||
# feed it to this.
|
||||
mint_reward_key() {
|
||||
command -v pass >/dev/null || { echo "error: 'pass' not found; needed to read the Gitea secret-writer token" >&2; exit 1; }
|
||||
|
||||
local bin tmp
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' RETURN
|
||||
|
||||
echo "fetching quantus-node ${NODE_VERSION} to derive the wormhole key locally"
|
||||
curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \
|
||||
-o "$tmp/node.tar.gz" "$NODE_URL"
|
||||
tar xzf "$tmp/node.tar.gz" -C "$tmp"
|
||||
bin="$(find "$tmp" -name quantus-node -type f | head -1)"
|
||||
chmod +x "$bin"
|
||||
|
||||
echo
|
||||
echo "Paste the wallet seed phrase, then press Ctrl-D."
|
||||
echo "It is read from stdin, never echoed, never written to disk."
|
||||
local phrase
|
||||
IFS= read -r -d '' phrase || true
|
||||
phrase="${phrase%$'\n'}"
|
||||
[[ -n "$phrase" ]] || { echo "error: empty seed phrase" >&2; exit 1; }
|
||||
|
||||
# `key quantus --scheme wormhole --words <phrase>` derives deterministically.
|
||||
# Output is parsed, never displayed.
|
||||
local out inner
|
||||
out="$(printf '%s' "$phrase" | "$bin" key quantus --scheme wormhole --words)"
|
||||
inner="$(printf '%s' "$out" | grep -oE '0x[0-9a-fA-F]{64}' | head -1)"
|
||||
unset phrase out
|
||||
[[ -n "$inner" ]] || { echo "error: could not parse an inner_hash from key output" >&2; exit 1; }
|
||||
echo "derived a 32-byte inner_hash (value withheld — agent-credentials.md §6)"
|
||||
|
||||
local token
|
||||
token="$(GNUPGHOME="$HOME/.agents/gnupg" PASSWORD_STORE_DIR="$HOME/.agents/password-store" \
|
||||
pass show gitea/agent-secret-writer-token)"
|
||||
|
||||
local code
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @<(printf '{"data":%s}' "$(printf '%s' "$inner" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')") \
|
||||
"${GITEA_API}/repos/${GITEA_REPO}/actions/secrets/QUANTUS_INNER_HASH")"
|
||||
unset inner token
|
||||
|
||||
case "$code" in
|
||||
201|204) echo "QUANTUS_INNER_HASH written to ${GITEA_REPO} (HTTP ${code})" ;;
|
||||
403) echo "error: HTTP 403 — agent-bot lacks admin on ${GITEA_REPO%%/*}; that grant is an operator step (agent-credentials.md §4)" >&2; exit 1 ;;
|
||||
*) echo "error: Gitea secret write failed (HTTP ${code})" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- roles ----
|
||||
|
||||
provision_node() {
|
||||
local host="$1"
|
||||
echo "== ${host} (node) =="
|
||||
if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then
|
||||
echo " ! unreachable as ${ADMIN_USER} — skipping" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# All privileged work in one remote `sudo bash`. The runner pubkey is the only
|
||||
# dynamic value, passed as $1 (single line, no quoting hazard). The sudoers
|
||||
# `\=` are the required escapes — visudo rejects a bare `=` in a command arg.
|
||||
# The `*` in an rsync line matches rsync's --server arg vector; the trailing
|
||||
# literal destination is what actually bounds the rule.
|
||||
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE'
|
||||
PUBKEY="$1"
|
||||
|
||||
if ! getent passwd gitea_ci >/dev/null; then
|
||||
useradd --system --create-home --home-dir /var/lib/gitea_ci \
|
||||
--shell /usr/sbin/nologin gitea_ci
|
||||
echo " + created gitea_ci"
|
||||
else
|
||||
echo " = gitea_ci already present"
|
||||
fi
|
||||
|
||||
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
|
||||
ak=/var/lib/gitea_ci/.ssh/authorized_keys
|
||||
touch "$ak"
|
||||
grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak"
|
||||
chown gitea_ci:gitea_ci "$ak"
|
||||
chmod 0600 "$ak"
|
||||
|
||||
# journalctl -u <unit> for the deploy's log capture, without sudo.
|
||||
usermod -aG systemd-journal gitea_ci
|
||||
|
||||
tmp=/etc/sudoers.d/.quantus-node_gitea_ci.tmp
|
||||
cat > "$tmp" <<'SUDO'
|
||||
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/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/systemd-sysusers
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-node -m 0750 /etc/quantus-node
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-node -g quantus-node -m 0750 /var/lib/quantus-node
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-node /etc/quantus-node /var/lib/quantus-node
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-service\=quantus-node
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-service\=quantus-node
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-service\=quantus-node
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-node.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-node.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/quantus-node/chains/*/miner-auth-token
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/quantus-node/chains/*/miner-tls-cert-sha256
|
||||
SUDO
|
||||
chmod 0440 "$tmp"
|
||||
visudo -cf "$tmp"
|
||||
mv "$tmp" /etc/sudoers.d/quantus-node_gitea_ci
|
||||
echo " = sudoers quantus-node_gitea_ci installed and visudo-verified"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
provision_miner() {
|
||||
local host="$1"
|
||||
echo "== ${host} (miner) =="
|
||||
if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then
|
||||
echo " ! unreachable as ${ADMIN_USER} — skipping" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE'
|
||||
PUBKEY="$1"
|
||||
|
||||
if ! getent passwd gitea_ci >/dev/null; then
|
||||
useradd --system --create-home --home-dir /var/lib/gitea_ci \
|
||||
--shell /usr/sbin/nologin gitea_ci
|
||||
echo " + created gitea_ci"
|
||||
else
|
||||
echo " = gitea_ci already present"
|
||||
fi
|
||||
|
||||
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
|
||||
ak=/var/lib/gitea_ci/.ssh/authorized_keys
|
||||
touch "$ak"
|
||||
grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak"
|
||||
chown gitea_ci:gitea_ci "$ak"
|
||||
chmod 0600 "$ak"
|
||||
usermod -aG systemd-journal gitea_ci
|
||||
|
||||
# The GPU must be present and enumerable before a miner deploy is worth
|
||||
# attempting; failing here beats a green deploy that mines nothing.
|
||||
if ! command -v nvidia-smi >/dev/null; then
|
||||
echo " ! nvidia-smi not found — this host has no usable NVIDIA driver" >&2
|
||||
exit 1
|
||||
fi
|
||||
nvidia-smi --query-gpu=name --format=csv,noheader | sed 's/^/ = gpu: /'
|
||||
|
||||
tmp=/etc/sudoers.d/.quantus-miner_gitea_ci.tmp
|
||||
cat > "$tmp" <<'SUDO'
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-miner.conf
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner.env
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-auth-token
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-tls-cert-sha256
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-miner.service
|
||||
SUDO
|
||||
chmod 0440 "$tmp"
|
||||
visudo -cf "$tmp"
|
||||
mv "$tmp" /etc/sudoers.d/quantus-miner_gitea_ci
|
||||
echo " = sudoers quantus-miner_gitea_ci installed and visudo-verified"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
# The node's libp2p identity. It must exist BEFORE the first start — without it
|
||||
# the node exits with NetworkKeyNotFound(".../network/secret_dilithium") rather
|
||||
# than generating one. It is also the node's stable identity on the network, so
|
||||
# it is generated once and never rotated by a deploy.
|
||||
ensure_node_key() {
|
||||
local host="$1"
|
||||
echo "== ${host} (node key) =="
|
||||
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '${NODE_URL}'" <<'REMOTE'
|
||||
NODE_URL="$1"
|
||||
|
||||
install -d -o root -g root -m 0755 /etc/sysusers.d
|
||||
cat > /etc/sysusers.d/quantus-node.conf <<'SYSUSERS'
|
||||
u quantus-node - "Quantus node service account" /var/lib/quantus-node /usr/sbin/nologin
|
||||
SYSUSERS
|
||||
systemd-sysusers
|
||||
install -d -o quantus-node -g quantus-node -m 0750 /var/lib/quantus-node
|
||||
|
||||
key=/var/lib/quantus-node/node_key.p2p
|
||||
if [ -s "$key" ]; then
|
||||
echo " = node key already present — left untouched"
|
||||
else
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \
|
||||
-o "$tmp/node.tar.gz" "$NODE_URL"
|
||||
tar xzf "$tmp/node.tar.gz" -C "$tmp"
|
||||
bin="$(find "$tmp" -name quantus-node -type f | head -1)"
|
||||
chmod +x "$bin"
|
||||
"$bin" key generate-node-key --file "$key"
|
||||
chown quantus-node:quantus-node "$key"
|
||||
chmod 0600 "$key"
|
||||
echo " + generated node key"
|
||||
fi
|
||||
# The peer id is public, and is what you look for on telemetry.
|
||||
echo -n " = peer id: "
|
||||
getent passwd quantus-node >/dev/null && cat /var/lib/quantus-node/node_key.p2p >/dev/null
|
||||
REMOTE
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------- main ---
|
||||
|
||||
if [[ "$MINT_REWARD_KEY" == 1 ]]; then
|
||||
mint_reward_key
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -n "$PUBKEY" ]] || { echo "error: --pubkey <path> is required" >&2; exit 2; }
|
||||
[[ -s "$PUBKEY" ]] || { echo "error: pubkey not found or empty: $PUBKEY" >&2; exit 2; }
|
||||
|
||||
rc=0
|
||||
for h in $NODE_HOSTS; do
|
||||
provision_node "$h" || { rc=1; continue; }
|
||||
ensure_node_key "$h" || rc=1
|
||||
done
|
||||
for h in $MINER_HOSTS; do
|
||||
provision_miner "$h" || rc=1
|
||||
done
|
||||
|
||||
echo
|
||||
echo "done. remaining operator steps:"
|
||||
echo " 1. ./script/infra-setup.sh --mint-reward-key (if QUANTUS_INNER_HASH is unset)"
|
||||
echo " 2. confirm RSYNC_SSH_KEY is set in ${GITEA_REPO} settings"
|
||||
echo " 3. push to main, or run the deploy workflow manually"
|
||||
exit $rc
|
||||
Reference in New Issue
Block a user