Files
rustingface/.gitea/workflows/deploy.yml
rob thijssen acd47a31c8 feat(web): add the operator UI and the /v1 inventory API it reads
Three routes, per architecture/generic.md §4 (Vite + React + SWC + TS,
static, served by nginx): / renders the repository readme, /models is a
paged and filterable listing of what the bucket holds, and
/{namespace}/{name} shows one repository -- its pinned refs, the files
actually stored, and its model card.

The API it reads is /v1/, deliberately not /api/. That surface is the
Hub's, recorded and replayed verbatim, and adding routes of our own to
it risks a client mistaking one for the real thing. /v1/ answers a
question the Hub has no equivalent for -- what is in this bucket --
which is inventory, not the model search the spec rules out.

The listing shows only what has actually been fetched, never upstream's
siblings. A repository pulled one file at a time shows one file, which
is the honest answer to "what can I get from here offline".

Model cards are third-party content, so their HTML is parsed and then
sanitised against GitHub's allowlist. The plugin order is load-bearing
and commented as such. Verified against a card crafted with <script>,
onerror, a javascript: href, an <iframe>, an SVG-embedded script and an
inline handler: none execute and ordinary markdown still renders. Card
images are not loaded at all -- fetching them would leak the viewer's
address to a third party and make an offline registry's pages depend on
the internet.

Routing: rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is
both a page and the prefix of a file. The vhost splits them the way the
Hub does -- /resolve/ anywhere in the path, plus /api/ and /v1/, go to
the service; everything else is the app. A repo legitimately named
v1/repos is handed back to the resolve path by the router, and there is
a test for it. The /resolve/ test is on the repo type rather than the
path substring, because a repo may contain a directory called resolve
and /api/models/a/b/tree/main/resolve/f must stay a tree request.

Deployment: the frontend ships to hanzalova:/var/www/rustingface, so
that host now gets its own scoped gitea_ci drop-in -- narrower than the
service host's: a webroot rsync, a relabel, nginx -t and a reload. The
health check probes rf.internal from the proxy rather than from the
runner, because a runner is a plain Fedora container with no internal
root CA (verified: fedora:43 gets 000, the proxy gets 200).

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

320 lines
13 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"
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.
- 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 }}
run: |
missing=""
for name in RSYNC_SSH_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; 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 }}
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"):
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")):
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
- 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; 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
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."
# And an API path must still reach the service, not the app.
curl -fsS https://rf.internal/api/whoami-v2 | grep -q rustingface'
echo "frontend and API both answering through rf.internal"
- name: nginx log
if: always() && steps.auth.outcome == 'success'
run: ssh "$PROXY_HOST" 'sudo tail -n 50 /var/log/nginx/rf.internal.error.log'