Files
Oxicloud/docs/plan/bundled-binary.md
T
Edouard Vanbelle 807a0efd27 docs: OXICLOUD_ENABLE_VIDEO_THUMBNAILS + bundled-binary design record
Documents OXICLOUD_ENABLE_VIDEO_THUMBNAILS (+ OXICLOUD_FFMPEG_PATH) in
example.env and docs/config/env.md — closes the discoverability gap
where the env var was only visible in Rust docstrings.

Also lands docs/plan/bundled-binary.md — the design record referenced
from code comments in src/cli/mod.rs, src/interfaces/web/embedded.rs,
and the Dockerfile.
2026-08-29 11:57:48 +02:00

876 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bundled Binary Distribution — Multi-Platform Plan
## Context
Users have asked for a way to run OxiCloud without Docker — a plain
binary. Today `release.yml` only creates a GitHub Release with notes;
no binary is attached. The Docker workflow (`docker-publish.yml`) ships
multi-arch images, but that's a separate audience.
The blocker for a "just download and run" experience is that the
`oxicloud` binary depends on the SvelteKit build output (`static-dist/`
under `<static_path>/static-dist/`, resolved by
`src/interfaces/web/mod.rs::resolve_static_path` at boot). Two files
to distribute per platform is friction; a single self-contained binary
is what users actually want.
The ask has two parts:
1. Ship **single-file binaries with frontend assets embedded**, for
the common Linux targets and macOS.
2. Audit the current binary set — the crate produces 6+ binaries today,
some of which are test-only. Strip anything that shouldn't ship to
end users.
The intended outcome: a `v0.9.0` release attaches **4 musl-static
tarballs** (Linux amd64/arm64 + macOS Intel/Apple Silicon), each
~15-30 MB, containing a **single `oxicloud` binary** with assets +
operator tools + one-off migrations all baked in. User extracts the
tarball, sets `DATABASE_URL`, runs `./oxicloud` — server up.
Subcommands (`oxicloud opaque setup`, `oxicloud migrate
nfc-filenames --dry-run`) provide operator access to the same tools
currently split across `oxicloud-cli` and `migrate-nfc-filenames`.
Design shape (confirmed 2026-08-27):
- **musl-only Linux** — parity with the existing Docker image (Alpine
base), no glibc-version fragmentation
- **Assets embedded via `rust-embed` with compile-time deflate
compression** — smaller binary
- **`bundled-assets` is opt-in** — default `cargo build` unchanged;
`just dev` still uses the filesystem `ServeDir` with Vite HMR
- **Single unified binary** — `oxicloud`, `oxicloud-cli`, and
`migrate-nfc-filenames` collapse into one clap-driven executable
with implicit-server default (backwards compat with existing Docker
CMD / systemd units)
## Current binary inventory
From `Cargo.toml` + `src/bin/`:
| Binary | Path | Purpose | Ship to end users? |
|---|---|---|---|
| `oxicloud` | `src/main.rs` (implicit) | Server | **YES** |
| `oxicloud-cli` | `src/bin/oxicloud-cli.rs` | Operator toolbox (`opaque setup/reset`) | **MERGED** — absorbed into `oxicloud` per Deliverable 1b |
| `migrate-nfc-filenames` | `src/bin/migrate-nfc-filenames.rs` | One-off filename migration (historical, June 2026 fix) | **MERGED** — absorbed into `oxicloud migrate nfc-filenames` per Deliverable 1a→1b |
| `generate-openapi` | `src/bin/generate-openapi.rs` | Regenerate `resources/gen/openapi.json` | NO — dev tool, gate behind `dev_tools` feature |
| `opaque-hurl-helper` | `src/bin/opaque-hurl-helper.rs` | Hurl test companion (OPRF client) | NO — gate behind `test_utils` feature |
| `dpop-hurl-helper` | `src/bin/dpop-hurl-helper.rs` | Hurl test companion (ES256 DPoP proof) | NO — gate behind `test_utils` feature |
| `load-seed` | `src/bin/load-seed.rs` | Test fixture seeder | Already gated behind `load_seed_bin` feature ✅ |
After Deliverables 1 + 1a + 1b, `cargo build --release --bins`
produces exactly ONE binary: `oxicloud`. That single binary ships in
the tarball and in the Docker image.
## Deliverables
### 1. Squash test/dev binaries with `required-features`
Cargo respects `required-features` per `[[bin]]` — a binary is only
built when its listed features are active. This gates test helpers
out of `cargo build --release --bins` cleanly without needing custom
Cargo commands or shell trimming.
Edits to `Cargo.toml`:
```toml
[features]
# ... existing features ...
dev_tools = [] # NEW: gates ops tooling that shouldn't ship
[[bin]]
name = "opaque-hurl-helper"
path = "src/bin/opaque-hurl-helper.rs"
required-features = ["test_utils"] # NEW gate
[[bin]]
name = "dpop-hurl-helper"
path = "src/bin/dpop-hurl-helper.rs"
required-features = ["test_utils"] # NEW gate
[[bin]]
name = "generate-openapi"
path = "src/bin/generate-openapi.rs"
required-features = ["dev_tools"] # NEW gate — `just openapi` flips it
# [[bin]] name = "migrate-nfc-filenames" ← DELETED per Deliverable 1a
# [[bin]] name = "oxicloud-cli" ← DELETED per Deliverable 1b
```
Existing invocations that need adjustment:
- `just openapi` recipe → add `--features dev_tools` to the underlying
`cargo run --bin generate-openapi` call (currently `cargo run --bin
generate-openapi` per justfile)
- `tests/api/run.sh` → add `--features test_utils` when building the
two hurl helpers (shape confirmed: `cargo build [--release] --bin
opaque-hurl-helper` / same for dpop in each helper's build-if-missing
branch)
After these edits + Deliverables 1a + 1b: `cargo build --release --bins`
produces exactly ONE binary — `oxicloud`. Everything else falls out of
the default build set.
### 1a. Merge `migrate-nfc-filenames` into `oxicloud-cli`
The standalone `migrate-nfc-filenames` binary is a June-2026 one-off:
it cleans up NFD/NFC filename collisions in databases populated
before the write-time fix (`normalize_storage_name()` at
`src/domain/services/path_service.rs:36`, called from
`src/infrastructure/repositories/pg/file_blob_read_repository.rs:1062`).
New installs never need it; only pre-June 2026 databases do.
`oxicloud-cli`'s header docstring (`src/bin/oxicloud-cli.rs:20-23`)
already documents the growth pattern for absorbing tools like this:
> *"each new domain gets its own module below (e.g. `mod opaque`)
> with a `#[derive(Subcommand)]` enum for its actions and a
> `run(args) -> ExitCode` entrypoint. Keep each module self-contained
> so a future extraction is a file move."*
Note: this Deliverable is an intermediate step. Deliverable 1b then
absorbs `oxicloud-cli` itself into `oxicloud`, so the final CLI form
becomes `oxicloud migrate nfc-filenames --dry-run` — but 1a lands
first so the migration logic is proven inside the clap subcommand
tree before the main-binary merge.
Edits:
- **New `mod migrate` in `src/bin/oxicloud-cli.rs`** — moves the ~149
non-boilerplate lines from `migrate-nfc-filenames.rs::main()` into
a `run_nfc_filenames(dry_run: bool) -> ExitCode` function.
`env::args()` parsing goes away; clap handles it.
- **Delete `src/bin/migrate-nfc-filenames.rs`**.
- **Delete the `[[bin]]` entry** in `Cargo.toml`.
- **Update `Dockerfile`** — 6 references to `migrate-nfc-filenames`
(build commands at :46, :49, :89, `cp` steps at :130, :143, doc
comment at :170, `COPY --chmod=755 --from=app` at :173).
- **Update `docs/plan/benchmake-and-performance-tracking.md`** — 2
references to `migrate-nfc-filenames` at lines :44 and :161. Reword
to reference `oxicloud-cli migrate nfc-filenames` (or, after 1b,
`oxicloud migrate nfc-filenames`) and update the Cargo.toml
placement example.
- **Any operator runbook** that documents `docker exec <container>
migrate-nfc-filenames --dry-run` becomes `docker exec <container>
oxicloud-cli migrate nfc-filenames --dry-run` (intermediate) then
`docker exec <container> oxicloud migrate nfc-filenames --dry-run`
after 1b.
Effort: ~1.5 hours mechanical. Extracts the "should the tarball ship
migrate-nfc-filenames?" question entirely — everything now ships as
one operator toolbox binary that also happens to include the
historical migration.
Future v1.0 removal path (deferred): delete `mod migrate` block + one
enum variant + docs. Much cleaner than removing a whole `.rs` file +
Cargo entry + Dockerfile refs.
### 1b. Merge `oxicloud-cli` into `oxicloud`
Single binary — server + operator tools + migrations — with an
**implicit-server** subcommand tree. `oxicloud` with no arguments
starts the server (backwards compat with existing Docker CMD /
systemd units / user configs). Subcommands add operator actions on
top.
After merge, the CLI shape is:
```
$ oxicloud --help
Usage: oxicloud [OPTIONS] [COMMAND]
Commands:
opaque OPAQUE aPAKE substrate management
migrate One-time data migrations
If no command is given, oxicloud starts the server (see docs/config).
```
Concrete forms:
- `oxicloud` — start server (unchanged)
- `oxicloud opaque setup` — was `oxicloud-cli opaque setup`
- `oxicloud opaque reset --user alice --dry-run` — was `oxicloud-cli
opaque reset ...`
- `oxicloud migrate nfc-filenames --dry-run` — was
`migrate-nfc-filenames --dry-run` (via Deliverable 1a)
**Backwards-compat guarantee**: `oxicloud` with no args continues to
start the server. Every existing `CMD ["oxicloud"]`, `ExecStart=/usr/local/bin/oxicloud`,
docker-compose entry, and k8s Deployment keeps working unchanged.
Users updating to v0.9.0 see no surprise.
**Migration impact**: the user-visible break is that `oxicloud-cli
opaque setup` (etc.) no longer exists as a separate binary. Given the
current audience for `oxicloud-cli` is very small (essentially only
the maintainer), the migration cost is trivial. Any user who had
scripted it can adapt with a one-line find/replace.
Edits:
- **`src/main.rs`** — top of `main()`, before the current server
init, parse args via clap. If a subcommand is provided, dispatch
to it and exit; otherwise fall through to the existing server-init
path. Zero-arg startup cost stays ≤ microseconds (clap parse of
empty args).
- **`src/cli/mod.rs`** — NEW module. Contains the `Domain` enum + the
`opaque` and `migrate` submodules moved from
`src/bin/oxicloud-cli.rs`. Each subcommand module keeps its
self-contained shape per the growth pattern documented in the
old `oxicloud-cli.rs` header.
- **Delete `src/bin/oxicloud-cli.rs`** entirely.
- **Delete the `[[bin]] name = "oxicloud-cli"` block** in `Cargo.toml`.
- **`Dockerfile`** — drop all 4 references to `oxicloud-cli` (build
target lines + COPY steps). Simplified build command becomes
`cargo build --release --bin oxicloud` — single-binary.
- **Docs** — all `docker exec <container> oxicloud-cli <domain>
<action>` become `docker exec <container> oxicloud <domain>
<action>`. Same shape, one fewer word.
Effort: ~2 hours mechanical. Comparable to Deliverable 1a but with
slightly more care at the `main.rs` entry point for the args-vs-server
branch.
**Tarball layout simplification** — the tarball now ships exactly
ONE binary:
```
oxicloud-0.9.0-<triple>/
├── oxicloud (single file, server + tools + embedded assets)
├── example.env
├── LICENSE
└── README-install.md
```
That's the "just download and run" ethos in physical form: one file,
one command, done.
### 2. Add `bundled-assets` cargo feature
Purpose: at compile time, choose between filesystem-served static
assets (current behaviour — filesystem `ServeDir`) and
embedded-into-binary assets (via `rust-embed`). Feature is
**opt-in** — the default `cargo build --release` still produces a
filesystem-based binary, matching the current Docker image behaviour
(where assets are separate volume layers). Release tarballs are built
with `--features bundled-assets`.
**Dev mode is untouched.** `just dev` runs `PROFILE=dev cargo run` +
`npm run dev`, neither of which activates `bundled-assets`. The dev
workflow continues to:
- Serve from `frontend/` via Vite's dev server with HMR
- Backend reads static assets from `<static_path>/static-dist/` via the
usual `ServeDir` (or falls back to `frontend/static/` when the
build hasn't been run)
- No rebuild required to change locales, styles, or vendor JS
The `bundled-assets` code paths only compile when the feature is
explicitly enabled — under a `#[cfg(feature = "bundled-assets")]` gate.
The non-feature build's binary shape, ergonomics, and dev loop stay
identical to today.
Measured footprint (2026-08-27):
| Slice | Size | Notes |
|---|---|---|
| Total `static-dist/` uncompressed | **9.8 MB** | 499 files |
| `_app/` (SvelteKit bundle) | 3.3 MB | JS + CSS chunks |
| `vendors/` | 3.6 MB | maplibre-gl 1.0 MB, pdf.worker 1.0 MB, others |
| `locales/` | 2.2 MB | 16 locales, ru.json + hi.json largest at ~116-140 KB |
| `logo/`, `geo/`, `basemaps/`, `workers/`, misc | ~600 KB | |
| **`.tar.gz` compressed** | **4.65 MB** | realistic embed cost after brotli/gzip inside binary |
| **`.tar.xz` compressed** | **4.22 MB** | not what rust-embed uses; reference only |
Expected release-binary size with embed: `oxicloud` today ships in
the 30-60 MB range (stripped, LTO). Add ~5-10 MB for embedded
static-dist. Tarball compression on top → ~20-30 MB shipped per
platform. Four platforms × ~25 MB = ~100 MB per release. Well within
GitHub Releases limits.
Cargo.toml additions:
```toml
[features]
bundled-assets = ["dep:rust-embed", "dep:mime_guess"]
[dependencies]
rust-embed = { version = "8", features = ["compression"], optional = true }
mime_guess = { version = "2", optional = true }
```
Runtime shape — a new module `src/interfaces/web/embedded.rs`:
```rust
#[cfg(feature = "bundled-assets")]
#[derive(rust_embed::RustEmbed)]
#[folder = "static-dist/"] // ← repo-root, matches SvelteKit adapter-static output
#[include = "*"]
#[exclude = "*.br"] // Vite's precompressed sibling — response compression handles on wire
#[exclude = "*.gz"] // ditto
pub struct EmbeddedAssets;
```
The `#[folder]` path is relative to Cargo.toml (repo root), where the
SvelteKit adapter-static config in `frontend/svelte.config.js` emits:
```js
adapter: adapter({
pages: '../static-dist',
assets: '../static-dist',
...
})
```
The current filesystem shape (at `src/interfaces/web/mod.rs:47-106`)
is more than one `ServeDir` — the embed swap replaces FOUR sites, all
downstream of `resolve_static_path()`:
1. **`spa` ServeDir** (`mod.rs:60-63`) — root fallback with
`precompressed_br().precompressed_gzip()` and SPA-shell fallback
pointing at `<static>/index.html`. Under embed: an axum handler
that resolves the request path against `EmbeddedAssets::get()`,
200 with correct MIME (via `mime_guess`) if hit, otherwise return
the embedded `index.html` bytes with `text/html` for SPA client-routing.
2. **`app_immutable` ServeDir** (`mod.rs:66-77`) — nested at
`/_app/immutable` with `Cache-Control: public, max-age=31536000,
immutable`. Under embed: same handler shape as (1), scoped to
the `_app/immutable/` prefix, plus a `.layer()` that stamps the
immutable cache header.
3. **`ServeFile::new(index.html)`** SPA fallback (`mod.rs:63`) —
folds into (1)'s not-found path.
4. **CSP inline-script scan** (`mod.rs:163-233`) — currently reads
every `.html` file in the resolved static dir via
`std::fs::read_dir` + `std::fs::read_to_string` at boot to compute
SHA-256 CSP source expressions for every inline `<script>`. Under
embed: iterate `EmbeddedAssets::iter()` filtered to `.html`
extensions, pull bytes via `::get()`, hash the same way. Same
arithmetic, different source. Boot-time only.
All four flow through `resolve_static_path()` at `src/interfaces/web/mod.rs:25-35`
— that helper is the natural pivot. Add a returned enum:
```rust
#[cfg(feature = "bundled-assets")]
pub enum StaticSource {
Filesystem(PathBuf), // OXICLOUD_STATIC_PATH points at a real dir
Embedded, // fall through to compiled-in bytes
}
```
Then the four callsites (`create_web_routes` + CSP scan) match on
`StaticSource` and pick their implementation. Under the default
feature set (no `bundled-assets`), the enum degrades to a bare
`PathBuf` — zero runtime cost, no cfg pollution across the wider
codebase.
**Locale loading — also needs embed treatment.** Two callsites read
locales at runtime:
- `src/main.rs:599-615` — resolves `<static_path>/locales/` at boot
and passes it to `LocaleRegistry::discover()` at
`src/common/locale.rs:150-221`, which does `fs::read_dir` +
`fs::read_to_string` + `serde_json::from_str` on each of 16 files.
Currently fail-fast panics if the directory is missing.
- `src/infrastructure/services/file_system_i18n_service.rs` — the
runtime translator, `translations_dir: PathBuf` field, does
`tokio::fs::read_to_string` on `<dir>/<code>.json` per lazy-load
miss (cached in `RwLock<HashMap<Locale, Value>>`).
Under `bundled-assets`, both get an alternative implementation that
reads from `EmbeddedAssets` (locale files are at
`static-dist/locales/*.json`, picked up by the same folder embed).
Recommended shape: constructor pair —
`LocaleRegistry::discover_filesystem(path)` and
`#[cfg(feature = "bundled-assets")] LocaleRegistry::discover_embedded()`.
`main.rs` picks based on the resolved `StaticSource`. Simpler than a
trait-based indirection for two static sources with the same interface.
Frontend at runtime ALSO fetches `/locales/*.json` for client-side
i18n — this path is served by the same static router in (1) above,
so no separate work; the embed already covers it.
Precedence rule: even in a bundled build, honour `OXICLOUD_STATIC_PATH`
when it points at an existing directory. Lets ops override embedded
assets for locale patches / theming without a full rebuild. The
`resolve_static_path` return value is checked at boot; a real directory
wins over embedded fallback. If the resolved directory does NOT exist,
fall through to the embedded handler cleanly (log at info level:
"OXICLOUD_STATIC_PATH points at <path> which doesn't exist; serving
embedded assets").
Build-time invariant: `cargo build --features bundled-assets` requires
`static-dist/` to exist AND be non-empty. Add a `build.rs` check that
emits a clear error if missing, pointing at `just fe-build` /
`(cd frontend && npm run build)`.
**Precompression + embed strategy**: minimize binary size by storing
assets compressed inside the binary, and use axum's response
compression on the wire.
`rust-embed`'s `compression` feature deflate-compresses each embedded
file at compile time. Files are decompressed lazily on first access
and cached in a per-file `OnceCell` for the remainder of the process.
Warms up quickly under real traffic — the first user's page load
touches ~30 files, all cached from then on.
On the wire, response compression is handled by axum's
`CompressionLayer` (tower-http) applied to the static router
subtree. Browsers get `Content-Encoding: br` when they Accept-Encoding
brotli; gzip fallback; identity for clients that ask for neither.
Projected embed size after excludes + rust-embed deflate compression:
**~4-5 MB**. Matches the `.tar.xz` reference size and roughly halves
what raw-embed-plus-siblings would cost. Runtime CPU: negligible under
any real load; the compressed variants would benefit from a
reverse-proxy cache in front for CPU-tight hosts (Pi 4/5).
Consequence for the `nginx`/reverse-proxy story users will run in
front: the binary responds correctly to `Accept-Encoding: br, gzip`
without configuration. Users terminating TLS at their proxy get
compressed responses either way (proxy passes through or re-compresses
its cache).
### 3. Target matrix — musl-only Linux
Four triples cover the practical need:
| Triple | Runner + toolchain | Notes |
|---|---|---|
| `x86_64-unknown-linux-musl` | `ubuntu-22.04` running `rust:1.96-alpine3.24` container | Static, no glibc dep, runs on ANY Linux distro from Alpine to CentOS 7 to Debian 10 to Ubuntu 25.04. Parity with existing Docker image. |
| `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` running `rust:1.96-alpine3.24` container | Native ARM64 runner (no QEMU), same container as amd64 for byte-for-byte parity. Pi 4/5, ARM servers, Graviton. |
| `aarch64-apple-darwin` | `macos-latest` | Apple Silicon, native |
| `x86_64-apple-darwin` | `macos-13` | last Intel-runner tier |
**Rationale for musl-only Linux**:
1. **Parity with Docker.** The Docker image is already Alpine/musl —
users get identical runtime behaviour whether they pull the
container or the tarball. One build shape, one test surface.
2. **Face-indexing regression is a NON-issue.** `faces-onnx` requires
glibc-only `libonnxruntime.so`; it's already unavailable on the
Docker image. Users who want face indexing build from source with
`--features faces-onnx` on a glibc host — same as today, no
change from musl-only tarballs.
3. **Zero glibc-version fragmentation.** No `GLIBC_2.35 not found`
errors on older distros. One binary works everywhere.
4. **Simpler install docs.** "Download this file, run it" without
a "which glibc do you have?" branch.
5. **Marginal perf hit is invisible under I/O-bound OxiCloud workloads.**
Musl's `malloc` and DNS resolver quirks matter for allocation-heavy
/ DNS-heavy servers; OxiCloud is neither.
**External runtime dependencies** — complete list. Codebase audit
2026-08-27 confirmed `ffmpeg` is the ONLY `Command::new` invocation
in `src/`; no other subprocess deps exist.
| Category | Dep | Required? | Notes |
|---|---|---|---|
| Subprocess | `ffmpeg` | Optional | Video thumbnails. Kill switch: `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false`. Path override: `OXICLOUD_FFMPEG_PATH` |
| System lib | `ca-certificates` | Required | Outbound HTTPS (OIDC, S3, webhooks). Pre-installed on nearly every distro |
| System lib | `tzdata` | Required | Timezone DB for chrono. Pre-installed on nearly every distro |
| External service | PostgreSQL 13+ | Required | With `pg_trgm` + `ltree` extensions. TCP/loopback only — no libpq client lib needed |
| Runtime dylib | `libonnxruntime.so` + ONNX models | N/A for tarball | Face indexing (glibc-only, requires build from source with `--features faces-onnx`). Not shipped in musl tarballs — Docker/tarball users don't have this feature |
**Explicit non-deps** (worth documenting to preempt questions):
- **No libpq** — sqlx uses pure-Rust tokio-postgres
- **No git** — only build-time metadata via `build.rs`, never runtime
- **No ImageMagick / libvips** — image thumbnails via pure-Rust `image` crate
- **No pandoc / rst2html / etc.** — no document conversion
- **No systemd/launchd** — daemon lifecycle user-managed
- **No sendmail / SMTP CLI** — email via pure-Rust SMTP client
**Per-distro install command** (for `README-install.md`):
| Distro | Command |
|---|---|
| Alpine | `apk add ca-certificates tzdata ffmpeg` |
| Debian / Ubuntu | `apt install ca-certificates tzdata ffmpeg` |
| Fedora / RHEL | `dnf install ca-certificates tzdata ffmpeg` (RPMFusion for full codec set) |
| Arch | `pacman -S ca-certificates tzdata ffmpeg` |
| macOS | `brew install ffmpeg` (ca-certificates + tzdata built in) |
| Portable Linux | Static ffmpeg from https://github.com/BtbN/FFmpeg-Builds/releases + `OXICLOUD_FFMPEG_PATH=<path>` |
Postgres install is documented separately (project docs) since it's a
per-distro-per-version story with per-extension setup.
**Windows deliberately deferred** — sqlx feature set, some C deps,
testing story on Windows are all extra work.
**Pi 2 / 32-bit ARM (`armv7-unknown-linux-gnueabihf`) excluded** —
1 GB RAM is below OxiCloud's practical floor even with face indexing
disabled.
**Building strategy for Linux musl targets** — run the compilation
inside the `rust:1.96-alpine3.24` container image the Dockerfile
already uses. Guarantees byte-for-byte parity with what ends up in
the published Docker image; zero new toolchain to maintain. Runner
just needs Docker (all GitHub-hosted Linux runners have it). No
`rustup target add`, no `apt install musl-tools`.
**CPU baseline** — the repo sets `-C target-cpu=native` for x86_64 and
aarch64 hosts (`.cargo/config.toml:11-12`). That flag makes the binary
use every CPU feature the BUILDER exposes — great for local dev,
catastrophic for distributed binaries: a runner with AVX-512 produces
a binary that segfaults on any older CPU. Precedent for the fix at
`.github/workflows/load-smoke.yml:28`, which already overrides with
`RUSTFLAGS="-C target-cpu=x86-64-v3"` for load tests.
Per-target baseline for `release-binaries.yml`:
| Triple | `RUSTFLAGS` |
|---|---|
| `x86_64-unknown-linux-musl` | `-C target-cpu=x86-64-v2` |
| `aarch64-unknown-linux-musl` | `-C target-cpu=generic` (safe ARMv8-A baseline) |
| `aarch64-apple-darwin` | `-C target-cpu=apple-m1` |
| `x86_64-apple-darwin` | `-C target-cpu=x86-64-v2` |
`x86-64-v2` covers ~2010+ processors (Nehalem, Bulldozer). Widest
realistic install base for a "runs everywhere" tarball. Notably
different from Docker's `x86-64-v3` (per `load-smoke.yml:28`) — Docker
targets performance-tuned deployments, tarballs target maximum
compatibility.
Trade-off left on the table: BLAKE3 SIMD + image codecs run somewhat
slower on v2 than v3. For a self-hosted personal cloud workload this
is invisible; for anyone who wants max perf, the Docker image is
still their better option.
### 4. Tarball layout
One archive per platform. **Four files inside**, all rooted under a
per-version-per-triple directory so extraction lands cleanly:
```
oxicloud-0.9.0-<triple>/
├── oxicloud ← the single binary (server + tools + embedded assets)
├── example.env ← copied verbatim from repo root (50 KB, all env vars documented)
├── LICENSE ← copied verbatim from repo root
└── README-install.md ← NEW, ~100 lines, tarball-audience-specific
```
Deliberate exclusions:
- **`README.md`** (repo root, 10 KB) — the GitHub landing page: features,
screenshots, tech stack, contribution guide. Wrong orientation for a
downloaded tarball. Users get `README-install.md` instead — shorter,
focused on "how do I run this thing on this box?"
- **`oxicloud.service` systemd unit** — inlined as a copy-paste block in
`README-install.md`. Users have to customize `User=` /
`WorkingDirectory=` anyway; a documented example beats a shipped file
that pretends to be canonical.
- **`CHANGELOG.md`** — the GitHub Release page carries the notes.
Duplicating invites drift.
- **`docs/`** — full documentation stays on GitHub, linked from
`README-install.md`.
`README-install.md` content shape (~100 lines):
- **Quickstart** — required env vars, one-command run
- **PostgreSQL setup** — link to project docs; note `pg_trgm` + `ltree`
extensions
- **Optional: video thumbnails** — mention ffmpeg + the
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` kill switch (first
user-facing surface for this env var, closing the discoverability
gap flagged in memory `bug_env_docs_video_thumbnails_missing`)
- **Systemd unit example** — inline copy-paste block, references
`/etc/oxicloud/oxicloud.env` for env vars
- **First-run** — direct to `/setup` for admin account creation
- **Verification** — `sha256sum -c ../SHA256SUMS` for tarball integrity
- **Upgrading** — replace binary in place, restart service; migrations
run automatically on boot per `sqlx::migrate!()`
- **Support links** — GitHub Issues, docs site
- **Docker note** — for users who want the container path instead
Tarball name: `oxicloud-<version>-<triple>.tar.gz`.
macOS tarballs stay `.tar.gz` too (not `.zip`) — Homebrew formulas
handle either, and it keeps the CI packaging step uniform. Same
extraction UX cross-platform (`tar xzf`).
`SHA256SUMS` file lists all archives with hashes at the release-level
(next to the tarballs, not inside them) — standard OSS practice.
Users verify via `sha256sum -c SHA256SUMS` before extraction.
### 5. New workflow: `.github/workflows/release-binaries.yml`
Three-stage pipeline, shared frontend build:
```
1. frontend-build (ubuntu-latest, single job)
- checkout
- Node 26 setup
- npm ci && npm run build (writes static-dist/ at repo root)
- upload static-dist/ as artifact "static-dist"
2. binary-build (matrix over 4 targets, needs: frontend-build)
- checkout
- download static-dist artifact into repo-root static-dist/
- Linux targets: docker run rust:1.96-alpine3.24, cargo build inside
- macOS targets: rustup target add + native cargo build
- cargo build --release --features bundled-assets --bin oxicloud
- tar czf oxicloud-<version>-<triple>.tar.gz oxicloud-<version>-<triple>/
- upload tarball as per-platform artifact
3. release (ubuntu-latest, needs: binary-build)
- download all tarball artifacts
- compute SHA256SUMS
- softprops/action-gh-release@v2 with files: dist/*
```
Triggers: `push: tags: v*` (real releases) + `workflow_dispatch` with
`dry_run: true` toggle (build tarballs, upload as workflow artifacts,
skip attaching to a release).
Interaction with existing `release.yml`: **new file**, because the
current `release.yml` is tiny (create release + notes) and mixing
concerns would clutter it. `release.yml` stays as "make the GitHub
Release exist"; `release-binaries.yml` stacks binaries into it. Both
trigger on `push: tags: v*`.
**Parallel-fire behaviour on tag push** — on `git push origin v0.9.0`,
three workflows fire simultaneously:
```
tag push v0.9.0
│
├─── release.yml (~1 min) Release + notes
├─── docker-publish.yml (~30-45 min) multi-arch Docker → GHCR + DockerHub
└─── release-binaries.yml (~25-30 min) 4 tarballs → attach to Release
```
Total wall-clock: ~30-45 min (dominated by whichever build is slower).
No sequencing between the three — each has a single responsibility
and runs independently.
Race with `release.yml` is **benign** because `release-binaries.yml`
uses `softprops/action-gh-release@v2`, which:
- **Adds files** to an existing Release if one exists for the tag.
- **Creates** the Release (with default settings, no notes) if
`release.yml` hasn't finished yet.
Worst case: `release-binaries.yml` finishes first on a tiny tag, creates
a bare Release, `release.yml` catches up and fills in the notes. Users
see the Release progressively; nothing breaks. If this becomes annoying
in practice (unlikely — `release.yml` is ~1 min), flip
`release-binaries.yml` to `on: workflow_run: { workflows: ["Release"],
types: [completed] }` to serialize.
Concurrency: same `${{ github.workflow }}-${{ github.ref }}` group as
`docker-publish.yml`, but `cancel-in-progress: false` — every tag is
unique and immutable, so a superseded release build has nothing to
cancel.
Publish gate: same fork-friendly pattern as `docker-publish.yml` —
`if: github.repository == 'AtalayaLabs/OxiCloud' ||
vars.ENABLE_BINARY_RELEASE == 'true'`. Prevents forks from
auto-attaching binaries to their own tag pushes.
### 6. Docs
- **`docs/install/binary.md`** — quickstart per platform, verify
SHA256SUMS, minimum env vars (`DATABASE_URL`), systemd unit
example, Pi-specific advice (link to the "verified on" hardware
table). Prose only — no code snippets that could go stale.
Include a "server-side video thumbnails" callout naming the
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` kill switch — this is
the first user-facing surface where the env var is discoverable
(per memory `bug_env_docs_video_thumbnails_missing`, it's not
in `example.env` nor `docs/env.md` today). Consider fixing the
underlying gap in `example.env` + `docs/env.md` as a companion
edit to this PR — small win, high visibility.
- **`README.md`** — add a one-line pointer under Installation:
"Binary releases attached to each GitHub Release — see
[docs/install/binary.md]". Do NOT list per-triple download links
by hand; they'd rot.
- **This file** — the design record. Kept alongside other
`docs/plan/*.md` docs so the next maintainer sees the rationale
before touching `release-binaries.yml` or the embed layer.
### 7. `Cargo.toml` `[package.metadata.binstall]` block
Free win: `cargo binstall oxicloud` starts working once the tarballs
land on GitHub Releases with predictable names. Two-line metadata
block declares the URL template:
```toml
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/oxicloud-{ version }-{ target }.tar.gz"
bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
```
No CI change; the tarballs already follow this shape from Deliverable 4.
## Critical files
- `Cargo.toml` — add `bundled-assets` + `dev_tools` features,
`required-features` on gated bins, `rust-embed` optional dep,
`[package.metadata.binstall]` block. Delete the
`[[bin]] name = "oxicloud-cli"` and `[[bin]] name = "migrate-nfc-filenames"`
blocks (Deliverables 1a + 1b).
- `src/main.rs` — add clap parsing at the top of `main()`. If a
subcommand is present → dispatch via new `src/cli/` module; otherwise
fall through to the existing server-init path (backwards-compat
implicit-server mode).
- `src/cli/mod.rs` — NEW. Root of the operator-tools tree; contains
`Domain` enum + submodules moved from `src/bin/oxicloud-cli.rs`.
- `src/cli/opaque.rs` — NEW. `opaque setup` + `opaque reset` moved
from the old `oxicloud-cli.rs`.
- `src/cli/migrate.rs` — NEW. `migrate nfc-filenames` — the ~149
non-boilerplate lines from the old `migrate-nfc-filenames.rs`,
wrapped as a clap subcommand.
- `src/bin/oxicloud-cli.rs` — DELETE (contents absorbed into `src/cli/`).
- `src/bin/migrate-nfc-filenames.rs` — DELETE (contents absorbed
into `src/cli/migrate.rs`).
- `src/interfaces/web/mod.rs` — 400-line file, owns the static-serving
surface. Four sites gain a `#[cfg(feature = "bundled-assets")]`
alternative:
- `resolve_static_path()` (`:25-35`) — returns a `StaticSource`
enum under bundled mode; a bare `PathBuf` otherwise
- `create_web_routes()` (`:47-106`) — swap the two `ServeDir`
constructions for embedded-asset handlers
- `content_security_policy()` + `inline_script_csp_hashes()`
(`:163-233`) — iterate `EmbeddedAssets::iter()` instead of
`fs::read_dir`
- Import block + type imports for the new source enum
- `src/interfaces/web/embedded.rs` — NEW: `#[derive(RustEmbed)]` struct
+ two axum handlers (root/SPA-fallback + `_app/immutable`-prefixed
with cache header) + shared MIME helper. ~100 lines.
- `src/main.rs:599-615` — locale-source resolution. Under bundled
mode, call `LocaleRegistry::discover_embedded()` instead of the
filesystem variant when `resolve_static_path()` returns
`StaticSource::Embedded`.
- `src/common/locale.rs:150-221` — add `LocaleRegistry::discover_embedded()`
under `#[cfg(feature = "bundled-assets")]`. Same parse + registry
build, source is `EmbeddedAssets::iter()` filtered to `locales/*.json`.
- `src/infrastructure/services/file_system_i18n_service.rs` — either
extend to accept an `EmbeddedLocales` source alongside the
filesystem one, OR ship a second `EmbeddedI18nService` impl of the
same trait. Latter avoids polluting the fast filesystem path with
cfg gates.
- `build.rs` — EXISTS today (injects `GIT_HASH`/`GIT_BRANCH` from git).
Extend with a second block: when the `bundled-assets` feature is
enabled (`env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_ok()`),
check that repo-root `static-dist/` exists and contains at least
`index.html`. Emit a clear compile error pointing at
`just fe-build` / `(cd frontend && npm run build)` if missing.
Also emit `cargo:rerun-if-changed=static-dist/` so a rebuild of
the frontend re-triggers rust-embed's compile-time embed step.
- `.cargo/config.toml` — NO CHANGES. The dev-preserving default of
`-C target-cpu=native` stays. Release CI overrides via per-job
`RUSTFLAGS` env var, per the load-smoke.yml precedent.
- `.github/workflows/release-binaries.yml` — NEW: three-stage pipeline.
- `justfile` — thread `--features dev_tools` into the `openapi` recipe.
- `tests/api/run.sh` — thread `--features test_utils` into the two
hurl-helper build lines.
- `docs/install/binary.md` — NEW: user-facing installation guide.
## Verification
1. **Local squash check**: after Cargo.toml + `src/cli/` edits, run
`cargo build --release --bins` and confirm exactly ONE binary
appears in `target/release/` (`oxicloud`). Run `cargo build --release
--bins --features test_utils` and confirm the two hurl helpers
appear. `cargo build --release --bins --features dev_tools`
should surface `generate-openapi`. Confirm subcommand shape via:
- `target/release/oxicloud --help` — shows `opaque` + `migrate`
domains
- `target/release/oxicloud opaque setup` — prints a fresh
ServerSetup base64 line (unchanged behaviour vs the old
`oxicloud-cli opaque setup`)
- `target/release/oxicloud migrate nfc-filenames --dry-run`
(against a sandbox DB) — same behaviour as the old
`migrate-nfc-filenames --dry-run`
- `target/release/oxicloud` (no args) — starts the server exactly
as today, no clap-related output surprises before the server
init banner.
2. **Local bundled-assets smoke**:
```
(cd frontend && npm ci && npm run build) # writes ../static-dist/
cargo build --release --features bundled-assets --bin oxicloud
# Wipe static-dist/ or point OXICLOUD_STATIC_PATH somewhere
# nonexistent to force the embedded path to be exercised.
mv static-dist/ static-dist.hidden/
OXICLOUD_STATIC_PATH=/tmp/nonexistent DATABASE_URL=... target/release/oxicloud
# Hit http://localhost:8086 — SPA shell + locales must load.
# Restore afterward: mv static-dist.hidden/ static-dist/
```
3. **Filesystem fallback still works in bundled build**: with the
same binary, point `OXICLOUD_STATIC_PATH` at a real static-dist,
confirm files served from disk (change a file, no rebuild → change
visible in browser). Verifies the precedence rule from Deliverable 2.
4. **Non-bundled build still works**: `cargo build --release`
(without `--features bundled-assets`) → binary boots + serves from
`./static/static-dist/` as today. Zero regression on the Docker
image path.
5. **CI dry-run**: dispatch `release-binaries.yml` with `dry_run: true`
from a fork. Confirms all four matrix entries build successfully,
tarballs land in the run's artifact list, no release is created.
6. **Manual extraction test**: download one tarball, extract, run
`./oxicloud` with just `DATABASE_URL` set (against a local
Postgres). Log in, upload a file, check that locale switching
works, confirm `/api/status` returns healthy. Then repeat on a Pi 5
for the `aarch64-unknown-linux-musl` variant if convenient.
## Not in scope
- **Windows target** — separate work when demand appears.
- **glibc Linux tarballs** — musl covers the Linux audience per the
design shape above; users wanting glibc-specific features
(`faces-onnx`) build from source.
- **32-bit ARM (`armv7`)** — hardware below the workload floor.
- **Debian/RPM packages** — post-tarball layer, adds repo-hosting burden.
- **Homebrew tap** — trivial once tarballs exist; separate decision.
- **Signing (Sigstore/GPG)** — worth adding but scope-creeping;
SHA256SUMS is the minimum table stakes for this PR.
## Delivery order
1. **Feature-flag squash** (Deliverable 1). Cargo.toml edits +
`just openapi` / `tests/api/run.sh` invocation fixes. Verify
`cargo build --release --bins` no longer builds hurl helpers.
2. **Merge migrate-nfc-filenames into oxicloud-cli** (Deliverable 1a).
Move logic to `mod migrate` submodule. Delete standalone bin.
Verify `oxicloud-cli migrate nfc-filenames --dry-run` works.
3. **Merge oxicloud-cli into oxicloud** (Deliverable 1b). Move
`src/bin/oxicloud-cli.rs` contents into new `src/cli/` module,
wire clap into `main.rs` with implicit-server default. Delete
`src/bin/oxicloud-cli.rs`. Verify `oxicloud` (no args) still
starts the server; `oxicloud opaque setup` + `oxicloud migrate
nfc-filenames --dry-run` work.
4. **Add `bundled-assets` feature** (Deliverable 2). `rust-embed` +
`build.rs` guard + `src/interfaces/web/embedded.rs` + locale
loader alt + CSP scan alt. Verify locally with the smoke sequence
in Verification §2.
5. **Add `.github/workflows/release-binaries.yml`** (Deliverable 5).
Dry-run on a fork. Iterate until all 4 targets green.
6. **Write docs** (Deliverable 6) — `docs/install/binary.md`. Prose
only, no snippets that will rot. Include the
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` callout for tarball users
without ffmpeg.
7. **Add `[package.metadata.binstall]` block** (Deliverable 7).
One-line change enabling `cargo binstall oxicloud`.
8. **Fix the `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` doc gap** — add to
`example.env` + `docs/env.md` per memory
`bug_env_docs_video_thumbnails_missing`. Small companion edit
surfaced by the binary-install docs work.
9. **Cut a test tag** (`v0.9.0-rc1`?) on a fork with
`vars.ENABLE_BINARY_RELEASE=true`. Confirm tarballs attach to the
Release, SHA256SUMS present, `cargo binstall oxicloud` works.
10. **When happy, cut on canonical.**
Total scope: ~2.5 days of careful work.
- Deliverables 1 + 1a + 1b: ~5 hours mechanical (Cargo config, CLI
merge, subcommand tree)
- Deliverable 2: ~1 day — the only piece with real design surface
(embed swap, four cfg sites, locale + CSP loaders)
- Deliverable 5: ~4 hours workflow authoring + iteration
- Deliverables 6-8: ~4 hours docs + small edits
- Verification + iteration: ~4 hours