1use 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#[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#[cfg(feature = "fallback")]
52pub const DEFAULT_SCOPE_BLOB_FAMILIES: &[&str] = &["hblob/", "mqp/", "mqgp/"];
53
54#[cfg(feature = "fallback")]
58const DEFAULT_SCOPE_FALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
59
60#[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#[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#[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#[cfg(feature = "fallback")]
146pub async fn default_scope_blob_fallback(
147 kv: &dyn boatramp_core::kv::KvStore,
148 inner: Arc<dyn boatramp_core::blob_backends::BlobBackends>,
149) -> Arc<dyn boatramp_core::blob_backends::BlobBackends> {
150 let in_soak = boatramp_core::migrate::read_state(kv)
151 .await
152 .map(|s| {
153 s.unfinalized
154 .contains(&boatramp_core::migrate::DEFAULT_SCOPE_REHOME_VERSION)
155 })
156 .unwrap_or(false);
157 if !in_soak {
158 return inner;
159 }
160 let mut projects = std::collections::HashSet::new();
162 if let Ok(keys) = kv.list_prefix(boatramp_core::project::POINTER_PREFIX).await {
163 for key in keys {
164 if let Some(name) = key.strip_prefix(boatramp_core::project::POINTER_PREFIX)
165 && !name.is_empty()
166 && !name.contains('/')
167 {
168 projects.insert(name.to_string());
169 }
170 }
171 }
172 tracing::warn!(
173 "default-scope blob read-fallback ACTIVE (migration {} in dual soak): serving bare \
174 default-scoped blobs at their new default/ spelling. Run the blob re-home drain, then \
175 `boatramp migrate --finalize` + retire the old blobs, and restart to disable it.",
176 boatramp_core::migrate::DEFAULT_SCOPE_REHOME_VERSION
177 );
178 Arc::new(DefaultScopeFallbackBackends {
179 inner,
180 to_old: default_scope_to_old(),
181 to_new: default_scope_to_new(std::sync::Arc::new(projects)),
182 })
183}
184
185#[derive(Debug, Clone)]
193pub struct BlobArgs {
194 pub blobs: BlobBackend,
195 pub s3_bucket: Option<String>,
196 pub s3_endpoint: Option<String>,
197 pub s3_region: Option<String>,
198 pub s3_path_style: bool,
199 pub s3_credential: Option<crate::s3_credential::SealedS3Credential>,
201 pub gcs_bucket: Option<String>,
202 pub gcs_endpoint: Option<String>,
203 pub gcs_anonymous: bool,
204 pub azure_account: Option<String>,
205 pub azure_container: Option<String>,
206 pub azure_access_key: Option<String>,
207 pub azure_emulator: bool,
208}
209
210pub struct BuiltBlobs {
215 pub storage: Arc<dyn Storage>,
216 #[cfg_attr(not(feature = "handlers"), allow(dead_code))]
217 pub watch_provider: Option<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
218 #[cfg_attr(not(feature = "handlers"), allow(dead_code))]
219 pub provision_tier: boatramp_core::blob_notify::ProvisionTier,
220}
221
222#[cfg_attr(not(feature = "fs"), allow(unused_variables))]
225pub async fn build_blobs(
226 args: &BlobArgs,
227 data_dir: &Path,
228 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
229 notify_account: Option<String>,
230) -> Result<BuiltBlobs> {
231 match args.blobs {
232 #[cfg(feature = "fs")]
233 BlobBackend::Fs => Ok(BuiltBlobs {
234 storage: Arc::new(FsStorage::new(data_dir.join("blobs"))),
235 watch_provider: None,
236 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
237 }),
238 #[cfg(not(feature = "fs"))]
239 BlobBackend::Fs => Err(Error::NoFsSupport),
240 BlobBackend::S3 => build_s3(args, notify_tier, notify_account).await,
241 BlobBackend::Gcs => build_gcs(args, notify_tier, notify_account).await,
242 BlobBackend::Azure => build_azure(args, notify_tier, notify_account).await,
243 }
244}
245
246#[cfg(feature = "fallback")]
261pub async fn build_blobs_with_fallback(
262 args: &BlobArgs,
263 fallback: Option<(&BlobArgs, Duration)>,
264 data_dir: &Path,
265 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
266 notify_account: Option<String>,
267) -> Result<BuiltBlobs> {
268 let primary = build_blobs(args, data_dir, notify_tier, notify_account).await?;
270 let Some((secondary_args, timeout)) = fallback else {
271 return Ok(primary);
272 };
273 let secondary = build_blobs(secondary_args, data_dir, None, None).await?;
276 let composite: Arc<dyn Storage> = Arc::new(boatramp_storage::FallbackStorage::new(
277 primary.storage,
278 secondary.storage,
279 blob_fallback_when(),
280 timeout,
281 ));
282 Ok(BuiltBlobs {
283 storage: composite,
284 watch_provider: primary.watch_provider,
286 provision_tier: primary.provision_tier,
287 })
288}
289
290#[cfg(feature = "azure")]
295async fn build_azure(
296 args: &BlobArgs,
297 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
298 _notify_account: Option<String>,
299) -> Result<BuiltBlobs> {
300 let (Some(account), Some(container)) =
301 (args.azure_account.clone(), args.azure_container.clone())
302 else {
303 return Err(Error::AzureConfigRequired);
304 };
305 let opts = boatramp_storage::AzureOptions {
306 account,
307 container,
308 access_key: args.azure_access_key.clone(),
309 emulator: args.azure_emulator,
310 };
311 match notify_tier {
312 Some(tier) => {
313 let (storage, provider) = boatramp_storage::AzureStorage::connect_with_notify(opts)
314 .map_err(|err| Error::AzureConnect(err.to_string()))?;
315 Ok(BuiltBlobs {
316 storage: Arc::new(storage),
317 watch_provider: Some(Arc::new(provider)),
318 provision_tier: tier,
319 })
320 }
321 None => {
322 let storage = boatramp_storage::AzureStorage::connect(opts)
323 .map_err(|err| Error::AzureConnect(err.to_string()))?;
324 Ok(BuiltBlobs {
325 storage: Arc::new(storage),
326 watch_provider: None,
327 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
328 })
329 }
330 }
331}
332
333#[cfg(not(feature = "azure"))]
334async fn build_azure(
335 _args: &BlobArgs,
336 _notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
337 _notify_account: Option<String>,
338) -> Result<BuiltBlobs> {
339 Err(Error::NoAzureSupport)
340}
341
342#[cfg(feature = "gcs")]
346async fn build_gcs(
347 args: &BlobArgs,
348 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
349 notify_account: Option<String>,
350) -> Result<BuiltBlobs> {
351 let bucket = args.gcs_bucket.clone().ok_or(Error::GcsBucketRequired)?;
352 let opts = boatramp_storage::GcsOptions {
353 bucket,
354 endpoint: args.gcs_endpoint.clone(),
355 anonymous: args.gcs_anonymous,
356 };
357 match notify_tier {
358 Some(tier) => {
359 let project = notify_account.unwrap_or_default();
360 let (storage, provider) =
361 boatramp_storage::GcsStorage::connect_with_notify(opts, project)
362 .await
363 .map_err(|err| Error::GcsConnect(err.to_string()))?;
364 Ok(BuiltBlobs {
365 storage: Arc::new(storage),
366 watch_provider: Some(Arc::new(provider)),
367 provision_tier: tier,
368 })
369 }
370 None => {
371 let storage = boatramp_storage::GcsStorage::connect(opts)
372 .await
373 .map_err(|err| Error::GcsConnect(err.to_string()))?;
374 Ok(BuiltBlobs {
375 storage: Arc::new(storage),
376 watch_provider: None,
377 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
378 })
379 }
380 }
381}
382
383#[cfg(not(feature = "gcs"))]
384async fn build_gcs(
385 _args: &BlobArgs,
386 _notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
387 _notify_account: Option<String>,
388) -> Result<BuiltBlobs> {
389 Err(Error::NoGcsSupport)
390}
391
392#[cfg(feature = "s3")]
393async fn build_s3(
394 args: &BlobArgs,
395 notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
396 notify_account: Option<String>,
397) -> Result<BuiltBlobs> {
398 let bucket = args.s3_bucket.clone().ok_or(Error::S3BucketRequired)?;
399 let opts = boatramp_storage::S3Options {
400 bucket,
401 endpoint: args.s3_endpoint.clone(),
402 region: args.s3_region.clone(),
403 force_path_style: args.s3_path_style,
404 credential: args.s3_credential.as_ref().map(|c| c.as_pair()),
407 };
408 match notify_tier {
409 Some(tier) => {
412 let account = notify_account.unwrap_or_default();
413 let (storage, provider) =
414 boatramp_storage::S3Storage::connect_with_notify(opts, account).await;
415 Ok(BuiltBlobs {
416 storage: Arc::new(storage),
417 watch_provider: Some(Arc::new(provider)),
418 provision_tier: tier,
419 })
420 }
421 None => Ok(BuiltBlobs {
423 storage: Arc::new(boatramp_storage::S3Storage::connect(opts).await),
424 watch_provider: None,
425 provision_tier: boatramp_core::blob_notify::ProvisionTier::default(),
426 }),
427 }
428}
429
430#[cfg(not(feature = "s3"))]
431async fn build_s3(
432 _args: &BlobArgs,
433 _notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
434 _notify_account: Option<String>,
435) -> Result<BuiltBlobs> {
436 Err(Error::NoS3Support)
437}