deploy: build on push to main and land the binary on the mining hosts
quantus/miner#8. The miner is now deployed from this repo, not from lair/quantus's release-pinned workflow (lair/quantus#4 retires that half). - .gitea/workflows/deploy.yaml: build on cuda-13.0 (Fedora 43 like the hosts, and the runner with nvcc for #3) with the commit embedded, then per host in the matrix: sudoers preflight against deploy/infra-setup.sh, credentials copied from the node, miner.env rendered with the node's mesh IP resolved on the miner host, checksum-gated push of binary and unit, scoped firewalld rule for the exporter, restart only on change. Validate asserts the unit is active, --version carries this commit, the miner sees the matrix's device count, and miner_hashes_total advances within 120 s. If validate fails after a restart, the previous binary is restored. - deploy/: the unit, sysusers, firewalld service and env template moved verbatim from lair/quantus asset/, plus infra-setup.sh with the miner role (and two new cp grants for the rollback). Applied to benjy and quadbrat. - Matrix: benjy and quadbrat; beast present but commented out per the operating policy in #1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
---
|
||||
# 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
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "deploy (apply) or validate (check only, no changes)"
|
||||
required: false
|
||||
default: deploy
|
||||
type: choice
|
||||
options: [deploy, validate]
|
||||
|
||||
# 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 quantus/chain's deploy. Planck is a testnet; mainnet is
|
||||
# expected 2026-09-09.
|
||||
CHAIN: planck
|
||||
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.
|
||||
#
|
||||
# Policy until the mainnet call (quantus/miner#1): benjy and quadbrat
|
||||
# mine testnet continuously; beast (2x RTX 5090) serves inference and
|
||||
# does not mine. Enabling it is uncommenting the entry.
|
||||
- host: benjy.hanzalova.internal
|
||||
node: bob.hanzalova.internal
|
||||
gpu_devices: "1" # 1x RTX 4090 (sm_89); reference card for #2
|
||||
- host: quadbrat.hanzalova.internal
|
||||
node: bob.hanzalova.internal
|
||||
gpu_devices: "1" # 1x RTX 3060 (sm_86)
|
||||
# - host: beast.hanzalova.internal
|
||||
# node: bob.hanzalova.internal
|
||||
# gpu_devices: "2" # 2x RTX 5090 (sm_120)
|
||||
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/${{ 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. 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
|
||||
# must reconnect and receive a job, so poll rather than sample twice.
|
||||
# Here-strings, not pipes: a pipe whose reader exits early SIGPIPEs the
|
||||
# writer and pipefail turns that into exit 141.
|
||||
deadline=$((SECONDS + 120))
|
||||
m=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics")
|
||||
h1=$(awk '/^miner_hashes_total/{print $2; exit}' <<<"$m")
|
||||
devs=$(awk '/^miner_gpu_devices/{print $2; exit}' <<<"$m")
|
||||
if [ "${devs%.*}" != "${{ matrix.gpu_devices }}" ]; then
|
||||
echo "miner_gpu_devices is ${devs:-?}, matrix says ${{ matrix.gpu_devices }}" >&2; fail=1
|
||||
else
|
||||
echo " ok miner_gpu_devices ${devs%.*}"
|
||||
fi
|
||||
ok=0
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
sleep 10
|
||||
m=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics")
|
||||
h2=$(awk '/^miner_hashes_total/{print $2; exit}' <<<"$m")
|
||||
if [ -n "${h1:-}" ] && [ -n "${h2:-}" ] && [ "${h2%.*}" -gt "${h1%.*}" ]; then
|
||||
echo " ok miner_hashes_total ${h1%.*} -> ${h2%.*}"
|
||||
ok=1; break
|
||||
fi
|
||||
done
|
||||
if [ "$ok" != 1 ]; then
|
||||
echo " miner_hashes_total did not advance within 120s (${h1:-?} -> ${h2:-?})" >&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"
|
||||
run sudo cp -p /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
|
||||
@@ -0,0 +1,42 @@
|
||||
# deploy
|
||||
|
||||
Everything the CI deploy of the miner ships or needs (`.gitea/workflows/deploy.yaml`).
|
||||
Moved from `lair/quantus` on 2026-09-03 so that one repo owns the deployed miner;
|
||||
the fleet's monitoring, GPU power limits and nvidia metrics stay there.
|
||||
|
||||
| File | Lands at |
|
||||
| --- | --- |
|
||||
| `quantus-miner.service` | `/etc/systemd/system/quantus-miner.service` |
|
||||
| `quantus-miner.sysusers.conf` | `/etc/sysusers.d/quantus-miner.conf` |
|
||||
| `quantus-miner-metrics.xml` | `/etc/firewalld/services/quantus-miner-metrics.xml` |
|
||||
| `miner.env.tmpl` | rendered to `/etc/quantus-miner/miner.env` |
|
||||
| `infra-setup.sh` | operator-run once per host; installs the `gitea_ci` sudoers |
|
||||
|
||||
The miner's credentials are generated by the node and copied host-to-host by the
|
||||
deploy on every run. Nothing secret lives here.
|
||||
|
||||
## Traps, all previously hit
|
||||
|
||||
- **Adding a file or privileged command means re-running `infra-setup.sh`.** The
|
||||
deploy preflights the host's `sudo -n -l` against the script's SUDO block and
|
||||
names the missing paths, instead of failing partway with `sudo: a password is required`.
|
||||
- **Deploys must be no-ops when nothing changed.** `push()` uses `rsync -ic`
|
||||
(checksum). The artifact is rebuilt every run so mtimes always differ. Restart
|
||||
only on an itemised content change.
|
||||
- **`--node-addr` takes an IP, not a name.** It parses as a Rust `SocketAddr`
|
||||
with no DNS. The deploy resolves the node on the miner host and renders the
|
||||
literal; the `10.x` never enters the repo.
|
||||
- **Pipes in validate steps.** The shell is `bash -e -o pipefail`; a reader that
|
||||
exits early (`awk ... exit`, `grep -q`, `head`) SIGPIPEs the writer, exit 141.
|
||||
Capture into a variable and parse with a here-string.
|
||||
- **ssh argument quoting.** `run()` is `ssh ... "$@"` and the remote shell
|
||||
re-splits; any argument with a space is passed as one pre-quoted string.
|
||||
- **`PrivateDevices=false`** in the unit is deliberate: the miner needs `/dev/nvidia*`.
|
||||
- **`systemctl is-active` is not evidence of mining.** A miner that found no
|
||||
adapter, or never got a job, is `active`. Validate asserts `miner_hashes_total`
|
||||
advances and `miner_gpu_devices` matches the matrix.
|
||||
- **Binaries must be built on `cuda-13.0`** (Fedora 43, like the hosts). The
|
||||
`rust` runner is Fedora 44; its binaries fail with `GLIBC_2.43 not found`.
|
||||
- **A benchmark (`bench.yaml`) stops the miner on its host.** A deploy landing
|
||||
on the same host during a measurement would find the unit down and start it
|
||||
mid-window. Both are serialised only by not running them together.
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time provisioning of a mining host for the CI deploy in
|
||||
# .gitea/workflows/deploy.yaml (quantus/miner#8). The miner role of
|
||||
# lair/quantus's script/infra-setup.sh, moved here so the deployer and its
|
||||
# sudoers grant list live in the same repo and cannot drift: the deploy
|
||||
# preflights the target's `sudo -n -l` against the SUDO block below.
|
||||
#
|
||||
# Convention: ~/git/architecture/deployment-gitea-actions.md §1–§2. Run from a
|
||||
# workstation with admin (sudo) ssh access to the targets — NOT the gitea_ci
|
||||
# account. Idempotent; re-running is a no-op. Skips unreachable hosts.
|
||||
#
|
||||
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
|
||||
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub --miner-hosts benjy.hanzalova.internal
|
||||
#
|
||||
# The runner keypair is NOT generated here. It already exists at
|
||||
# ~/.ssh/id_gitea_ci and is shared by every project's RSYNC_SSH_KEY secret and
|
||||
# every host's gitea_ci authorized_keys — regenerating it would silently break
|
||||
# every other deploy on the fleet.
|
||||
#
|
||||
# Re-run this whenever the deploy gains a new file to ship or a new privileged
|
||||
# command; the preflight fails up front naming the missing paths rather than
|
||||
# dying partway through an rsync with "sudo: a password is required".
|
||||
set -euo pipefail
|
||||
|
||||
ADMIN_USER="${ADMIN_USER:-$USER}"
|
||||
# Must agree with the deploy matrix in .gitea/workflows/deploy.yaml.
|
||||
MINER_HOSTS="${MINER_HOSTS-benjy.hanzalova.internal quadbrat.hanzalova.internal}"
|
||||
PUBKEY=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--pubkey) PUBKEY="$2"; shift 2 ;;
|
||||
--miner-hosts) MINER_HOSTS="$2"; shift 2 ;;
|
||||
--admin) ADMIN_USER="$2"; shift 2 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PUBKEY" ] && [ -r "$PUBKEY" ] || { echo "--pubkey <file> is required and must be readable" >&2; exit 2; }
|
||||
|
||||
provision_miner() {
|
||||
local host="$1"
|
||||
echo "== ${host} (miner) =="
|
||||
if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then
|
||||
echo " ! unreachable as ${ADMIN_USER} — skipping" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# All privileged work in one remote `sudo bash`. The runner pubkey is the only
|
||||
# dynamic value, passed as $1 (single line, no quoting hazard). The sudoers
|
||||
# `\=` are the required escapes — visudo rejects a bare `=` in a command arg.
|
||||
# The `*` in an rsync line matches rsync's --server arg vector; the trailing
|
||||
# literal destination is what actually bounds the rule.
|
||||
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE'
|
||||
PUBKEY="$1"
|
||||
|
||||
# /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host <command>`, and
|
||||
# a nologin shell refuses that with "This account is currently not
|
||||
# available": the key authenticates, then the command cannot run.
|
||||
if ! getent passwd gitea_ci >/dev/null; then
|
||||
useradd --system --create-home --home-dir /var/lib/gitea_ci \
|
||||
--shell /bin/bash gitea_ci
|
||||
echo " + created gitea_ci"
|
||||
else
|
||||
echo " = gitea_ci already present"
|
||||
fi
|
||||
cur=$(getent passwd gitea_ci | cut -d: -f7)
|
||||
if [ "$cur" != /bin/bash ]; then
|
||||
usermod -s /bin/bash gitea_ci
|
||||
echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run"
|
||||
fi
|
||||
|
||||
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
|
||||
ak=/var/lib/gitea_ci/.ssh/authorized_keys
|
||||
touch "$ak"
|
||||
grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak"
|
||||
chown gitea_ci:gitea_ci "$ak"
|
||||
chmod 0600 "$ak"
|
||||
usermod -aG systemd-journal gitea_ci
|
||||
|
||||
# The GPU must be present and enumerable before a miner deploy is worth
|
||||
# attempting; failing here beats a green deploy that mines nothing.
|
||||
if ! command -v nvidia-smi >/dev/null; then
|
||||
echo " ! nvidia-smi not found — this host has no usable NVIDIA driver" >&2
|
||||
exit 1
|
||||
fi
|
||||
nvidia-smi --query-gpu=name --format=csv,noheader | sed 's/^/ = gpu: /'
|
||||
|
||||
tmp=/etc/sudoers.d/.quantus-miner_gitea_ci.tmp
|
||||
cat > "$tmp" <<'SUDO'
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-miner.conf
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner.env
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-auth-token
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-tls-cert-sha256
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-miner-metrics.xml
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-miner.service
|
||||
# stop/start (not just restart) so the benchmark harness (bench.yaml) can pause
|
||||
# mining for a measurement window and resume it afterwards.
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start quantus-miner.service
|
||||
# Rollback: the deploy keeps the previous binary as .prev and restores it when
|
||||
# validate fails. Deploying from main means a bad commit reaches production;
|
||||
# this is what makes that survivable.
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cp -p /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
|
||||
SUDO
|
||||
chmod 0440 "$tmp"
|
||||
visudo -cf "$tmp"
|
||||
mv "$tmp" /etc/sudoers.d/quantus-miner_gitea_ci
|
||||
echo " = sudoers quantus-miner_gitea_ci installed and visudo-verified"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
rc=0
|
||||
for h in $MINER_HOSTS; do
|
||||
provision_miner "$h" || rc=1
|
||||
done
|
||||
echo
|
||||
echo "done. remaining operator steps:"
|
||||
echo " 1. confirm RSYNC_SSH_KEY is set in the repo's Actions secrets"
|
||||
echo " 2. push to main, or run the deploy workflow manually (mode: validate first)"
|
||||
exit $rc
|
||||
@@ -0,0 +1,6 @@
|
||||
# Rendered by .gitea/workflows/deploy.yaml (quantus/miner) and rsynced to
|
||||
# /etc/quantus-miner/miner.env (0640 root:quantus-miner). Not secret — the
|
||||
# miner's actual credentials are the auth token and TLS pin, which are copied
|
||||
# from the node host as separate 0640 files.
|
||||
QUANTUS_NODE_ADDR={{QUANTUS_NODE_ADDR}}
|
||||
QUANTUS_GPU_DEVICES={{QUANTUS_GPU_DEVICES}}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>quantus-miner-metrics</short>
|
||||
<description>Quantus miner Prometheus exporter. The miner binds this on
|
||||
0.0.0.0 unconditionally (crates/metrics: SocketAddr from [0,0,0,0]) — there is
|
||||
no loopback option — so the firewalld rich rule scoped to the scrape host is
|
||||
the only thing bounding who can reach it.</description>
|
||||
<port protocol="tcp" port="9900"/>
|
||||
</service>
|
||||
@@ -0,0 +1,64 @@
|
||||
# Quantus external miner. Runs on the GPU host and connects OUT to the node's
|
||||
# QUIC control channel; it listens on nothing but its loopback metrics port, so
|
||||
# it ships no firewalld service of its own.
|
||||
#
|
||||
# Hardened per ~/git/architecture/generic.md §8, with one deliberate relaxation:
|
||||
#
|
||||
# PrivateDevices=false
|
||||
# The miner needs /dev/nvidia*; PrivateDevices=true hides them and wgpu
|
||||
# finds no adapter, silently falling back to nothing.
|
||||
#
|
||||
# Poseidon2-over-Goldilocks is compute-bound in registers, not memory-bound, so
|
||||
# there is no tuning knob here worth more than the GPU power limit — which is a
|
||||
# host-level concern (nvidia-smi -pl), not a unit-file one.
|
||||
#
|
||||
# --cpu-workers 0 is deliberate: a current-gen desktop CPU contributes ~4 MH/s
|
||||
# next to a 4090's ~183 MH/s, for a couple hundred watts. Measured, not assumed.
|
||||
|
||||
[Unit]
|
||||
Description=Quantus external miner (GPU)
|
||||
Documentation=https://github.com/Quantus-Network/quantus-miner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=quantus-miner
|
||||
Group=quantus-miner
|
||||
Environment=RUST_LOG=info
|
||||
WorkingDirectory=/var/lib/quantus-miner
|
||||
|
||||
ExecStart=/usr/local/bin/quantus-miner serve \
|
||||
--node-addr ${QUANTUS_NODE_ADDR} \
|
||||
--auth-token-file /etc/quantus-miner/miner-auth-token \
|
||||
--tls-cert-sha256-file /etc/quantus-miner/miner-tls-cert-sha256 \
|
||||
--gpu-devices ${QUANTUS_GPU_DEVICES} \
|
||||
--cpu-workers 0 \
|
||||
--metrics-port 9900
|
||||
EnvironmentFile=/etc/quantus-miner/miner.env
|
||||
|
||||
# The node may be down, still syncing, or mid-redeploy; reconnecting is normal
|
||||
# operation, not an error condition.
|
||||
Restart=always
|
||||
RestartSec=15s
|
||||
LimitNOFILE=65536
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=false
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=false
|
||||
|
||||
ReadWritePaths=/var/lib/quantus-miner
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,4 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u quantus-miner - "Quantus miner service account" /var/lib/quantus-miner /usr/sbin/nologin
|
||||
m quantus-miner video
|
||||
m quantus-miner render
|
||||
Reference in New Issue
Block a user