Files
rustingface/.gitea/workflows/deploy.yml
rob thijssen 5244585ae0
All checks were successful
deploy / build-web (push) Successful in 1m39s
deploy / build (push) Successful in 6m31s
deploy / deploy (push) Successful in 17s
deploy / deploy-web (push) Successful in 11s
ci(deploy): read the nginx logs without sudo
The nginx log capture ran `sudo tail`, which is not in the proxy's
scoped sudoers, so deploy-web failed on its very last step -- after the
frontend had shipped, nginx had reloaded and every health check had
passed. The deployment was complete and the job still reported failure.

nginx's logs are 0644 and /var/log/nginx is traversable, so the deploy
account reads them as itself; verified with `sudo -u gitea_ci tail`.
Dropping the sudo is the fix, rather than widening the whitelist for
something that never needed it.

Also captures the public vhost's error log alongside the mesh one, since
there are now two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:46:06 +03:00

374 lines
16 KiB
YAML

name: deploy
on:
push: { branches: [main] }
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
# --- infra truth: hosts, ports and paths live here, not in a manifest ---
SERVICE_HOST: bob.hanzalova.internal
# Bind the mesh address rather than a wildcard: the reverse proxy is on a
# different host, so loopback will not do, and a wildcard bind on a host that
# may later gain another interface would publish the registry there too.
LISTEN_ADDR: 10.6.0.193:20482
APP_PORT: "20482"
S3_ENDPOINT: http://caveman.kosherinata.internal:9000
S3_BUCKET: rustingface
UPSTREAM_ENABLED: "true"
AUTH_MODE: bearer
AUTH_ANONYMOUS: catalog
ALLOW_NEW_REPOS: "false"
PROXY_HOST: hanzalova.internal
WEBROOT: /var/www/rustingface
jobs:
build:
runs-on: rust
steps:
- uses: actions/checkout@v4
# The gate runs before anything is built for deployment, so a broken
# commit on main never reaches a host.
- name: format
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: test
run: cargo test --workspace --all-features
# Static musl: the runner is Fedora 44 and the target is Fedora 43, so a
# dynamically linked binary could reference a newer glibc than bob has.
- name: build
run: |
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl --bin rustingface
- uses: actions/upload-artifact@v3
with:
name: rustingface
path: target/x86_64-unknown-linux-musl/release/rustingface
build-web:
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
# The gate for the frontend, matching the Rust one: a type error or a
# lint failure must not reach a host either.
# The runner's pnpm is not the workstation's -- 11.x here against 10.x
# there at the time of writing -- and the build-script allowlist was
# renamed between those majors. Printing the version and the resolved
# settings is what turned three failed guesses into a one-line answer,
# so it stays.
- name: pnpm environment
working-directory: web
run: |
pnpm --version
pnpm config get --json onlyBuiltDependencies || true
pnpm config list || true
- name: install
working-directory: web
run: pnpm install --frozen-lockfile
- name: lint
working-directory: web
run: pnpm lint
- name: build
working-directory: web
# `pnpm build` runs `tsc -b` first, so type errors fail here.
run: pnpm build
- uses: actions/upload-artifact@v3
with:
name: rustingface-web
path: web/dist
deploy:
needs: build
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: rustingface
path: dist
# Without this, a missing secret first surfaces as ssh failing to parse an
# empty key, which names neither the secret nor the fix.
- name: check the required secrets are set
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
CLIENT_TOKENS: ${{ secrets.CLIENT_TOKENS }}
run: |
missing=""
for name in RSYNC_SSH_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY CLIENT_TOKENS; do
[ -n "${!name:-}" ] || missing="$missing $name"
done
if [ -n "$missing" ]; then
echo "missing repo secret(s):$missing" >&2
echo "Set them in Settings > Actions > Secrets. HF_TOKEN is optional and" >&2
echo "only needed for gated repositories." >&2
exit 1
fi
echo "all required secrets are present"
- name: authenticate to the target
id: auth
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 600 ~/.ssh/id_gitea_ci
cat > ~/.ssh/config <<CFG
Host $SERVICE_HOST
User gitea_ci
IdentityFile ~/.ssh/id_gitea_ci
IdentitiesOnly yes
StrictHostKeyChecking accept-new
CFG
ssh "$SERVICE_HOST" hostname -f
- name: render config
env:
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
CLIENT_TOKENS: ${{ secrets.CLIENT_TOKENS }}
run: |
# Literal substitution, not a shell expansion: a secret containing
# $, ` or \ must survive intact.
python3 - <<'PY'
import os, pathlib
template = pathlib.Path("asset/config/config.toml.tmpl").read_text()
for key in ("LISTEN_ADDR", "S3_ENDPOINT", "S3_BUCKET", "UPSTREAM_ENABLED",
"AUTH_MODE", "AUTH_ANONYMOUS", "ALLOW_NEW_REPOS"):
template = template.replace("{{%s}}" % key, os.environ[key])
pathlib.Path("dist/config.toml").write_text(template)
for name, var in (("s3-access-key", "S3_ACCESS_KEY_ID"),
("s3-secret-key", "S3_SECRET_ACCESS_KEY"),
("hf-token", "HF_TOKEN"),
("client-tokens", "CLIENT_TOKENS")):
pathlib.Path("dist", name).write_text(os.environ.get(var, ""))
PY
# Fail loudly rather than shipping a config with an unrendered
# placeholder that would only surface as a runtime parse error.
# Matches the placeholder *shape* rather than a bare "{{", so the
# template's own prose describing the syntax is not a false positive.
if grep -nE '\{\{[A-Z_][A-Z0-9_]*\}\}' dist/config.toml; then
echo "unrendered placeholder in the config" >&2
exit 1
fi
if [ ! -s dist/s3-access-key ] || [ ! -s dist/s3-secret-key ]; then
echo "S3 credentials are empty; set the S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY secrets" >&2
exit 1
fi
# Bearer auth with no tokens refuses to start, so catch it here
# rather than as a service that restart-loops after the files land.
if [ "$AUTH_MODE" = bearer ] && [ ! -s dist/client-tokens ]; then
echo "AUTH_MODE is bearer but the CLIENT_TOKENS secret is empty." >&2
echo "Set it to one accepted token per line; '#' comments are allowed." >&2
exit 1
fi
- name: create the service account and its config directory
run: |
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/systemd/rustingface.sysusers.conf \
"$SERVICE_HOST:/etc/sysusers.d/rustingface.conf"
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo systemd-sysusers
sudo install -d -o root -g rustingface -m 0750 /etc/rustingface'
- name: ship the binary, config and credentials
run: |
set -euo pipefail
rsync -az --rsync-path='sudo rsync' \
dist/rustingface "$SERVICE_HOST:/usr/local/bin/rustingface"
for f in config.toml s3-access-key s3-secret-key hf-token client-tokens; do
rsync -az --rsync-path='sudo rsync' --mkpath \
"dist/$f" "$SERVICE_HOST:/etc/rustingface/$f"
done
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/systemd/rustingface.service \
"$SERVICE_HOST:/etc/systemd/system/rustingface.service"
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo chmod 0755 /usr/local/bin/rustingface
sudo chown -R root:rustingface /etc/rustingface
sudo chmod 0640 /etc/rustingface/config.toml /etc/rustingface/s3-access-key /etc/rustingface/s3-secret-key /etc/rustingface/hf-token /etc/rustingface/client-tokens
sudo restorecon -R /usr/local/bin/rustingface /etc/rustingface'
- name: firewalld
run: |
set -euo pipefail
# rsync the XML first: firewalld only learns a freshly-shipped custom
# service after --reload, and querying it before that fails.
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/firewalld/rustingface.xml \
"$SERVICE_HOST:/etc/firewalld/services/rustingface.xml"
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo firewall-cmd --reload
zone=$(sudo firewall-cmd --get-default-zone)
if sudo firewall-cmd --zone="$zone" --query-service=rustingface; then
echo "rustingface already enabled in $zone"
else
sudo firewall-cmd --permanent --zone="$zone" --add-service=rustingface
sudo firewall-cmd --zone="$zone" --add-service=rustingface
fi'
- name: restart
run: |
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo systemctl daemon-reload
sudo systemctl enable rustingface.service
sudo systemctl restart rustingface.service'
- name: health check
run: |
set -euo pipefail
ssh "$SERVICE_HOST" 'sudo systemctl is-active rustingface.service'
for attempt in $(seq 1 20); do
if ssh "$SERVICE_HOST" "curl -fsS http://$LISTEN_ADDR/healthz"; then
echo "healthy after $attempt attempt(s)"
exit 0
fi
sleep 2
done
echo "rustingface did not answer /healthz" >&2
exit 1
- name: doctor
run: |
# Proves the deployment can actually reach and write to the bucket,
# which a liveness probe deliberately does not.
ssh "$SERVICE_HOST" \
'sudo -u rustingface /usr/local/bin/rustingface --config /etc/rustingface/config.toml doctor'
# Capture the unit's startup journal even when a later step failed --
# that is the record worth having. Conditioned on the target actually
# being reachable: without it, a job that failed before authenticating
# ends with "Host key verification failed", which buries the real cause
# under an error about something else entirely.
- name: journal
if: always() && steps.auth.outcome == 'success'
run: ssh "$SERVICE_HOST" 'journalctl -u rustingface.service -n 200 --no-pager'
deploy-web:
# The frontend is only useful once the API it reads is live, and the vhost
# it is served from proxies to that API.
needs: [build-web, deploy]
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: rustingface-web
path: dist
- name: check the required secrets are set
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
run: |
[ -n "${RSYNC_SSH_KEY:-}" ] || { echo "missing repo secret: RSYNC_SSH_KEY" >&2; exit 1; }
echo "all required secrets are present"
- name: authenticate to the proxy
id: auth
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 600 ~/.ssh/id_gitea_ci
cat > ~/.ssh/config <<CFG
Host $PROXY_HOST
User gitea_ci
IdentityFile ~/.ssh/id_gitea_ci
IdentitiesOnly yes
StrictHostKeyChecking accept-new
CFG
ssh "$PROXY_HOST" hostname -f
- name: ship the built frontend
run: |
set -euo pipefail
[ -f dist/index.html ] || { echo "the build produced no index.html" >&2; exit 1; }
# --delete so a rename leaves no orphaned asset behind; the webroot
# holds nothing but this build's output.
rsync -az --delete --rsync-path='sudo rsync' \
dist/ "$PROXY_HOST:$WEBROOT/"
# Webroots must be httpd_sys_content_t or nginx answers 403.
ssh "$PROXY_HOST" "sudo restorecon -R $WEBROOT"
- name: reload nginx
run: |
set -euo pipefail
# `nginx -t` cannot catch a bind failure, but it does catch a config
# that would break every other vhost on this shared proxy.
ssh "$PROXY_HOST" 'sudo nginx -t'
ssh "$PROXY_HOST" 'sudo systemctl reload nginx'
- name: health check
run: |
set -euo pipefail
# Probed from the proxy, not from this runner. The runner is a plain
# Fedora container and does not carry the internal root CA, so a TLS
# probe from here would fail on trust rather than on anything real
# (verified: a fedora:43 container gets 000, the proxy gets 200).
# The proxy resolves rf.internal to itself, and a mesh name does not
# hit the public-name hairpin.
ssh "$PROXY_HOST" 'set -euo pipefail
# The app is served, and the API it reads is reachable through the
# same vhost. Both, because a working page over a dead API looks
# fine until somebody clicks Models.
curl -fsS https://rf.internal/ | grep -q "id=.root."
curl -fsS https://rf.internal/v1/status | grep -q bucket
curl -fsS https://rf.internal/healthz | grep -q ok
# A client route must fall back to the app rather than 404.
curl -fsS https://rf.internal/models | grep -q "id=.root."
# An API path must still reach the service, not the app. With
# bearer auth on this is a 401, which is itself the proof that the
# request reached the service rather than the static root.
code=$(curl -s -o /dev/null -w "%{http_code}" https://rf.internal/api/whoami-v2)
case "$code" in 200|401) ;; *) echo "api path returned $code" >&2; exit 1;; esac
# The public vhost, if its certificate exists yet. Checked over
# loopback with the SNI name, because the public name may not
# resolve from inside the mesh until the DNS record is published.
if sudo test -d /etc/letsencrypt/live/rustingface.com; then
curl -fsS --resolve rustingface.com:443:127.0.0.1 \
https://rustingface.com/ | grep -q "id=.root."
# The catalogue is the public face and must answer anonymously.
curl -fsS --resolve rustingface.com:443:127.0.0.1 \
https://rustingface.com/v1/status | grep -q bucket
# /metrics must not be public.
m=$(curl -s -o /dev/null -w "%{http_code}" --resolve rustingface.com:443:127.0.0.1 \
https://rustingface.com/metrics)
[ "$m" = 404 ] || { echo "/metrics is exposed publicly ($m)" >&2; exit 1; }
echo "public vhost answering, /metrics closed"
else
echo "rustingface.com has no certificate yet; skipping the public checks"
fi'
echo "frontend and API both answering through rf.internal"
# No sudo: nginx's logs are world-readable and /var/log/nginx is
# traversable, so the deploy account can read them as itself. Reaching
# for sudo here would have meant widening the scoped whitelist for
# something it does not need.
- name: nginx log
if: always() && steps.auth.outcome == 'success'
run: |
for log in rf.internal rustingface.com; do
echo "--- /var/log/nginx/$log.error.log"
ssh "$PROXY_HOST" "tail -n 30 /var/log/nginx/$log.error.log"
done