The site is currently serving a frontend that asks for `window=3600` from an API that only knows `six_hours`, and every step behaved as designed: run 51ef367d5rust + web BUILD FAILED — nothing deployed run 522594190web only web deployed, deploy-api skipped run 5349f50bddocs only nothing deployed Run 52 diffed against `github.event.before`, which is run 51's commit — a commit whose Rust half never shipped. It correctly saw only `web/src/lib/routes.ts` and correctly skipped the API. The frontend went out alone. The gate was asking the wrong question. "What changed since the previous push" is only the same as "what changed since what is deployed" while every push deploys, and a failed run breaks that quietly — the workflow's own comment warns about "a binary on bob that does not match this commit, which nothing would report", and this is that case arriving by a route the path list cannot see. So ask the API. `/v1/healthz` now carries the commit it was built from, stamped via `GIT_SHA` at build time, and the gate diffs from there. `build.rs` exists only to make cargo notice the value changed, since `option_env!` is read at compile time and a warm cache would otherwise keep a stale stamp. Best effort throughout: an unreachable API, a binary too old to carry a stamp, or a commit this clone does not have all fall back to `github.event.before`, which is exactly today's behaviour. The gate only ever widens — a false positive costs a slow deploy, and that trade is already written down beside it. This commit touches `.gitea/` and `crates/`, so it deploys the API and unbreaks the site as it lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
459 lines
21 KiB
YAML
459 lines
21 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:
|
|
# `rust`, not `fedora-43`: the fedora images are node/shell runners with no
|
|
# cargo at all (gitea-runners.md §4). `runner-rust` is built on
|
|
# `runner-fedora-44`, so it carries node + pnpm too and one job can build
|
|
# both halves.
|
|
runs-on: rust
|
|
outputs:
|
|
rust: ${{ steps.changes.outputs.rust }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# The change detection below diffs against the previous head, which a
|
|
# depth-1 clone does not contain.
|
|
fetch-depth: 0
|
|
|
|
- name: what changed
|
|
id: changes
|
|
# The Rust half is the expensive half — a cold musl release build plus
|
|
# the gate is most of the wall clock — and a frontend commit cannot
|
|
# change a byte of it. Deciding here rather than with `on.push.paths`
|
|
# because that filter skips the *whole* workflow, and the site still
|
|
# has to build and ship.
|
|
#
|
|
# Anything that is not clearly frontend-only counts as Rust. The cost of
|
|
# a false positive is a slow deploy; the cost of a false negative is a
|
|
# binary on bob that does not match this commit, which nothing would
|
|
# report. Two entries are less obvious than they look:
|
|
#
|
|
# - `asset/` because deploy-api ships the systemd units, the firewalld
|
|
# service and the rendered config template from it.
|
|
# - `web/src/api/generated/` because the drift gate below only runs
|
|
# when `cargo test` has regenerated those files. A hand-edit of them
|
|
# is exactly what that gate exists to catch, so it must not be able
|
|
# to arrive as a "frontend-only" change.
|
|
#
|
|
# `.gitea/` is in the list so that editing this file exercises the whole
|
|
# path it describes.
|
|
run: |
|
|
set -euo pipefail
|
|
base="${{ github.event.before }}"
|
|
reason=""
|
|
if [ "${{ github.event_name }}" != "push" ]; then
|
|
reason="not a push"
|
|
elif [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then
|
|
reason="no previous head — new branch or force push"
|
|
elif ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
|
|
reason="previous head ${base} is not in this clone"
|
|
fi
|
|
|
|
if [ -n "$reason" ]; then
|
|
echo "building everything: ${reason}"
|
|
echo "rust=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Prefer the commit the API is *actually running* over the previous
|
|
# push. They are usually the same and the difference only shows up
|
|
# when it matters: if a run fails, its changes never ship, and the
|
|
# next push diffs against a commit that was never deployed. That is
|
|
# how a frontend asking for `window=3600` came to be served by an API
|
|
# that only knew `six_hours` — the run that would have taught it
|
|
# failed, and the next push was frontend-only, so nothing rebuilt the
|
|
# API and nothing said so.
|
|
#
|
|
# Best effort: an unreachable API, an old binary with no stamp, or a
|
|
# commit this clone does not have all fall back to `event.before`,
|
|
# which is no worse than before. The gate only ever widens.
|
|
live=$(curl -sS --max-time 10 "http://${API_HOST}:${API_PORT}/v1/healthz" 2>/dev/null \
|
|
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("commit") or "")' 2>/dev/null || true)
|
|
if [ -n "$live" ] && git cat-file -e "${live}^{commit}" 2>/dev/null; then
|
|
if [ "$live" != "$base" ]; then
|
|
echo "diffing against the running API at ${live}, not the previous push ${base}"
|
|
fi
|
|
base="$live"
|
|
else
|
|
echo "the running API did not name a commit we have; falling back to ${base}"
|
|
fi
|
|
|
|
changed=$(git diff --name-only "$base" "${{ github.sha }}")
|
|
echo "changed since ${base}:"
|
|
echo "$changed" | sed 's/^/ /'
|
|
|
|
if echo "$changed" | grep -qE '^(crates/|asset/|script/|\.gitea/|\.cargo/|\.sqlx/|web/src/api/generated/|Cargo\.(toml|lock)$|rust-toolchain\.toml$|rustfmt\.toml$)'; then
|
|
echo "rust=true" >> "$GITHUB_OUTPUT"
|
|
echo "→ building and deploying the API"
|
|
else
|
|
echo "rust=false" >> "$GITHUB_OUTPUT"
|
|
echo "→ frontend only; skipping the Rust build and the API deploy"
|
|
fi
|
|
|
|
- name: rust gate
|
|
if: steps.changes.outputs.rust == 'true'
|
|
# 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
|
|
if: steps.changes.outputs.rust == 'true'
|
|
env:
|
|
SQLX_OFFLINE: "true"
|
|
run: |
|
|
set -euo pipefail
|
|
# musl, statically linked. gitea-runners.md §4 notes a native build is
|
|
# simpler when the runner matches the target fleet — it does not here:
|
|
# `runner-rust` is Fedora 44 and the targets are Fedora 43, so a
|
|
# glibc-linked binary built here would not load there.
|
|
# Stamped so the API can answer "which commit am I?" — see the
|
|
# deploy gate above, which asks exactly that.
|
|
GIT_SHA="${{ github.sha }}" \
|
|
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
|
|
if: steps.changes.outputs.rust == 'true'
|
|
# 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
|
|
# No `corepack enable`: no runner image bundles it, and pnpm is
|
|
# already on PATH from `npm i -g pnpm` (gitea-runners.md §4).
|
|
cd web
|
|
pnpm install --frozen-lockfile
|
|
# The web half's counterpart to `cargo fmt --check` above. It skips
|
|
# src/api/generated (.prettierignore) — ts-rs owns those files and
|
|
# regenerates them in its own style, so formatting them here would be
|
|
# undone on the next `cargo test` and fail the drift gate above.
|
|
pnpm format:check
|
|
pnpm lint
|
|
pnpm build
|
|
|
|
- uses: actions/upload-artifact@v3
|
|
with:
|
|
name: blackbeard
|
|
# The binaries are absent on a frontend-only run. `warn` rather than
|
|
# `error` for exactly that case — `deploy-api` is skipped alongside,
|
|
# so nothing downstream goes looking for them.
|
|
if-no-files-found: warn
|
|
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
|
|
# Skipped when nothing that reaches bob changed. The binary already there is
|
|
# still the right one — this deploy is idempotent and its absence changes
|
|
# nothing about what is running.
|
|
if: needs.build.outputs.rust == 'true'
|
|
# `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
|
|
# NOT 127.0.0.1. The API binds the host's *mesh* address, never
|
|
# loopback and never 0.0.0.0 — the fleet has one firewalld default
|
|
# zone, so a wildcard bind plus the named service would publish it
|
|
# on every address the host carries, and the edge proxy is at
|
|
# another site so loopback-only would not serve it at all. Probing
|
|
# loopback therefore gets connection-refused against a perfectly
|
|
# healthy daemon. `hostname -f` resolves to the mesh address, so
|
|
# this needs no value carried between steps.
|
|
if ssh "$API_HOST" "curl -sf http://\$(hostname -f):$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
|
|
# --resolve to loopback: from inside the mesh the public name resolves
|
|
# to the site's WAN address and dead-ends on the OPNsense LAN
|
|
# interface (reverse-proxies.md §2). Pinning it to 127.0.0.1 still
|
|
# exercises the real path — the :443 stream router, SNI, the vhost,
|
|
# the cross-site hop and the API — just without leaving the host.
|
|
ssh "$EDGE_HOST" "curl -sf --max-time 20 --resolve $PUBLIC_NAME:443:127.0.0.1 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
|