Skip to main content

sui_cache/
config.rs

1//! Cache configuration types.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::storage::WritePolicy;
8
9/// Top-level cache configuration.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CacheConfig {
12    /// Network address to listen on.
13    pub listen: String,
14    /// Storage backend configuration.
15    pub backend: BackendConfig,
16    /// Path to the ed25519 signing secret key file.
17    ///
18    /// In production this path is a cofre/ESO-materialized Kubernetes Secret
19    /// mount, never a plaintext literal. When set, the daemon signs every
20    /// ingested narinfo (see [`serve`](crate::server::serve)).
21    pub signing_key: Option<PathBuf>,
22    /// Cache priority (lower = preferred). Reported in nix-cache-info.
23    pub priority: u32,
24    /// Whether to want mass query (narinfo pipelining).
25    pub want_mass_query: bool,
26    /// The Nix store directory (almost always `/nix/store`).
27    pub store_dir: String,
28    /// Whether this cache's consumers should require a valid signature.
29    ///
30    /// This is a serving-side advertisement of the fail-closed posture: a
31    /// signing cache SHOULD publish `require_sigs = true` so operators know
32    /// the served paths are signed and consumers must verify. It does not by
33    /// itself change what the daemon serves (signing is driven by
34    /// `signing_key`); it is the typed knob a consuming config reads to know
35    /// the cache is trustworthy fail-closed. Defaults to `false` to preserve
36    /// legacy behavior for caches that have not yet been given a key.
37    #[serde(default)]
38    pub require_sigs: bool,
39}
40
41impl Default for CacheConfig {
42    fn default() -> Self {
43        Self {
44            listen: "0.0.0.0:5000".to_string(),
45            backend: BackendConfig::default(),
46            signing_key: None,
47            priority: 40,
48            want_mass_query: true,
49            store_dir: "/nix/store".to_string(),
50            require_sigs: false,
51        }
52    }
53}
54
55/// Storage backend selection.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "lowercase")]
58pub enum BackendConfig {
59    /// Local filesystem storage.
60    Local {
61        /// Root directory for NAR and narinfo files.
62        path: PathBuf,
63    },
64    /// S3-compatible object storage (stub).
65    S3 {
66        bucket: String,
67        region: String,
68        endpoint: Option<String>,
69    },
70    /// Redis L1 hot cache (requires the `redis-client` feature to construct).
71    Redis {
72        /// Connection URL, e.g. `redis://redis.super-cache-ci.svc:6379`.
73        url: String,
74        /// Optional per-write TTL in seconds; `None` relies on `maxmemory` LRU.
75        #[serde(default)]
76        ttl_secs: Option<u64>,
77    },
78    /// Postgres L2 durable cache tier (requires the `postgres` feature to
79    /// construct).
80    Pg {
81        /// Connection URL, e.g. `postgres://user@postgres.super-cache-ci.svc:5432/sui`.
82        url: String,
83        /// Connection-pool ceiling.
84        max_conns: u32,
85    },
86    /// Tiered `L1 → L2 → L3` resolver composing three nested backends.
87    ///
88    /// The canonical super-cache shape: `l1: Redis`, `l2: Pg`, `l3: S3`. Any
89    /// nesting is legal (the arms recurse), so a deployment can pick `{disk |
90    /// tiered}` — or any composition — purely by config.
91    Tiered {
92        /// L1 hot tier (typically [`Redis`](BackendConfig::Redis)).
93        l1: Box<BackendConfig>,
94        /// L2 durable tier (typically [`Pg`](BackendConfig::Pg)).
95        l2: Box<BackendConfig>,
96        /// L3 object tier (typically [`S3`](BackendConfig::S3)).
97        l3: Box<BackendConfig>,
98        /// How `put`s propagate across the tiers.
99        #[serde(default)]
100        write_policy: WritePolicy,
101    },
102}
103
104impl Default for BackendConfig {
105    fn default() -> Self {
106        Self::Local {
107            path: PathBuf::from("/var/cache/sui"),
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn default_config_has_sane_values() {
118        let config = CacheConfig::default();
119        assert_eq!(config.listen, "0.0.0.0:5000");
120        assert_eq!(config.store_dir, "/nix/store");
121        assert_eq!(config.priority, 40);
122        assert!(config.want_mass_query);
123        assert!(config.signing_key.is_none());
124    }
125
126    #[test]
127    fn default_backend_is_local() {
128        let config = CacheConfig::default();
129        assert!(matches!(config.backend, BackendConfig::Local { .. }));
130    }
131
132    #[test]
133    fn config_serializes_to_json() {
134        let config = CacheConfig::default();
135        let json = serde_json::to_string(&config).unwrap();
136        assert!(json.contains("local"));
137        assert!(json.contains("5000"));
138    }
139
140    #[test]
141    fn config_roundtrips_through_json() {
142        let config = CacheConfig {
143            listen: "127.0.0.1:8080".to_string(),
144            backend: BackendConfig::S3 {
145                bucket: "my-cache".to_string(),
146                region: "us-east-1".to_string(),
147                endpoint: Some("http://localhost:9000".to_string()),
148            },
149            signing_key: Some(PathBuf::from("/tmp/key.sec")),
150            priority: 30,
151            want_mass_query: false,
152            store_dir: "/nix/store".to_string(),
153            require_sigs: true,
154        };
155        let json = serde_json::to_string_pretty(&config).unwrap();
156        let parsed: CacheConfig = serde_json::from_str(&json).unwrap();
157        assert_eq!(parsed.listen, "127.0.0.1:8080");
158        assert_eq!(parsed.priority, 30);
159        assert!(!parsed.want_mass_query);
160        assert!(parsed.require_sigs);
161        assert!(matches!(parsed.backend, BackendConfig::S3 { .. }));
162    }
163
164    #[test]
165    fn require_sigs_defaults_to_false_when_absent() {
166        // A config JSON that omits require_sigs deserializes to false
167        // (the serde default) — legacy configs keep working.
168        let json = r#"{
169            "listen": "0.0.0.0:5000",
170            "backend": { "type": "local", "path": "/var/cache/sui" },
171            "signing_key": null,
172            "priority": 40,
173            "want_mass_query": true,
174            "store_dir": "/nix/store"
175        }"#;
176        let parsed: CacheConfig = serde_json::from_str(json).unwrap();
177        assert!(!parsed.require_sigs);
178    }
179
180    #[test]
181    fn tiered_backend_roundtrips_through_json() {
182        let backend = BackendConfig::Tiered {
183            l1: Box::new(BackendConfig::Redis {
184                url: "redis://redis:6379".to_string(),
185                ttl_secs: Some(3600),
186            }),
187            l2: Box::new(BackendConfig::Pg {
188                url: "postgres://pg:5432/sui".to_string(),
189                max_conns: 16,
190            }),
191            l3: Box::new(BackendConfig::S3 {
192                bucket: "sui-super-cache".to_string(),
193                region: "us-east-1".to_string(),
194                endpoint: None,
195            }),
196            write_policy: WritePolicy::WriteThrough,
197        };
198        let json = serde_json::to_string_pretty(&backend).unwrap();
199        assert!(json.contains("tiered"));
200        assert!(json.contains("redis"));
201        assert!(json.contains("write-through"));
202        let parsed: BackendConfig = serde_json::from_str(&json).unwrap();
203        match parsed {
204            BackendConfig::Tiered { l1, l2, l3, write_policy } => {
205                assert!(matches!(*l1, BackendConfig::Redis { .. }));
206                assert!(matches!(*l2, BackendConfig::Pg { .. }));
207                assert!(matches!(*l3, BackendConfig::S3 { .. }));
208                assert_eq!(write_policy, WritePolicy::WriteThrough);
209            }
210            other => panic!("expected tiered, got {other:?}"),
211        }
212    }
213
214    #[test]
215    fn tiered_write_policy_defaults_when_absent() {
216        // A tiered config that omits `write_policy` deserializes to the default.
217        let json = r#"{
218            "type": "tiered",
219            "l1": { "type": "redis", "url": "redis://r:6379" },
220            "l2": { "type": "pg", "url": "postgres://p:5432/s", "max_conns": 8 },
221            "l3": { "type": "local", "path": "/var/cache/sui" }
222        }"#;
223        let parsed: BackendConfig = serde_json::from_str(json).unwrap();
224        match parsed {
225            BackendConfig::Tiered { write_policy, .. } => {
226                assert_eq!(write_policy, WritePolicy::default());
227                assert_eq!(write_policy, WritePolicy::WriteThrough);
228            }
229            other => panic!("expected tiered, got {other:?}"),
230        }
231    }
232
233    #[test]
234    fn redis_ttl_defaults_to_none() {
235        let json = r#"{ "type": "redis", "url": "redis://r:6379" }"#;
236        let parsed: BackendConfig = serde_json::from_str(json).unwrap();
237        assert!(matches!(parsed, BackendConfig::Redis { ttl_secs: None, .. }));
238    }
239}