Skip to main content

gonzalo_server/
config.rs

1//! Runtime substrate selection for `gonzalod` (gonzalo#62).
2//!
3//! The daemon can back its record + blob store with the local filesystem
4//! (default) or an S3-compatible object store (MinIO/Garage), chosen by
5//! environment variables. Parsing is a pure function over an env accessor so it
6//! is unit-tested without touching the process environment; the binary does the
7//! (impure, async) store construction from the parsed [`StoreConfig`].
8
9/// The selected storage substrate and its parameters.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum StoreConfig {
12    /// Filesystem-backed store rooted at `root`.
13    Fs { root: String },
14    /// S3-compatible store. `endpoint`/`region` are `None` when the ambient AWS
15    /// configuration should supply them (real AWS S3); `endpoint` is set for
16    /// MinIO/Garage.
17    S3 {
18        bucket: String,
19        endpoint: Option<String>,
20        region: Option<String>,
21    },
22}
23
24impl StoreConfig {
25    /// Parse the substrate selection from an environment accessor `get`.
26    ///
27    /// - `GONZALO_STORE` = `fs` (default) or `s3`.
28    /// - `fs`: `GONZALO_ROOT` (default `./gonzalo-data`).
29    /// - `s3`: `GONZALO_S3_BUCKET` (required), `GONZALO_S3_ENDPOINT`,
30    ///   `GONZALO_S3_REGION` (both optional); credentials come from the standard
31    ///   `AWS_*` environment as usual.
32    ///
33    /// Returns `Err` for an unknown `GONZALO_STORE` or a missing required S3
34    /// variable, so the daemon fails fast with a clear message instead of
35    /// silently falling back.
36    pub fn from_env(get: impl Fn(&str) -> Option<String>) -> Result<StoreConfig, String> {
37        match get("GONZALO_STORE").as_deref() {
38            None | Some("") | Some("fs") => Ok(StoreConfig::Fs {
39                root: get("GONZALO_ROOT").unwrap_or_else(|| "./gonzalo-data".into()),
40            }),
41            Some("s3") => {
42                let bucket = get("GONZALO_S3_BUCKET")
43                    .filter(|b| !b.is_empty())
44                    .ok_or("GONZALO_STORE=s3 requires GONZALO_S3_BUCKET")?;
45                Ok(StoreConfig::S3 {
46                    bucket,
47                    endpoint: get("GONZALO_S3_ENDPOINT").filter(|s| !s.is_empty()),
48                    region: get("GONZALO_S3_REGION").filter(|s| !s.is_empty()),
49                })
50            }
51            Some(other) => Err(format!(
52                "unknown GONZALO_STORE={other:?} (expected \"fs\" or \"s3\")"
53            )),
54        }
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use std::collections::HashMap;
62
63    /// Build an accessor over a fixed map (owns its data — no borrow of `pairs`).
64    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
65        let map: HashMap<String, String> = pairs
66            .iter()
67            .map(|(k, v)| (k.to_string(), v.to_string()))
68            .collect();
69        move |k: &str| map.get(k).cloned()
70    }
71
72    #[test]
73    fn default_is_fs_with_default_root() {
74        let cfg = StoreConfig::from_env(env(&[])).unwrap();
75        assert_eq!(
76            cfg,
77            StoreConfig::Fs {
78                root: "./gonzalo-data".into()
79            }
80        );
81    }
82
83    #[test]
84    fn fs_honors_root() {
85        let cfg = StoreConfig::from_env(env(&[("GONZALO_STORE", "fs"), ("GONZALO_ROOT", "/data")]))
86            .unwrap();
87        assert_eq!(
88            cfg,
89            StoreConfig::Fs {
90                root: "/data".into()
91            }
92        );
93    }
94
95    #[test]
96    fn s3_requires_bucket() {
97        let err = StoreConfig::from_env(env(&[("GONZALO_STORE", "s3")])).unwrap_err();
98        assert!(err.contains("GONZALO_S3_BUCKET"), "got {err}");
99    }
100
101    #[test]
102    fn s3_reads_bucket_endpoint_region() {
103        let cfg = StoreConfig::from_env(env(&[
104            ("GONZALO_STORE", "s3"),
105            ("GONZALO_S3_BUCKET", "gonzalo"),
106            ("GONZALO_S3_ENDPOINT", "http://garage:3900"),
107            ("GONZALO_S3_REGION", "garage"),
108        ]))
109        .unwrap();
110        assert_eq!(
111            cfg,
112            StoreConfig::S3 {
113                bucket: "gonzalo".into(),
114                endpoint: Some("http://garage:3900".into()),
115                region: Some("garage".into()),
116            }
117        );
118    }
119
120    #[test]
121    fn s3_endpoint_and_region_optional() {
122        let cfg =
123            StoreConfig::from_env(env(&[("GONZALO_STORE", "s3"), ("GONZALO_S3_BUCKET", "b")]))
124                .unwrap();
125        assert_eq!(
126            cfg,
127            StoreConfig::S3 {
128                bucket: "b".into(),
129                endpoint: None,
130                region: None,
131            }
132        );
133    }
134
135    #[test]
136    fn unknown_store_is_an_error() {
137        let err = StoreConfig::from_env(env(&[("GONZALO_STORE", "cassandra")])).unwrap_err();
138        assert!(err.contains("cassandra"), "got {err}");
139    }
140}