Files
miner/.gitea/workflows/deploy.yaml
rob thijssen 7831323362
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m15s
ci / clippy (pull_request) Successful in 1m43s
ci / doc (pull_request) Successful in 2m0s
bench / measure (pull_request) Successful in 2m52s
ci / test (pull_request) Successful in 6m55s
release: tag-triggered binaries, README section with measured rates
For independent miners (quantus/miner#27): a `v<version>-lair.<n>` tag
builds quantus-miner and quantus-bench on the cuda-13.0 runner with the
tag's commit in --version, and publishes a Gitea release with the
tarball, its sha256 and a build report. The README now leads with what
this fork is, the measured rates per card against upstream and the two
closed pool binaries, where the binaries are, how to reproduce the
numbers with quantus-bench, and that there is no fee, licence server or
telemetry. release.yaml is excluded from deploy triggers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 01:13:49 +03:00

384 lines
18 KiB
YAML

---
# Deploy — or validate — the miner on the fleet's mining hosts (quantus/miner#8).
#
# Every push to main builds the binary and lands it on each host in the matrix,
# validated, with rollback. This workflow is the source of infra truth for the
# miner: hosts and per-host settings live in the `deploy` matrix and nowhere
# else. The node it attaches to is deployed by quantus/chain; the fleet's
# monitoring, GPU power limits and nvidia metrics stay in lair/quantus.
#
# Build on cuda-13.0: it is Fedora 43 like the hosts (the `rust` image is
# Fedora 44 and its binaries fail on the hosts with GLIBC_2.43 not found), and
# it is the only runner with nvcc for the CUDA engine (#3). Deploy on
# fedora-43: ssh + rsync are on every runner (gitea-runners.md §3).
name: deploy
on:
push:
branches: [main]
paths-ignore:
- "**.md"
- .gitea/workflows/ci.yml
- .gitea/workflows/bench.yaml
- .gitea/workflows/release.yaml
workflow_dispatch:
inputs:
mode:
description: "deploy (apply) or validate (check only, no changes)"
required: false
default: deploy
type: choice
options: [deploy, validate]
chain:
description: "chain id under the node's chains/ directory to take miner credentials from (overrides CHAIN); the node deploy's validate step prints it"
required: false
# Never half-apply two deploys at once. (Not relied on for correctness of a
# single host: the restart is checksum-gated and the binary is rsynced atomically.)
concurrency:
group: deploy-miner
cancel-in-progress: false
env:
# The chain spec: the miner credential path on the node host derives from it.
# Must agree with lair/quantus's node deploy. Mainnet since 2026-09-09; the
# node writes the credentials under chains/mainnet/.
CHAIN: mainnet
MINER_LINK_PORT: "9833"
MINER_METRICS_PORT: "9900"
# Fleet Prometheus host; the miner's exporter is unauthenticated and bound to
# 0.0.0.0, so this is the only host allowed to reach it.
METRICS_HOST: golgafrinchans.kosherinata.internal
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: cuda-13.0
steps:
- uses: actions/checkout@v4
- name: build quantus-miner
env:
# Embedded by crates/miner-cli/build.rs into --version; validate below
# asserts the host runs exactly this commit.
MINER_BUILD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
cargo build --release --locked -p miner-cli
./target/release/quantus-miner --version
- uses: actions/upload-artifact@v3
with:
name: quantus-miner
path: target/release/quantus-miner
deploy:
runs-on: fedora-43
needs: build
strategy:
fail-fast: false # one host's failure must not abort the other
matrix:
include:
# `node` is the host whose QUIC control channel this miner attaches
# to, and whose reward address therefore receives what it earns. The
# node's deploy (quantus/chain) must list this host in its `miners`
# so the firewalld rich rule admits it. lair/quantus's SCRAPE_MINERS
# must list it so Prometheus scrapes it.
#
# Mainnet call 2026-09-09 (quantus/miner#1): all three hosts mine;
# neuron (cortex inference) is disabled on each of them.
# `kernel` is the kernel id validate expects on the per-device metric:
# cuda (engine-cuda, #3) on every NVIDIA host once the build carries
# the fat binary; u64 would mean the miner silently fell back to wgpu.
- host: benjy.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1x RTX 4090 (sm_89); reference card for #2
kernel: cuda
- host: quadbrat.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1x RTX 3060 (sm_86)
kernel: cuda
- host: beast.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "2" # 2x RTX 5090 (sm_120)
kernel: cuda
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: quantus-miner, 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@${{ matrix.host }} hostname -f
- name: preflight — sudoers covers this deploy
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
# Every path deploy/infra-setup.sh grants must already be permitted on
# the host. Compare up front, from the script itself so the two cannot
# drift, and name the missing paths instead of failing forty lines into
# an rsync with "sudo: a password is required".
sed -n "/quantus-miner_gitea_ci.tmp/,/^SUDO$/p" deploy/infra-setup.sh \
| grep '^gitea_ci ALL=' | grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > expected-paths.txt
ssh $SSHOPTS gitea_ci@${{ matrix.host }} 'sudo -n -l' \
| grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > permitted-paths.txt
comm -23 expected-paths.txt permitted-paths.txt > missing-paths.txt
if [ -s missing-paths.txt ]; then
echo "the miner sudoers on ${{ matrix.host }} is out of date." >&2
echo "not permitted, but this deploy needs them:" >&2
sed 's/^/ /' missing-paths.txt >&2
echo "" >&2
echo "run: ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub" >&2
exit 1
fi
echo "sudoers covers all $(wc -l < expected-paths.txt) paths this deploy needs"
- name: deploy miner
id: deploy
if: ${{ github.event.inputs.mode != 'validate' }}
env:
GPU_DEVICES: ${{ matrix.gpu_devices }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
nrun() { ssh $SSHOPTS gitea_ci@"${{ matrix.node }}" "$@"; }
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
# -c (checksum), not rsync's default size+mtime quick check: the
# artifact is freshly built every run so mtimes always differ, and the
# default heuristic would report a change on every deploy. Restart is
# gated on an itemised content difference only.
RESTART=0
push() {
local out
out=$(rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic "$@")
if [ -n "$out" ]; then
RESTART=1
printf '%s\n' "$out" | sed 's/^/ changed: /'
fi
}
# 1. service account + dirs
push --mkpath --chmod=F0644 \
deploy/quantus-miner.sysusers.conf \
gitea_ci@"${{ matrix.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 is what makes that self-healing instead of a
# silent auth failure. They pass through the runner in memory,
# never the workspace.
umask 077
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
nrun sudo cat /var/lib/quantus-node/chains/${{ github.event.inputs.chain || env.CHAIN }}/miner-auth-token \
> "$tmp/miner-auth-token"
nrun sudo cat /var/lib/quantus-node/chains/${{ github.event.inputs.chain || env.CHAIN }}/miner-tls-cert-sha256 \
> "$tmp/miner-tls-cert-sha256"
test -s "$tmp/miner-auth-token" || { echo "node auth token empty — has ${{ matrix.node }} started?" >&2; exit 1; }
test -s "$tmp/miner-tls-cert-sha256" || { echo "node TLS pin empty — has ${{ matrix.node }} started?" >&2; exit 1; }
push --chown=root:quantus-miner --chmod=F0640 \
"$tmp/miner-auth-token" gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner-auth-token
push --chown=root:quantus-miner --chmod=F0640 \
"$tmp/miner-tls-cert-sha256" gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner-tls-cert-sha256
# 3. non-secret runtime config.
# --node-addr parses as a Rust SocketAddr: an IP and a port, with NO
# DNS resolution. Resolve on the MINER host — it is the one
# dialling — and keep the 10.x literal out of the repo.
node_addrs=$(run "getent ahostsv4 ${{ matrix.node }}")
node_ip=$(awk '{print $1; exit}' <<<"$node_addrs")
case "$node_ip" in
10.*) echo "node address: ${{ matrix.node }} -> ${node_ip}" ;;
*) echo "refusing to point the miner at non-mesh address '${node_ip}'" >&2; exit 1 ;;
esac
export NODE_ADDR="${node_ip}:${{ env.MINER_LINK_PORT }}"
python3 - <<'PY'
import os, pathlib
t = pathlib.Path("deploy/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@"${{ matrix.host }}":/etc/quantus-miner/miner.env
# 4. binary + unit. Keep the running binary as .prev first so a failed
# validate can put it back (rollback step below).
if run test -x /usr/local/bin/quantus-miner; then
run sudo cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
echo "previous binary kept: $(run /usr/local/bin/quantus-miner.prev --version)"
fi
push --chmod=F0755 _bin/quantus-miner gitea_ci@"${{ matrix.host }}":/usr/local/bin/quantus-miner
push --chmod=F0644 deploy/quantus-miner.service \
gitea_ci@"${{ matrix.host }}":/etc/systemd/system/quantus-miner.service
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
# 5. firewalld for the exporter, scoped to the scrape host. The miner
# binds metrics on 0.0.0.0 unconditionally.
rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic --mkpath --chmod=F0644 \
deploy/quantus-miner-metrics.xml \
gitea_ci@"${{ matrix.host }}":/etc/firewalld/services/quantus-miner-metrics.xml \
| sed 's/^/ changed: /'
run sudo firewall-cmd --reload
zone=$(run sudo firewall-cmd --get-default-zone)
metrics_addrs=$(run "getent ahostsv4 ${{ env.METRICS_HOST }}")
metrics_ip=$(awk '{print $1; exit}' <<<"$metrics_addrs")
case "$metrics_ip" in
10.*) echo "scrape source: ${metrics_ip}" ;;
*) echo "refusing to expose metrics to non-mesh address '${metrics_ip}'" >&2; exit 1 ;;
esac
mrich="rule family=ipv4 source address=${metrics_ip}/32 service name=quantus-miner-metrics accept"
if run "sudo firewall-cmd --zone=$zone --query-rich-rule='$mrich'"; then
echo "firewalld: metrics rich rule already present in ${zone}"
else
run "sudo firewall-cmd --permanent --zone=$zone --add-rich-rule='$mrich'"
run "sudo firewall-cmd --zone=$zone --add-rich-rule='$mrich'"
fi
run sudo systemctl enable quantus-miner.service # idempotent
if [ "$RESTART" = 1 ]; then
echo "changes applied — restarting"
run sudo systemctl daemon-reload
run sudo systemctl restart quantus-miner.service
elif run systemctl is-active --quiet quantus-miner.service; then
echo "nothing changed and the miner is running — left alone"
else
echo "nothing changed but the miner is down — starting it"
run sudo systemctl restart quantus-miner.service
fi
echo "restarted=$RESTART" >> "$GITHUB_OUTPUT"
- name: validate miner
id: validate
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
fail=0
echo "--- unit (${{ matrix.host }} -> ${{ matrix.node }}) ---"
run systemctl is-active quantus-miner.service
echo "--- version ---"
got=$(run /usr/local/bin/quantus-miner --version)
echo "installed: ${got}"
case "$got" in
*"${{ github.sha }}"*) echo "binary is this commit" ;;
*)
if [ "${{ github.event.inputs.mode }}" = validate ]; then
echo "note: installed binary is not this commit (validate mode; not a failure)"
else
echo "installed binary does NOT carry commit ${{ github.sha }}" >&2; fail=1
fi ;;
esac
echo "--- gpu ---"
# An `active` miner that found no adapter still looks healthy to
# systemd; assert the GPU is enumerated and that the miner sees the
# number of devices the matrix says it should.
run "nvidia-smi --query-gpu=name,power.draw,utilization.gpu --format=csv,noheader"
echo "--- hashing ---"
# The counter is the only honest evidence this process is doing work
# rather than idling on a failed connection. After a restart the miner
# exports miner_gpu_devices only once it has connected to the node and
# miner_hashes_total only once it has hashed, so first wait for the
# gauge to appear (readiness), then require the counter to advance.
# Here-strings, not pipes: a pipe whose reader exits early SIGPIPEs the
# writer and pipefail turns that into exit 141.
scrape() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" || true; }
deadline=$((SECONDS + 90))
devs=""
while [ $SECONDS -lt $deadline ]; do
m=$(scrape)
devs=$(awk '/^miner_gpu_devices /{print $2; exit}' <<<"$m")
[ -n "$devs" ] && break
sleep 5
done
if [ -z "$devs" ]; then
echo " miner did not connect to ${{ matrix.node }} within 90s (no miner_gpu_devices exported)" >&2; fail=1
elif [ "${devs%.*}" != "${{ matrix.gpu_devices }}" ]; then
echo " miner_gpu_devices is ${devs}, matrix says ${{ matrix.gpu_devices }}" >&2; fail=1
else
echo " ok connected; miner_gpu_devices ${devs%.*}"
fi
# The build-info gauge is what ties every other metric to a commit
# (quantus/miner#9). In deploy mode it must carry this commit.
if grep -q "^miner_build_info{.*commit=\"${{ github.sha }}\"" <<<"$m"; then
echo " ok miner_build_info carries ${{ github.sha }}"
elif [ "${{ github.event.inputs.mode }}" != validate ]; then
echo " miner_build_info does not carry commit ${{ github.sha }}" >&2; fail=1
fi
# The kernel actually running. A wgpu fallback on a host that should
# run CUDA passes every other check at a fraction of the hashrate.
if grep -q "^miner_device_hashes_total{.*kernel=\"${{ matrix.kernel }}\"" <<<"$m"; then
echo " ok kernel ${{ matrix.kernel }} on device 0"
else
echo " expected kernel ${{ matrix.kernel }}, exported series:" >&2
grep "^miner_device_hashes_total" <<<"$m" >&2 || echo " (none yet)" >&2
if [ "${{ github.event.inputs.mode }}" != validate ]; then fail=1; fi
fi
deadline=$((SECONDS + 120))
h1=""; h2=""; ok=0
while [ $SECONDS -lt $deadline ]; do
m=$(scrape)
h=$(awk '/^miner_hashes_total /{print $2; exit}' <<<"$m")
if [ -n "$h" ]; then
if [ -z "$h1" ]; then
h1="$h"
elif [ "${h%.*}" -gt "${h1%.*}" ]; then
h2="$h"; ok=1; break
fi
fi
sleep 10
done
if [ "$ok" = 1 ]; then
echo " ok miner_hashes_total ${h1%.*} -> ${h2%.*}"
else
echo " miner_hashes_total did not advance within 120s (${h1:-none} -> ${h2:-none})" >&2
fail=1
fi
exit $fail
- name: rollback
# Only when this run replaced the binary and validate then failed.
if: ${{ failure() && steps.deploy.outputs.restarted == '1' }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
if run test -x /usr/local/bin/quantus-miner.prev; then
echo "validate failed after a restart — restoring the previous binary"
# install, not cp: cp writes in place and fails with "Text file busy"
# on a running binary; install unlinks the destination first (the
# same reason rsync's temp-file-and-rename works for the push).
run sudo install -m 0755 /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
run sudo systemctl restart quantus-miner.service
echo "restored: $(run /usr/local/bin/quantus-miner --version)"
else
echo "no previous binary to restore" >&2
fi
- name: journal
if: always()
run: |
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
gitea_ci@${{ matrix.host }} journalctl -u quantus-miner.service -n 60 --no-pager