boatramp_node/blobs.rs
1//! Blob (object-store) backend construction: build the configured object store
2//! (fs/S3/GCS/Azure, with optional blob-change notification provisioning) from a
3//! resolved [`BlobArgs`]. Each cloud backend is feature-gated; a disabled one
4//! returns an explanatory error rather than a misleading no-op. Moved out of the
5//! binary (node-library N2b.2c); the binary populates `BlobArgs` from its CLI
6//! `ServeArgs`.
7
8use std::path::Path;
9use std::sync::Arc;
10#[cfg(feature = "fallback")]
11use std::time::Duration;
12
13use boatramp_core::Storage;
14
15use crate::backends::BlobBackend;
16use crate::error::{Error, Result};
17
18#[cfg(feature = "fs")]
19use boatramp_storage::FsStorage;
20
21/// The boatramp read-fallback allowlist predicate (blob-backend migration Part 2): a primary miss
22/// falls through to the read-only secondary ONLY for a boatramp-OWNED key —
23/// - a content-addressed deploy blob (`{2hex}/{64hex}`, via [`boatramp_core::deploy::is_blob_key`]),
24/// - a guest object (`hblob/…`),
25/// - a messaging PAYLOAD — both the work-queue payload (`mqp/…`) and the grouped/retained payload
26/// (`mqgp/…`).
27///
28/// Any OTHER key (e.g. a stray `config/…` that should never live in the blob store — the mutable
29/// control-plane records are in the KV, not `Storage`) is primary-only, so it can NEVER silently
30/// resurrect off a secondary during a transition (Security Finding F4/G5-cp, defense-in-depth). The
31/// predicate is supplied to the generic [`FallbackStorage`](boatramp_storage::FallbackStorage), which
32/// hardcodes no app prefixes.
33///
34/// v0.31.0: `mqp/` was added alongside `mqgp/` so that, during the default-scope re-home soak
35/// (Finding 8), a work-queue payload re-homed from `mqp/{topic}/…` to `mqp/default/{topic}/…` is
36/// fallback-eligible too — without it a default-scoped work-queue payload would 404 on the new
37/// spelling during a concurrent backend switch.
38#[cfg(feature = "fallback")]
39pub fn blob_fallback_when() -> boatramp_storage::FallbackWhen {
40 Arc::new(|k: &str| {
41 boatramp_core::deploy::is_blob_key(k)
42 || k.starts_with("hblob/")
43 || k.starts_with("mqp/")
44 || k.starts_with("mqgp/")
45 })
46}
47
48/// The guest-blob data families whose keys the default-scope re-home (v0.31.0 Finding 8) re-homes:
49/// the guest object store and both messaging payload stores. `hkv/` + the `mq*/` INDICES live in the
50/// KV, not `Storage`, so they migrate via `boatramp migrate`, not here.
51#[cfg(feature = "fallback")]
52pub const DEFAULT_SCOPE_BLOB_FAMILIES: &[&str] = &["hblob/", "mqp/", "mqgp/"];
53
54/// Timeout bounding each translated secondary read in the default-scope blob fallback (a wedged
55/// backend degrades a translated miss to `NotFound`, never a serve-path hang). Matches the default
56/// used by the backend-switch fallback.
57#[cfg(feature = "fallback")]
58const DEFAULT_SCOPE_FALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
59
60/// The **new→old** translator for the default-scope blob fallback: for a post-flip key
61/// `{family}default/{rest}` under a known data family, name the pre-flip bare key `{family}{rest}` to
62/// fall back to. Purely syntactic (no project set needed): only a `…/default/…` key is translatable,
63/// so a named project's `{family}{project}/…` key never falls back and no cross-scope resurrection is
64/// possible.
65#[cfg(feature = "fallback")]
66pub fn default_scope_to_old() -> boatramp_storage::KeyTranslate {
67 Arc::new(|k: &str| {
68 for fam in DEFAULT_SCOPE_BLOB_FAMILIES {
69 if let Some(rest) = k.strip_prefix(fam) {
70 let suffix = rest.strip_prefix("default/")?;
71 return Some(format!("{fam}{suffix}"));
72 }
73 }
74 None
75 })
76}
77
78/// The **old→new** translator (project-aware) for the default-scope blob fallback's `list` union and
79/// the [`blob_rehome`](boatramp_storage::blob_rehome) drain/retire: for a bare default-scoped key
80/// `{family}{base}/…` (whose scope segment is NOT a known project), name the re-homed
81/// `{family}default/{base}/…`. A named project's key and an already-`default/` key both yield `None`.
82#[cfg(feature = "fallback")]
83pub fn default_scope_to_new(
84 projects: std::sync::Arc<std::collections::HashSet<String>>,
85) -> boatramp_storage::KeyTranslate {
86 Arc::new(move |k: &str| {
87 for fam in DEFAULT_SCOPE_BLOB_FAMILIES {
88 if let Some(rest) = k.strip_prefix(fam) {
89 let first = rest.split('/').next().unwrap_or("");
90 if first.is_empty()
91 || first == boatramp_core::project::DEFAULT_PROJECT
92 || projects.contains(first)
93 {
94 return None;
95 }
96 return Some(format!(
97 "{fam}{}/{rest}",
98 boatramp_core::project::DEFAULT_PROJECT
99 ));
100 }
101 }
102 None
103 })
104}
105
106/// A [`BlobBackends`](boatramp_core::blob_backends::BlobBackends) decorator that wraps each resolved
107/// backend in a [`TranslatingFallbackStorage`](boatramp_storage::TranslatingFallbackStorage) — the
108/// serve-path half of the default-scope blob re-home (Finding 8). Enabled ONLY while the store is in
109/// the default-scope migration's `dual` soak (see [`default_scope_blob_fallback`]); removed (by
110/// restarting after `boatramp migrate --finalize` + a verified blob retire) once the soak ends.
111#[cfg(feature = "fallback")]
112pub struct DefaultScopeFallbackBackends {
113 inner: Arc<dyn boatramp_core::blob_backends::BlobBackends>,
114 to_old: boatramp_storage::KeyTranslate,
115 to_new: boatramp_storage::KeyTranslate,
116}
117
118#[cfg(feature = "fallback")]
119impl boatramp_core::blob_backends::BlobBackends for DefaultScopeFallbackBackends {
120 fn backend(&self, project: boatramp_core::project::ProjectRef<'_>) -> Arc<dyn Storage> {
121 Arc::new(boatramp_storage::TranslatingFallbackStorage::new(
122 self.inner.backend(project),
123 self.to_old.clone(),
124 self.to_new.clone(),
125 DEFAULT_SCOPE_FALLBACK_TIMEOUT,
126 ))
127 }
128 fn default_backend(&self) -> Arc<dyn Storage> {
129 Arc::new(boatramp_storage::TranslatingFallbackStorage::new(
130 self.inner.default_backend(),
131 self.to_old.clone(),
132 self.to_new.clone(),
133 DEFAULT_SCOPE_FALLBACK_TIMEOUT,
134 ))
135 }
136}
137
138/// A [`BlobBackends`](boatramp_core::blob_backends::BlobBackends) decorator that wraps each resolved
139/// backend in a pure [`TranslatingStorage`](boatramp_storage::TranslatingStorage) — the serve-path
140/// blob half of the **un-migrated-store backward-read-compat** (the Finding-8 migration-safety
141/// rewrite). Unlike [`DefaultScopeFallbackBackends`] (the `dual`-soak read-fallback, which writes the
142/// new spelling), this rewrites the key new→old on EVERY op, so a legacy store that has not yet run
143/// `boatramp migrate` serves its default project's `hblob/{site}/…`/`mqp/…`/`mqgp/…` objects at the
144/// bare old spelling IN PLACE — reads and writes both land there, so rolling the binary is a no-op and
145/// a rollback loses nothing.
146#[cfg(feature = "fallback")]
147pub struct DefaultScopeTranslatingBackends {
148 inner: Arc<dyn boatramp_core::blob_backends::BlobBackends>,
149 to_old: boatramp_storage::KeyTranslate,
150 to_new: boatramp_storage::KeyTranslate,
151}
152
153#[cfg(feature = "fallback")]
154impl boatramp_core::blob_backends::BlobBackends for DefaultScopeTranslatingBackends {
155 fn backend(&self, project: boatramp_core::project::ProjectRef<'_>) -> Arc<dyn Storage> {
156 Arc::new(boatramp_storage::TranslatingStorage::new(
157 self.inner.backend(project),
158 self.to_old.clone(),
159 self.to_new.clone(),
160 ))
161 }
162 fn default_backend(&self) -> Arc<dyn Storage> {
163 Arc::new(boatramp_storage::TranslatingStorage::new(
164 self.inner.default_backend(),
165 self.to_old.clone(),
166 self.to_new.clone(),
167 ))
168 }
169}
170
171/// Wrap `inner` for the default-scope blob transition, selecting the mode from the control-plane
172/// store's schema state:
173///
174/// - **UN-MIGRATED** (below the re-home version, carrying bare default-scoped data — see
175/// [`boatramp_core::migrate::default_scope_compat_needed`]): a pure [`TranslatingStorage`] wrap, so
176/// the default project's blobs are read AND written at their bare old spelling in place (rolling the
177/// binary is a no-op; nothing is lost on rollback).
178/// - **`dual` soak** (forward staged, finalize deferred): the read-fallback
179/// ([`DefaultScopeFallbackBackends`]) — serve a not-yet-drained bare object at the new spelling while
180/// writes converge onto the new spelling.
181/// - otherwise (fresh / finalized): `inner` unchanged (byte-for-byte).
182///
183/// The named-project set (for the project-aware old→new `list` translator) is read once here, at
184/// serve-boot. This serve-path snapshot is **safe against staleness** (Security MED-1 — unlike the KV
185/// decorator, it is NOT made fresh-per-list, and it need not be): (1) guest/serve blob lists are
186/// ALWAYS project-SCOPED (`backend(project)` + a `hblob/{scope}/…` prefix), and `TranslatingStorage`
187/// bounds the inner list to that scope via `to_old` BEFORE `to_new` relabels — so a stale `to_new`
188/// can only relabel keys already inside the requested scope, never pull an out-of-scope tenant
189/// key in (proven by `translating::tests::a_stale_project_snapshot_cannot_expose_a_tenant_via_a_scoped_list`);
190/// (2) the only FAMILY-WIDE (un-scoped) consumer, the `boatramp blob rehome` drain, builds its OWN
191/// `to_new` from a project set read fresh at command invocation (see `crates/boatramp/src/blob.rs`),
192/// so it is never stale; (3) blob GC/prune is REFUSED while this decorator is active (`allows_prune()
193/// == false`). A tenant that shares the default's scoped prefix is the separate `CommingleProject`
194/// collision the migration refuses pre-stage AND on resume (Security M-1 + HIGH-1), not a snapshot
195/// issue.
196#[cfg(feature = "fallback")]
197pub async fn default_scope_blob_fallback(
198 kv: &dyn boatramp_core::kv::KvStore,
199 inner: Arc<dyn boatramp_core::blob_backends::BlobBackends>,
200) -> Arc<dyn boatramp_core::blob_backends::BlobBackends> {
201 let unmigrated = boatramp_core::migrate::default_scope_compat_needed(kv)
202 .await
203 .unwrap_or(false);
204 let in_soak = boatramp_core::migrate::read_state(kv)
205 .await
206 .map(|s| {
207 s.unfinalized
208 .contains(&boatramp_core::migrate::DEFAULT_SCOPE_REHOME_VERSION)
209 })
210 .unwrap_or(false);
211 if !unmigrated && !in_soak {
212 return inner;
213 }
214 // The named projects (for the project-aware old→new translator). Read once at wiring.
215 let mut projects = std::collections::HashSet::new();
216 if let Ok(keys) = kv.list_prefix(boatramp_core::project::POINTER_PREFIX).await {
217 for key in keys {
218 if let Some(name) = key.strip_prefix(boatramp_core::project::POINTER_PREFIX)
219 && !name.is_empty()
220 && !name.contains('/')
221 {
222 projects.insert(name.to_string());
223 }
224 }
225 }
226 let to_old = default_scope_to_old();
227 let to_new = default_scope_to_new(std::sync::Arc::new(projects));
228 if unmigrated {
229 tracing::warn!(
230 "default-scope blob backward-read-compat ACTIVE (store un-migrated): the default \
231 project's blobs are read AND written at their bare old spelling in place. Rolling this \
232 binary is a no-op; run `boatramp migrate` + `boatramp blob rehome` then \
233 `boatramp migrate --finalize` to re-home to the default/ spelling."
234 );
235 return Arc::new(DefaultScopeTranslatingBackends {
236 inner,
237 to_old,
238 to_new,
239 });
240 }
241 tracing::warn!(
242 "default-scope blob read-fallback ACTIVE (migration {} in dual soak): serving bare \
243 default-scoped blobs at their new default/ spelling. Run the blob re-home drain, then \
244 `boatramp migrate --finalize` + retire the old blobs, and restart to disable it.",
245 boatramp_core::migrate::DEFAULT_SCOPE_REHOME_VERSION
246 );
247 Arc::new(DefaultScopeFallbackBackends {
248 inner,
249 to_old,
250 to_new,
251 })
252}
253
254/// The resolved blob-backend selection — the binary populates this from its CLI
255/// `ServeArgs` (the credential/endpoint flags), keeping clap out of the library.
256///
257/// `s3_credential` (#505) is the optional node-level **sealed base S3 credential** the operator
258/// resolved from the `[secrets]` store; when present it is injected into the S3 client's SDK config
259/// instead of the ambient env chain. It is a redacted [`SealedS3Credential`], so `BlobArgs` can keep its
260/// derived `Debug` without leaking the secret.
261#[derive(Debug, Clone)]
262pub struct BlobArgs {
263 pub blobs: BlobBackend,
264 pub s3_bucket: Option<String>,
265 pub s3_endpoint: Option<String>,
266 pub s3_region: Option<String>,
267 pub s3_path_style: bool,
268 /// The node-level sealed base S3 credential (#505). `None` ⇒ the ambient AWS env chain (unchanged).
269 pub s3_credential: Option<crate::s3_credential::SealedS3Credential>,
270 pub gcs_bucket: Option<String>,
271 pub gcs_endpoint: Option<String>,
272 pub gcs_anonymous: bool,
273 pub azure_account: Option<String>,
274 pub azure_container: Option<String>,
275 pub azure_access_key: Option<String>,
276 pub azure_emulator: bool,
277}
278
279/// The blob backend plus, on a cloud object store with notification provisioning
280/// configured, its blob-change [`WatchProvider`](boatramp_core::blob_provision::WatchProvider)
281/// and operator tier (FA-5b2). The provider/tier are consumed only by the handler
282/// runtime, so they are dead code in a `--no-default-features` (no `handlers`) build.
283pub struct BuiltBlobs {
284 pub storage: Arc<dyn Storage>,
285 #[cfg_attr(not(feature = "handlers"), allow(dead_code))]
286 pub watch_provider: Option<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
287 #[cfg_attr(not(feature = "handlers"), allow(dead_code))]
288 pub provision_tier: boatramp_core::blob_notify::ProvisionTier,
289}
290
291/// Build the object store for the selected [`BlobBackend`]. `data_dir` is used
292/// only by the `fs` backend (unused when `fs` is off).
293#[cfg_attr(not(feature = "fs"), allow(unused_variables))]
294pub async fn build_blobs(
295 args: &BlobArgs,
296 data_dir: &Path,
297 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
298 notify_account: Option<String>,
299) -> Result<BuiltBlobs> {
300 match args.blobs {
301 #[cfg(feature = "fs")]
302 BlobBackend::Fs => Ok(BuiltBlobs {
303 storage: Arc::new(FsStorage::new(data_dir.join("blobs"))),
304 watch_provider: None,
305 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
306 }),
307 #[cfg(not(feature = "fs"))]
308 BlobBackend::Fs => Err(Error::NoFsSupport),
309 BlobBackend::S3 => build_s3(args, notify_tier, notify_account).await,
310 BlobBackend::Gcs => build_gcs(args, notify_tier, notify_account).await,
311 BlobBackend::Azure => build_azure(args, notify_tier, notify_account).await,
312 }
313}
314
315/// Build the PRIMARY blob backend (with its notify provisioning) and, when `fallback` is supplied,
316/// build a read-only SECONDARY and wrap the pair in a [`FallbackStorage`](boatramp_storage::FallbackStorage)
317/// — the zero-downtime backend switch (blob-backend migration Part 2).
318///
319/// The secondary is built via the SAME [`build_blobs`] path but with **NO watcher provisioning**
320/// (`notify_tier: None`, `notify_account: None`) — a read-only drain source never watches — and its
321/// `watch_provider`/`provision_tier` are discarded. The returned [`BuiltBlobs`] keeps the PRIMARY's
322/// `watch_provider`/`provision_tier` (watching is the primary's capability). The secondary is NEVER
323/// handed to the blob-upload/STS minter (that path takes the primary `BlobArgs` only).
324///
325/// CRITICAL ordering: if a read-through cache is ever wrapped around the result, it must wrap the
326/// OUTSIDE — `cache(fallback(primary, secondary))` — so the cache's `allows_prune` delegates through
327/// the fallback's `false`. `FallbackStorage` is therefore the INNERMOST composite here; the returned
328/// `storage` is the fallback (or the bare primary when no fallback is configured).
329#[cfg(feature = "fallback")]
330pub async fn build_blobs_with_fallback(
331 args: &BlobArgs,
332 fallback: Option<(&BlobArgs, Duration)>,
333 data_dir: &Path,
334 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
335 notify_account: Option<String>,
336) -> Result<BuiltBlobs> {
337 // The PRIMARY carries its notify provisioning (watching stays a primary capability).
338 let primary = build_blobs(args, data_dir, notify_tier, notify_account).await?;
339 let Some((secondary_args, timeout)) = fallback else {
340 return Ok(primary);
341 };
342 // The read-only SECONDARY: same build path, NO watcher provisioning; discard its
343 // watch_provider/provision_tier (a drain source never watches).
344 let secondary = build_blobs(secondary_args, data_dir, None, None).await?;
345 let composite: Arc<dyn Storage> = Arc::new(boatramp_storage::FallbackStorage::new(
346 primary.storage,
347 secondary.storage,
348 blob_fallback_when(),
349 timeout,
350 ));
351 Ok(BuiltBlobs {
352 storage: composite,
353 // Keep the PRIMARY's watcher/tier — watching is the primary's capability.
354 watch_provider: primary.watch_provider,
355 provision_tier: primary.provision_tier,
356 })
357}
358
359// Azure storage + optional blob-change notification (Event Grid → Storage Queue,
360// FA-5b2). When a notify tier is configured the backend is consumer-wired and
361// paired with the AzureWatchProvider (the Event Grid subscription is an operator
362// step — see the provider recipe).
363#[cfg(feature = "azure")]
364async fn build_azure(
365 args: &BlobArgs,
366 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
367 _notify_account: Option<String>,
368) -> Result<BuiltBlobs> {
369 let (Some(account), Some(container)) =
370 (args.azure_account.clone(), args.azure_container.clone())
371 else {
372 return Err(Error::AzureConfigRequired);
373 };
374 let opts = boatramp_storage::AzureOptions {
375 account,
376 container,
377 access_key: args.azure_access_key.clone(),
378 emulator: args.azure_emulator,
379 };
380 match notify_tier {
381 Some(tier) => {
382 let (storage, provider) = boatramp_storage::AzureStorage::connect_with_notify(opts)
383 .map_err(|err| Error::AzureConnect(err.to_string()))?;
384 Ok(BuiltBlobs {
385 storage: Arc::new(storage),
386 watch_provider: Some(Arc::new(provider)),
387 provision_tier: tier,
388 })
389 }
390 None => {
391 let storage = boatramp_storage::AzureStorage::connect(opts)
392 .map_err(|err| Error::AzureConnect(err.to_string()))?;
393 Ok(BuiltBlobs {
394 storage: Arc::new(storage),
395 watch_provider: None,
396 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
397 })
398 }
399 }
400}
401
402#[cfg(not(feature = "azure"))]
403async fn build_azure(
404 _args: &BlobArgs,
405 _notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
406 _notify_account: Option<String>,
407) -> Result<BuiltBlobs> {
408 Err(Error::NoAzureSupport)
409}
410
411// GCS storage + optional blob-change notification (GCS→Pub/Sub, FA-5b2). When a
412// notify tier is configured the backend is consumer-wired and paired with the
413// GcsWatchProvider; `blob_notify_account_id` is read as the GCP project id.
414#[cfg(feature = "gcs")]
415async fn build_gcs(
416 args: &BlobArgs,
417 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
418 notify_account: Option<String>,
419) -> Result<BuiltBlobs> {
420 let bucket = args.gcs_bucket.clone().ok_or(Error::GcsBucketRequired)?;
421 let opts = boatramp_storage::GcsOptions {
422 bucket,
423 endpoint: args.gcs_endpoint.clone(),
424 anonymous: args.gcs_anonymous,
425 };
426 match notify_tier {
427 Some(tier) => {
428 let project = notify_account.unwrap_or_default();
429 let (storage, provider) =
430 boatramp_storage::GcsStorage::connect_with_notify(opts, project)
431 .await
432 .map_err(|err| Error::GcsConnect(err.to_string()))?;
433 Ok(BuiltBlobs {
434 storage: Arc::new(storage),
435 watch_provider: Some(Arc::new(provider)),
436 provision_tier: tier,
437 })
438 }
439 None => {
440 let storage = boatramp_storage::GcsStorage::connect(opts)
441 .await
442 .map_err(|err| Error::GcsConnect(err.to_string()))?;
443 Ok(BuiltBlobs {
444 storage: Arc::new(storage),
445 watch_provider: None,
446 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
447 })
448 }
449 }
450}
451
452#[cfg(not(feature = "gcs"))]
453async fn build_gcs(
454 _args: &BlobArgs,
455 _notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
456 _notify_account: Option<String>,
457) -> Result<BuiltBlobs> {
458 Err(Error::NoGcsSupport)
459}
460
461#[cfg(feature = "s3")]
462async fn build_s3(
463 args: &BlobArgs,
464 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
465 notify_account: Option<String>,
466) -> Result<BuiltBlobs> {
467 let bucket = args.s3_bucket.clone().ok_or(Error::S3BucketRequired)?;
468 let opts = boatramp_storage::S3Options {
469 bucket,
470 endpoint: args.s3_endpoint.clone(),
471 region: args.s3_region.clone(),
472 force_path_style: args.s3_path_style,
473 // #505: when the operator configured a node-level sealed base credential, inject it so the S3
474 // client signs with the sealed key rather than the ambient `AWS_ACCESS_KEY_ID`/`_SECRET`.
475 credential: args.s3_credential.as_ref().map(|c| c.as_pair()),
476 };
477 match notify_tier {
478 // Blob-change notification provisioning is enabled: build the
479 // consumer-wired storage + the S3→SQS provider from one AWS config.
480 Some(tier) => {
481 let account = notify_account.unwrap_or_default();
482 let (storage, provider) =
483 boatramp_storage::S3Storage::connect_with_notify(opts, account).await;
484 Ok(BuiltBlobs {
485 storage: Arc::new(storage),
486 watch_provider: Some(Arc::new(provider)),
487 provision_tier: tier,
488 })
489 }
490 // No provisioning configured: a plain S3 backend (blob triggers refuse).
491 None => Ok(BuiltBlobs {
492 storage: Arc::new(boatramp_storage::S3Storage::connect(opts).await),
493 watch_provider: None,
494 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
495 }),
496 }
497}
498
499#[cfg(not(feature = "s3"))]
500async fn build_s3(
501 _args: &BlobArgs,
502 _notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
503 _notify_account: Option<String>,
504) -> Result<BuiltBlobs> {
505 Err(Error::NoS3Support)
506}