Skip to main content

khive_runtime/
blob.rs

1//! Config-driven `BlobStore` selection (ADR-111 Amendment 2).
2//!
3//! `khive-db` cannot parse `khive.toml` itself (it sits below `khive-runtime`
4//! in the crate dependency chain), so the fs-vs-s3 selector lives here, one
5//! layer up, where `KhiveConfig` is already parsed. This is the choke point
6//! every boot path (single- and multi-backend) resolves the configured blob
7//! store through, so the two never drift onto different construction logic.
8
9use std::sync::Arc;
10
11use khive_db::stores::blob_s3::{S3BlobStore, S3BlobStoreConfig};
12use khive_db::{SqliteError, StorageBackend};
13use khive_storage::BlobStore;
14
15use crate::engine_config::BlobConfig;
16use crate::KhiveConfig;
17
18/// Resolve the `BlobStore` this `backend` should use, per `cfg.storage.blob`.
19///
20/// - Absent, or `backend = "fs"`: `FsBlobStore` via `StorageBackend::blob_store`,
21///   using the existing `KHIVE_BLOB_ROOT` > `root` > `<db_dir>/blobs` precedence
22///   (khive#292) — unchanged from every configuration written before this
23///   section existed.
24/// - `backend = "s3"`: `S3BlobStore`, built from the non-secret TOML fields
25///   plus environment credentials (`S3BlobStore::new`).
26pub fn resolve_blob_store(
27    cfg: &KhiveConfig,
28    backend: &StorageBackend,
29) -> Result<Arc<dyn BlobStore>, SqliteError> {
30    match &cfg.storage.blob {
31        None => backend.blob_store(None, None),
32        Some(BlobConfig::Fs { root, floor_bytes }) => {
33            let root_path = root.as_ref().map(std::path::PathBuf::from);
34            backend.blob_store(root_path.as_deref(), *floor_bytes)
35        }
36        Some(BlobConfig::S3 {
37            bucket,
38            region,
39            endpoint,
40            prefix,
41            allow_http,
42        }) => {
43            let mut s3_cfg = S3BlobStoreConfig::new(bucket.clone(), region.clone());
44            if let Some(endpoint) = endpoint {
45                s3_cfg = s3_cfg.with_endpoint(endpoint.clone());
46            }
47            if let Some(prefix) = prefix {
48                s3_cfg = s3_cfg.with_prefix(prefix.clone());
49            }
50            if let Some(allow_http) = allow_http {
51                s3_cfg = s3_cfg.with_allow_http(*allow_http);
52            }
53            let store = S3BlobStore::new(s3_cfg)?;
54            Ok(Arc::new(store))
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::engine_config::StorageSectionConfig;
63
64    fn memory_backend() -> StorageBackend {
65        StorageBackend::memory().expect("memory backend should create")
66    }
67
68    #[test]
69    fn absent_storage_section_selects_fs_with_explicit_root() {
70        // An in-memory backend has no data_dir to default beside, so this
71        // exercises the "existing configurations keep working" path via an
72        // explicit override rather than proving the full khive#292 chain
73        // (already covered by `StorageBackend::blob_store`'s own tests).
74        let dir = tempfile::tempdir().unwrap();
75        let backend = memory_backend();
76        let cfg = KhiveConfig::default();
77        // `resolve_blob_store` with no override falls through to
78        // `backend.blob_store(None, None)`, which errors for an in-memory
79        // backend with no root -- confirm that specific, documented failure
80        // mode rather than silently picking an arbitrary path.
81        let err = match resolve_blob_store(&cfg, &backend) {
82            Err(e) => e,
83            Ok(_) => panic!("expected an error for an in-memory backend with no root override"),
84        };
85        assert!(matches!(err, SqliteError::InvalidData(_)));
86        drop(dir);
87    }
88
89    #[test]
90    fn explicit_fs_root_is_selected() {
91        let dir = tempfile::tempdir().unwrap();
92        let backend = memory_backend();
93        let cfg = KhiveConfig {
94            storage: StorageSectionConfig {
95                blob: Some(BlobConfig::Fs {
96                    root: Some(dir.path().to_string_lossy().into_owned()),
97                    floor_bytes: Some(0),
98                }),
99            },
100            ..KhiveConfig::default()
101        };
102        let store = resolve_blob_store(&cfg, &backend).expect("fs store should build");
103        drop(store);
104    }
105
106    #[test]
107    fn s3_backend_selection_reaches_s3_construction() {
108        // No AWS credentials in this test process: `S3BlobStore::new` must
109        // fail at the credential-env check, proving the S3 arm was actually
110        // selected and reached (not silently falling back to fs).
111        let _guard = ENV_LOCK.lock().unwrap();
112        std::env::remove_var("AWS_ACCESS_KEY_ID");
113        std::env::remove_var("AWS_SECRET_ACCESS_KEY");
114        let backend = memory_backend();
115        let cfg = KhiveConfig {
116            storage: StorageSectionConfig {
117                blob: Some(BlobConfig::S3 {
118                    bucket: "khive-blobs".to_string(),
119                    region: "us-east-1".to_string(),
120                    endpoint: None,
121                    prefix: None,
122                    allow_http: None,
123                }),
124            },
125            ..KhiveConfig::default()
126        };
127        let err = match resolve_blob_store(&cfg, &backend) {
128            Err(e) => e,
129            Ok(_) => panic!("expected the credential-env error with no AWS env vars set"),
130        };
131        let msg = err.to_string();
132        assert!(
133            msg.contains("AWS_ACCESS_KEY_ID"),
134            "expected the credential-env error, got: {msg}"
135        );
136    }
137
138    // Guards the two credential env vars this module's test toggles, since
139    // `std::env::set_var`/`remove_var` mutate real process-global state and
140    // the crate's default parallel test runner would otherwise interleave.
141    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
142}