Some checks failed
deploy / Build prerendered web (push) Successful in 7m14s
deploy / Deploy web to oolon (push) Successful in 18s
deploy / Build api + worker (static musl) (push) Successful in 5m41s
deploy / Deploy moments-worker to frootmig (push) Successful in 19s
deploy / Deploy moments-api to nikola (push) Successful in 24s
refresh / Rebuild prerendered web (push) Successful in 7m15s
refresh / Deploy refreshed web to oolon (push) Failing after 26s
oolon's TCP 443 belongs to the stream SNI router, which ssl_prereads the
handshake and forwards to the local https tier on 127.0.0.1:14443 with
PROXY protocol. site.conf.tmpl predates that and still bound 443 itself,
so every deploy and every daily refresh rsynced a vhost that collides
with the router.
Nothing in the pipeline caught it. `nginx -t` only detects duplicate
listeners within a context, not across http{} and stream{}, and
`systemctl reload` merely sends SIGHUP, so it exits 0 while nginx logs
"bind() to 0.0.0.0:443 failed (98: Address already in use) ... still
could not bind()", aborts the reconfiguration and keeps its old cycle.
The deploy went green while oolon's running config was frozen. It stayed
frozen for a day, stranding every cert the step@ timers renewed on disk
until eleven internal vhosts were serving expired certs. A cold start
would have failed outright, taking the whole public edge down.
Template the listen line from WEB_LISTEN (manifest web.config.listen for
script/deploy.sh, which renders the same template), and assert that the
reload landed by requiring a fresh worker generation, dumping the nginx
error log when it did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182wzZE8DguMPWhxD21gfP2
398 lines
18 KiB
YAML
398 lines
18 KiB
YAML
name: deploy
|
|
|
|
# Build and roll out all three moments components on a push to main:
|
|
# - moments-api (static musl binary, read-only API) -> nikola
|
|
# - moments-worker (static musl binary, ingestion daemon) -> frootmig
|
|
# - web (prerendered static SPA + nginx vhost) -> oolon
|
|
#
|
|
# This workflow is the source of infra truth (hosts, ports, paths live in `env`
|
|
# below — there is no manifest.yml in this model). It replaces script/deploy.sh:
|
|
# instead of an operator running it from a workstation with `pass`, a Gitea
|
|
# Actions runner deploys as the dedicated `gitea_ci` user over SSH, with secrets
|
|
# from the repo settings and scoped sudo (see asset/sudoers.d/ + script/infra-setup.sh).
|
|
#
|
|
# The api/worker binaries are pure-rustls (no openssl), so they build as fully
|
|
# static musl — a runner newer than the target host can't produce an unloadable
|
|
# glibc binary (architecture/deployment-gitea-actions.md §6).
|
|
#
|
|
# Secrets (Gitea -> repo -> Settings -> Actions -> Secrets):
|
|
# RSYNC_SSH_KEY private SSH key whose pubkey script/infra-setup.sh installed
|
|
# QUERY_GITHUB_TOKEN github api token for the worker's poller
|
|
# QUERY_GITEA_TOKEN git.lair.cafe api token for the worker's poller
|
|
# (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names, hence the QUERY_ prefix.)
|
|
#
|
|
# One-time per-host provisioning (gitea_ci user, authorized_keys, scoped
|
|
# sudoers, and the postgres mTLS host cert the api/worker need) is done by
|
|
# script/infra-setup.sh — run it once per host before this workflow can succeed.
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
# Serialize deploys; never cancel an in-flight one (a half-applied rollout is
|
|
# worse than a slightly stale one).
|
|
concurrency:
|
|
group: deploy
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
# --- infra truth (mirrors the former asset/manifest.yml) ---
|
|
API_HOST: nikola.kosherinata.internal
|
|
WORKER_HOST: frootmig.kosherinata.internal
|
|
WEB_HOST: oolon.kosherinata.internal
|
|
API_BIND: 0.0.0.0:42424
|
|
API_PORT: "42424"
|
|
SERVER_NAME: rob.tn
|
|
WEB_ROOT: /var/www/rob.tn
|
|
# TCP 443 on the oolon edge is owned by the stream SNI router
|
|
# (/etc/nginx/streams-enabled/sni-router.conf), which ssl_prereads the
|
|
# handshake and forwards to the local https tier on 127.0.0.1:14443 with
|
|
# PROXY protocol. An http vhost that binds 443 itself collides with the
|
|
# router: `nginx -t` still passes (nginx only detects duplicate listeners
|
|
# within a context, not across http{} and stream{}), but every subsequent
|
|
# reload aborts with "bind() to 0.0.0.0:443 failed (98: Address already in
|
|
# use)" while `systemctl reload` still exits 0. That silently froze oolon's
|
|
# running config for a day and stranded every renewed cert on disk.
|
|
WEB_LISTEN: 127.0.0.1:14443 ssl proxy_protocol
|
|
API_UPSTREAM_SCHEME: http
|
|
API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424
|
|
MUSL_TARGET: x86_64-unknown-linux-musl
|
|
# Build-time prerender base: the runner is on the internal mesh and can't
|
|
# resolve/reach the public rob.tn, so fetch the API directly over the mesh
|
|
# (same host the deploy reaches). The api binary serves its routes under /v1.
|
|
# The client bundle still ships the same-origin relative /api/v1 that nginx
|
|
# proxies (VITE_API_BASE only affects the SSR build, not the browser bundle).
|
|
VITE_API_BASE: http://nikola.kosherinata.internal:42424/v1
|
|
DEPLOY_KEY: |
|
|
${{ secrets.RSYNC_SSH_KEY }}
|
|
|
|
jobs:
|
|
# The rust runner has cargo + musl but no node; the fedora runner has
|
|
# node + pnpm but no cargo — so the build is split across the two images.
|
|
build-binaries:
|
|
name: Build api + worker (static musl)
|
|
runs-on: rust
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
# The `rust` runner image provides cargo/clippy/rustfmt, musl-gcc, and the
|
|
# x86_64-unknown-linux-musl std. No package installs at run time.
|
|
- name: Lint + test (deploy gate)
|
|
run: |
|
|
cargo fmt --check --all
|
|
cargo clippy --workspace --all-targets -- -D warnings
|
|
cargo test --workspace
|
|
|
|
- name: Build moments-api + moments-worker (static musl release)
|
|
run: cargo build --release --target "${MUSL_TARGET}" -p moments-api -p moments-worker
|
|
|
|
- name: Stage binaries
|
|
run: |
|
|
mkdir --parents artifacts
|
|
cp "target/${MUSL_TARGET}/release/moments-api" artifacts/moments-api
|
|
cp "target/${MUSL_TARGET}/release/moments-worker" artifacts/moments-worker
|
|
|
|
- uses: actions/upload-artifact@v3
|
|
with: { name: moments-api, path: artifacts/moments-api, retention-days: 1 }
|
|
- uses: actions/upload-artifact@v3
|
|
with: { name: moments-worker, path: artifacts/moments-worker, retention-days: 1 }
|
|
|
|
build-web:
|
|
name: Build prerendered web
|
|
runs-on: fedora-44
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
# The fedora-44 runner image bakes in node + pnpm (+ rsync) — no install.
|
|
# pnpm 10 blocks dependency build scripts unless approved, and CI doesn't
|
|
# reliably pick up the pnpm-workspace.yaml allowlist; so install with
|
|
# --ignore-scripts (no approval gate) and explicitly rebuild the two native
|
|
# deps vite needs (esbuild, @swc/core). Version-independent.
|
|
- name: Build web (vite client + prerender)
|
|
working-directory: ui
|
|
run: |
|
|
pnpm install --frozen-lockfile --ignore-scripts
|
|
pnpm rebuild @swc/core esbuild
|
|
pnpm run build
|
|
- uses: actions/upload-artifact@v3
|
|
with: { name: web-dist, path: ui/dist, retention-days: 1 }
|
|
|
|
deploy-api:
|
|
name: Deploy moments-api to nikola
|
|
needs: build-binaries
|
|
runs-on: fedora-44
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/download-artifact@v3
|
|
with: { name: moments-api, path: artifact }
|
|
|
|
- name: SSH init
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
|
|
chmod 600 ~/.ssh/id_ed25519
|
|
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
|
|
gitea_ci@"${API_HOST}" 'hostname -f'
|
|
|
|
- name: Render config + units from infra truth
|
|
run: |
|
|
mkdir -p rendered
|
|
# HOSTNAME is the host's own fqdn (used for the postgres mTLS cert path).
|
|
python3 - "$API_HOST" "$API_BIND" "$API_PORT" <<'PY'
|
|
import sys
|
|
host, bind, port = sys.argv[1:4]
|
|
def render(src, dst, subs):
|
|
t = open(src).read()
|
|
for k, v in subs.items():
|
|
t = t.replace("{{%s}}" % k, v)
|
|
open(dst, "w").write(t)
|
|
render("asset/config/api.env.tmpl", "rendered/api.env",
|
|
{"HOSTNAME": host, "BIND": bind})
|
|
render("asset/systemd/moments-api-cert.path", "rendered/moments-api-cert.path",
|
|
{"HOSTNAME": host})
|
|
render("asset/firewalld/moments-api.xml.tmpl", "rendered/moments-api.xml",
|
|
{"API_PORT": port})
|
|
PY
|
|
|
|
- name: Provision service account + directories
|
|
run: |
|
|
# --mkpath: /etc/sysusers.d and /etc/firewalld/services don't exist by
|
|
# default on Fedora (only the /usr/lib variants ship).
|
|
rsync -az --mkpath --rsync-path='sudo rsync' \
|
|
asset/systemd/moments.sysusers.conf \
|
|
gitea_ci@"${API_HOST}":/etc/sysusers.d/moments.conf
|
|
ssh gitea_ci@"${API_HOST}" '
|
|
set -euo pipefail
|
|
sudo /usr/bin/systemd-sysusers
|
|
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
|
|
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
|
|
|
|
- name: Sync binary, env, units, firewalld service
|
|
run: |
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
|
|
artifact/moments-api gitea_ci@"${API_HOST}":/usr/local/bin/moments-api
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
|
|
rendered/api.env gitea_ci@"${API_HOST}":/etc/moments/api.env
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
asset/systemd/moments-api.service \
|
|
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api.service
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
rendered/moments-api-cert.path \
|
|
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert.path
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
asset/systemd/moments-api-cert-reload.service \
|
|
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert-reload.service
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
rendered/moments-api.xml \
|
|
gitea_ci@"${API_HOST}":/etc/firewalld/services/moments-api.xml
|
|
|
|
- name: Apply SELinux + firewalld + cert ACL (idempotent)
|
|
run: |
|
|
ssh gitea_ci@"${API_HOST}" '
|
|
set -euo pipefail
|
|
fqdn="$(hostname -f)"
|
|
# Let the moments user read the host private key for postgres mTLS.
|
|
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
|
|
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments
|
|
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
|
|
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
|
|
fi
|
|
# firewalld only learns a freshly-shipped custom service after reload.
|
|
sudo /usr/bin/firewall-cmd --reload
|
|
if ! sudo /usr/bin/firewall-cmd --query-service=moments-api; then
|
|
sudo /usr/bin/firewall-cmd --add-service=moments-api --permanent
|
|
sudo /usr/bin/firewall-cmd --reload
|
|
fi'
|
|
|
|
- name: Restart moments-api
|
|
run: |
|
|
ssh gitea_ci@"${API_HOST}" '
|
|
set -euo pipefail
|
|
sudo /usr/bin/systemctl daemon-reload
|
|
sudo /usr/bin/systemctl enable --now moments-api-cert.path
|
|
sudo /usr/bin/systemctl enable moments-api.service
|
|
sudo /usr/bin/systemctl restart moments-api.service'
|
|
|
|
- name: Health probe
|
|
run: |
|
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
if ssh gitea_ci@"${API_HOST}" "curl -fsS http://127.0.0.1:${API_PORT}/v1/healthz"; then
|
|
echo; echo "moments-api healthy"; exit 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
echo "moments-api did not become healthy" >&2
|
|
exit 1
|
|
|
|
- name: Capture startup journal
|
|
if: always()
|
|
run: |
|
|
sleep 3
|
|
ssh gitea_ci@"${API_HOST}" 'journalctl --unit moments-api.service --no-pager -n 200'
|
|
|
|
deploy-worker:
|
|
name: Deploy moments-worker to frootmig
|
|
needs: build-binaries
|
|
runs-on: fedora-44
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/download-artifact@v3
|
|
with: { name: moments-worker, path: artifact }
|
|
|
|
- name: SSH init
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
|
|
chmod 600 ~/.ssh/id_ed25519
|
|
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
|
|
gitea_ci@"${WORKER_HOST}" 'hostname -f'
|
|
|
|
- name: Render worker config + cert.path from secrets
|
|
env:
|
|
# GITHUB_TOKEN / GITEA_TOKEN are reserved Actions secret names, so the
|
|
# repo secrets are QUERY_*; the rendered worker.env still uses the
|
|
# GITHUB_TOKEN / GITEA_TOKEN env vars the worker poller expects.
|
|
GITHUB_TOKEN: ${{ secrets.QUERY_GITHUB_TOKEN }}
|
|
GITEA_TOKEN: ${{ secrets.QUERY_GITEA_TOKEN }}
|
|
run: |
|
|
mkdir -p rendered
|
|
# Literal substitution via python so tokens with shell-special chars
|
|
# survive; secrets come from the environment, never a command line.
|
|
python3 - "$WORKER_HOST" <<'PY'
|
|
import os, sys
|
|
host = sys.argv[1]
|
|
t = open("asset/config/worker.env.tmpl").read()
|
|
subs = {
|
|
"HOSTNAME": host,
|
|
"GITHUB_TOKEN": os.environ.get("GITHUB_TOKEN", ""),
|
|
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""),
|
|
}
|
|
for k, v in subs.items():
|
|
t = t.replace("{{%s}}" % k, v)
|
|
open("rendered/worker.env", "w").write(t)
|
|
c = open("asset/systemd/moments-worker-cert.path").read().replace("{{HOSTNAME}}", host)
|
|
open("rendered/moments-worker-cert.path", "w").write(c)
|
|
PY
|
|
|
|
- name: Provision service account + directories
|
|
run: |
|
|
rsync -az --mkpath --rsync-path='sudo rsync' \
|
|
asset/systemd/moments.sysusers.conf \
|
|
gitea_ci@"${WORKER_HOST}":/etc/sysusers.d/moments.conf
|
|
ssh gitea_ci@"${WORKER_HOST}" '
|
|
set -euo pipefail
|
|
sudo /usr/bin/systemd-sysusers
|
|
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
|
|
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
|
|
|
|
- name: Sync binary, env, units
|
|
run: |
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
|
|
artifact/moments-worker gitea_ci@"${WORKER_HOST}":/usr/local/bin/moments-worker
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
|
|
rendered/worker.env gitea_ci@"${WORKER_HOST}":/etc/moments/worker.env
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
asset/systemd/moments-worker.service \
|
|
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker.service
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
rendered/moments-worker-cert.path \
|
|
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert.path
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
asset/systemd/moments-worker-cert-reload.service \
|
|
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert-reload.service
|
|
|
|
- name: Apply cert ACL + SELinux, restart worker
|
|
run: |
|
|
ssh gitea_ci@"${WORKER_HOST}" '
|
|
set -euo pipefail
|
|
fqdn="$(hostname -f)"
|
|
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
|
|
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments
|
|
sudo /usr/bin/systemctl daemon-reload
|
|
sudo /usr/bin/systemctl enable --now moments-worker-cert.path
|
|
sudo /usr/bin/systemctl enable moments-worker.service
|
|
sudo /usr/bin/systemctl restart moments-worker.service
|
|
sudo /usr/bin/systemctl is-active --quiet moments-worker.service'
|
|
|
|
- name: Capture startup journal
|
|
if: always()
|
|
run: |
|
|
sleep 3
|
|
ssh gitea_ci@"${WORKER_HOST}" 'journalctl --unit moments-worker.service --no-pager -n 200'
|
|
|
|
deploy-web:
|
|
name: Deploy web to oolon
|
|
needs: build-web
|
|
runs-on: fedora-44
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/download-artifact@v3
|
|
with: { name: web-dist, path: dist }
|
|
|
|
- name: SSH init
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
|
|
chmod 600 ~/.ssh/id_ed25519
|
|
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
|
|
gitea_ci@"${WEB_HOST}" 'hostname -f'
|
|
|
|
- name: Render nginx vhost
|
|
run: |
|
|
mkdir -p rendered
|
|
python3 - <<'PY'
|
|
import os
|
|
t = open("asset/nginx/site.conf.tmpl").read()
|
|
subs = {
|
|
"SERVER_NAME": os.environ["SERVER_NAME"],
|
|
"DOCROOT": os.environ["WEB_ROOT"],
|
|
"WEB_LISTEN": os.environ["WEB_LISTEN"],
|
|
"API_UPSTREAM_SCHEME": os.environ["API_UPSTREAM_SCHEME"],
|
|
"API_UPSTREAM_ADDR": os.environ["API_UPSTREAM_ADDR"],
|
|
}
|
|
for k, v in subs.items():
|
|
t = t.replace("{{%s}}" % k, v)
|
|
open("rendered/site.conf", "w").write(t)
|
|
PY
|
|
|
|
- name: Sync static site (prerendered)
|
|
run: |
|
|
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}"
|
|
rsync -az --delete --mkpath --rsync-path='sudo rsync' \
|
|
--chown=root:root --chmod=D755,F644 \
|
|
dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/"
|
|
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}"
|
|
|
|
- name: Sync nginx vhost + apply SELinux
|
|
run: |
|
|
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
|
|
rendered/site.conf \
|
|
gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf
|
|
ssh gitea_ci@"${WEB_HOST}" '
|
|
set -euo pipefail
|
|
# nginx proxies /api/ to the api host across the WG mesh.
|
|
sudo /usr/sbin/setsebool -P httpd_can_network_connect on
|
|
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
|
|
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
|
|
fi
|
|
sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf
|
|
sudo /usr/sbin/nginx -t
|
|
# `nginx -t` cannot see a listener that collides across http{} and
|
|
# stream{}, and `systemctl reload` is just SIGHUP — it exits 0 even
|
|
# when nginx aborts the reconfiguration and keeps the old cycle. A
|
|
# reload that really landed always spawns a fresh worker generation,
|
|
# so assert that rather than trusting the exit code.
|
|
master=$(cat /run/nginx.pid)
|
|
before=$(pgrep -P "$master" | sort | tr "\n" " ")
|
|
sudo /usr/bin/systemctl reload nginx
|
|
for _ in $(seq 1 10); do
|
|
sleep 1
|
|
after=$(pgrep -P "$master" | sort | tr "\n" " ")
|
|
[ "$before" = "$after" ] || break
|
|
done
|
|
if [ "$before" = "$after" ]; then
|
|
echo "nginx reload did not take effect: worker generation unchanged" >&2
|
|
sudo /usr/bin/tail -n 50 /var/log/nginx/error.log >&2 || true
|
|
exit 1
|
|
fi'
|