Sets allow_new_repos true in the deployed configuration, so an authenticated request for a repository the bucket does not hold fetches it from upstream and stores it on the way past. This is what makes the registry useful as a drop-in HF_ENDPOINT for a consumer: helexa's neurons resolve eight catalogued models, none of which were in the bucket, and pre-seeding each one by hand before it can be served defeats the purpose of putting a mirror in front of the Hub at all. With the flag off the service answered exactly as designed -- "forbidden by policy: Qwen/Qwen3-1.7B is not already stored and policy.allow_new_repos is false" -- which is correct behaviour and the wrong deployment for this use. The trade is real and now stated where it will be read rather than buried: the bucket becomes bounded by auth rather than by policy, so every issued client token is a licence to store arbitrary upstream repositories. policy.allowlist is the middle setting that keeps on-demand mirroring while confining it to expected namespaces, and it stays empty here only because the consumer's catalogue is expected to grow. The agent notes previously asserted that the deployment relied on the flag being false; that claim would have been quietly wrong from this commit onward, so it is rewritten rather than left to mislead.
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.kosherinata.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.
|