1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use shikumi::TieredConfig;
11
12use crate::push::{NarCodec, XzLevel, ZstdLevel};
13
14pub use sui_castore::BackendConfig;
16pub use sui_castore::WritePolicy;
17
18pub const CACHE_TIER_ENV: &str = "SUI_CACHE_TIER";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(default)]
37pub struct CacheConfig {
38 pub listen: String,
40 pub backend: BackendConfig,
42 pub signing_key: Option<PathBuf>,
48 pub priority: u32,
50 pub want_mass_query: bool,
52 pub store_dir: String,
54 #[serde(default)]
64 pub require_sigs: bool,
65
66 #[serde(default)]
80 pub nar_codec: NarCodec,
81}
82
83impl Default for CacheConfig {
84 fn default() -> Self {
88 <Self as TieredConfig>::prescribed_default()
89 }
90}
91
92impl CacheConfig {
93 #[must_use]
101 pub fn resolve() -> Self {
102 <Self as TieredConfig>::resolve_from_env(CACHE_TIER_ENV)
103 }
104}
105
106impl TieredConfig for CacheConfig {
107 fn bare() -> Self {
116 Self {
117 listen: "0.0.0.0:5000".to_string(),
118 backend: BackendConfig::default(),
119 signing_key: None,
120 priority: 40,
121 want_mass_query: true,
122 store_dir: "/nix/store".to_string(),
123 require_sigs: false,
124 nar_codec: NarCodec::Xz {
125 level: XzLevel::default(),
126 },
127 }
128 }
129
130 fn prescribed_default() -> Self {
134 Self {
135 nar_codec: NarCodec::Zstd {
136 level: ZstdLevel::default(),
137 },
138 ..Self::bare()
139 }
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use shikumi::ConfigTier;
147 use std::path::PathBuf;
148
149 #[test]
150 fn default_config_has_sane_values() {
151 let config = CacheConfig::default();
152 assert_eq!(config.listen, "0.0.0.0:5000");
153 assert_eq!(config.store_dir, "/nix/store");
154 assert_eq!(config.priority, 40);
155 assert!(config.want_mass_query);
156 assert!(config.signing_key.is_none());
157 }
158
159 #[test]
160 fn default_backend_is_local() {
161 let config = CacheConfig::default();
162 assert!(matches!(config.backend, BackendConfig::Local { .. }));
163 }
164
165 #[test]
166 fn config_serializes_to_json() {
167 let config = CacheConfig::default();
168 let json = serde_json::to_string(&config).unwrap();
169 assert!(json.contains("local"));
170 assert!(json.contains("5000"));
171 }
172
173 #[test]
174 fn config_roundtrips_through_json() {
175 let config = CacheConfig {
176 listen: "127.0.0.1:8080".to_string(),
177 backend: BackendConfig::S3 {
178 bucket: "my-cache".to_string(),
179 region: "us-east-1".to_string(),
180 endpoint: Some("http://localhost:9000".to_string()),
181 },
182 signing_key: Some(PathBuf::from("/tmp/key.sec")),
183 priority: 30,
184 want_mass_query: false,
185 store_dir: "/nix/store".to_string(),
186 require_sigs: true,
187 nar_codec: NarCodec::default(),
188 };
189 let json = serde_json::to_string_pretty(&config).unwrap();
190 let parsed: CacheConfig = serde_json::from_str(&json).unwrap();
191 assert_eq!(parsed.listen, "127.0.0.1:8080");
192 assert_eq!(parsed.priority, 30);
193 assert!(!parsed.want_mass_query);
194 assert!(parsed.require_sigs);
195 assert!(matches!(parsed.backend, BackendConfig::S3 { .. }));
196 }
197
198 #[test]
199 fn require_sigs_defaults_to_false_when_absent() {
200 let json = r#"{
203 "listen": "0.0.0.0:5000",
204 "backend": { "type": "local", "path": "/var/cache/sui" },
205 "signing_key": null,
206 "priority": 40,
207 "want_mass_query": true,
208 "store_dir": "/nix/store"
209 }"#;
210 let parsed: CacheConfig = serde_json::from_str(json).unwrap();
211 assert!(!parsed.require_sigs);
212 }
213
214 #[test]
215 fn tiered_backend_roundtrips_through_json() {
216 let backend = BackendConfig::Tiered {
217 l1: Box::new(BackendConfig::Redis {
218 url: "redis://redis:6379".to_string(),
219 ttl_secs: Some(3600),
220 }),
221 l2: Box::new(BackendConfig::Pg {
222 url: "postgres://pg:5432/sui".to_string(),
223 max_conns: 16,
224 }),
225 l3: Box::new(BackendConfig::S3 {
226 bucket: "sui-super-cache".to_string(),
227 region: "us-east-1".to_string(),
228 endpoint: None,
229 }),
230 write_policy: WritePolicy::WriteThrough,
231 };
232 let json = serde_json::to_string_pretty(&backend).unwrap();
233 assert!(json.contains("tiered"));
234 assert!(json.contains("redis"));
235 assert!(json.contains("write-through"));
236 let parsed: BackendConfig = serde_json::from_str(&json).unwrap();
237 match parsed {
238 BackendConfig::Tiered {
239 l1,
240 l2,
241 l3,
242 write_policy,
243 } => {
244 assert!(matches!(*l1, BackendConfig::Redis { .. }));
245 assert!(matches!(*l2, BackendConfig::Pg { .. }));
246 assert!(matches!(*l3, BackendConfig::S3 { .. }));
247 assert_eq!(write_policy, WritePolicy::WriteThrough);
248 }
249 other => panic!("expected tiered, got {other:?}"),
250 }
251 }
252
253 #[test]
254 fn tiered_write_policy_defaults_when_absent() {
255 let json = r#"{
257 "type": "tiered",
258 "l1": { "type": "redis", "url": "redis://r:6379" },
259 "l2": { "type": "pg", "url": "postgres://p:5432/s", "max_conns": 8 },
260 "l3": { "type": "local", "path": "/var/cache/sui" }
261 }"#;
262 let parsed: BackendConfig = serde_json::from_str(json).unwrap();
263 match parsed {
264 BackendConfig::Tiered { write_policy, .. } => {
265 assert_eq!(write_policy, WritePolicy::default());
266 assert_eq!(write_policy, WritePolicy::WriteThrough);
267 }
268 other => panic!("expected tiered, got {other:?}"),
269 }
270 }
271
272 #[test]
275 fn the_prescribed_tier_is_the_measured_fast_path() {
276 assert_eq!(
280 CacheConfig::prescribed_default().nar_codec,
281 NarCodec::Zstd {
282 level: ZstdLevel::default()
283 }
284 );
285 assert_eq!(CacheConfig::default(), CacheConfig::prescribed_default());
286 }
287
288 #[test]
289 fn the_bare_tier_is_the_documented_past() {
290 assert_eq!(
294 CacheConfig::bare().nar_codec,
295 NarCodec::Xz {
296 level: XzLevel::default()
297 }
298 );
299 let promoted = CacheConfig {
302 nar_codec: CacheConfig::prescribed_default().nar_codec,
303 ..CacheConfig::bare()
304 };
305 assert_eq!(promoted, CacheConfig::prescribed_default());
306 }
307
308 #[test]
309 fn a_partial_yaml_overlay_changes_one_field_and_keeps_the_rest() {
310 let dir = tempfile::tempdir().unwrap();
314 let path = dir.path().join("cache.yaml");
315 std::fs::write(&path, "nar_codec:\n codec: xz\n level: 9\n").unwrap();
316
317 let resolved = CacheConfig::resolve_tier(ConfigTier::Custom(path));
318 assert_eq!(
319 resolved.nar_codec,
320 NarCodec::Xz {
321 level: XzLevel::new(9).unwrap()
322 },
323 "the overlay must reach the codec"
324 );
325 assert_eq!(
326 resolved.listen,
327 CacheConfig::prescribed_default().listen,
328 "an unmentioned field must keep its prescribed value"
329 );
330 assert_eq!(
331 resolved.priority,
332 CacheConfig::prescribed_default().priority
333 );
334 }
335
336 #[test]
337 fn a_yaml_overlay_may_omit_the_codec_and_keep_the_fast_path() {
338 let dir = tempfile::tempdir().unwrap();
339 let path = dir.path().join("cache.yaml");
340 std::fs::write(&path, "priority: 10\n").unwrap();
341
342 let resolved = CacheConfig::resolve_tier(ConfigTier::Custom(path));
343 assert_eq!(resolved.priority, 10);
344 assert_eq!(
345 resolved.nar_codec,
346 CacheConfig::prescribed_default().nar_codec,
347 "not naming a codec must leave the fast default in place"
348 );
349 }
350
351 #[test]
352 fn the_named_tiers_resolve_to_their_tier_methods() {
353 assert_eq!(
354 CacheConfig::resolve_tier(ConfigTier::Bare),
355 CacheConfig::bare()
356 );
357 assert_eq!(
358 CacheConfig::resolve_tier(ConfigTier::Default),
359 CacheConfig::prescribed_default()
360 );
361 }
362
363 #[test]
364 fn the_config_round_trips_the_codec_through_json() {
365 let cfg = CacheConfig {
366 nar_codec: NarCodec::Xz {
367 level: XzLevel::new(3).unwrap(),
368 },
369 ..CacheConfig::default()
370 };
371 let parsed: CacheConfig =
372 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
373 assert_eq!(parsed, cfg);
374 }
375
376 #[test]
377 fn redis_ttl_defaults_to_none() {
378 let json = r#"{ "type": "redis", "url": "redis://r:6379" }"#;
379 let parsed: BackendConfig = serde_json::from_str(json).unwrap();
380 assert!(matches!(
381 parsed,
382 BackendConfig::Redis { ttl_secs: None, .. }
383 ));
384 }
385}