//! Factory that builds a `BlobStorageBackend` from a `NamedStorageEntry`. //! //! Central to `docs/plan/storage-multi-entry.md`: the same function is //! called by the boot path (to build the LIVE backend for the active //! entry) and by the migration handler (to build a target backend for //! any named entry). Keeping one factory means the encryption-decorator //! wrapping decision is expressed exactly once — no chance of the //! migration copy path silently omitting encryption while boot applies //! it (or vice versa). //! //! What this factory does NOT do: //! - Retry decorator — applied per-app-instance in `common/di.rs` //! because policy comes from `AppConfig.storage.retry`, not the //! entry. If per-entry retry becomes a need, add a //! `RetryConfig` field to `NamedStorageEntry` and move the //! wrapping in here. //! - Cache decorator — same story: cache path/size are ambient //! `AppConfig.storage.cache` settings, not per-entry. //! //! So the returned backend is `base [+ encryption]` — the two layers //! whose choice is tied to the entry itself. The caller stacks any //! remaining decorators. use std::path::{Path, PathBuf}; use std::sync::Arc; use sqlx::PgPool; use crate::application::ports::blob_storage_ports::BlobStorageBackend; use crate::common::config::{NamedStorageEntry, StorageBackendType}; /// Key in `auth.admin_settings` that holds the currently-active /// storage entry's name. Single source of truth for runtime backend /// selection (see `docs/plan/storage-multi-entry.md` §"One DB row"). pub const ACTIVE_BACKEND_NAME_KEY: &str = "storage.active_backend_name"; /// Key in `auth.admin_settings` that holds the persistent-across-restart /// migration-readonly flag. See /// `docs/plan/storage-multi-entry.md` §"Read-only mode reuses the /// existing AuthZ short-circuit". Value is `"true"` or `"false"` /// (plain text; the settings table stores strings). pub const MIGRATION_READONLY_KEY: &str = "storage.migration_readonly"; /// Read the persisted `migration_readonly` flag from `admin_settings`. /// Absent row / parse failure / DB error all resolve to `false` — the /// safer default when we can't determine the intent, since a false /// value only means "writes allowed by AuthZ" not "migration is /// running." Called once at boot to seed the in-memory `AtomicBool`. pub async fn load_migration_readonly(pool: &PgPool) -> bool { let row: Result,)>, sqlx::Error> = sqlx::query_as("SELECT value FROM auth.admin_settings WHERE key = $1") .bind(MIGRATION_READONLY_KEY) .fetch_optional(pool) .await; match row { Ok(Some((Some(v),))) => matches!(v.to_lowercase().as_str(), "true" | "1"), Ok(_) => false, Err(e) => { tracing::warn!( target: "oxicloud::scheduler", event = "storage.migration_readonly.load_failed", error = %e, "failed to read {MIGRATION_READONLY_KEY} at boot; defaulting to false" ); false } } } /// Persist the `migration_readonly` flag. Idempotent — upserts the /// `admin_settings` row. Called by the cutover state machine (slice 5) /// when a migration starts (set true) or completes cleanly across a /// restart (set false via the boot clear rule). Handler / trigger /// callers should also update the in-memory `AtomicBool` alongside /// this call to keep the two in sync. pub async fn persist_migration_readonly(pool: &PgPool, value: bool) -> Result<(), sqlx::Error> { sqlx::query( r#" INSERT INTO auth.admin_settings (key, value, category, is_secret) VALUES ($1, $2, 'storage', FALSE) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() "#, ) .bind(MIGRATION_READONLY_KEY) .bind(if value { "true" } else { "false" }) .execute(pool) .await?; Ok(()) } /// Persist the `active_backend_name` pointer. Called by the migration /// handler on `RunOutcome::Completed` to flip the runtime backend to /// the just-migrated target entry. The next boot reads this via /// `resolve_active_entry` and picks the new entry for the LIVE /// backend; before the restart the process is still on the OLD /// backend (that's what the `migration_readonly` gate is protecting). /// Idempotent UPSERT. pub async fn persist_active_backend_name(pool: &PgPool, name: &str) -> Result<(), sqlx::Error> { sqlx::query( r#" INSERT INTO auth.admin_settings (key, value, category, is_secret) VALUES ($1, $2, 'storage', FALSE) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() "#, ) .bind(ACTIVE_BACKEND_NAME_KEY) .bind(name) .execute(pool) .await?; Ok(()) } /// Result of [`resolve_active_entry`]. pub enum ActiveEntry<'a> { /// DB has an `active_backend_name` set AND that name matches an /// entry declared in the current env. Boot uses this entry. Explicit(&'a NamedStorageEntry), /// DB has NO `active_backend_name` set (fresh install, or the row /// was intentionally cleared). Caller falls back to a sensible /// default — typically the first entry in `_ENTRIES` order. Unset, } /// Look up the entry the app should boot with. /// /// Returns: /// - `Ok(ActiveEntry::Explicit(entry))` when DB has a value AND that /// value names an entry in `entries`. /// - `Ok(ActiveEntry::Unset)` when the DB row is absent (never /// written). Caller decides the fallback. /// - `Err(msg)` when the DB row IS set but the named entry is missing /// from the current env (deploy drift — someone removed an entry /// from `.env` or renamed it). The error message names the missing /// entry, lists the available ones, and points at the /// `oxicloud --select-storage ` repair flag. Boot must abort /// on this — silently falling back to a different entry would move /// the app's live backend without operator consent. pub async fn resolve_active_entry<'a>( pool: &PgPool, entries: &'a [NamedStorageEntry], ) -> Result, String> { let stored: Option = sqlx::query_scalar("SELECT value FROM auth.admin_settings WHERE key = $1") .bind(ACTIVE_BACKEND_NAME_KEY) .fetch_optional(pool) .await .map_err(|e| { format!("reading `{ACTIVE_BACKEND_NAME_KEY}` from auth.admin_settings failed: {e}") })?; match stored { None => Ok(ActiveEntry::Unset), Some(name) => match entries.iter().find(|e| e.name == name) { Some(entry) => Ok(ActiveEntry::Explicit(entry)), None => { let available = if entries.is_empty() { "(none — no OXICLOUD_STORAGE_ENTRIES declared)".to_string() } else { entries .iter() .map(|e| e.name.as_str()) .collect::>() .join(", ") }; Err(format!( "auth.admin_settings.storage.active_backend_name = `{name}`, but no entry \ with that name is declared in OXICLOUD_STORAGE_ENTRIES. Available: [{available}]. \ Either add `{name}` back to your .env, or repair the DB pointer with:\n \ oxicloud --select-storage " )) } }, } } /// Build a `BlobStorageBackend` matching the given entry, with the /// encryption decorator applied when the entry declares a key. /// /// `local_storage_path_fallback` is the ambient `AppConfig.storage_path` /// — used for a Local entry when `entry.root_dir` is `None`. Matches /// the fallback rule documented in /// `docs/plan/storage-multi-entry.md` §Legacy: per-entry `_ROOT_DIR` /// falls back to `OXICLOUD_STORAGE_PATH` for Local entries when unset. /// /// Panics with a targeted message on the two configuration errors that /// slip past env-parse-time validation: /// - S3 entry with `entry.s3 == None` — parser invariant violated. /// - Encryption key that fails base64 / length validation — the parser /// validates at env time, so hitting this means the entry was /// constructed programmatically without going through /// `parse_storage_entries`. /// /// Both are boot-fatal and indicate a code (not config) bug, so /// panic is the honest response. pub fn build_entry_backend( entry: &NamedStorageEntry, local_storage_path_fallback: &Path, ) -> Arc { let base: Arc = match entry.backend { StorageBackendType::Local => { let path = entry .root_dir .as_ref() .map(PathBuf::from) .unwrap_or_else(|| local_storage_path_fallback.to_path_buf()); Arc::new( crate::infrastructure::services::local_blob_backend::LocalBlobBackend::new(&path), ) } StorageBackendType::S3 => { let s3 = entry.s3.as_ref().unwrap_or_else(|| { panic!( "entry `{}` has backend=s3 but no s3 config — parser invariant violated", entry.name ) }); Arc::new(crate::infrastructure::services::s3_blob_backend::S3BlobBackend::new(s3)) } StorageBackendType::Azure => { let az = entry.azure.as_ref().unwrap_or_else(|| { panic!( "entry `{}` has backend=azure but no azure config — parser invariant violated", entry.name ) }); Arc::new(crate::infrastructure::services::azure_blob_backend::AzureBlobBackend::new(az)) } }; // v1 wrapper (Choice 1/B: always wrap). Every entry gets the // header-aware read/write path — even entries with no // `_ENCRYPTION_KEY` at all. This normalises the on-disk format // going forward: all new writes carry the OXCPT v1 header, all // reads magic-byte-dispatch (with legacy fallback for // header-less pre-K2 blobs). Not backwards-compatible with // pre-K2 code trying to read new writes — but Ed's called it: // uniform format is worth the one-way door. use crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend; let pairs = entry.encryption.clone().unwrap_or_default(); let mode = match entry.head_cipher() { Some(crate::common::config::CipherKind::AesGcm256) => "encrypted-v1", _ => "plaintext-v1", }; tracing::info!( "Storage entry `{}` — {} wrapper (pairs: {})", entry.name, mode, pairs.len() ); Arc::new(EncryptedBlobBackend::new(base, pairs)) }