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
340 lines
14 KiB
YAML
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
|