The render step guards against shipping a config with an unrendered
{{PLACEHOLDER}} by grepping for a bare "{{". The template's own header
comment explains the substitution syntax and contains that string, so
the guard matched line 3 of every successfully rendered config and
failed the deploy with "unrendered placeholder in the config" -- while
every actual placeholder had been substituted correctly.
Matches the placeholder shape ({{UPPER_SNAKE}}) instead, so prose about
the syntax is not a false positive, and rewores the template comment to
keep it clear of the guard either way.
Also whitelists the post-deploy `doctor` invocation in the scoped
sudoers drop-in. It runs as the service account rather than root -- the
point is to prove the credentials the service will actually use can
reach and write to the bucket -- and `sudo -u rustingface` was not
covered by any existing rule, so the step would have been refused.
Verified with `sudo -l -U gitea_ci`: the rule is listed, and `sudo -u
rustingface /bin/bash` remains denied.
Found by auditing every deploy step against the installed whitelist
after run 7 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
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:
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. Architectural conventions this
project inherits are in ~/git/architecture
— workspace layout, deployment, systemd hardening, firewalld, SELinux, port
allocation.
What it guarantees
- Fetch-once retention. Any blob served through rustingface is durably stored before the response completes.
- No eviction. No TTL, no LRU, no background cleaner. Storage growth is the operator's problem, by design.
- Offline sufficiency. Once a
(repo, revision, file)triple has been served, it is servable forever with no network path to any upstream. - Reference stability.
mainresolves to the same commit on every later request until an operator repins it. - 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.
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
-apiplus-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-apiis a library, consumed by the binary, rather than being the binary itself. Same reason.
Build and run
cargo build --release
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
Locally, against a directory instead of a bucket:
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.
The two settings worth understanding:
policy.ref_resolution—freeze(default) records the first resolution of a mutable ref and reuses it indefinitely.followre-resolves upstream, which breaks guarantee 4 and warns at startup.upstream.enabled—falseseals the registry. No upstream client is constructed at all, so there is no code path that could reach the network.
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 | rf.internal, via the hanzalova edge proxy (mesh-only, no public record) |
| 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, ships it as
gitea_ci over scoped sudo, and health-checks with /healthz followed by
rustingface doctor — which proves the bucket is actually writable rather than
merely that a socket opened.
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, HF_TOKEN.
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
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.