magrathea, caveman, nikola and frootmig moved from kosherinata (DC) to hanzalova (office) on 2026-11-01 and were renamed accordingly: magrathea.hanzalova.internal 10.6.0.194 (postgres primary) caveman.hanzalova.internal 10.6.0.186 (minio, gitea) nikola.hanzalova.internal 10.6.0.173 frootmig.hanzalova.internal 10.6.0.131 The old *.kosherinata.internal names remain as transitional DNS aliases and will be removed once deploys use the new names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzeoVkZAgsEiJaTpxRZ42Q
289 lines
12 KiB
Markdown
289 lines
12 KiB
Markdown
# rustingface
|
|
|
|
**A sovereign, HuggingFace-compatible model registry.**
|
|
|
|
Single Rust binary. One config file. S3-compatible storage. Anything a client
|
|
fetches through it lives in your bucket until you explicitly remove it.
|
|
|
|
Set `HF_ENDPOINT` and nothing else changes:
|
|
|
|
```sh
|
|
export HF_ENDPOINT=https://rf.internal
|
|
python -c "from transformers import AutoModel; AutoModel.from_pretrained('Qwen/Qwen3-0.6B')"
|
|
```
|
|
|
|
The design is in [`doc/spec.md`](doc/spec.md). Architectural conventions this
|
|
project inherits are in [`~/git/architecture`](https://git.lair.cafe/grenade/architecture)
|
|
— workspace layout, deployment, systemd hardening, firewalld, SELinux, port
|
|
allocation.
|
|
|
|
---
|
|
|
|
## What it guarantees
|
|
|
|
1. **Fetch-once retention.** Any blob served through rustingface is durably
|
|
stored before the response completes.
|
|
2. **No eviction.** No TTL, no LRU, no background cleaner. Storage growth is
|
|
the operator's problem, by design.
|
|
3. **Offline sufficiency.** Once a `(repo, revision, file)` triple has been
|
|
served, it is servable forever with no network path to any upstream.
|
|
4. **Reference stability.** `main` resolves to the same commit on every later
|
|
request until an operator repins it.
|
|
5. **State portability.** The bucket is the entire system state.
|
|
|
|
It does not reimplement the Hub's semantics. It records the exact HTTP metadata
|
|
upstream returned at first fetch — `ETag`, `X-Repo-Commit`, `X-Linked-Etag`,
|
|
`X-Linked-Size` — and replays it byte-identically, so a `~/.cache/huggingface`
|
|
populated through rustingface is interchangeable with one populated directly
|
|
from the Hub.
|
|
|
|
---
|
|
|
|
## Layout
|
|
|
|
```
|
|
crates/
|
|
rustingface-entities/ manifest, ref and repo schemas; bucket key layout;
|
|
configuration; the X-Error-Code taxonomy. No I/O.
|
|
rustingface-core/ resolver, freeze pinning, single-flight, the
|
|
streaming tee, policy, gc/verify/refresh. Defines the
|
|
Store and Upstream ports.
|
|
rustingface-data/ adapters: object_store (S3 and local) and reqwest.
|
|
rustingface-api/ the axum surface and the header contract.
|
|
rustingface/ the binary: serve, plus the admin subcommands.
|
|
web/ Vite + React + SWC + TS; the operator-facing UI
|
|
asset/ systemd, firewalld, nginx, config template
|
|
script/infra-setup.sh one-time host provisioning
|
|
test/conformance/ the suite that drives a real huggingface_hub
|
|
```
|
|
|
|
Two departures from `architecture/generic.md`, both deliberate:
|
|
|
|
- **One binary, not `-api` plus `-cli`.** `doc/spec.md` §9 specifies a single
|
|
binary with subcommands so the CLI and the service cannot drift. The library
|
|
crate split is unchanged.
|
|
- **`rustingface-api` is a library**, consumed by the binary, rather than being
|
|
the binary itself. Same reason.
|
|
|
|
---
|
|
|
|
## The web UI
|
|
|
|
`web/` is a static single-page app served from the nginx host. Three routes:
|
|
`/` renders this readme, `/models` is a paged, filterable listing of what the
|
|
bucket holds, and `/{namespace}/{name}` shows one repository — its pinned refs,
|
|
the files actually stored, and its model card.
|
|
|
|
It reads a **`/v1/` API** that is deliberately separate from `/api/`. That
|
|
surface belongs to the Hub: it is 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.
|
|
|
|
```
|
|
GET /v1/status what this instance is; whether it is sealed
|
|
GET /v1/repos?page=&per_page=&type=&q= paged inventory
|
|
GET /v1/repos/{type}/{repo_id} one repository, with its stored files
|
|
```
|
|
|
|
Two things the UI is careful about, because model cards are third-party
|
|
content:
|
|
|
|
- Card HTML is parsed and then **sanitised** against GitHub's allowlist.
|
|
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.
|
|
- Images referenced by a card are **not loaded**; the alt text stands in.
|
|
Fetching them would leak the viewer's address to a third party and make an
|
|
offline registry's pages depend on the internet.
|
|
|
|
```sh
|
|
cd web && pnpm install
|
|
pnpm dev # proxies /v1, /api and the resolve paths to a local service
|
|
VITE_DEV_API=http://bob.hanzalova.internal:20482 pnpm dev
|
|
```
|
|
|
|
### Routing
|
|
|
|
rustingface's URL space *is* the Hub's, so `/Qwen/Qwen3-0.6B` is both a page a
|
|
person reads and the prefix of a file a client downloads. The vhost splits them
|
|
the same way the Hub does — anything containing `/resolve/`, plus `/api/` and
|
|
`/v1/`, goes to the service; everything else is the app, with client-side
|
|
routes falling back to `index.html`.
|
|
|
|
The one consequence worth knowing: a repository named `v1/repos` or `api/…`
|
|
collides with those prefixes. `/v1/repos/…` is disambiguated in the router (its
|
|
first segment is always a repo type, so anything else is handed back to the
|
|
resolve path), and the Hub itself cannot host a repo at `api/models` either.
|
|
|
|
---
|
|
|
|
## Build and run
|
|
|
|
```sh
|
|
cargo build --release
|
|
cargo test --workspace
|
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
|
```
|
|
|
|
Locally, against a directory instead of a bucket:
|
|
|
|
```sh
|
|
cargo run -- --config asset/config/config.toml.example serve
|
|
HF_ENDPOINT=http://127.0.0.1:20482 hf download Qwen/Qwen3-0.6B config.json
|
|
```
|
|
|
|
`storage.local_path` forfeits guarantee 5 — a directory is not a bucket you can
|
|
re-point a new host at — and the service says so at startup. It exists for
|
|
tests and trials.
|
|
|
|
### Configuration
|
|
|
|
One TOML file, `/etc/rustingface/config.toml` by default. Every key is
|
|
overridable by environment (`RUSTINGFACE__SERVER__LISTEN`). Secrets are named
|
|
by path, never inline, and a path that does not exist is retried under
|
|
`$CREDENTIALS_DIRECTORY` so one file works both under systemd's
|
|
`LoadCredential=` and when run by hand. See
|
|
[`asset/config/config.toml.tmpl`](asset/config/config.toml.tmpl).
|
|
|
|
The two settings worth understanding:
|
|
|
|
- **`policy.ref_resolution`** — `freeze` (default) records the first resolution
|
|
of a mutable ref and reuses it indefinitely. `follow` re-resolves upstream,
|
|
which breaks guarantee 4 and warns at startup.
|
|
- **`upstream.enabled`** — `false` seals the registry. No upstream client is
|
|
constructed at all, so there is no code path that could reach the network.
|
|
- **`auth.mode` / `auth.anonymous`** — `bearer` requires a token from
|
|
`auth.token_file`; `anonymous = "catalog"` then carves out the `/v1/`
|
|
surface so an unauthenticated visitor can browse the inventory and read
|
|
model cards, but cannot download a weight. That carve-out is safe because
|
|
every handler under `/v1/` reads the bucket and never consults upstream, so
|
|
an anonymous visitor cannot cause anything to be stored.
|
|
- **`policy.allow_new_repos`** — `false` restricts serving to repositories
|
|
already in the bucket. This, **not** auth, is what bounds how large the
|
|
bucket gets: a token says *who*, not *what*, and any token holder can
|
|
otherwise cause an arbitrary model to be fetched. Growing the registry
|
|
becomes an operator action (`rustingface fetch`).
|
|
|
|
The reference deployment sets it **`true`**, because the point of that
|
|
deployment is that a client pointed here by `HF_ENDPOINT` mirrors what it
|
|
uses on first request. There the bucket is bounded by who holds a token, and
|
|
`policy.allowlist` is available to confine on-demand mirroring to expected
|
|
namespaces without switching it off.
|
|
|
|
---
|
|
|
|
## Admin CLI
|
|
|
|
Same binary, subcommands.
|
|
|
|
```
|
|
rustingface serve # default
|
|
rustingface fetch <repo>[@rev] [--files …] # warm the bucket ahead of need
|
|
rustingface pin <repo> <ref> <commit> # set a ref explicitly
|
|
rustingface refresh <repo> [<ref>] # re-resolve upstream and repin
|
|
rustingface list [--repo-type models] # stored repos, revisions, sizes
|
|
rustingface show <repo>[@rev] # manifest contents
|
|
rustingface rm <repo>[@rev] --yes # remove refs/manifests (NOT blobs)
|
|
rustingface gc [--dry-run] # delete blobs no manifest references
|
|
rustingface verify [<repo>] # re-read blobs, check digests
|
|
rustingface doctor # config, S3 reachability, permissions
|
|
```
|
|
|
|
`rm` and `gc` are separate on purpose. Removal detaches metadata; reclamation
|
|
is a second, explicit, dry-runnable step. Nothing deletes bytes without an
|
|
operator typing `gc`.
|
|
|
|
---
|
|
|
|
## Deployment
|
|
|
|
| Concern | Where |
|
|
| --- | --- |
|
|
| Service | `bob.hanzalova.internal:20482` |
|
|
| Object storage | MinIO on `caveman.hanzalova.internal:9000`, bucket `rustingface` |
|
|
| Ingress (mesh) | `rf.internal`, internal CA, via the hanzalova edge proxy |
|
|
| Ingress (public) | `rustingface.com`, Let's Encrypt, same proxy and webroot |
|
|
| Frontend | `/var/www/rustingface` on `hanzalova`, served by the same vhost |
|
|
| Port | `20482`, derived from the service name per `architecture/port-allocations.md` §3 |
|
|
|
|
Deployment is CI-driven (`.gitea/workflows/deploy.yml`): push to `main`, the
|
|
workflow runs the lint/test gate, builds a static musl binary and the frontend,
|
|
ships both as `gitea_ci` over scoped sudo, and health-checks. The service is
|
|
probed with `/healthz` and then `rustingface doctor`, which proves the bucket is
|
|
actually writable rather than merely that a socket opened; the frontend is
|
|
probed through `rf.internal` **from the proxy**, since a CI runner is a plain
|
|
container that does not carry the internal root CA.
|
|
|
|
The frontend deploys to `hanzalova` (`/var/www/rustingface`) and the service to
|
|
`bob`, so both hosts carry a scoped `gitea_ci` sudoers drop-in. The proxy's is
|
|
the narrower of the two: a webroot rsync, a relabel, `nginx -t` and a reload.
|
|
|
|
One-time host provisioning is `script/infra-setup.sh`, run by an operator from
|
|
a workstation with full sudo. It is idempotent and skips unreachable hosts.
|
|
|
|
Required Gitea repo secrets: `RSYNC_SSH_KEY`, `S3_ACCESS_KEY_ID`,
|
|
`S3_SECRET_ACCESS_KEY`, `CLIENT_TOKENS` (one accepted client token per line),
|
|
and `HF_TOKEN` (optional; only for gated repositories).
|
|
|
|
### The two audiences
|
|
|
|
`rustingface.com` is a shop window: an operator evaluating whether to run their
|
|
own instance can browse the catalogue and read model cards without a
|
|
credential. Downloads need a token, on both names — auth is enforced by the
|
|
service, not per-vhost, so a token is required on `rf.internal` too.
|
|
|
|
The public vhost additionally closes `/metrics`, which the service leaves
|
|
auth-exempt so a mesh Prometheus can scrape it without a credential. That
|
|
exemption is fine on the mesh and unacceptable publicly, so nginx closes it
|
|
rather than the service relaxing it.
|
|
|
|
The service holds no durable state, which is what makes the systemd hardening
|
|
easy: `ProtectSystem=strict` with no `ReadWritePaths` and no `StateDirectory`
|
|
at all.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
cargo test --workspace # unit + the sovereignty suite
|
|
python3 -m venv .venv && .venv/bin/pip install -r test/conformance/requirements.txt
|
|
.venv/bin/python test/conformance/conformance.py --binary target/release/rustingface
|
|
```
|
|
|
|
`crates/rustingface-api/tests/sovereignty.rs` runs the whole service against a
|
|
fake Hub that speaks the real header protocol and counts what it was asked for:
|
|
cold fetch, sealed replay, single-flight (eight clients, one upstream fetch),
|
|
client disconnect mid-transfer, range resume, freeze stability across an
|
|
upstream commit move, `gc` after `rm`, digest mismatch, deduplication, policy,
|
|
bearer auth, and state portability.
|
|
|
|
`test/conformance/conformance.py` drives a **pinned, unmodified
|
|
`huggingface_hub`** through rustingface against the real Hub, then seals it and
|
|
re-runs. It also re-checks the `X-Error-Code` strings against the installed
|
|
client, because those are client-internal constants rather than a stable API —
|
|
a client upgrade is a spec-review trigger.
|
|
|
|
Run the real offline test by hand before trusting a deployment: null-route
|
|
`huggingface.co` at the site router and verify there, not by trusting the
|
|
service.
|
|
|
|
---
|
|
|
|
## Xet
|
|
|
|
The Hub is migrating to Xet-backed storage. rustingface implements none of it:
|
|
it never advertises Xet capability downstream and never negotiates it upstream,
|
|
so files arrive whole through the Git LFS bridge. The cost is fetch-time
|
|
bandwidth on first pull. The benefit is that the on-disk format stays "files
|
|
with names" and no future maintainer can strand your data behind a
|
|
chunk-reconstruction format. `upstream.disable_xet = false` is refused at
|
|
startup.
|
|
|
|
---
|
|
|
|
## Licence
|
|
|
|
GPL-3.0-or-later.
|