diff --git a/docs/config/admin-settings.md b/docs/config/admin-settings.md index a04f81f6..e874f81e 100644 --- a/docs/config/admin-settings.md +++ b/docs/config/admin-settings.md @@ -64,6 +64,50 @@ If a value is overridden by environment variables, the admin API can expose that Successful responses include discovered endpoints such as the authorization endpoint, token endpoint, and userinfo endpoint. +## Storage & Migration + +The admin storage tab operates on the **named storage entries** declared in `.env` (see [Storage Entries](/config/env#storage-entries-multi-entry-recommended)). The set of entries is immutable per-deploy — adding or removing one requires a server restart. Runtime behaviour is driven by a single DB row that names which entry is currently active. + +### Endpoints + +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/api/admin/settings/storage` | List entries + active pointer + read-only flag + basic stats | +| `POST` | `/api/admin/settings/storage/test` | Reachability + round-trip test against the currently-effective backend | +| `POST` | `/api/admin/storage/migration/start` | Trigger a cross-entry migration. Body: `{"target_name": ""}` | +| `POST` | `/api/admin/storage/migration/pause` | Cooperative cancel — handler yields at the next batch boundary | +| `POST` | `/api/admin/storage/migration/resume` | Resume a paused run (target read from `params.target_name`, no body needed) | +| `GET` | `/api/admin/storage/migration` | Poll the current run's progress | + +Runs are recoverable — status, cursor, and per-blob failure findings all live in `jobs.recoverable_runs` / `jobs.run_findings`. The same run history is browsable via `GET /api/admin/jobs/storage_migration/runs`. + +### Cutover flow (moving the active pointer) + +1. Declare the target entry in `.env` and restart so `OXICLOUD_STORAGE_ENTRIES` picks it up. +2. Admin storage tab → pick the target from the dropdown → **Start migration**. The server engages global read-only mode (writes refused across the whole app; reads keep working), then copies blobs from source → target. +3. On `Completed`, the server writes `admin_settings.storage.active_backend_name = `. Read-only stays ON — writes on the OLD backend would strand data now that the pointer says the new one is active. +4. **Operator restarts the server.** Boot picks the new active entry, and the boot-clear rule drops the read-only flag (`no in-flight run + booted-entry matches DB pointer`). Server writable again, on the new backend. + +### Repair flag — pointer / entry drift + +If an entry is renamed or removed from `.env` while the DB pointer still names the old one, boot aborts with a clear error pointing at: + +``` +oxicloud --select-storage +``` + +This one-shot repair command re-runs the same env-parse the server does at boot, verifies `` is declared in `OXICLOUD_STORAGE_ENTRIES`, updates `admin_settings.storage.active_backend_name` in the DB, and exits. Operator then restarts normally. See [Environment Variables — Storage Entries](/config/env#storage-entries-multi-entry-recommended) for the model, and [`oxicloud --help`](https://github.com/oxicloud/oxicloud/blob/main/src/main.rs) for the full flag list. + +### Auditing entries other than the active one + +`blobs_consistency` and `backend_consistency` (recoverable jobs on the Jobs tab) accept `?storage=` to probe any declared entry — not just the live one. Use this to verify a migration target before cutover, or to audit an old backend after cutover but before decommissioning: + +``` +POST /api/admin/jobs/blobs_consistency/trigger?storage= +``` + +Unknown names 400 at the HTTP layer. + ## Data Storage Runtime settings are stored in `auth.admin_settings`. diff --git a/docs/config/env.md b/docs/config/env.md index 0b5d4ea1..c39651e7 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -78,7 +78,59 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. | | `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. | -## Storage Backend +## Storage Entries (multi-entry, recommended) + +Declare one or more **named** storage backends. The one the app runs on is picked from the DB (`admin_settings.storage.active_backend_name`); the admin panel's storage tab flips the pointer, and cross-backend migration is a recoverable job that copies blobs between two entries with a read-only safety window. See [Admin Settings — Storage & Migration](/config/admin-settings) for the operator flow and the [multi-entry design doc](https://github.com/oxicloud/oxicloud/blob/main/docs/plan/storage-multi-entry.md) for the full model. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_STORAGE_ENTRIES` | — | Comma-separated allowlist of entry names. Names must match `[a-z0-9_-]{1,32}` and be unique. Order is preserved (the first entry is the fallback when the DB pointer is unset — fresh install). | + +Each declared name `` then reads its own set of per-entry variables: + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_STORAGE__BACKEND` | — | Backend type for entry ``: `local` \| `s3` \| `azure` (required per entry) | +| `OXICLOUD_STORAGE__ROOT_DIR` | `OXICLOUD_STORAGE_PATH` | Local-only: root directory for this entry's `.blobs/`. Falls back to the ambient `OXICLOUD_STORAGE_PATH` when unset. | +| `OXICLOUD_STORAGE__S3_BUCKET` | — | S3-only: bucket name (required when backend=s3) | +| `OXICLOUD_STORAGE__S3_REGION` | `us-east-1` | S3-only: AWS region | +| `OXICLOUD_STORAGE__S3_ENDPOINT_URL` | — | S3-only: custom endpoint for non-AWS providers | +| `OXICLOUD_STORAGE__S3_ACCESS_KEY` | — | S3-only: access key ID | +| `OXICLOUD_STORAGE__S3_SECRET_KEY` | — | S3-only: secret access key | +| `OXICLOUD_STORAGE__S3_FORCE_PATH_STYLE` | `false` | S3-only: path-style URLs (required for MinIO, R2) | +| `OXICLOUD_STORAGE__AZURE_ACCOUNT_NAME` | — | Azure-only: storage account name | +| `OXICLOUD_STORAGE__AZURE_ACCOUNT_KEY` | — | Azure-only: storage account key | +| `OXICLOUD_STORAGE__AZURE_CONTAINER` | — | Azure-only: blob container name (required when backend=azure) | +| `OXICLOUD_STORAGE__AZURE_SAS_TOKEN` | — | Azure-only: SAS token (alternative to account key) | +| `OXICLOUD_STORAGE__AZURE_ENDPOINT_URL` | — | Azure-only: custom endpoint (Azurite, private deployments) | +| `OXICLOUD_STORAGE__ENCRYPTION_KEY` | — | Base64-encoded 32-byte AES-256 key. **Presence implies encryption is enabled** on this entry — no separate enable flag. Bad base64 / wrong length aborts boot. | +| `OXICLOUD_STORAGE__ENCRYPTION_CIPHER` | `aes-256-gcm` when `_ENCRYPTION_KEY` is set | Cipher choice for this entry. Only `aes-256-gcm` is accepted today (future-proofing knob — the enum is ready for a second cipher, the implementation still hardcodes AES-256-GCM). Setting the cipher without a key aborts boot. | + +**Fail-fast rules** (boot aborts with actionable message): + +- A declared name whose required per-entry fields are missing (`_BACKEND` never set, S3 with no `_S3_BUCKET`, Azure with no `_AZURE_CONTAINER`). +- Setting `OXICLOUD_STORAGE_ENTRIES` alongside any of the legacy flat vars below (`OXICLOUD_STORAGE_BACKEND`, `OXICLOUD_S3_*`, `OXICLOUD_AZURE_*`, `OXICLOUD_STORAGE_ENCRYPTION_*`). Pick one mode; the error lists every conflicting var to remove. +- A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud --select-storage ` — verify + UPDATE DB + exit. + +**Example** — two entries, local disk plus an S3 target for planned 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=us-east-1 +OXICLOUD_STORAGE_s3_prod_S3_ACCESS_KEY=… +OXICLOUD_STORAGE_s3_prod_S3_SECRET_KEY=… +OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=… # openssl rand -base64 32 +``` + +## Storage Backend (DEPRECATED — legacy single-backend) + +> ⚠️ **Deprecated.** Use [Storage Entries](#storage-entries-multi-entry-recommended) above for new deployments. These flat variables still work when `OXICLOUD_STORAGE_ENTRIES` is **unset** — the parser then synthesises one entry named `default` from them, keeping pre-multi-entry `.env` files booting unchanged. Booting via this path emits a `storage.legacy_flat_vars_deprecated` warning so operators see it in logs. Removal target: not yet fixed; migrate at your convenience by moving each variable below into `OXICLOUD_STORAGE__*` form under an entry declared in `OXICLOUD_STORAGE_ENTRIES`. **Setting any variable from this section alongside `OXICLOUD_STORAGE_ENTRIES` is a fail-fast boot error** — pick one mode. | Variable | Default | Description | |---|---|---| @@ -118,10 +170,12 @@ A least-recently-used disk cache that can speed up repeated reads from S3 or Azu | `OXICLOUD_STORAGE_CACHE_MAX_SIZE` | `53687091200` | Max cache size in bytes (50 GB) | | `OXICLOUD_STORAGE_CACHE_PATH` | `{STORAGE_PATH}/.blob-cache` | Cache directory | -### Client-Side Encryption +### Client-Side Encryption (DEPRECATED — per-entry key is the new home) AES-256-GCM encryption applied to blobs before they are written to any backend. +> ⚠️ **Deprecated.** Prefer per-entry `OXICLOUD_STORAGE__ENCRYPTION_KEY` under an entry declared in `OXICLOUD_STORAGE_ENTRIES` — presence of the key implies encryption is enabled on that entry (no separate flag), and multi-entry enables cross-key rotation via migration to a new entry. The flat vars below still work in zero-entries mode and get folded into the synthesised `default` entry, alongside the same deprecation warning at boot. + | Variable | Default | Description | |---|---|---| | `OXICLOUD_STORAGE_ENCRYPTION_ENABLED` | `false` | Enable at-rest blob encryption | diff --git a/example.env b/example.env index 9c673fe0..f84b531a 100644 --- a/example.env +++ b/example.env @@ -353,8 +353,73 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud #OXICLOUD_PLUGIN_LOG_QUEUE_CAPACITY=1024 # ----------------------------------------------------------------------------- -# STORAGE BACKEND +# STORAGE ENTRIES (multi-entry, recommended) # ----------------------------------------------------------------------------- +# +# Declare one or more NAMED storage backends. The one the app runs on is +# picked from the DB (`admin_settings.storage.active_backend_name`) — the +# admin panel's storage tab flips it, and cross-backend migration is a +# recoverable-run job that copies blobs between two entries. See +# `docs/plan/storage-multi-entry.md` for the full model. +# +# Rules: +# * `OXICLOUD_STORAGE_ENTRIES` is a comma-separated allowlist of names. +# Names must match `[a-z0-9_-]{1,32}` and be unique. Order is +# preserved (the first entry is the fallback when no active pointer +# is set in the DB yet — e.g. fresh install). +# * For each name `N`, the parser reads +# `OXICLOUD_STORAGE__BACKEND` (local | s3 | azure) plus the +# backend-specific fields below. A missing required field aborts +# boot with the exact var name in the error message. +# * Presence of `OXICLOUD_STORAGE__ENCRYPTION_KEY` implies AES-256 +# encryption is enabled on that entry (no separate enable flag). +# Bad base64 / wrong length aborts boot with the entry name. +# * SETTING `_ENTRIES` alongside the legacy flat vars below (e.g. +# `OXICLOUD_STORAGE_BACKEND` + `OXICLOUD_S3_BUCKET`) is a FAIL-FAST +# boot error — pick one mode. Migrate any leftover flat vars into +# per-entry `_STORAGE__*` form. +# +# Example: local disk today, S3 target for a planned 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=us-east-1 +#OXICLOUD_STORAGE_s3_prod_S3_ENDPOINT_URL=https://s3.example.com +#OXICLOUD_STORAGE_s3_prod_S3_ACCESS_KEY= +#OXICLOUD_STORAGE_s3_prod_S3_SECRET_KEY= +#OXICLOUD_STORAGE_s3_prod_S3_FORCE_PATH_STYLE=false +#OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY= # generate: openssl rand -base64 32 +# Cipher declaration — future-proofing. Today only `aes-256-gcm` is +# accepted (and it's the default when `_ENCRYPTION_KEY` is set), so +# this line can be omitted. Explicit here as documentation. +#OXICLOUD_STORAGE_s3_prod_ENCRYPTION_CIPHER=aes-256-gcm +# +# Repair flag: if you rename an entry in .env while the DB still points +# at the old name, boot aborts with an actionable error pointing at: +# +# oxicloud --select-storage +# +# which verifies the entry exists in `_ENTRIES` and updates the DB +# pointer without booting the server. See §Fallback in the plan doc. + +# ----------------------------------------------------------------------------- +# STORAGE BACKEND — DEPRECATED (single-backend flat vars) +# ----------------------------------------------------------------------------- +# +# ⚠️ DEPRECATED. Use the STORAGE ENTRIES section above for new deployments. +# These flat variables still work when `OXICLOUD_STORAGE_ENTRIES` is UNSET — +# the parser then synthesises a single entry named `default` from them AND +# emits a boot-time deprecation warning +# (`storage.legacy_flat_vars_deprecated`) so operators see it in logs. +# Removal target: not yet fixed. Migrate at your convenience by moving +# each `OXICLOUD_STORAGE_BACKEND` / `OXICLOUD_S3_*` / `OXICLOUD_AZURE_*` / +# `OXICLOUD_STORAGE_ENCRYPTION_*` into `OXICLOUD_STORAGE__*` under +# an entry declared in `OXICLOUD_STORAGE_ENTRIES`. # Blob storage backend: local (default), s3, or azure #OXICLOUD_STORAGE_BACKEND=local @@ -400,9 +465,15 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Cache directory (default: {STORAGE_PATH}/.blob-cache) #OXICLOUD_STORAGE_CACHE_PATH= -# --- Client-Side Encryption --- +# --- Client-Side Encryption --- DEPRECATED (per-entry key is the new home) # AES-256-GCM encryption applied to blobs before writing to any backend. # WARNING: losing the key means losing all data. Back it up securely. +# +# ⚠️ DEPRECATED. Prefer per-entry `OXICLOUD_STORAGE__ENCRYPTION_KEY` +# under an entry declared in `OXICLOUD_STORAGE_ENTRIES` (see the top +# multi-entry section). The flat vars below still work in +# zero-entries mode and get folded into the synthesised `default` +# entry, alongside a deprecation warning at boot. # Enable at-rest blob encryption (default: false) #OXICLOUD_STORAGE_ENCRYPTION_ENABLED=false diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index b15c5bae..a4001438 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -476,14 +476,7 @@ export interface StorageEntrySummary { } export interface StorageSettings { - backend: string; - s3_endpoint_url?: string | null; - s3_bucket?: string | null; - s3_region?: string | null; - s3_access_key_set?: boolean; - s3_secret_key_set?: boolean; - s3_force_path_style?: boolean; - env_overrides?: string[]; + // Live stats — what the running process reports. current_backend?: string; total_blobs?: number; total_bytes_stored?: number; diff --git a/frontend/src/lib/api/endpoints/adminJobs.ts b/frontend/src/lib/api/endpoints/adminJobs.ts index fb7f4764..97db6419 100644 --- a/frontend/src/lib/api/endpoints/adminJobs.ts +++ b/frontend/src/lib/api/endpoints/adminJobs.ts @@ -53,11 +53,16 @@ export function listJobs(): Promise { */ export async function triggerJob( name: string, - opts: { force?: boolean; deep?: boolean } = {} + opts: { force?: boolean; deep?: boolean; storage?: string } = {} ): Promise { const params = new URLSearchParams(); if (opts.force) params.set('force', 'true'); if (opts.deep) params.set('deep', 'true'); + // `storage` scopes tenants that respect JobRunArgs.storage — + // currently blobs_consistency / backend_consistency (probes the + // named entry instead of the live backend). See + // `docs/plan/storage-multi-entry.md` slice 7. + if (opts.storage) params.set('storage', opts.storage); const q = params.toString(); const url = `/api/admin/jobs/${encodeURIComponent(name)}/trigger${q ? `?${q}` : ''}`; const res = await apiFetch(url, { diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 8b28a792..f6e2d890 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -26,7 +26,6 @@ resetUserPassword, saveOidc, savePluginRetention, - saveStorage, sendSmtpTest, setPluginEnabled, setRegistrationEnabled, @@ -75,6 +74,7 @@ DrivePoliciesPartial, User } from '$lib/api/types'; + import { triggerJob } from '$lib/api/endpoints/adminJobs'; import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; @@ -370,129 +370,72 @@ } } - // Storage - const STORAGE_PRESETS: Record = - { - custom: { endpoint: '', region: '', pathStyle: false }, - aws: { endpoint: '', region: 'us-east-1', pathStyle: false }, - backblaze: { - endpoint: 'https://s3.{region}.backblazeb2.com', - region: 'us-west-004', - pathStyle: false - }, - 'cloudflare-r2': { - endpoint: 'https://{accountId}.r2.cloudflarestorage.com', - region: 'auto', - pathStyle: true - }, - minio: { endpoint: 'http://localhost:9000', region: 'us-east-1', pathStyle: true }, - digitalocean: { - endpoint: 'https://{region}.digitaloceanspaces.com', - region: 'nyc3', - pathStyle: false - }, - wasabi: { - endpoint: 'https://s3.{region}.wasabisys.com', - region: 'us-east-1', - pathStyle: false - } - }; + // Storage — multi-entry read-only view. + // + // Post `docs/plan/storage-multi-entry.md`, the .env is the SOLE + // place to declare backends. The admin storage tab is now: + // - a read-only list of the entries the server booted with, + // - a per-entry test button (round-trip against that entry), + // - a per-entry audit action (triggers blobs_consistency?storage=), + // - a per-non-active migrate+activate button, + // - the migration status line + cutover hint. + // No form. No save. The retired save endpoint / DTO are still on + // the backend during the deprecation window but the UI never + // hits them. let storage = $state(null); - let sForm = $state({ - backend: 'local', - preset: 'custom', - endpoint: '', - bucket: '', - region: '', - accessKey: '', - secretKey: '', - pathStyle: false - }); let storageMsg = $state<{ text: string; ok: boolean } | null>(null); - let storageBusy = $state(false); + // Per-entry test state — keyed by entry name so the buttons don't + // step on each other and the last result stays visible per row. + let entryTest = $state< + Record + >({}); async function loadStorage() { try { storage = await getStorageSettings(); - sForm = { - backend: storage.backend ?? 'local', - preset: 'custom', - endpoint: storage.s3_endpoint_url ?? '', - bucket: storage.s3_bucket ?? '', - region: storage.s3_region ?? '', - accessKey: '', - secretKey: '', - pathStyle: storage.s3_force_path_style ?? false + } catch (e) { + storageMsg = { text: errorMessage(e), ok: false }; + } + } + + async function doTestEntry(name: string) { + entryTest = { ...entryTest, [name]: { busy: true } }; + try { + const r: StorageTestResult = await testStorage({ entry_name: name }); + entryTest = { ...entryTest, [name]: { busy: false, result: r } }; + } catch (e) { + entryTest = { ...entryTest, [name]: { busy: false, error: errorMessage(e) } }; + } + } + + async function doAuditEntry(name: string) { + try { + await triggerJob('blobs_consistency', { storage: name }); + storageMsg = { + text: t( + 'admin.storage_audit_triggered', + { name }, + 'blobs_consistency triggered for `{{name}}` — watch it on the Jobs tab.' + ), + ok: true }; } catch (e) { storageMsg = { text: errorMessage(e), ok: false }; } } - function applyPreset() { - const p = STORAGE_PRESETS[sForm.preset]; - if (!p) return; - if (p.endpoint) sForm.endpoint = p.endpoint; - if (p.region) sForm.region = p.region; - sForm.pathStyle = p.pathStyle; - } - function storageBody() { - return { - backend: sForm.backend, - s3_endpoint_url: sForm.endpoint.trim() || null, - s3_bucket: sForm.bucket.trim() || null, - s3_region: sForm.region.trim() || null, - s3_access_key: sForm.accessKey || null, - s3_secret_key: sForm.secretKey || null, - s3_force_path_style: sForm.pathStyle - }; - } - async function doSaveStorage() { - storageBusy = true; - storageMsg = null; - try { - await saveStorage(storageBody()); - storageMsg = { text: t('admin.storage_saved', 'Storage settings saved.'), ok: true }; - await loadStorage(); - } catch (e) { - storageMsg = { text: errorMessage(e), ok: false }; - } finally { - storageBusy = false; - } - } - async function doTestStorage() { - storageBusy = true; - storageMsg = null; - try { - const r: StorageTestResult = await testStorage(storageBody()); - // Backend now performs BOTH reachability (health-check) - // and a full read/write round-trip. `connected` gets - // flipped to false by the service if the round-trip - // itself fails, so a single boolean covers the whole - // pass/fail signal. `roundtrip_passed` distinguishes the - // two flavours of failure for the operator. - const ok = r.connected ?? r.success ?? false; - if (ok) { - let text = t('admin.storage_test_success', 'Connection + read/write OK'); - if (r.backend_type) text += ` (${r.backend_type})`; - if (r.roundtrip_elapsed_ms != null) text += ` — round-trip ${r.roundtrip_elapsed_ms} ms`; - if (r.available_bytes != null) - text += ` · ${formatBytes(r.available_bytes)} ${t('admin.available', 'available')}`; - if (r.cleanup_ok === false) - text += ` · ⚠ cleanup DELETE failed — orphan test blob left on backend`; - storageMsg = { text, ok: true }; - } else { - const phase = r.phase_reached ? ` [phase: ${r.phase_reached}]` : ''; - const label = - r.roundtrip_passed === false - ? t('admin.storage_test_failure', 'Read/write test failed') - : t('admin.storage_test_failure', 'Connection failed'); - storageMsg = { text: `${label}${phase}: ${r.message ?? ''}`, ok: false }; - } - } catch (e) { - storageMsg = { text: errorMessage(e), ok: false }; - } finally { - storageBusy = false; - } + + async function doMigrateActivate(name: string) { + if ( + !confirm( + t( + 'admin.storage_migrate_confirm', + { name }, + 'Migrate all blobs to `{{name}}` and set it as the active entry? The server enters read-only mode during the copy; restart is required to finish cutover.' + ) + ) + ) + return; + await doMigration('start', name); } // Migration @@ -526,96 +469,18 @@ } } - // ── Multi-entry migration target picker (slice 6) ──────────────── - // - // Multi-entry mode requires the admin to name the target entry - // before starting a migration. Backend rejects an unnamed start - // with 400. Dropdown shows every non-active entry; picking one - // enables the Start button. - let migrationTarget = $state(''); - const availableTargets = $derived( - (storage?.entries ?? []).filter((e) => !e.is_active).map((e) => e.name) - ); - // Sync target when the entries list first appears — pick the first - // non-active entry by default so the operator can just click Start - // on a simple two-entry setup. - $effect(() => { - if (!migrationTarget && availableTargets.length > 0) { - migrationTarget = availableTargets[0]; - } - // Also unset when the previously-chosen target became active - // (cutover completed under our feet). - if (migrationTarget && !availableTargets.includes(migrationTarget)) { - migrationTarget = availableTargets[0] ?? ''; - } - }); + // The old target-name picker state was retired — the entries + // table now has per-row "Migrate & activate" buttons on + // non-active entries. Simpler mental model; no picker to sync. - // ── Post-migration .env cutover hint ───────────────────────────── - // - // Migration copies blobs to the target backend, but boot-time - // backend selection reads env vars only — never the DB config the - // admin filled in. So the app keeps running on the SOURCE backend - // even after the copy completes. To actually cut over, the - // operator has to add the equivalent env vars to `.env` and - // restart. This hint block spells out those lines with a - // copy-to-clipboard button. - // - // Shown only when: - // - a migration has completed successfully, AND - // - the live backend still differs from the configured target - // (so we're actually pending cutover), AND - // - the backend env var isn't ALREADY overriding (which would - // mean the admin already updated .env or the platform sets it). - const cutoverPending = $derived( - migration?.status === 'completed' && - !!storage && - storage.current_backend != null && - storage.current_backend !== storage.backend && - !(storage.env_overrides ?? []).includes('backend') - ); - - // Env-var lines the admin needs to paste. Credentials are NEVER - // echoed — the storage-settings DTO only returns `_set` booleans - // for access/secret keys (not the values), so we render a - // placeholder line the admin fills in from their own records. - // Local backend still gets a line for completeness, but a Local - // deployment typically has no reason to explicitly set the var - // (default is Local). - const cutoverEnvLines = $derived.by((): string[] => { - if (!storage) return []; - const lines: string[] = []; - switch (storage.backend) { - case 's3': - lines.push('OXICLOUD_STORAGE_BACKEND=s3'); - if (storage.s3_endpoint_url) - lines.push(`OXICLOUD_S3_ENDPOINT_URL=${storage.s3_endpoint_url}`); - if (storage.s3_bucket) lines.push(`OXICLOUD_S3_BUCKET=${storage.s3_bucket}`); - if (storage.s3_region) lines.push(`OXICLOUD_S3_REGION=${storage.s3_region}`); - if (storage.s3_access_key_set) - lines.push('OXICLOUD_S3_ACCESS_KEY='); - if (storage.s3_secret_key_set) - lines.push('OXICLOUD_S3_SECRET_KEY='); - if (storage.s3_force_path_style) lines.push('OXICLOUD_S3_FORCE_PATH_STYLE=true'); - break; - case 'local': - lines.push('OXICLOUD_STORAGE_BACKEND=local'); - break; - // Azure not yet exposed in the admin form; add here when it is. - } - return lines; - }); - - let cutoverCopied = $state(false); - async function copyCutoverEnv() { - try { - await navigator.clipboard.writeText(cutoverEnvLines.join('\n')); - cutoverCopied = true; - setTimeout(() => (cutoverCopied = false), 2000); - } catch { - // Clipboard permission denied — silent; the block is - // selectable so the operator can copy manually. - } - } + // Retired: the .env cutover-hint state (cutoverPending + + // cutoverEnvLines + cutoverCopied + copyCutoverEnv). It served + // the pre-multi-entry flow that made admins paste env vars + // into .env after migration. Post-multi-entry, the server + // writes `active_backend_name` to the DB automatically on + // migration completion; the operator just restarts. The new + // short "restart to switch" hint is rendered inline in the + // entries card template, no derived state needed. // Migration integrity verification retired in slice 7 — the // sample-based /storage/migration/verify endpoint is replaced by @@ -2011,136 +1876,36 @@ {/if} {:else if tab === 'storage'} +
-

{t('admin.storage_tab', 'Storage')}

+

{t('admin.storage_tab', 'Storage entries')}

{#if !storage}

{t('common.loading', 'Loading…')}

- {:else} -
(e.preventDefault(), doSaveStorage())} - > - - {#if sForm.backend === 's3'} - - - - - - - - {/if} - {#if storageMsg}

- {storageMsg.text} -

{/if} -
- - - -
-
+ {:else if !storage.entries || storage.entries.length === 0} + +

+ + {t( + 'admin.storage_no_entries', + { backend: storage.current_backend ?? '?' }, + 'No OXICLOUD_STORAGE_ENTRIES declared. Running on the legacy single-backend fallback ({{backend}}). Migrate to the multi-entry model — see docs/config/env.md.' + )} +

{t('admin.storage_current', 'Current backend')}
{storage.current_backend ?? '—'}
@@ -2153,15 +1918,7 @@
{t('admin.storage_dedup', 'Dedup ratio')}
{storage.dedup_ratio != null ? `${storage.dedup_ratio.toFixed(2)}x` : '—'}
- {/if} -
- -
-

{t('admin.migration', 'Storage migration')}

- - {#if storage?.entries && storage.entries.length > 0} + {:else} {#if storage.migration_readonly}
{/if} - - - - - - - - - - - - {#each storage.entries as entry (entry.name)} - - - - - - - - {/each} - -
{t('admin.entry_name', 'Entry')}{t('admin.entry_backend', 'Backend')}{t('admin.entry_location', 'Location')}{t('admin.entry_encryption', 'Encryption')}{t('admin.entry_status', 'Status')}
{entry.name}{entry.backend}{entry.location_hint ?? '—'} - {#if entry.encryption_enabled} - AES-256 - {:else} - — - {/if} - + + + {@const migrationInFlight = + migration != null && (migration.status === 'running' || migration.status === 'paused')} +
+ {#each storage.entries as entry (entry.name)} + {@const test = entryTest[entry.name]} +
+
+
+ {entry.name} {#if entry.is_active} - {t('admin.entry_active', 'active')} + + + {t('admin.entry_active', 'active')} + {:else} - {t('admin.entry_inactive', 'available')} + + {t('admin.entry_inactive', 'available')} + {/if} -
- {/if} - {#if !migration} -

{t('common.loading', 'Loading…')}

- {:else} -

{t('admin.status', 'Status')}: {migration.status}

- {#if migration.total_blobs > 0} -
-
-
-

- {migration.migrated_blobs} / {migration.total_blobs} ({migrationPct}%) · - {formatBytes(migration.migrated_bytes)} - {#if migration.throughput_bytes_per_sec && migration.status === 'running'} - · {formatBytes(Math.round(migration.throughput_bytes_per_sec))}/s - {/if} - {#if migrationEtaMin != null} - · {t('admin.mig_eta', { min: migrationEtaMin }, `~${migrationEtaMin} min remaining`)} - {/if} -

- {/if} - {#if migration.failed_blobs && migration.failed_blobs.length > 0} -
- - {t( - 'admin.mig_failed', - { n: migration.failed_blobs.length }, - `${migration.failed_blobs.length} failed blobs` - )} - -
{migration.failed_blobs.join('\n')}
-
- {/if} -
- - {#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'} - {#if storage?.entries && storage.entries.length > 0} - - - {:else} - - {/if} - {/if} - {#if migration.status === 'running'} - - {/if} - {#if migration.status === 'paused'} - - {/if} - +
+ +
+ + + {#if !entry.is_active && !migrationInFlight} + + {:else} + + {/if} +
+ +
+
{t('admin.entry_backend', 'Backend')}
+
{entry.backend}
+
{t('admin.entry_location', 'Location')}
+
{entry.location_hint ?? '—'}
+ {#if entry.is_active} +
{t('admin.storage_blobs', 'Blobs')}
+
{storage.total_blobs ?? '—'}
+
{t('admin.storage_size', 'Stored')}
+
+ {storage.total_bytes_stored != null + ? formatBytes(storage.total_bytes_stored) + : '—'} +
+
{t('admin.storage_dedup', 'Dedup ratio')}
+
+ {storage.dedup_ratio != null ? `${storage.dedup_ratio.toFixed(2)}x` : '—'} +
+ {/if} +
+ {#if test?.result != null || test?.error != null} +
+ {#if test.error} + {test.error} + {:else if test.result} + {@const ok = test.result.connected ?? false} + {@const rt = test.result.roundtrip_elapsed_ms} + {@const cleanup = test.result.cleanup_ok} + + + {ok + ? t('admin.storage_test_success', 'Read/write OK') + : t('admin.storage_test_failure', 'Test failed')} + {#if rt != null} + · {t('admin.storage_test_elapsed', { ms: rt }, '{{ms}} ms')} + {/if} + {#if cleanup === false} + · ⚠ {t('admin.storage_test_cleanup_warn', 'cleanup DELETE failed')} + {/if} + {#if !ok} + — {test.result.message} + {/if} + + {/if} +
+ {/if} + + {/each}
- {#if cutoverPending} - + +
+

+ {t('admin.mig_status', 'Migration status')}: + {migration?.status ?? '—'} + {#if migration?.status === 'running'} + + {/if} + {#if migration?.status === 'paused'} + + {/if} +

+ {#if migration && migration.total_blobs > 0} +
+
+
+

+ {migration.migrated_blobs} / {migration.total_blobs} ({migrationPct}%) + {#if migrationEtaMin != null} + · {t( + 'admin.mig_eta', + { min: migrationEtaMin }, + `~${migrationEtaMin} min remaining` + )} + {/if} +

+ {/if} + {#if migration?.failed_blobs && migration.failed_blobs.length > 0} +
+ + {t( + 'admin.mig_failed', + { n: migration.failed_blobs.length }, + `${migration.failed_blobs.length} failed blobs` + )} + +
{migration.failed_blobs.join('\n')}
+
+ {/if} +
+ + {#if migration?.status === 'completed' && storage.migration_readonly} +

- - {t('admin.mig_cutover_title', 'Cutover pending — update .env and restart')} + + {t('admin.mig_cutover_done_title', 'Migration complete — restart to switch')}

{t( - 'admin.mig_cutover_body', - { target: storage?.backend ?? '?', live: storage?.current_backend ?? '?' }, - 'Blobs are now on {{target}} but the server is still running on {{live}}. To switch, add these lines to your .env and restart the server.' + 'admin.mig_cutover_done_body', + { active: storage.active_entry_name ?? '?' }, + 'The DB pointer now names `{{active}}` as the active backend, but the running process is still bound to the previous entry. Restart the server to complete the cutover; boot picks up the new active entry and clears read-only mode automatically.' )}

-
{cutoverEnvLines.join('\n')}
-
- -

- {t( - 'admin.mig_cutover_secret_note', - 'The access key and secret key are placeholders — paste the values you entered when saving these settings. Credentials are never displayed here.' - )} -

-
{/if} {/if} + {#if storageMsg} +

{storageMsg.text}

+ {/if}
@@ -2361,7 +2168,7 @@

{t( 'admin.encryption_hint', - 'Generate an AES-256 key for at-rest blob encryption, then set it as OXICLOUD_STORAGE_ENCRYPTION_KEY in your server environment.' + 'Generate an AES-256 key for at-rest blob encryption. Set it as OXICLOUD_STORAGE__ENCRYPTION_KEY under an entry declared in OXICLOUD_STORAGE_ENTRIES — presence of the key implies encryption is enabled on that entry (no separate flag).' )}