From 142afecbbffb34e1a21e352a8cef6640f881ba41 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 1 Aug 2026 17:50:35 +0200 Subject: [PATCH] feat(storage): add a guide on storage --- docs/.vitepress/config.mts | 1 + docs/guide/backend-storage.md | 123 ++++++++++++++++++ docs/guide/index.md | 1 + .../src/routes/admin/[[tab]]/+page.svelte | 66 +++++++++- src/infrastructure/services/mod.rs | 2 +- .../services/s3_blob_backend.rs | 10 +- .../services/storage_migration_service.rs | 10 +- .../services/swappable_blob_backend.rs | 7 +- 8 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 docs/guide/backend-storage.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b615f3dd..8959cd65 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -154,6 +154,7 @@ export default defineConfig({ { text: "Trash & Recycle Bin", link: "/guide/trash" }, { text: "ZIP & Compression", link: "/guide/zip-and-compression" }, { text: "Internationalization", link: "/guide/i18n" }, + { text: "Backend Storage", link: "/guide/backend-storage" }, ], }, { diff --git a/docs/guide/backend-storage.md b/docs/guide/backend-storage.md new file mode 100644 index 00000000..6735711a --- /dev/null +++ b/docs/guide/backend-storage.md @@ -0,0 +1,123 @@ +# Backend Storage + +OxiCloud writes uploaded files to a **backend storage** — the physical place where the bytes actually live. Three kinds are supported: + +- **Local disk** — a folder on the server. +- **S3-compatible** — AWS S3, OVH, MinIO, Backblaze B2, Cloudflare R2, DigitalOcean Spaces, Wasabi. +- **Azure Blob Storage** — Microsoft Azure. + +You can declare more than one backend at a time (for example, keep the current disk backend while adding an S3 target), pick which one is currently in use from the admin panel, and migrate all your data between them without downtime. + +## Declaring backends + +Backends are declared in your **`.env`** file (or the equivalent environment variables in a Docker Compose / Kubernetes deployment). Each backend gets a short **name** you choose — like `local_main`, `s3_prod`, `s3_archive` — and its own set of settings under that name. + +Example: one local backend today and an S3 backend ready for a future migration. + +``` +OXICLOUD_STORAGE_ENTRIES=local_main,s3_prod + +OXICLOUD_STORAGE_local_main_BACKEND=local +OXICLOUD_STORAGE_local_main_ROOT_DIR=/srv/oxicloud + +OXICLOUD_STORAGE_s3_prod_BACKEND=s3 +OXICLOUD_STORAGE_s3_prod_S3_BUCKET=my-oxicloud-bucket +OXICLOUD_STORAGE_s3_prod_S3_REGION=gra +OXICLOUD_STORAGE_s3_prod_S3_ENDPOINT_URL=https://s3.gra.io.cloud.ovh.net +OXICLOUD_STORAGE_s3_prod_S3_ACCESS_KEY=… +OXICLOUD_STORAGE_s3_prod_S3_SECRET_KEY=… +``` + +A few things to know: + +- The first entry in `OXICLOUD_STORAGE_ENTRIES` is used on first boot if you haven't picked one from the admin panel yet. +- Names must be short (letters, digits, `_`, `-`), unique, and stable — once you pick a name, keep it. +- After editing `.env`, **restart the server** so it picks up the new declaration. The declaration is what unlocks the entry in the admin panel — from then on you can switch to it (and move data to it) without another restart. +- The full list of settings each backend type accepts lives in the [Environment Variables reference](/config/env#storage-entries-multi-entry-recommended). + +### Encryption at rest + +Any backend can be encrypted at rest by adding an encryption key to it: + +``` +OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=… # base64 of 32 random bytes +``` + +A key can be generated from **Settings → Storage → Generate key** in the admin panel. Set the same key on a new backend during migration and OxiCloud re-encrypts the data as it copies. + +::: warning +If you lose the encryption key, the data encrypted with it is unrecoverable. Store the key somewhere as safe as you'd store a database backup. +::: + +## Checking a backend + +Once a backend is declared, it appears on the admin **Storage** tab as a card: + +- **Location** — the folder path or `endpoint / bucket`. +- **Encryption** — a lock icon when a key is set for this entry. +- **Status** — either **active** (the one currently in use) or **available** (declared but not in use yet). + +Each card has three buttons: + +- **Test** — connects to the backend and does a small write / read / delete round-trip. On success it reports the round-trip time. On failure it tells you exactly what went wrong (bad credentials, wrong region, missing permission, unreachable host). Nothing persists on the backend after the test. +- **Blob consistency** — runs a full integrity audit against that backend. It verifies every file OxiCloud knows about is present on this backend and reports any missing pieces on the Jobs tab. +- **Migrate & activate** — visible on non-active backends. Moves all data to this backend and makes it the new active one. Details below. + +## Migrating between backends + +Migration copies every file from the currently-active backend to a chosen target backend, then switches the app over to the target. The typical flow: + +1. Add the new backend to your `.env` **without removing the current one**. +2. Restart the server so the new backend becomes visible in the admin panel. +3. On the **Storage** tab, click **Test** on the new backend to confirm it's reachable and writable. +4. Click **Migrate & activate** on the new backend and confirm the prompt. + +During the migration: + +- The server enters **read-only mode**. Users can still browse and download files; uploads, renames, deletes, and shares are refused until the migration finishes. A banner at the top of the Storage tab reminds you. +- Progress is shown on the migration status line under the entries list — how many blobs have been copied, the estimated time remaining. +- If the migration fails partway (network drops, quota exceeded), it pauses rather than losing progress. Clicking **Migrate & activate** again resumes from where it stopped. + +When the migration reaches 100%: + +- The new backend automatically takes over as the active one. +- Read-only mode is lifted. Users can write again — everything now goes to the new backend. +- No restart is required. + +The old backend is left untouched. Nothing is deleted from it. Once you're confident the new backend is holding up, you can decommission the old one at your own pace (empty the old S3 bucket, unmount the old disk, etc.). + +### Migrations that are refused + +The admin panel refuses to start a migration in two cases: + +- **Target equals source** — pointing a migration at the currently-active backend does nothing useful. +- **Target and source share the same physical storage** — for example, two backends that name the same S3 bucket but with different credentials or encryption keys. This would corrupt the data mid-migration. If you're trying to rotate an encryption key, migrate to a **different** bucket first, then rotate. + +### Verifying a migration + +After a migration completes, the "Blob consistency" button on the new backend runs a full audit. It walks every file OxiCloud knows about and confirms it's really present on the backend, byte-for-byte. Results appear on the Jobs tab. A clean run confirms the migration was complete. + +## Repairing a stuck config + +If you rename or remove a backend from `.env` while it was still the active one, the server may refuse to boot with an error like: + +``` +active_backend_name = `s3_prod`, but no entry with that name is declared in +OXICLOUD_STORAGE_ENTRIES. Available: [local_main]. […] +oxicloud --select-storage +``` + +Run the command it suggests to pick a still-declared backend and the server will boot again on the next start: + +``` +oxicloud --select-storage local_main +``` + +This just updates which backend OxiCloud considers active — it doesn't move any data. + +## Common gotchas + +- **S3 region must match the endpoint.** Every S3-compatible provider signs requests against a specific region string. `us-east-1` is right for real AWS S3 but wrong for OVH (`gra`, `sbg`, etc.), Backblaze B2, Wasabi, and others. Check your provider's docs for the exact region name. +- **`FORCE_PATH_STYLE=true` for non-AWS.** Path-style URLs (`endpoint/bucket/…`) are safer with providers whose bucket-name DNS setup isn't standard, or with bucket names that contain dots. +- **Test before migrating.** The **Test** button on each backend does a proper write/read/delete round-trip — a green result means the credentials and permissions are correct for the operations a migration actually needs. Don't skip it. +- **Keep the old backend around** until at least one full `blob consistency` audit passes on the new one. Cheap insurance. diff --git a/docs/guide/index.md b/docs/guide/index.md index bd9d1296..ed89175a 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -27,6 +27,7 @@ NextCloud was too slow on a home server. So OxiCloud was built to run on minimal ### Storage & Files - [Drives](/guide/drives) — Personal + Shared spaces with per-drive quota, members, and policies +- [Backend Storage](/guide/backend-storage) — Local disk, S3, Azure; multiple backends side-by-side; live migration between them - Drag-and-drop upload, multi-file, grid & list views - Chunked uploads (TUS-like, parallel, resumable, MD5 integrity) - BLAKE3 content-addressable file deduplication with ref-counting diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index e55196ce..0e617957 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -448,21 +448,70 @@ migrationTimer = null; } } + // Polling model for the migration flow: + // 1. `lastMigrationStatus` — last observed status. Storage + // state is refreshed on any transition so the readonly + // banner + active-entry indicators track the server without + // the admin having to reload the tab. + // 2. `sawActive` — flips true the first time we observe + // `running` or `paused` after a user action. We only STOP + // polling on `idle` / `completed` / `failed` AFTER + // `sawActive` is true — otherwise a trigger endpoint's + // instant-202 response (the run row hasn't landed in the + // DB yet) would kill the poll loop before the migration + // even started, and the banner + active-entry update + // would never show up until the admin manually refreshed. + // 3. `pendingSince` — timestamp of the last user action. + // Bounds how long we keep polling on `idle` while waiting + // for the run row to appear. If the run never opens within + // the grace window (60 s — dispatch spawn + DB insert + // normally takes < 100 ms), we give up. + let lastMigrationStatus: string | undefined; + let sawActive = false; + let pendingSince: number | undefined; + const PENDING_GRACE_MS = 60_000; async function loadMigration() { try { migration = await getMigration(); - if (migration.status === 'running') { + const status = migration.status; + const active = status === 'running' || status === 'paused'; + if (active) sawActive = true; + + // Refresh storage on any status change so the readonly + // banner + active-entry indicators reflect current + // server state. + if (status !== lastMigrationStatus) { + lastMigrationStatus = status; + void loadStorage(); + } + + // Keep polling while the migration is active OR while + // we're within the grace window waiting for a + // user-triggered run to appear. + const withinGrace = + !sawActive && pendingSince != null && performance.now() - pendingSince < PENDING_GRACE_MS; + if (active || withinGrace) { if (!migrationTimer) migrationTimer = setInterval(loadMigration, 5000); } else { + // Not active and (either we've already seen it run OR + // the grace window ran out) → stop polling. stopMigrationPoll(); + pendingSince = undefined; } } catch { stopMigrationPoll(); + pendingSince = undefined; } } async function doMigration(action: 'start' | 'pause' | 'resume', targetName?: string) { try { await migrationAction(action, targetName); + // Reset the status memo so the very next `loadMigration` + // tick unconditionally reloads storage (an admin-triggered + // action is exactly when the readonly flag flips). + lastMigrationStatus = undefined; + sawActive = false; + pendingSince = performance.now(); await loadMigration(); } catch (e) { reportError(e); @@ -1934,10 +1983,10 @@ {t('admin.mig_readonly_title', 'Server in migration read-only mode')} -

+

{t( 'admin.mig_readonly_body', - 'All writes (upload, rename, delete, share) are refused by AuthZ until the migration completes and you restart the server. Reads (browse, download) are unaffected.' + 'All writes (upload, rename, delete, share) are refused until the migration completes. Reads (browse, download) are unaffected. When the copy finishes the server switches to the new backend automatically — no restart needed.' )}

@@ -4050,6 +4099,17 @@ background: var(--color-danger-bg, var(--color-bg-muted)); } + /* On the readonly banner the danger-tinted background swallows + `.muted` (which is a light grey). Use the strong text color + instead so the body message stays legible in both themes. + `color-danger-text` if the design system publishes one, else + fall back to the regular text color which still meets WCAG + contrast against the muted-red/pink bg tokens. */ + .cutover-hint__readonly-body { + margin: 0; + color: var(--color-danger-text, var(--color-text)); + } + .entries-list { display: flex; flex-direction: column; diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 924c3115..b7ab9895 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -11,7 +11,6 @@ pub mod dedup_service; pub mod drives_consistency_service; pub mod encrypted_blob_backend; pub mod entry_backend; -pub mod swappable_blob_backend; pub mod exif_service; pub mod face_geometry; pub mod face_indexing_service; @@ -47,6 +46,7 @@ pub mod search_index; pub mod share_unlock_cookie; pub mod smtp_email_sender; pub mod storage_migration_service; +pub mod swappable_blob_backend; pub mod thumbnail_service; #[cfg(test)] mod thumbnail_service_test; diff --git a/src/infrastructure/services/s3_blob_backend.rs b/src/infrastructure/services/s3_blob_backend.rs index 36b60e8b..7e507546 100644 --- a/src/infrastructure/services/s3_blob_backend.rs +++ b/src/infrastructure/services/s3_blob_backend.rs @@ -421,10 +421,7 @@ impl BlobStorageBackend for S3BlobBackend { Ok(StorageHealthStatus { connected: false, backend_type: "s3".to_string(), - message: format!( - "S3 bucket '{}' is not accessible: {detail}", - self.bucket - ), + message: format!("S3 bucket '{}' is not accessible: {detail}", self.bucket), available_bytes: None, }) } @@ -595,7 +592,10 @@ where } SdkError::TimeoutError(_) => "timeout".to_string(), SdkError::ResponseError(r) => { - format!("malformed response (HTTP {}): {r:?}", r.raw().status().as_u16()) + format!( + "malformed response (HTTP {}): {r:?}", + r.raw().status().as_u16() + ) } SdkError::ConstructionFailure(c) => format!("request construction failed: {c:?}"), _ => format!("unknown SDK error: {err:?}"), diff --git a/src/infrastructure/services/storage_migration_service.rs b/src/infrastructure/services/storage_migration_service.rs index a8cfac4d..5a5f45aa 100644 --- a/src/infrastructure/services/storage_migration_service.rs +++ b/src/infrastructure/services/storage_migration_service.rs @@ -119,9 +119,8 @@ pub struct StorageMigrationService { /// restart. Shared with `CoreServices.blob_backend_hot_swap` — /// same instance the coerced `blob_backend: Arc` /// delegates through. - blob_backend_hot_swap: Arc< - crate::infrastructure::services::swappable_blob_backend::SwappableBlobBackend, - >, + blob_backend_hot_swap: + Arc, } impl StorageMigrationService { @@ -700,8 +699,9 @@ impl StorageMigrationService { // 4. Drop read-only. In this order (after swap) so no write // slips through against the OLD backend between "readonly // off" and "backend swapped". - let readonly_persisted = - persist_migration_readonly(self.pool.as_ref(), false).await.is_ok(); + let readonly_persisted = persist_migration_readonly(self.pool.as_ref(), false) + .await + .is_ok(); self.migration_readonly.store(false, Ordering::Relaxed); if !readonly_persisted { diff --git a/src/infrastructure/services/swappable_blob_backend.rs b/src/infrastructure/services/swappable_blob_backend.rs index 40c30d2c..e28abadb 100644 --- a/src/infrastructure/services/swappable_blob_backend.rs +++ b/src/infrastructure/services/swappable_blob_backend.rs @@ -126,11 +126,7 @@ impl BlobStorageBackend for SwappableBlobBackend { Box::pin(async move { inner.put_blob(&hash, &source_path).await }) } - fn put_blob_from_bytes( - &self, - hash: &str, - data: Bytes, - ) -> BoxFut<'_, Result> { + fn put_blob_from_bytes(&self, hash: &str, data: Bytes) -> BoxFut<'_, Result> { let inner = self.current(); let hash = hash.to_owned(); Box::pin(async move { inner.put_blob_from_bytes(&hash, data).await }) @@ -222,4 +218,3 @@ impl BlobStorageBackend for SwappableBlobBackend { Box::pin(async move { inner.list_blob_hashes(cursor, limit).await }) } } -