| Unset / empty | Present | **Synthesize** a single entry named `default` from the legacy vars. Preserves upgrade path — existing deployments keep working without touching `.env`. |
| Set (e.g. `foo,bar`) | Absent | Parse each named entry from `_STORAGE_<NAME>_*` vars. Fail-fast if any declared entry is missing its required per-name fields. |
| Set (e.g. `foo,bar`) | **Present** | **FAIL FAST**. Boot aborts with an error listing every legacy var found. Admin must remove them or migrate them into per-entry `_STORAGE_<NAME>_*` form. |
**Why fail-fast on the "both set" case:** without it, admin state is
ambiguous — someone edits `OXICLOUD_S3_BUCKET` expecting it to matter,
but it's silently ignored because `_ENTRIES` won. The subtle-bug cost
is much higher than the one-time cleanup cost. Refusing to boot forces
the conversion once, with a clear message naming the exact vars to
remove.
**Set of "legacy storage-backend vars"** counted for the conflict check:
`OXICLOUD_STORAGE_BACKEND`, all seven `OXICLOUD_S3_*`, all five
`OXICLOUD_AZURE_*`, `OXICLOUD_STORAGE_ENCRYPTION_ENABLED`, and
`OXICLOUD_STORAGE_ENCRYPTION_KEY`. `OXICLOUD_STORAGE_PATH` is NOT in
this set — it drives multiple non-backend things (chunk dir default,
etc.) and remains a valid ambient path; per-entry `_ROOT_DIR` falls
back to it for Local entries when unset.
### One DB row: `active_backend_name`
Single setting in `admin_settings`:
```
storage.active_backend_name = "local_main"
```
- **Boot logic**:
1. Parse `AppConfig.storage_entries` from env.
2. Read `active_backend_name` from `admin_settings`.
3. If unset (fresh install): use the FIRST name in `_ENTRIES`. Log which
one, don't fail.
4. Look up the entry by name. Build `blob_backend` from it.
5. If the name doesn't exist in current env (deploy drift — someone removed
an entry): **fail-fast at boot** with a clear error naming the missing
entry AND listing the available ones. Admin fixes env or overrides the
setting via a fallback CLI (see §Fallback).
- Everything the admin panel currently writes about storage
(`s3.bucket`, `s3.access_key`, …) is **removed** from DB. `save_storage_settings`
is deleted along with those rows.
### Encryption is per-entry
`OXICLOUD_STORAGE_<NAME>_ENCRYPTION_KEY` (base64 of exactly 32 bytes) on an
entry → that entry's backend is wrapped in `EncryptedBlobBackend` at build
time. Absent → raw backend.
- **Presence-implies-enabled.** No separate `_ENCRYPTION_ENABLED` toggle —
one env var per entry is enough.
- **Fail-fast on invalid key**: bad base64, wrong decoded length → boot
aborts with the entry name in the error message. A bad key is a real
deployment error, must be caught at boot, never silently disabled.
- **Legacy `OXICLOUD_STORAGE_ENCRYPTION_KEY`** remains honoured for the
synthesized `default` entry when `_ENTRIES` is empty (upgrade path).
Cross-entry encryption combinations work out of the box because
`EncryptedBlobBackend` is a decorator and `copy_blob` in the migration
handler always spools plaintext to a tmp file:
| Source | Target | Migration behaviour |
|---|---|---|
| Raw local | Encrypted S3 (K1) | Read plaintext → write encrypts with K1 |
| Encrypted S3 (K1) | Raw local | Read decrypts with K1 → write plaintext |
| Encrypted S3 (K1) | Encrypted S3 (K2) on different bucket | Read decrypts K1 → write encrypts K2 (rotation via new bucket) |
| Encrypted S3 (K1) | Encrypted S3 (K2) on SAME bucket | **REFUSED** — see below |
**In-place key rotation is refused.** Same physical bucket + different
encryption key would have the migration overwrite `<hash>.blob` with K2
ciphertext while the LIVE backend is still K1-configured → readers get
K1-decrypt-of-K2-bytes → 500. Silent data-loss window. The
`is_source_target_identical` guard (see §Migration flow) catches this because
`storage_identity` deliberately excludes the encryption key. The refusal
message spells the case out and recommends the two-step workaround (rotate
— no need to install extra tooling in the container.
- **Systemd-friendly**: can be run as a `ExecStartPre=` one-shot before the
main service unit.
- **No dep on `just`** being installed (dev-machine tool, not typical prod).
- **Same binary, same env-parse code path**: the repair uses THE SAME
`.env` parser the server does, so "verified present" means the server
will succeed on next boot too. No parser drift possible.
The boot-time error message points at this flag explicitly, with the
exact command line filled in.
### Interaction with existing surfaces
- **`/admin/storage` tab** rewritten:
- Read-only listing of entries (name, backend type, encryption on/off, is
active).
- "Migrate to X" dropdown (choose target entry).
- Migration progress + verify/cancel (existing).
- Read-only banner when `migration_readonly` is on.
- The Save form, S3-field editors, .env cutover hint — all deleted (no
settings edit here anymore).
- **`test_storage_connection`** loses the S3-fields DTO; becomes a
round-trip probe against a named entry: `POST .../test?storage=<name>`.
- **`verify_migration`** deleted; the "Verify integrity" button on the
storage tab is rewired to trigger `blobs_consistency?storage=<name>`
against the migration target (or dropped in favour of the standard
admin/jobs Run button — TBD in slice 6).
## Slice breakdown
Single PR is too big; splitting into a coherent sequence. Slices 1-2 are
foundational; the rest layer on top independently within reason.
| # | Slice | Depends on | Rough size |
|---|---|---|---|
| 1 | Config parser: `OXICLOUD_STORAGE_ENTRIES` + per-entry vars + `_ENCRYPTION_KEY` → `Vec<NamedStorageEntry>` on `AppConfig`. Legacy synthesis for empty `_ENTRIES` (default entry from legacy flat vars). | — | 1 day |
| 2 | Boot: read `active_backend_name` from `admin_settings`; look up entry; build `blob_backend` via a shared `build_entry_backend(&NamedStorageEntry)` factory (wraps encryption decorator when key present). Fail-fast on missing entry with actionable message. | 1 | ~half day |
| 3 | Migration handler rewrite: params carry `target_name` only. Handler resolves target by name from `storage_settings.build_entry_backend(name)`. `is_source_target_identical` guard refactored to compare NAMES first; keeps physical-identity check as second-line refusal (with encryption-differs-specific message). Retire the migration DTO S3-field body. | 1, 2 | ~half day |
| 4 | Global `migration_readonly` flag on `AppState`, backed by `admin_settings.storage.migration_readonly`. One clause added to `PgAclEngine::check_inner`. Boot-time clear rule (no in-flight + active matches booted → clear). | 2 | ~half day |
| 5 | Cutover state machine: on migration `Completed`, write `active_backend_name = target_name`, keep read-only on. Boot on new backend after operator restart. | 4 | ~half day |
| 6 | Admin storage tab rewrite: list entries, show active, migrate dropdown, read-only banner. Delete Save form + S3 field editors + .env cutover hint. | 1, 3, 4 | 1 day |
| 7 | `?storage=<name>` on `blobs_consistency` + `backend_consistency`. `JobRunArgs.storage` plumbing, `TriggerJobQuery.storage`, entry-resolver at run start, params records probed name. Retire `verify_migration` + its DTO + its route + its handler. | 1, 3 | 1 day |