Files
Oxicloud/docs/plan/bundled-binary.md
T

889 lines
41 KiB
Markdown
Raw Normal View History

# 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
Three triples cover the practical need:
| Triple | Runner + toolchain | Notes |
|---|---|---|
| `x86_64-unknown-linux-musl` | `ubuntu-22.04` + `musl-tools` + `rustup target add` | Static, no glibc dep, runs on ANY Linux distro from Alpine to CentOS 7 to Debian 10 to Ubuntu 25.04. Cross-compiled natively with glibc host + musl target; produces same output as the alpine-container path we originally planned. |
| `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` + `musl-tools` + `rustup target add` | Same shape as the amd64 twin. Native ARM64 runner (no QEMU). Pi 4/5, ARM servers, Graviton. |
| `aarch64-apple-darwin` | `macos-latest` | Apple Silicon, native |
**Historical note — Intel macOS dropped 2026-08-29** (Apple phasing
out `macos-13`; runner-availability tax exceeded value). Intel Mac
users fall back to `cargo install`, Docker `--platform linux/amd64`,
or one of the Linux musl tarballs inside a Linux VM.
**Historical note — Alpine-container approach abandoned 2026-08-29**
in favour of native cross-compile. Original plan built inside the
Dockerfile's `rust:1.96-alpine3.24` for byte-for-byte parity with
Docker; broke on `ubuntu-22.04-arm` because JS-based GitHub Actions
(checkout, artifact steps, setup-node) can't run inside Alpine on
ARM64 (Node.js binary requires glibc; the x64-Alpine workaround
doesn't extend to arm64). Native `ubuntu-22.04` + `musl-tools` +
`rustup target add` produces the same `--target *-musl` output
without the container gymnastics.
**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-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