Files
quantus/.gitea/workflows/deploy.yaml
Rob Thijssen 2cd478646f fix: validate metrics without shell-escaped python or grafana auth
Two bugs, both in validation only -- the deploy half worked and both
scrape targets are up.

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

  SyntaxError: unexpected character after line continuation character

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

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

794 lines
38 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: deploy
# Deploy — or validate — Quantus Planck nodes and their external GPU miners.
#
# Topology (the workflow is the source of infra truth,
# ~/git/architecture/deployment-gitea-actions.md — hosts belong here, and only
# here; see readme.md "Scope"):
#
# quantus-node consensus, P2P, rewards
# ▲ QUIC/9833 (mesh only, rich-rule scoped to that node's miners)
# quantus-miner GPU search
#
# 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:
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"
# Fleet Prometheus/Grafana host. Exporters are unauthenticated, so this is the
# only host allowed to reach them.
METRICS_HOST: golgafrinchans.kosherinata.internal
# Host-published port of the fleet Prometheus (port-allocations.md §5).
PROM_PORT: "26559"
GRAFANA_PORT: "28767"
# Scrape targets, rendered into the Prometheus scrape config. These must agree
# with the deploy-node / deploy-miner matrices below; `validate metrics`
# asserts Prometheus actually has every target UP, so drift fails loudly rather
# than silently monitoring nothing.
SCRAPE_NODES: bob.hanzalova.internal
SCRAPE_MINERS: benjy.hanzalova.internal
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
strategy:
fail-fast: false # one site's failure must not abort the other
matrix:
include:
# ONE inner_hash PER HOST — never share one across nodes.
#
# The reward preimage is published verbatim in the PreRuntime digest
# of every block a node authors, and the payout address is
# Poseidon2(inner_hash). It is therefore public, and it is STATIC —
# derived per wallet, not per block. Two nodes sharing an inner_hash
# are publicly and permanently identifiable as the same operator,
# which silently collapses the independence that running nodes at
# separate sites is meant to provide. See doc/wormhole-rewards.md §5.
#
# Not a secret, so it lives here with the rest of the infra truth.
# Derive one per host, offline, from a DEDICATED mining wallet
# (doc/wormhole-rewards.md §6):
# quantus-node key quantus --scheme wormhole --words < mnemonic.txt
- host: bob.hanzalova.internal
inner_hash: "0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59"
miners: benjy.hanzalova.internal # space-separated if more than one
# Complete flag, or "" for a node with no public ingress. Requires a
# matching inbound TCP forward at the site edge — the flag only
# advertises, it does not open anything.
#
# /dns4/ rather than a literal address: the site's WAN IP can change
# without a redeploy, and this is the per-site indirection name the
# fleet already uses (architecture public-dns.md).
public_addr: "--public-addr /dns4/nh.thgttg.com/tcp/30333"
# Second site, for decentralisation. Uncomment when provisioned, and
# give it its OWN inner_hash derived from its OWN wallet — sharing
# bob's would publicly tie the two sites to one operator and defeat
# the point of running them separately (doc/wormhole-rewards.md §5).
#
# NOTE: a node with no miners authors nothing, because
# --miner-listen-port disables built-in mining. A node intended purely
# to relay and validate needs a unit WITHOUT
# --validator/--miner-listen-port, not this one — and then it needs no
# inner_hash at all.
# - host: <second-node>.internal
# inner_hash: "0x..."
# miners: ""
# public_addr: ""
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@${{ matrix.host }} hostname -f
- name: render node config
if: ${{ github.event.inputs.mode != 'validate' }}
env:
QUANTUS_INNER_HASH: ${{ matrix.inner_hash }}
QUANTUS_PUBLIC_ADDR: ${{ matrix.public_addr }}
run: |
set -euo pipefail
case "$QUANTUS_INNER_HASH" in
0x0000000000000000000000000000000000000000000000000000000000000000)
echo "${{ matrix.host }}: inner_hash is still the placeholder." >&2
echo "Set a real one for THIS HOST in the deploy-node matrix." >&2
echo "Derive it from a dedicated mining wallet, offline:" >&2
echo " quantus-node key quantus --scheme wormhole --words < mnemonic.txt" >&2
echo "See doc/wormhole-rewards.md §5-§6." >&2
exit 1 ;;
esac
case "$QUANTUS_INNER_HASH" in
0x*) ;;
*) echo "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"])
out = out.replace("{{QUANTUS_PUBLIC_ADDR}}", os.environ.get("QUANTUS_PUBLIC_ADDR", ""))
pathlib.Path("node.env").write_text(out)
PY
- name: deploy node
if: ${{ github.event.inputs.mode != 'validate' }}
env:
HOST: ${{ matrix.host }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"$HOST" "$@"; }
# -c (checksum), not rsync's default size+mtime quick check: `fetch`
# re-downloads the binary every run, so its mtime is always new and the
# default heuristic reports a change on every deploy. -c makes "changed"
# mean "the content differs", which is what RESTART must key off.
# -i itemizes, so an empty result is proof of no change.
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
}
# firewalld definitions are picked up by --reload below and are never a
# reason to bounce the daemon.
pushfw() {
rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic "$@" | sed 's/^/ changed: /'
}
# 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
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 \
gitea_ci@"$HOST":/etc/firewalld/services/quantus-node-miner.xml
pushfw --mkpath --chmod=F0644 asset/firewalld/quantus-node-metrics.xml \
gitea_ci@"$HOST":/etc/firewalld/services/quantus-node-metrics.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 this node's miner hosts, 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 each miner's mesh address ON
# THE NODE — this repo carries no 10.x literals, and the node's own
# resolution is the address the miner will actually present.
for m in ${{ matrix.miners }}; do
miner_addrs=$(run "getent ahostsv4 $m")
miner_ip=$(awk '{print $1; exit}' <<<"$miner_addrs")
case "$miner_ip" in
10.*) echo "miner source: ${m} -> ${miner_ip}" ;;
*) echo "refusing to open ${{ env.MINER_LINK_PORT }}/udp to non-mesh address '${miner_ip}' for ${m}" >&2; exit 1 ;;
esac
rich="rule family=ipv4 source address=${miner_ip}/32 service name=quantus-node-miner accept"
# Pass these as ONE pre-quoted string, not as separate run() args.
# `run()` is `ssh ... "$@"`, and ssh concatenates its argument vector
# with spaces for the REMOTE shell to re-split — so local quoting is
# lost and a rich rule arrives as a dozen bare words
# ("unrecognized arguments: family=ipv4 source address=..."). Every
# other command here survives only because no other argument
# contains a space. Keep the inner single quotes.
if run "sudo firewall-cmd --zone=$zone --query-rich-rule='$rich'"; then
echo "firewalld: rich rule for ${m} 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
done
# 5c. Metrics: scoped to the scrape host only. The exporter is
# unauthenticated and --prometheus-external binds every interface,
# so this rule is the whole access boundary.
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-node-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
# 6. (re)start — ONLY if something actually changed. Bouncing a
# syncing node costs it every peer and a RocksDB reopen, and this
# workflow also runs to validate an unchanged deployment.
run sudo systemctl enable quantus-node.service # idempotent, no service impact
if [ "$RESTART" = 1 ]; then
echo "changes applied — restarting"
run sudo systemctl daemon-reload
run sudo systemctl restart quantus-node.service
elif run systemctl is-active --quiet quantus-node.service; then
echo "nothing changed and the node is running — left alone"
else
echo "nothing changed but the node is down — starting it"
run sudo systemctl restart quantus-node.service
fi
- name: validate node
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 }}) ---"
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 "--- reward address ---"
# The node logs the address it derived from this host's inner_hash.
# Surfacing it makes a copy-pasted or shared inner_hash visible.
run journalctl -u quantus-node.service -n 500 --no-pager \
| grep -F "Rewards wormhole address" | tail -1 || {
echo " WARN no reward address in the recent journal" >&2; }
echo "--- readiness ---"
# A restarted node opens its RocksDB and initialises litep2p before it
# binds anything; asserting immediately after `systemctl restart` reads
# a node that is merely still starting as one that is broken. Wait for
# the P2P socket, bounded, rather than sleeping a guessed constant.
ready=0
for i in $(seq 1 60); do
if p=$(run "ss -Hln -t sport = :${{ env.P2P_PORT }}") && [ -n "$p" ]; then
echo " ready after ~$((i*2))s"; ready=1; break
fi
sleep 2
done
if [ "$ready" = 0 ]; then
echo " node did not open tcp/${{ env.P2P_PORT }} within 120s" >&2
fail=1
fi
echo "--- listeners ---"
# P2P must be listening; the miner link must be listening or the node
# is authoring nothing (--miner-listen-port disables local mining).
# `-t`/`-u` matter: without a protocol flag ss errors with
# "RTNETLINK answers: Invalid argument" and reports nothing useful.
# Capture, then test — piping into `grep -q` makes grep exit early,
# SIGPIPEs ssh, and `set -o pipefail` turns that into a 141 that looks
# exactly like "not listening".
for spec in "${{ env.P2P_PORT }} tcp -t" "${{ env.MINER_LINK_PORT }} udp -u"; do
set -- $spec
if listeners=$(run "ss -Hln $3 sport = :$1"); then
if [ -n "$listeners" ]; then
echo " ok $2/$1 listening"
else
echo " FAIL $2/$1 not listening" >&2; fail=1
fi
else
echo " FAIL could not query $2/$1 on the host" >&2; fail=1
fi
done
echo "--- advertised address ---"
if [ -n "${{ matrix.public_addr }}" ]; then
echo " advertising: ${{ matrix.public_addr }}"
else
echo " none set — this node accepts no inbound peers and is a" >&2
echo " leech on the network rather than a contribution to it." >&2
fi
echo "--- sync ---"
# Here-strings, not pipes: `awk ... exit` closes the pipe early, the
# writer takes SIGPIPE, and pipefail turns a successful parse into 141.
# The metrics body is large enough for that race to fire reliably.
m=$(run "curl -fsS http://127.0.0.1:${{ env.NODE_METRICS_PORT }}/metrics")
peers=$(awk '/^substrate_sub_libp2p_peers_count/{print $2; exit}' <<<"$m")
best=$(awk '/^substrate_block_height\{status="best"/{print $2; exit}' <<<"$m")
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@${{ matrix.host }} journalctl -u quantus-node.service -n 80 --no-pager
deploy-miner:
runs-on: infra
needs: [fetch, deploy-node]
strategy:
fail-fast: false
matrix:
include:
# `node` is the host whose QUIC control channel this miner attaches
# to, and whose inner_hash therefore receives what it earns. A miner
# holds no reward configuration of its own — the mining protocol
# carries no payout address at all.
- host: benjy.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1× RTX 4090, measured 183 MH/s @ ~450 W
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@${{ matrix.host }} hostname -f
- name: deploy miner
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 }}" "$@"; }
# See the node job for why -c rather than rsync's default quick check.
# A rotated auth token or TLS pin shows up here as a content change and
# correctly forces a restart.
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 \
asset/systemd/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 — 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, never 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 ${{ 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 ("invalid socket address syntax" on a hostname).
# Resolve on the MINER host — it is the one dialling, so its own view
# of the node's address is the correct one — and keep the 10.x
# literal out of the repo, same as the firewall rich rule.
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("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@"${{ matrix.host }}":/etc/quantus-miner/miner.env
# 4. binary + unit
push --chmod=F0755 _bin/quantus-miner gitea_ci@"${{ matrix.host }}":/usr/local/bin/quantus-miner
push --chmod=F0644 asset/systemd/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. The miner binds metrics on 0.0.0.0
# unconditionally — there is no loopback option — so without this
# rule the port is simply closed, and with an unscoped one it would
# be open to the whole mesh.
rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic --mkpath --chmod=F0644 \
asset/firewalld/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
- name: validate miner
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
*"${{ 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 counter is the only honest evidence this process is doing work
# rather than idling on a failed connection.
# Here-strings, not pipes — see the node's sync check for why.
m1=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics")
h1=$(awk '/^miner_hashes_total/{print $2; exit}' <<<"$m1")
sleep 20
m2=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics")
h2=$(awk '/^miner_hashes_total/{print $2; exit}' <<<"$m2")
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 — ${{ matrix.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@${{ matrix.host }} journalctl -u quantus-miner.service -n 80 --no-pager
deploy-metrics:
runs-on: infra
needs: [deploy-node, deploy-miner]
# Monitoring assets only. Deliberately last and deliberately separate: a
# Grafana dashboard failing to provision must never be able to leave a
# half-deployed validator behind it.
steps:
- uses: actions/checkout@v4
- 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.METRICS_HOST }} hostname -f
- name: deploy scrape config and dashboard
if: ${{ github.event.inputs.mode != 'validate' }}
env:
HOST: ${{ env.METRICS_HOST }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"$HOST" "$@"; }
RELOAD=0
push() {
local out
out=$(rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic "$@")
if [ -n "$out" ]; then RELOAD=1; printf '%s\n' "$out" | sed 's/^/ changed: /'; fi
}
# Render the scrape targets. Quoted, comma-separated, from the env
# lists above — a space-separated host list becomes a YAML array.
fmt() { local o=""; for h in $1; do o="${o:+$o, }\"${h}:${2}\""; done; printf '%s' "$o"; }
NODE_TARGETS=$(fmt "${{ env.SCRAPE_NODES }}" "${{ env.NODE_METRICS_PORT }}")
MINER_TARGETS=$(fmt "${{ env.SCRAPE_MINERS }}" "${{ env.MINER_METRICS_PORT }}")
echo "node targets: ${NODE_TARGETS}"
echo "miner targets: ${MINER_TARGETS}"
NODE_TARGETS="$NODE_TARGETS" MINER_TARGETS="$MINER_TARGETS" python3 - <<'PY'
import os, pathlib
t = pathlib.Path("asset/prometheus/quantus.yml.tmpl").read_text()
t = t.replace("{{NODE_TARGETS}}", os.environ["NODE_TARGETS"])
t = t.replace("{{MINER_TARGETS}}", os.environ["MINER_TARGETS"])
pathlib.Path("quantus-scrape.yml").write_text(t)
PY
python3 -c "import yaml,sys; yaml.safe_load(open('quantus-scrape.yml'))" \
|| { echo "rendered scrape config is not valid YAML" >&2; exit 1; }
push --mkpath --chmod=F0644 quantus-scrape.yml \
gitea_ci@"$HOST":/etc/prometheus/scrape_configs.d/quantus.yml
push --mkpath --chmod=F0644 asset/grafana/quantus.json \
gitea_ci@"$HOST":/etc/grafana/provisioning/dashboards/quantus/quantus.json
# The PROVIDER tells Grafana the dashboards directory exists at all;
# without it the JSON above is just a file nobody reads. Tracked
# separately from the dashboard because Grafana reads providers ONLY at
# startup, while dashboards are re-read on updateIntervalSeconds — so a
# provider change needs a restart and a dashboard change does not.
PROVIDER=0
pout=$(rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic --mkpath --chmod=F0644 \
asset/grafana/quantus-dashboards.yaml \
gitea_ci@"$HOST":/etc/grafana/provisioning/dashboards/quantus-dashboards.yaml)
if [ -n "$pout" ]; then
PROVIDER=1
printf '%s\n' "$pout" | sed 's/^/ changed: /'
fi
if [ "$PROVIDER" = 1 ]; then
echo "dashboard provider changed — restarting grafana (providers are read at startup only)"
run sudo systemctl restart grafana.service
fi
if [ "$RELOAD" = 1 ]; then
# Hot reload rather than a restart: Prometheus restarts drop nothing
# permanent but do interrupt scraping, and --web.enable-lifecycle is
# already set for exactly this.
echo "config changed — reloading prometheus"
run "curl -fsS -X POST http://127.0.0.1:${{ env.PROM_PORT }}/-/reload"
echo "grafana picks the dashboard up on its own provisioning interval"
else
echo "nothing changed — no reload"
fi
- name: validate metrics
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ env.METRICS_HOST }}" "$@"; }
fail=0
echo "--- prometheus targets ---"
# The point of this check: a target that drifted out of the matrices,
# or a firewalld rule that never landed, shows up here as missing or
# DOWN instead of an empty graph nobody looks at.
#
# Written as a quoted heredoc reading a file, NOT `python3 -c '...'`:
# escaping quotes through the shell into an f-string produced
# "SyntaxError: unexpected character after line continuation character",
# because Python reads \" inside the expression as a continuation.
for i in $(seq 1 20); do
run "curl -fsS 'http://127.0.0.1:${{ env.PROM_PORT }}/api/v1/targets?state=active'" > targets.json
if grep -q 'quantus-' targets.json; then break; fi
sleep 3
done
if python3 - <<'PY'
import json, sys
d = json.load(open("targets.json"))
rows = [t for t in d["data"]["activeTargets"]
if t["labels"].get("job", "").startswith("quantus-")]
if not rows:
print(" FAIL prometheus has no quantus-* targets", file=sys.stderr)
print(" is scrape_config_files wired into prometheus.yml?", file=sys.stderr)
print(" one-time: script/infra-setup.sh --metrics-hosts", file=sys.stderr)
sys.exit(1)
bad = 0
for t in sorted(rows, key=lambda x: (x["labels"].get("job", ""),
x["labels"].get("instance", ""))):
j = t["labels"].get("job")
i = t["labels"].get("instance")
h = t["health"]
e = t.get("lastError", "")
print((" ok " if h == "up" else " FAIL ") + " ".join(filter(None, [j, i, h, e])))
if h != "up":
bad += 1
sys.exit(1 if bad else 0)
PY
then
echo " all quantus targets up"
else
echo " FAIL not every quantus target is up" >&2; fail=1
fi
echo "--- grafana dashboard ---"
# Grafana's /api/search requires auth (401 unauthenticated), and this
# job holds no Grafana credentials by design — so assert provisioning
# from the journal instead, which gitea_ci can read via systemd-journal.
run "curl -fsS http://127.0.0.1:${{ env.GRAFANA_PORT }}/api/health" > /dev/null \
&& echo " ok grafana is healthy" \
|| { echo " FAIL grafana health endpoint not responding" >&2; fail=1; }
if run "test -s /etc/grafana/provisioning/dashboards/quantus/quantus.json" \
&& run "test -s /etc/grafana/provisioning/dashboards/quantus-dashboards.yaml"; then
echo " ok dashboard and provider present on the host"
else
echo " FAIL dashboard JSON or provider missing" >&2; fail=1
fi
# A dashboard that fails to parse is logged by the provisioner and then
# silently absent from the UI, so treat any provisioning error as fatal.
if run "journalctl -u grafana.service --since '-10min' --no-pager" \
| grep -E 'logger=provisioning\.dashboard.*level=error'; then
echo " FAIL grafana reported a dashboard provisioning error" >&2; fail=1
else
echo " ok no dashboard provisioning errors in the last 10 minutes"
fi
exit $fail