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}