Skip to main content

sui_cache/
config.rs

1//! Cache configuration types.
2//!
3//! [`BackendConfig`] (the storage backend selector) lives in `sui-castore` and
4//! is re-exported here for backward compatibility. [`CacheConfig`] (the
5//! cache-server configuration) is owned by this module.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use shikumi::TieredConfig;
11
12use crate::push::{NarCodec, XzLevel, ZstdLevel};
13
14// BackendConfig is defined in sui-castore; import it for use in CacheConfig.
15pub use sui_castore::BackendConfig;
16pub use sui_castore::WritePolicy;
17
18/// The shikumi tier-selector environment variable for this cache.
19///
20/// Fleet convention (`<APP>_TIER`): unset or `default` → [`prescribed
21/// default`](CacheConfig::prescribed_default); `bare` → the honest floor;
22/// anything else is read as a path to a YAML overlay laid over the prescribed
23/// default. See [`CacheConfig::resolve`].
24pub const CACHE_TIER_ENV: &str = "SUI_CACHE_TIER";
25
26/// Top-level cache configuration.
27///
28/// `#[serde(default)]` is what makes this an *overlay* rather than a
29/// replacement: shikumi's `Custom` tier deserializes the operator's YAML into
30/// this type whole, so without it a file that wants to change one field would
31/// have to restate every other one — and a file that failed to would be
32/// silently discarded (shikumi falls back to `prescribed_default` on a parse
33/// error). Absent fields now come from [`Default`], which *is*
34/// [`TieredConfig::prescribed_default`].
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(default)]
37pub struct CacheConfig {
38    /// Network address to listen on.
39    pub listen: String,
40    /// Storage backend configuration.
41    pub backend: BackendConfig,
42    /// Path to the ed25519 signing secret key file.
43    ///
44    /// In production this path is a cofre/ESO-materialized Kubernetes Secret
45    /// mount, never a plaintext literal. When set, the daemon signs every
46    /// ingested narinfo (see [`serve`](crate::server::serve)).
47    pub signing_key: Option<PathBuf>,
48    /// Cache priority (lower = preferred). Reported in nix-cache-info.
49    pub priority: u32,
50    /// Whether to want mass query (narinfo pipelining).
51    pub want_mass_query: bool,
52    /// The Nix store directory (almost always `/nix/store`).
53    pub store_dir: String,
54    /// Whether this cache's consumers should require a valid signature.
55    ///
56    /// This is a serving-side advertisement of the fail-closed posture: a
57    /// signing cache SHOULD publish `require_sigs = true` so operators know
58    /// the served paths are signed and consumers must verify. It does not by
59    /// itself change what the daemon serves (signing is driven by
60    /// `signing_key`); it is the typed knob a consuming config reads to know
61    /// the cache is trustworthy fail-closed. Defaults to `false` to preserve
62    /// legacy behavior for caches that have not yet been given a key.
63    #[serde(default)]
64    pub require_sigs: bool,
65
66    /// How a pushed NAR is packed — the codec **and** its level, as one
67    /// inseparable value (see [`NarCodec`]).
68    ///
69    /// This is the deployment knob the benchmark in [`NarCodec`]'s docs argues
70    /// about. rio is a *local* origin serving a handful of fleet nodes over
71    /// tailscale: CPU-bound, bandwidth-cheap, so zstd -12 is right and is the
72    /// prescribed default. A bandwidth-bound origin — one paying egress, or
73    /// seeding cold clients over the public internet — legitimately wants
74    /// `{ codec: xz, level: 6 }` and now gets it from a config file instead of
75    /// a recompile.
76    ///
77    /// A mixed cache needs no migration: every narinfo declares its own codec,
78    /// so flipping this changes only what *new* pushes look like.
79    #[serde(default)]
80    pub nar_codec: NarCodec,
81}
82
83impl Default for CacheConfig {
84    /// Delegates to [`TieredConfig::prescribed_default`] so the standard idiom
85    /// (`CacheConfig::default()`) and the tiered resolution can never describe
86    /// two different caches.
87    fn default() -> Self {
88        <Self as TieredConfig>::prescribed_default()
89    }
90}
91
92impl CacheConfig {
93    /// Resolve this cache's configuration the fleet-standard way — the one
94    /// call site every entry point uses (★★ CONFIGURATION MANAGEMENT).
95    ///
96    /// Precedence is shikumi's: the [`CACHE_TIER_ENV`] environment variable
97    /// selects the tier, and when it names a path that YAML file is overlaid
98    /// on the prescribed default. Unset → the prescribed default, unchanged
99    /// from what this cache did before it had a config surface.
100    #[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    /// Tier 0 — the honest floor: **sui-cache exactly as it shipped before
108    /// 2026-08-05**. xz -6 packing, no signing key, no fail-closed
109    /// advertisement, the on-disk backend.
110    ///
111    /// This tier is not a worse default, it is the *documented past*: every
112    /// `.nar.xz` already in a fleet cache was written by it, and an origin that
113    /// wants the old ratio back asks for it by name (`SUI_CACHE_TIER=bare`)
114    /// rather than by editing a constant.
115    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    /// Tier 2 — the prescribed posture: identical to [`bare`](Self::bare)
131    /// except that pushes pack with **zstd**, the measured fast path. The
132    /// whole point of the 2026-08-05 change is that you get it without asking.
133    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        // A config JSON that omits require_sigs deserializes to false
201        // (the serde default) — legacy configs keep working.
202        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        // A tiered config that omits `write_policy` deserializes to the default.
256        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    // ── The shikumi tier surface (★★ CONFIGURATION MANAGEMENT) ──────────
273
274    #[test]
275    fn the_prescribed_tier_is_the_measured_fast_path() {
276        // An operator who configures nothing gets zstd at the measured knee.
277        // This is the guarantee the whole 2026-08-05 change exists for, stated
278        // at the CONFIG surface — the place a deployment can now move it.
279        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        // Tier 0 is not "a worse default", it is what every already-stored
291        // `.nar.xz` in the fleet was written by. Asking for it by name is how
292        // a bandwidth-bound origin opts out of the CPU-cheap posture.
293        assert_eq!(
294            CacheConfig::bare().nar_codec,
295            NarCodec::Xz {
296                level: XzLevel::default()
297            }
298        );
299        // …and the two tiers differ ONLY in the codec — the tier selector is
300        // not a back door for changing the listen address or the backend.
301        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        // The overlay property, through shikumi's own loader. A file naming
311        // only the codec must not silently reset the listen address, and must
312        // not be discarded for being incomplete.
313        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}