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/// Resolve the backing store's ancestor cap from `GONZALO_ANCESTOR_CAP`
59/// (gonzalo#203, spec ยง3.9), via the environment accessor `get`.
60///
61/// Unset or empty โ†’ `gonzalo_core::DEFAULT_ANCESTOR_CAP`. Zero or a
62/// non-number is an error, so the daemon fails fast with a clear message
63/// instead of silently running at the default โ€” the same policy as
64/// [`StoreConfig::from_env`].
65pub fn ancestor_cap_from_env(get: impl Fn(&str) -> Option<String>) -> Result<usize, String> {
66    let Some(raw) = get("GONZALO_ANCESTOR_CAP").filter(|s| !s.is_empty()) else {
67        return Ok(gonzalo_core::DEFAULT_ANCESTOR_CAP);
68    };
69    let cap: usize = raw
70        .parse()
71        .map_err(|e| format!("GONZALO_ANCESTOR_CAP must be a positive integer: {e}"))?;
72    gonzalo_core::validate_ancestor_cap(cap).map_err(|e| format!("GONZALO_ANCESTOR_CAP: {e}"))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use std::collections::HashMap;
79
80    /// Build an accessor over a fixed map (owns its data โ€” no borrow of `pairs`).
81    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
82        let map: HashMap<String, String> = pairs
83            .iter()
84            .map(|(k, v)| (k.to_string(), v.to_string()))
85            .collect();
86        move |k: &str| map.get(k).cloned()
87    }
88
89    #[test]
90    fn default_is_fs_with_default_root() {
91        let cfg = StoreConfig::from_env(env(&[])).unwrap();
92        assert_eq!(
93            cfg,
94            StoreConfig::Fs {
95                root: "./gonzalo-data".into()
96            }
97        );
98    }
99
100    #[test]
101    fn fs_honors_root() {
102        let cfg = StoreConfig::from_env(env(&[("GONZALO_STORE", "fs"), ("GONZALO_ROOT", "/data")]))
103            .unwrap();
104        assert_eq!(
105            cfg,
106            StoreConfig::Fs {
107                root: "/data".into()
108            }
109        );
110    }
111
112    #[test]
113    fn s3_requires_bucket() {
114        let err = StoreConfig::from_env(env(&[("GONZALO_STORE", "s3")])).unwrap_err();
115        assert!(err.contains("GONZALO_S3_BUCKET"), "got {err}");
116    }
117
118    #[test]
119    fn s3_reads_bucket_endpoint_region() {
120        let cfg = StoreConfig::from_env(env(&[
121            ("GONZALO_STORE", "s3"),
122            ("GONZALO_S3_BUCKET", "gonzalo"),
123            ("GONZALO_S3_ENDPOINT", "http://garage:3900"),
124            ("GONZALO_S3_REGION", "garage"),
125        ]))
126        .unwrap();
127        assert_eq!(
128            cfg,
129            StoreConfig::S3 {
130                bucket: "gonzalo".into(),
131                endpoint: Some("http://garage:3900".into()),
132                region: Some("garage".into()),
133            }
134        );
135    }
136
137    #[test]
138    fn s3_endpoint_and_region_optional() {
139        let cfg =
140            StoreConfig::from_env(env(&[("GONZALO_STORE", "s3"), ("GONZALO_S3_BUCKET", "b")]))
141                .unwrap();
142        assert_eq!(
143            cfg,
144            StoreConfig::S3 {
145                bucket: "b".into(),
146                endpoint: None,
147                region: None,
148            }
149        );
150    }
151
152    #[test]
153    fn unknown_store_is_an_error() {
154        let err = StoreConfig::from_env(env(&[("GONZALO_STORE", "cassandra")])).unwrap_err();
155        assert!(err.contains("cassandra"), "got {err}");
156    }
157
158    #[test]
159    fn ancestor_cap_defaults_when_unset_or_empty() {
160        assert_eq!(
161            ancestor_cap_from_env(env(&[])).unwrap(),
162            gonzalo_core::DEFAULT_ANCESTOR_CAP
163        );
164        // An empty value is unset, like the other GONZALO_* knobs.
165        assert_eq!(
166            ancestor_cap_from_env(env(&[("GONZALO_ANCESTOR_CAP", "")])).unwrap(),
167            gonzalo_core::DEFAULT_ANCESTOR_CAP
168        );
169    }
170
171    #[test]
172    fn ancestor_cap_reads_the_variable() {
173        assert_eq!(
174            ancestor_cap_from_env(env(&[("GONZALO_ANCESTOR_CAP", "8")])).unwrap(),
175            8
176        );
177    }
178
179    #[test]
180    fn ancestor_cap_rejects_zero_and_garbage() {
181        let zero = ancestor_cap_from_env(env(&[("GONZALO_ANCESTOR_CAP", "0")])).unwrap_err();
182        assert!(zero.contains("GONZALO_ANCESTOR_CAP"), "{zero}");
183        let garbage = ancestor_cap_from_env(env(&[("GONZALO_ANCESTOR_CAP", "lots")])).unwrap_err();
184        assert!(garbage.contains("GONZALO_ANCESTOR_CAP"), "{garbage}");
185        assert!(ancestor_cap_from_env(env(&[("GONZALO_ANCESTOR_CAP", "-1")])).is_err());
186    }
187}