Files
observer/.gitea/workflows/deploy.yaml
rob thijssen 5f3aac2a13
Some checks failed
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped
feat(chains): discover every Quantus chain from telemetry; track the ones we can reach
The chain list is no longer config. substrate-telemetry announces every chain it
knows — with live node counts — before and independent of any subscription, so
one feed connection that never subscribes is a complete, self-maintaining index.
A chain that launches tomorrow appears in the nav on its own.

Config now contributes only RPC endpoints. Authorship lives in the `pow_` digest
of a block *header*, and telemetry publishes hashes and heights but never
headers — so a chain with no endpoint is listed with its node count and is not
navigable, and the nav says why rather than offering an empty board. The two
sets join on genesis hash, never on name.

Two chains are tracked in full: Planck (our own node on bob) and Heisenberg
(a1-heisenberg.quantus.cat, found via Quantus-Network/quantus-apps). Both decode
identically — same digest shape, same QPoW runtime API — which is the proof that
adding a chain is one [[chains]] entry and no new code. Quantus Staging Mainnet
and Quantus Dirac Testnet are listed and disabled: no endpoint exists under the
a1-/a2- pattern and none resolves under any plausible name.

Nav is ordered by node count descending, ties broken on id so it cannot reshuffle
between refreshes.

Telemetry now also yields chain-wide best height, best finalized height, average
block time and the client-version histogram (feed codes 1, 2, 12 and 22).

Infra moves to a single site: oolon fronts blackbeard.observer, reverse-proxying
cross-site over the mesh to the API beside the node on bob. That hop is the one
the loopback health probe cannot see, so the deploy now checks it explicitly —
a firewalld service scoped to bob's own /16 would leave a live site with a dead
/v1 and nothing would fail. infra-setup gains `dns` and `cert` roles that run on
oolon with the credential already there: the Cloudflare token is never copied off
the proxy, and it refuses to repoint an apex record that already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:04:11 +03:00

340 lines
14 KiB
YAML

name: deploy
# The workflow is the source of infra truth (architecture/deployment-gitea-actions.md):
# hosts, ports, paths and component→host mapping live here and nowhere else.
# There is no separate deployment manifest.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
mode:
description: "deploy (apply then validate) or validate (check only)"
type: choice
options: [deploy, validate]
default: deploy
concurrency:
# Serialise deploys; never half-apply two at once.
group: deploy
cancel-in-progress: false
env:
# --- infra truth -----------------------------------------------------------
# The API runs beside quantus-node so it can read the node's loopback RPC.
API_HOST: bob.hanzalova.internal
API_PORT: "25864"
# The site's edge proxy. It serves the built SPA and reverse-proxies /v1.
# Cross-site on purpose: the API has to sit beside the node it reads, and the
# public name is fronted from the DC.
EDGE_HOST: oolon.kosherinata.internal
WEBROOT: /var/www/blackbeard.observer
PUBLIC_NAME: blackbeard.observer
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
build:
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- name: rust gate
# Format, lint-as-error and the full test suite before anything is
# built, so a broken commit never reaches a host. SQLX_OFFLINE makes the
# compile-time-checked queries read the committed .sqlx cache instead of
# needing a live database on the runner.
env:
SQLX_OFFLINE: "true"
run: |
set -euo pipefail
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all
- name: build api and cli
env:
SQLX_OFFLINE: "true"
run: |
set -euo pipefail
# musl, statically linked: the runner image is newer than the target
# host, and a glibc-linked binary built here would not load there
# (deployment-gitea-actions.md §6).
cargo build --release --target x86_64-unknown-linux-musl \
-p blackbeard-api -p blackbeard-cli
file target/x86_64-unknown-linux-musl/release/blackbeard-api
- name: generated types are current
# ts-rs writes web/src/api/generated/ from the Rust entities crate.
# `cargo test` above regenerates them; a diff here means someone edited
# a DTO and committed the Rust without the TypeScript, which would
# compile on both sides and disagree at runtime.
run: |
set -euo pipefail
if ! git diff --quiet -- web/src/api/generated; then
echo "generated TypeScript is out of date; run 'cargo test -p blackbeard-entities' and commit:" >&2
git diff --stat -- web/src/api/generated >&2
exit 1
fi
- name: build web
run: |
set -euo pipefail
corepack enable
cd web
pnpm install --frozen-lockfile
pnpm lint
pnpm build
- uses: actions/upload-artifact@v3
with:
name: blackbeard
path: |
target/x86_64-unknown-linux-musl/release/blackbeard-api
target/x86_64-unknown-linux-musl/release/blackbeard
web/dist
asset
deploy-api:
needs: build
# `infra`, not `fedora-43`: the targets are mesh-only .internal names and
# the fedora runners have no route to the WireGuard mesh. Same reason as
# lair/quantus and lair/mail.
runs-on: infra
steps:
- uses: actions/download-artifact@v3
with:
name: blackbeard
- name: ssh key and reachability
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 0600 ~/.ssh/id_deploy
cat > ~/.ssh/config <<EOF
Host *
IdentityFile ~/.ssh/id_deploy
StrictHostKeyChecking accept-new
User gitea_ci
EOF
ssh "$API_HOST" hostname -f
- name: preflight sudoers
# Compare what infra-setup.sh grants against what the target actually
# allows, and fail up front naming the missing paths — rather than
# dying partway through an rsync with a bare "sudo: a password is
# required" after some files have already landed.
run: |
set -euo pipefail
granted=$(ssh "$API_HOST" sudo -n -l || true)
missing=""
for path in /usr/local/bin/blackbeard-api /usr/local/bin/blackbeard \
/etc/blackbeard/config.toml /etc/sysusers.d/blackbeard.conf \
/etc/systemd/system/blackbeard-api.service \
/etc/firewalld/services/blackbeard-api.xml; do
echo "$granted" | grep -qF "$path" || missing="$missing $path"
done
if [ -n "$missing" ]; then
echo "gitea_ci on $API_HOST is not permitted to write:$missing" >&2
echo "re-run script/infra-setup.sh against this host." >&2
exit 1
fi
- name: render config
# python3 .replace(), not sed or envsubst: a literal substitution
# survives values containing shell-special characters.
run: |
set -euo pipefail
fqdn=$(ssh "$API_HOST" hostname -f)
# The API binds the host's mesh address, never 0.0.0.0 — the fleet has
# one firewalld default zone, so a wildcard bind plus the named
# service would publish this on every address the host carries.
bind=$(ssh "$API_HOST" "ip -4 -o addr show | awk '/10\\.[0-9]+\\./ {print \$4}' | cut -d/ -f1 | head -1")
if [ -z "$bind" ]; then
echo "could not determine the mesh address of $API_HOST" >&2
exit 1
fi
echo "binding $bind:$API_PORT on $fqdn"
python3 - "$fqdn" "$bind" <<'PY'
import sys, pathlib
fqdn, bind = sys.argv[1], sys.argv[2]
tmpl = pathlib.Path("asset/config/config.toml.tmpl").read_text()
out = tmpl.replace("{{TARGET_FQDN}}", fqdn).replace("{{BIND_ADDRESS}}", bind)
pathlib.Path("config.toml").write_text(out)
PY
- name: verify the rendered config
# The binary validates its own configuration and exits without binding
# anything. A bad config fails the deploy here rather than leaving a
# daemon that will not start.
run: |
set -euo pipefail
chmod +x target/x86_64-unknown-linux-musl/release/blackbeard-api
# --check reads the certificate paths, which exist on the target and
# not on the runner, so this runs on the target after the config
# lands. Placeholder substitution is what is checked here.
grep -q '{{' config.toml && { echo "config still has unsubstituted placeholders" >&2; exit 1; }
echo "no placeholders remain"
- name: ship
run: |
set -euo pipefail
r() { rsync -a --checksum --mkpath --rsync-path='sudo rsync' "$@"; }
# --checksum, not rsync's default size+mtime: the build produces a new
# mtime every run, so the default heuristic reports a change on every
# deploy and would restart a healthy daemon each time.
r --chmod=0755 target/x86_64-unknown-linux-musl/release/blackbeard-api \
"$API_HOST:/usr/local/bin/blackbeard-api"
r --chmod=0755 target/x86_64-unknown-linux-musl/release/blackbeard \
"$API_HOST:/usr/local/bin/blackbeard"
r --chown=root:blackbeard --chmod=0640 config.toml \
"$API_HOST:/etc/blackbeard/config.toml"
r asset/systemd/blackbeard.sysusers.conf "$API_HOST:/etc/sysusers.d/blackbeard.conf"
r asset/systemd/blackbeard-api.service "$API_HOST:/etc/systemd/system/blackbeard-api.service"
r asset/systemd/blackbeard-api-cert.path "$API_HOST:/etc/systemd/system/blackbeard-api-cert.path"
r asset/systemd/blackbeard-api-cert-reload.service \
"$API_HOST:/etc/systemd/system/blackbeard-api-cert-reload.service"
r asset/firewalld/blackbeard-api.xml "$API_HOST:/etc/firewalld/services/blackbeard-api.xml"
- name: apply system state
run: |
set -euo pipefail
ssh "$API_HOST" bash -euo pipefail <<'REMOTE'
sudo systemd-sysusers
sudo install -d -o root -g blackbeard -m 0750 /etc/blackbeard
# The service account needs to read the host private key: it is the
# credential for the mTLS Postgres connection and is not
# world-readable (architecture/generic.md §11).
sudo setfacl -m u:blackbeard:r "/etc/pki/tls/private/$(hostname -f).pem"
sudo restorecon -R /usr/local/bin/blackbeard-api /usr/local/bin/blackbeard /etc/blackbeard
# SELinux must know about the port or the bind is denied. Guarded so
# a re-run is a no-op; `semanage port -a` on an already-labelled
# port reassigns it rather than failing cleanly.
if ! sudo semanage port -l | grep -qE "^http_port_t.*\b25864\b"; then
sudo semanage port -a -t http_port_t -p tcp 25864
else
echo "port 25864 already labelled http_port_t"
fi
# firewalld only learns a freshly-shipped custom service after a
# reload; querying or adding it before reloading fails
# INVALID_SERVICE.
sudo firewall-cmd --reload
zone=$(sudo firewall-cmd --get-default-zone)
if ! sudo firewall-cmd --zone="$zone" --query-service=blackbeard-api; then
sudo firewall-cmd --permanent --zone="$zone" --add-service=blackbeard-api
sudo firewall-cmd --zone="$zone" --add-service=blackbeard-api
else
echo "blackbeard-api already open in zone $zone"
fi
sudo systemctl daemon-reload
sudo systemctl enable --now blackbeard-api-cert.path
# Validate the config on the target, where the certificates it names
# actually exist.
sudo -u blackbeard /usr/local/bin/blackbeard-api \
--config /etc/blackbeard/config.toml --check
sudo systemctl enable blackbeard-api.service
sudo systemctl restart blackbeard-api.service
REMOTE
- name: health probe
run: |
set -euo pipefail
for attempt in $(seq 1 30); do
if ssh "$API_HOST" "curl -sf http://127.0.0.1:$API_PORT/v1/healthz" > health.json; then
cat health.json
# `healthy` covers the database. Chain reachability is
# deliberately not part of it: a chain configured before it
# launches is not a deploy failure.
python3 -c "import json,sys; sys.exit(0 if json.load(open('health.json'))['healthy'] else 1)"
exit 0
fi
sleep 2
done
echo "blackbeard-api did not become healthy" >&2
exit 1
- name: reachable from the edge proxy
# The health probe above runs on the API host over loopback and so
# cannot see the hop that actually matters: the edge proxy is at a
# different site, and if the firewalld service were scoped to this
# host's own /16 the site would serve a static bundle with a dead /v1
# and nothing would fail.
run: |
set -euo pipefail
ssh "$EDGE_HOST" "curl -sf --max-time 10 http://$API_HOST:$API_PORT/v1/healthz -o /dev/null -w '%{http_code}\n'"
- name: journal
if: always()
run: ssh "$API_HOST" journalctl -u blackbeard-api.service -n 80 --no-pager || true
deploy-web:
needs: build
runs-on: infra
steps:
- uses: actions/download-artifact@v3
with:
name: blackbeard
- name: ssh key and reachability
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 0600 ~/.ssh/id_deploy
cat > ~/.ssh/config <<EOF
Host *
IdentityFile ~/.ssh/id_deploy
StrictHostKeyChecking accept-new
User gitea_ci
EOF
ssh "$EDGE_HOST" hostname -f
- name: ship the bundle
# --delete: hashed asset filenames accumulate forever otherwise. The
# webroot holds only build output, so there is nothing else to lose.
run: |
set -euo pipefail
rsync -a --checksum --delete --mkpath --rsync-path='sudo rsync' \
web/dist/ "$EDGE_HOST:$WEBROOT/"
- name: label and reload
run: |
set -euo pipefail
ssh "$EDGE_HOST" bash -euo pipefail <<REMOTE
# SELinux: an unlabelled webroot makes nginx return 403 for every
# file, with nothing in the nginx error log to explain it.
sudo restorecon -R "$WEBROOT"
sudo nginx -t
sudo systemctl reload nginx
REMOTE
- name: fetch the site
# `nginx -t` parses without binding, so a passing test is not evidence
# the reload landed. Fetching the page is — and fetching the chain list
# through the vhost proves the whole path: nginx, the cross-site hop,
# the API, and the telemetry directory behind it. A 200 with an empty
# chain list is a broken deploy that a status code alone would pass.
run: |
set -euo pipefail
ssh "$EDGE_HOST" "curl -sf --max-time 15 https://$PUBLIC_NAME/v1/chains" > chains.json
python3 - <<'PY'
import json, sys
chains = json.load(open("chains.json"))
full = [c for c in chains if c["tracking"] == "full"]
for c in chains:
print(f"{c['node_count'] or 0:>4} nodes {c['tracking']:<12} {c['display_name']}")
if not full:
print("no chain is tracked in full — the site would show an empty board", file=sys.stderr)
sys.exit(1)
PY