Skip to main content

kopiur_api/common/
cache.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// How a mover's kopia cache volume is provisioned.
5#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
6pub enum CacheVolumeMode {
7    /// Cache lives only for the run (ephemeral volume or `emptyDir`), fresh each run; the default.
8    #[default]
9    Ephemeral,
10    /// Cache persists across runs in a controller-owned `ReadWriteOnce` PVC (a warm kopia cache).
11    Persistent,
12}
13
14/// kopia cache defaults inherited by every mover unless overridden per-recipe.
15#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
16#[serde(rename_all = "camelCase")]
17pub struct CacheDefaults {
18    /// Size of the PVC backing the mover's kopia cache (e.g. `10Gi`).
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub capacity: Option<String>,
21    /// StorageClass for the cache PVC; absent uses the cluster default.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub storage_class_name: Option<String>,
24    /// kopia metadata cache budget in MiB (`--metadata-cache-size-mb`).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub metadata_cache_size_mb: Option<i64>,
27    /// kopia content cache budget in MiB (`--content-cache-size-mb`).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub content_cache_size_mb: Option<i64>,
30    /// How the cache volume is provisioned (`Ephemeral` default, or `Persistent`).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub mode: Option<CacheVolumeMode>,
33}
34
35impl CacheDefaults {
36    /// Overlay `over` onto `base` field-by-field — a value set in `over` wins,
37    /// otherwise `base`'s is kept. Resolves a mover's effective cache config from the
38    /// repository's `cacheDefaults` (base) and the run's `mover.cache` (override).
39    /// Returns `None` only when both are absent.
40    pub fn merge(
41        base: Option<&CacheDefaults>,
42        over: Option<&CacheDefaults>,
43    ) -> Option<CacheDefaults> {
44        match (base, over) {
45            (None, None) => None,
46            (Some(b), None) => Some(b.clone()),
47            (None, Some(o)) => Some(o.clone()),
48            (Some(b), Some(o)) => Some(CacheDefaults {
49                capacity: o.capacity.clone().or_else(|| b.capacity.clone()),
50                storage_class_name: o
51                    .storage_class_name
52                    .clone()
53                    .or_else(|| b.storage_class_name.clone()),
54                metadata_cache_size_mb: o.metadata_cache_size_mb.or(b.metadata_cache_size_mb),
55                content_cache_size_mb: o.content_cache_size_mb.or(b.content_cache_size_mb),
56                mode: o.mode.or(b.mode),
57            }),
58        }
59    }
60
61    /// The provisioning mode, defaulting to `Ephemeral` when unset.
62    pub fn effective_mode(&self) -> CacheVolumeMode {
63        self.mode.unwrap_or_default()
64    }
65}
66
67/// Defaults for the deep-verification **scratch** volume — the throwaway restore-test target.
68#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
69#[serde(rename_all = "camelCase")]
70pub struct ScratchDefaults {
71    /// StorageClass for the ephemeral scratch PVC; only applies when `capacity` is set.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub storage_class_name: Option<String>,
74    /// Size of the ephemeral scratch PVC (e.g. `100Gi`); absent falls back to an `emptyDir`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub capacity: Option<String>,
77}
78
79impl ScratchDefaults {
80    /// Overlay `over` onto `base` field-by-field — a value set in `over` wins,
81    /// otherwise `base`'s is kept. Resolves a deep-verify's effective scratch config
82    /// from the repository's `moverDefaults.scratch` (base) and the recipe's
83    /// `verification.deep.{storageClassName,capacity}` (override). Returns `None` only
84    /// when both are absent. Mirrors [`CacheDefaults::merge`].
85    pub fn merge(
86        base: Option<&ScratchDefaults>,
87        over: Option<&ScratchDefaults>,
88    ) -> Option<ScratchDefaults> {
89        match (base, over) {
90            (None, None) => None,
91            (Some(b), None) => Some(b.clone()),
92            (None, Some(o)) => Some(o.clone()),
93            (Some(b), Some(o)) => Some(ScratchDefaults {
94                storage_class_name: o
95                    .storage_class_name
96                    .clone()
97                    .or_else(|| b.storage_class_name.clone()),
98                capacity: o.capacity.clone().or_else(|| b.capacity.clone()),
99            }),
100        }
101    }
102}
103
104/// How catalog discovery treats snapshots written by ANOTHER cluster.
105/// `status.catalog.foreignSnapshotCount` counts an identity hostname carrying
106/// a different `.<cluster>` suffix ALWAYS, under either value below, plus —
107/// under `Ignore` only — a bare hostname naming no allowed namespace here.
108/// Under `Fallback`, that same disallowed bare host is NOT counted foreign:
109/// it materializes into `catalog.fallbackNamespace` exactly like a disallowed
110/// `OwnCluster` host would, so it is placed rather than dropped-and-counted
111/// (see `classify_hostname`/`decide_cluster_placement`).
112/// Meaningful only when `identityDefaults.cluster` is set: without a cluster
113/// identity there is no notion of "foreign" and the legacy
114/// hostname-names-a-namespace placement applies (validators enforce this).
115#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
116pub enum ForeignSnapshots {
117    /// Skip foreign snapshots: no discovered `Snapshot` CR is materialized;
118    /// they are counted in `status.catalog.foreignSnapshotCount` and the
119    /// `kopiur_repo_foreign_snapshots` gauge, never silently invisible.
120    #[default]
121    Ignore,
122    /// Materialize foreign snapshots into `catalog.fallbackNamespace`
123    /// (ClusterRepository only; requires `fallbackNamespace`).
124    Fallback,
125}
126
127/// Whether a discovered snapshot whose resolved identity matches a live
128/// `SnapshotPolicy` is automatically adopted — re-attached (stamped with that
129/// policy's config label, `status.origin` flipped to `Adopted`) so GFS
130/// retention governs it and eventually prunes it, instead of it sitting in the
131/// catalog forever as an immortal `discovered` row.
132#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
133pub enum SnapshotAdoption {
134    /// Automatically adopt an identity-matching discovered snapshot (the default).
135    #[default]
136    Adopt,
137    /// Leave identity-matching discovered snapshots alone; they stay `discovered`.
138    Ignore,
139}
140
141/// Bounds on materialization of `origin: discovered` `Snapshot` CRs.
142#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
143#[serde(rename_all = "camelCase")]
144pub struct CatalogBounds {
145    /// How many discovered `Snapshot` CRs to keep materialized (bounds etcd footprint).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub retain: Option<CatalogRetain>,
148    /// Opt-in: periodically re-scan the repository to keep discovered `Snapshot` CRs
149    /// current (re-list snapshots; for object-store / volume-backed repos this recycles
150    /// the `<name>-discovery` mover Job every `refreshInterval`). **Off by default** —
151    /// the repository still bootstraps once, re-bootstraps on a spec change, and
152    /// re-probes on a backup failure, but does not re-run on a timer. Enable it if you
153    /// rely on discovered snapshots reflecting changes made outside this operator.
154    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
155    pub periodic_refresh: bool,
156    /// How often to re-scan when `periodicRefresh: true` (Go-style duration; minimum
157    /// `30s`, default `1h`). Inert unless `periodicRefresh` is enabled.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    #[schemars(default = "default_catalog_refresh_interval")]
160    pub refresh_interval: Option<String>,
161    /// Where to materialize discovered `Snapshot`s with no allowed-namespace mapping (ClusterRepository only).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub fallback_namespace: Option<String>,
164    /// How to treat discovered snapshots written by another cluster. Requires
165    /// `identityDefaults.cluster`. Absent resolves to `Ignore`. When BOTH
166    /// `cluster` and `fallbackNamespace` are set this field is REQUIRED
167    /// (explicit), so adopting a cluster identity can never silently switch a
168    /// configured fallback collector off.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub foreign_snapshots: Option<ForeignSnapshots>,
171    /// Whether a discovered snapshot matching a live `SnapshotPolicy`'s resolved
172    /// identity is automatically adopted. Per-policy `SnapshotPolicySpec.adoption`
173    /// overrides this when set; absent here resolves to
174    /// [`SnapshotAdoption::Adopt`] (see [`effective_adoption`]). NOT context-free
175    /// (it is the middle link of a policy → repo → constant inheritance chain),
176    /// so this field carries no schema default.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub adoption: Option<SnapshotAdoption>,
179}
180
181impl CatalogBounds {
182    /// Whether periodic re-scan is opted in (`catalog.periodicRefresh`). Off by
183    /// default, so a repository does not re-run its bootstrap Job on a timer.
184    pub fn periodic_refresh_enabled(catalog: Option<&Self>) -> bool {
185        catalog.is_some_and(|c| c.periodic_refresh)
186    }
187
188    /// The effective catalog re-scan cadence used **when `periodicRefresh` is on**:
189    /// `refreshInterval` when set and parseable, else
190    /// [`crate::consts::DEFAULT_CATALOG_REFRESH_INTERVAL`]. (The webhook rejects an
191    /// unparseable value, so the fallback only covers objects admitted before the
192    /// validator existed.)
193    pub fn effective_refresh_interval(catalog: Option<&Self>) -> std::time::Duration {
194        catalog
195            .and_then(|c| c.refresh_interval.as_deref())
196            .and_then(crate::duration::parse_go_duration)
197            .unwrap_or(crate::consts::DEFAULT_CATALOG_REFRESH_INTERVAL)
198    }
199
200    /// The effective foreign-snapshot policy: `catalog.foreignSnapshots` when
201    /// set, else [`ForeignSnapshots::Ignore`] (absent `catalog`, or the field
202    /// unset). Only meaningful alongside a cluster identity
203    /// (`identityDefaults.cluster`) — the validators enforce that coupling, so
204    /// this resolver never has to guess at one.
205    pub fn effective_foreign_snapshots(catalog: Option<&Self>) -> ForeignSnapshots {
206        catalog
207            .and_then(|c| c.foreign_snapshots)
208            .unwrap_or_default()
209    }
210}
211
212/// The effective adoption policy for a `SnapshotPolicy`: its own
213/// `spec.adoption` wins; else the target repository's `catalog.adoption`; else
214/// [`SnapshotAdoption::Adopt`]. Neither link is context-free (the repository
215/// link resolves per-repo, the policy link per-policy), so neither field
216/// carries a schema default — every read goes through this resolver.
217pub fn effective_adoption(
218    policy: Option<SnapshotAdoption>,
219    repo_catalog: Option<&CatalogBounds>,
220) -> SnapshotAdoption {
221    policy
222        .or_else(|| repo_catalog.and_then(|c| c.adoption))
223        .unwrap_or_default()
224}
225
226/// schemars default for [`CatalogBounds::refresh_interval`] — the string form of
227/// [`DEFAULT_CATALOG_REFRESH_INTERVAL`](crate::consts::DEFAULT_CATALOG_REFRESH_INTERVAL)
228/// (`1h`). `effective_refresh_interval` resolves an absent value to that same
229/// duration and the field is inert unless `periodicRefresh`, so materializing
230/// `1h` is behavior-preserving. A unit test pins `"1h"` to the constant.
231fn default_catalog_refresh_interval() -> Option<String> {
232    Some("1h".to_string())
233}
234
235/// Bounds on the *number* of discovered `Snapshot` CRs kept materialized.
236#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
237#[serde(rename_all = "camelCase")]
238pub struct CatalogRetain {
239    /// Keep the most-recent N discovered `Snapshot` CRs per identity; `0` disables materialization.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub per_identity: Option<i64>,
242    /// Expire discovered `Snapshot` CRs older than this many days (minimum 1); kopia snapshots untouched.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub max_age_days: Option<i64>,
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn catalog_refresh_interval_schema_default_matches_the_duration_constant() {
253        // The schema default is the STRING "1h"; the controller resolves an
254        // absent value to the Duration constant. Keep them in lockstep so
255        // server-side defaulting materializes exactly the resolver's fallback.
256        let s = default_catalog_refresh_interval().expect("some");
257        assert_eq!(
258            crate::duration::parse_go_duration(&s),
259            Some(crate::consts::DEFAULT_CATALOG_REFRESH_INTERVAL),
260            "default_catalog_refresh_interval() string must parse to DEFAULT_CATALOG_REFRESH_INTERVAL"
261        );
262        // And the resolver agrees when the field is absent.
263        assert_eq!(
264            CatalogBounds::effective_refresh_interval(None),
265            crate::consts::DEFAULT_CATALOG_REFRESH_INTERVAL
266        );
267    }
268
269    #[test]
270    fn foreign_snapshots_serializes_to_bare_pascal_case_strings() {
271        // Same wire encoding as TakeoverPolicy/NamespaceDeletePolicy — bare
272        // PascalCase unit-variant strings, not a `{ "ignore": {} }` externally
273        // tagged shape (this is a closed enum with no payload).
274        assert_eq!(
275            serde_json::to_value(ForeignSnapshots::Ignore).unwrap(),
276            "Ignore"
277        );
278        assert_eq!(
279            serde_json::to_value(ForeignSnapshots::Fallback).unwrap(),
280            "Fallback"
281        );
282        assert_eq!(ForeignSnapshots::default(), ForeignSnapshots::Ignore);
283        assert_eq!(
284            serde_json::from_value::<ForeignSnapshots>(serde_json::json!("Ignore")).unwrap(),
285            ForeignSnapshots::Ignore
286        );
287        assert_eq!(
288            serde_json::from_value::<ForeignSnapshots>(serde_json::json!("Fallback")).unwrap(),
289            ForeignSnapshots::Fallback
290        );
291        // Unknown variant rejected.
292        assert!(serde_json::from_value::<ForeignSnapshots>(serde_json::json!("Delete")).is_err());
293    }
294
295    #[test]
296    fn snapshot_adoption_serializes_to_bare_pascal_case_strings() {
297        // Same wire encoding as ForeignSnapshots/NamespaceDeletePolicy — bare
298        // PascalCase unit-variant strings.
299        assert_eq!(
300            serde_json::to_value(SnapshotAdoption::Adopt).unwrap(),
301            "Adopt"
302        );
303        assert_eq!(
304            serde_json::to_value(SnapshotAdoption::Ignore).unwrap(),
305            "Ignore"
306        );
307        assert_eq!(SnapshotAdoption::default(), SnapshotAdoption::Adopt);
308        assert_eq!(
309            serde_json::from_value::<SnapshotAdoption>(serde_json::json!("Adopt")).unwrap(),
310            SnapshotAdoption::Adopt
311        );
312        assert_eq!(
313            serde_json::from_value::<SnapshotAdoption>(serde_json::json!("Ignore")).unwrap(),
314            SnapshotAdoption::Ignore
315        );
316        // Unknown variant rejected.
317        assert!(serde_json::from_value::<SnapshotAdoption>(serde_json::json!("Fallback")).is_err());
318    }
319
320    #[test]
321    fn effective_adoption_precedence_matrix() {
322        let repo_adopt = CatalogBounds {
323            adoption: Some(SnapshotAdoption::Adopt),
324            ..Default::default()
325        };
326        let repo_ignore = CatalogBounds {
327            adoption: Some(SnapshotAdoption::Ignore),
328            ..Default::default()
329        };
330        let repo_unset = CatalogBounds::default();
331
332        // Policy wins over repo, either direction.
333        assert_eq!(
334            effective_adoption(Some(SnapshotAdoption::Ignore), Some(&repo_adopt)),
335            SnapshotAdoption::Ignore
336        );
337        assert_eq!(
338            effective_adoption(Some(SnapshotAdoption::Adopt), Some(&repo_ignore)),
339            SnapshotAdoption::Adopt
340        );
341        // Policy absent ⇒ repo value.
342        assert_eq!(
343            effective_adoption(None, Some(&repo_ignore)),
344            SnapshotAdoption::Ignore
345        );
346        assert_eq!(
347            effective_adoption(None, Some(&repo_adopt)),
348            SnapshotAdoption::Adopt
349        );
350        // Both absent (or repo catalog present but field unset) ⇒ Adopt.
351        assert_eq!(effective_adoption(None, None), SnapshotAdoption::Adopt);
352        assert_eq!(
353            effective_adoption(None, Some(&repo_unset)),
354            SnapshotAdoption::Adopt
355        );
356    }
357
358    #[test]
359    fn effective_foreign_snapshots_defaults_to_ignore() {
360        // Absent catalog.
361        assert_eq!(
362            CatalogBounds::effective_foreign_snapshots(None),
363            ForeignSnapshots::Ignore
364        );
365        // Catalog present but the field unset.
366        let bare = CatalogBounds::default();
367        assert_eq!(
368            CatalogBounds::effective_foreign_snapshots(Some(&bare)),
369            ForeignSnapshots::Ignore
370        );
371        // Explicit value resolves verbatim.
372        let explicit = CatalogBounds {
373            foreign_snapshots: Some(ForeignSnapshots::Fallback),
374            ..Default::default()
375        };
376        assert_eq!(
377            CatalogBounds::effective_foreign_snapshots(Some(&explicit)),
378            ForeignSnapshots::Fallback
379        );
380    }
381}