Files
quantus/.gitea/workflows/deploy.yaml
Rob Thijssen 71bbc2e826
All checks were successful
deploy / fetch (push) Successful in 17s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333, --unsafe-rpc-external --rpc-methods safe --rpc-c… (push) Successful in 31s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-metrics (push) Successful in 9s
fix: --rpc-external is refused on a validator
The node crash-looped 7 times and was down ~100s, not mining:

  Error: --rpc-external option shouldn't be used if the node is running
  as a validator. Use `--unsafe-rpc-external` or `--rpc-methods=unsafe`
  if you understand the risks.

Substrate rejects --rpc-external on a validator outright and exits,
whatever --rpc-methods says. --unsafe-rpc-external binds identically
('Same as --rpc-external' per --help) and differs only in demanding an
explicit acknowledgement. The safety still comes from --rpc-methods
safe, which is unchanged.

Restored on the host immediately; this makes CI converge on the same
thing rather than reverting it.

Also: validate opened with a bare , which exits 3
the moment a unit is 'activating' and under set -e aborted the step
before printing anything. A crash-looping unit sits in
activating/auto-restart forever, so a validator refusing its own flags
surfaced as an opaque 'exit code 3'. It now waits for a terminal state
and, on failure, reports NRestarts and the actual error lines from the
journal.
2026-09-01 04:50:01 +03:00

1021 lines
51 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:
# The chain spec. Switching to mainnet is meant to be THIS line plus the
# version pins — the unit takes it from config, and the miner credential path
# (<base-path>/chains/<chain>/) already derives from it. Planck is a testnet;
# mainnet is expected 2026-09-09.
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
# Arena exporter: network hashrate, difficulty, authorship leaderboard.
# Derived from the service name per port-allocations.md §3.
ARENA_PORT: "25033"
# 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 quadbrat.hanzalova.internal
# The arena exporter runs beside each node, reading its loopback RPC.
SCRAPE_ARENA: bob.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"
# One node fans the same job out to every connected miner
# (node/src/miner_server.rs: broadcast_job over a HashMap of
# MinerHandle), and each miner picks its own random starting nonce,
# so no coordination or range allocation is needed. A node per miner
# would only be needed for a separate reward address.
miners: benjy.hanzalova.internal quadbrat.hanzalova.internal
# 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"
# JSON-RPC for other machines on the site LAN. Empty = loopback
# only. `safe` is not optional here: it is what keeps
# author_rotateKeys and friends off an exposed port. Loopback is
# rate-limit-whitelisted so the arena exporter beside the node is not
# throttled by its own polling.
# --unsafe-rpc-external, NOT --rpc-external: Substrate REFUSES
# --rpc-external on a validator outright and exits, whatever
# --rpc-methods says. The two flags bind identically ("Same as
# `--rpc-external`" per --help); the unsafe- name is purely a
# forced acknowledgement. What actually keeps this safe is
# --rpc-methods safe, which stays.
rpc_expose: "--unsafe-rpc-external --rpc-methods safe --rpc-cors all --rpc-rate-limit 300 --rpc-rate-limit-whitelisted-ips 127.0.0.1/32"
# 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: ""
# rpc_expose: ""
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: preflight — sudoers covers this deploy
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
# Every path infra-setup.sh grants for this role must already be
# permitted on the host. Adding an asset here without re-running
# infra-setup produces a bare "sudo: a password is required" forty
# lines into the deploy, after some files have already landed. Compare
# up front instead, from infra-setup.sh itself so the two cannot drift.
sed -n "/quantus-node_gitea_ci.tmp/,/^SUDO$/p" script/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 node 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: ./script/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: render node config
if: ${{ github.event.inputs.mode != 'validate' }}
env:
QUANTUS_INNER_HASH: ${{ matrix.inner_hash }}
QUANTUS_PUBLIC_ADDR: ${{ matrix.public_addr }}
QUANTUS_CHAIN: ${{ env.CHAIN }}
QUANTUS_RPC_EXPOSE: ${{ matrix.rpc_expose }}
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", ""))
out = out.replace("{{QUANTUS_CHAIN}}", os.environ["QUANTUS_CHAIN"])
out = out.replace("{{QUANTUS_RPC_EXPOSE}}", os.environ.get("QUANTUS_RPC_EXPOSE", ""))
pathlib.Path("node.env").write_text(out)
a = pathlib.Path("asset/config/arena.env.tmpl").read_text()
a = a.replace("{{QUANTUS_INNER_HASH}}", os.environ["QUANTUS_INNER_HASH"])
pathlib.Path("arena.env.partial").write_text(a)
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
pushfw --mkpath --chmod=F0644 asset/firewalld/quantus-node-rpc.xml \
gitea_ci@"$HOST":/etc/firewalld/services/quantus-node-rpc.xml
# 3b. Arena exporter — network-wide stats from the node's loopback RPC.
push --mkpath --chmod=F0644 asset/systemd/quantus-arena.sysusers.conf \
gitea_ci@"$HOST":/etc/sysusers.d/quantus-arena.conf
run sudo systemd-sysusers
run sudo install -d -o root -g quantus-arena -m 0750 /etc/quantus-arena
run sudo install -d -o quantus-arena -g quantus-arena -m 0750 /var/lib/quantus-arena
push --chmod=F0755 asset/arena/quantus-arena-exporter.py \
gitea_ci@"$HOST":/usr/local/bin/quantus-arena-exporter.py
push --chmod=F0644 asset/systemd/quantus-arena.service \
gitea_ci@"$HOST":/etc/systemd/system/quantus-arena.service
# The reward address is Poseidon2(inner_hash); rather than reimplement
# that, take it from the node's own startup log, which prints it. Empty
# is tolerated — on a genuinely first deploy the node has not logged it
# yet, the exporter omits balance metrics, and the next deploy fills it.
run "journalctl -u quantus-node.service --no-pager" > node-journal.txt
if grep -qF "Rewards wormhole address" node-journal.txt; then
reward_addr=$(grep -F "Rewards wormhole address" node-journal.txt \
| tail -1 | sed 's/.*address: //' | tr -d '[:space:]')
echo "reward address: ${reward_addr}"
else
reward_addr=""
echo "WARN no reward address in the node journal yet — balance metrics will be omitted" >&2
fi
REWARD_ADDR="$reward_addr" python3 - <<'PY'
import os, pathlib
a = pathlib.Path("arena.env.partial").read_text()
a = a.replace("{{QUANTUS_REWARD_ADDRESS}}", os.environ["REWARD_ADDR"])
pathlib.Path("arena.env").write_text(a)
PY
push --chown=root:quantus-arena --chmod=F0640 arena.env \
gitea_ci@"$HOST":/etc/quantus-arena/arena.env
pushfw --mkpath --chmod=F0644 asset/firewalld/quantus-arena.xml \
gitea_ci@"$HOST":/etc/firewalld/services/quantus-arena.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
run sudo restorecon -R /usr/local/bin/quantus-arena-exporter.py /etc/quantus-arena /var/lib/quantus-arena
# 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
for svc in quantus-node-metrics quantus-arena; do
mrich="rule family=ipv4 source address=${metrics_ip}/32 service name=${svc} accept"
if run "sudo firewall-cmd --zone=$zone --query-rich-rule='$mrich'"; then
echo "firewalld: ${svc} 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
done
# 5d. JSON-RPC, if exposed. Scoped to the node's own site subnet,
# derived from its own address so this repo carries no 10.x
# literal and the rule follows the host to whatever site it is in.
if [ -n "${{ matrix.rpc_expose }}" ]; then
self_addrs=$(run "getent ahostsv4 $HOST")
self_ip=$(awk '{print $1; exit}' <<<"$self_addrs")
case "$self_ip" in
10.*) ;;
*) echo "refusing to derive an RPC subnet from non-mesh address '${self_ip}'" >&2; exit 1 ;;
esac
subnet="$(cut -d. -f1,2 <<<"$self_ip").0.0/16"
echo "rpc allowed from: ${subnet}"
rrich="rule family=ipv4 source address=${subnet} service name=quantus-node-rpc accept"
if run "sudo firewall-cmd --zone=$zone --query-rich-rule='$rrich'"; then
echo "firewalld: rpc rich rule already present in ${zone}"
else
run "sudo firewall-cmd --permanent --zone=$zone --add-rich-rule='$rrich'"
run "sudo firewall-cmd --zone=$zone --add-rich-rule='$rrich'"
fi
else
echo "rpc: loopback only (rpc_expose empty)"
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
run sudo systemctl enable quantus-arena.service # idempotent
if [ "$RESTART" = 1 ]; then
echo "changes applied — restarting"
run sudo systemctl daemon-reload
run sudo systemctl restart quantus-node.service
run sudo systemctl restart quantus-arena.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
run systemctl is-active --quiet quantus-arena.service \
|| { echo "arena exporter down — starting it"; run sudo systemctl restart quantus-arena.service; }
- 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 }}) ---"
# NOT a bare `systemctl is-active`: that exits 3 the instant the unit
# is "activating", which under set -e aborts the whole step before any
# diagnostic runs. A crash-looping unit sits in activating/auto-restart
# indefinitely, so this waits, then reports WHY rather than an exit
# code — that is how a validator refusing its own flags looked like a
# bare "exit code 3".
state=""
for i in $(seq 1 30); do
state=$(run "systemctl show quantus-node.service -p ActiveState --value")
[ "$state" = "active" ] && break
[ "$state" = "failed" ] && break
sleep 4
done
if [ "$state" = "active" ]; then
echo " active"
else
restarts=$(run "systemctl show quantus-node.service -p NRestarts --value")
echo " FAIL unit is '${state}' after 120s (NRestarts=${restarts}) — crash-looping" >&2
jr=$(run "journalctl -u quantus-node.service --no-pager -n 40")
printf '%s\n' "$jr" | grep -iE "error|panic|refus" | tail -5 | sed 's/^/ /' >&2
exit 1
fi
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 "--- json-rpc ---"
if [ -n "${{ matrix.rpc_expose }}" ]; then
if rpcl=$(run "ss -Hln -t sport = :9944") && [ -n "$rpcl" ]; then
echo " ok rpc listening"
printf '%s\n' "$rpcl" | sed 's/^/ /'
else
echo " FAIL rpc_expose is set but nothing is listening on 9944" >&2; fail=1
fi
else
echo " loopback only by configuration"
fi
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
- host: quadbrat.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1× RTX 3060, hashrate not yet measured
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: preflight — sudoers covers this deploy
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
# Every path infra-setup.sh grants for this role must already be
# permitted on the host. Adding an asset here without re-running
# infra-setup produces a bare "sudo: a password is required" forty
# lines into the deploy, after some files have already landed. Compare
# up front instead, from infra-setup.sh itself so the two cannot drift.
sed -n "/quantus-miner_gitea_ci.tmp/,/^SUDO$/p" script/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: ./script/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
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: preflight — sudoers covers this deploy
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
# Every path infra-setup.sh grants for this role must already be
# permitted on the host. Adding an asset here without re-running
# infra-setup produces a bare "sudo: a password is required" forty
# lines into the deploy, after some files have already landed. Compare
# up front instead, from infra-setup.sh itself so the two cannot drift.
sed -n "/quantus-metrics_gitea_ci.tmp/,/^SUDO$/p" script/infra-setup.sh \
| grep '^gitea_ci ALL=' | grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > expected-paths.txt
ssh $SSHOPTS gitea_ci@${{ env.METRICS_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 metrics sudoers on ${{ env.METRICS_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: ./script/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 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 }}")
ARENA_TARGETS=$(fmt "${{ env.SCRAPE_ARENA }}" "${{ env.ARENA_PORT }}")
echo "node targets: ${NODE_TARGETS}"
echo "miner targets: ${MINER_TARGETS}"
echo "arena targets: ${ARENA_TARGETS}"
NODE_TARGETS="$NODE_TARGETS" MINER_TARGETS="$MINER_TARGETS" ARENA_TARGETS="$ARENA_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"])
t = t.replace("{{ARENA_TARGETS}}", os.environ["ARENA_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.
# A target Prometheus has only just learned about reports health
# "unknown" until its FIRST scrape completes. With a 15s scrape
# interval and this step running seconds after the config reload, any
# run that adds a target would otherwise fail on a target that is
# perfectly healthy. Wait for the unknowns to resolve, bounded; a
# genuinely dead target goes to "down", not "unknown", so this does
# not paper over real failures.
cat > count_unknown.py <<'PY'
import json
d = json.load(open("targets.json"))
rows = [t for t in d["data"]["activeTargets"]
if t["labels"].get("job", "").startswith("quantus-")]
print(sum(1 for t in rows if t["health"] == "unknown") if rows else 1)
PY
for i in $(seq 1 24); do
run "curl -fsS 'http://127.0.0.1:${{ env.PROM_PORT }}/api/v1/targets?state=active'" > targets.json
pending=$(python3 count_unknown.py)
if [ "$pending" = "0" ]; then
[ "$i" -gt 1 ] && echo " settled after ~$((i*5))s"
break
fi
[ "$i" = "24" ] && echo " ${pending} target(s) still unscraped after 120s" >&2
sleep 5
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