Skip to main content

kopiur_api/
cluster_repository.rs

1//! The `ClusterRepository` CRD — a cluster-scoped, shared kopia repository
2//! operated by a platform team. ADR-0001 §3.2, ADR-0003 §3.2.
3//!
4//! Same spec surface as `Repository` (backend/encryption/create/moverDefaults/
5//! catalog), plus a tenancy gate (`allowedNamespaces`) and per-namespace identity
6//! expressions (`identityDefaults`).
7
8use crate::backend::Backend;
9use crate::common::{
10    CatalogBounds, CreateBehavior, DeletionProtectionSpec, Encryption, IdentityDefaults,
11    MoverDefaults, NamespaceDeletePolicy, RepositoryMode, ScheduleDefaults,
12    default_namespace_delete_policy, default_repository_mode,
13};
14use crate::maintenance::RepositoryMaintenanceSpec;
15use crate::repository::{
16    BootstrapSpec, CatalogStatus, ObservedRepositoryParameters, RepositoryHealthSpec,
17    RepositoryHealthStatus, RepositoryParameters, RepositoryPhase, StorageStats,
18};
19use crate::server::{ClusterServerSpec, ServerStatus};
20use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
21use kube::CustomResource;
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24
25/// A cluster-scoped kopia repository referenceable from allow-listed namespaces.
26#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
27#[kube(
28    group = "kopiur.home-operations.com",
29    version = "v1alpha1",
30    kind = "ClusterRepository",
31    status = "ClusterRepositoryStatus",
32    shortname = "kopiacrepo",
33    category = "kopiur",
34    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
35    printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
36    printcolumn = r#"{"name":"Namespaces","type":"integer","jsonPath":".status.allowedNamespaceCount"}"#,
37    printcolumn = r#"{"name":"Server","type":"string","jsonPath":".status.server.endpoint"}"#,
38    printcolumn = r#"{"name":"IndexBlobs","type":"integer","jsonPath":".status.storageStats.indexBlobCount","priority":1}"#,
39    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
40)]
41// §7/§15: create-time-immutability transition rules (apiserver + CI), same set as
42// the namespaced Repository — and like it, `encryption` (the password Secret reference)
43// is deliberately NOT locked (kopia fixes only the resolved value; a rename with identical
44// content must pass). Each `create.*` leaf is `has()`-guarded: CEL field access on an
45// absent optional key raises a "no such key" error that fails the whole rule (→ 422 on
46// *every* update, wedging the controller's finalizer/status writes), so we compare
47// presence first and only dereference when set — see the namespaced `Repository` for the
48// full rationale.
49#[schemars(extend("x-kubernetes-validations" = [
50    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.splitter) == has(oldSelf.create.splitter) && (!has(self.create.splitter) || self.create.splitter == oldSelf.create.splitter))", "message": "create.splitter is immutable after creation"},
51    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.hash) == has(oldSelf.create.hash) && (!has(self.create.hash) || self.create.hash == oldSelf.create.hash))", "message": "create.hash is immutable after creation"},
52    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.encryption) == has(oldSelf.create.encryption) && (!has(self.create.encryption) || self.create.encryption == oldSelf.create.encryption))", "message": "create.encryption is immutable after creation"},
53    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.ecc) == has(oldSelf.create.ecc) && (!has(self.create.ecc) || self.create.ecc == oldSelf.create.ecc))", "message": "create.ecc is immutable after creation"}
54]))]
55#[serde(rename_all = "camelCase")]
56pub struct ClusterRepositorySpec {
57    /// Exactly one storage backend.
58    pub backend: Backend,
59    /// Repository password (a Secret reference that must carry an explicit `namespace`).
60    pub encryption: Encryption,
61    /// What to do when the repository does not yet exist (absent means it must already exist).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub create: Option<CreateBehavior>,
64    /// Tuning for the bootstrap/discovery mover Job (`<name>-discovery`) that
65    /// connects/creates an object-store repository the operator cannot reach
66    /// in-process (and re-runs for catalog re-scans).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub bootstrap: Option<BootstrapSpec>,
69    /// Base mover configuration inherited by every mover this repository spawns.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub mover_defaults: Option<MoverDefaults>,
72    /// Scheduling defaults (e.g. `timezone`) inherited by consumers that don't set
73    /// their own equivalent field — verification, replication, and maintenance
74    /// schedules today; set once here instead of repeating it on every cron.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub schedule_defaults: Option<ScheduleDefaults>,
77    /// Bounds materialization of `origin: discovered` `Snapshot` CRs from the kopia catalog.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub catalog: Option<CatalogBounds>,
80    /// Which namespaces are permitted to reference this repository.
81    pub allowed_namespaces: AllowedNamespaces,
82    /// Identity defaults (CEL `*Expr`) applied when consumers don't override.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub identity_defaults: Option<IdentityDefaults>,
85    /// Optional kopia web-UI server (the target `namespace` is required).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub server: Option<ClusterServerSpec>,
88    /// Maintenance control; `maintenance.namespace` selects where the owned `Maintenance` CR lands.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub maintenance: Option<RepositoryMaintenanceSpec>,
91    /// What happens to this repository's snapshots when a consuming namespace is deleted.
92    #[serde(default = "default_namespace_delete_policy")]
93    #[schemars(default = "default_namespace_delete_policy")]
94    pub on_namespace_delete: NamespaceDeletePolicy,
95    /// Mass-deletion circuit breaker for this repository's Snapshots.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub deletion_protection: Option<DeletionProtectionSpec>,
98    /// Repository-owner gate for projecting credential Secrets into a foreign consumer namespace.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub credential_projection: Option<ClusterRepoCredentialProjection>,
101    /// Access mode: `ReadWrite` (default) or `ReadOnly` (serves restores only).
102    #[serde(default = "default_repository_mode")]
103    #[schemars(default = "default_repository_mode")]
104    pub mode: RepositoryMode,
105    /// Pause this cluster repository: skip connect/bootstrap and maintenance projection.
106    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
107    pub suspend: bool,
108    /// Repository health thresholds (tunes the index-blob-count warning).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub health: Option<RepositoryHealthSpec>,
111    /// Mutable kopia repository parameters, re-applied on bootstrap whenever they drift.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub parameters: Option<RepositoryParameters>,
114}
115
116/// The repository-owner side of credential projection on a `ClusterRepository`.
117#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
118#[serde(rename_all = "camelCase")]
119pub struct ClusterRepoCredentialProjection {
120    /// When `true`, the owner permits projecting this repository's credential Secret(s) into a consumer namespace.
121    #[serde(default)]
122    pub allowed: bool,
123}
124
125/// The set of namespaces permitted to reference this `ClusterRepository` (exactly one of).
126#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
127#[serde(rename_all = "camelCase")]
128pub enum AllowedNamespaces {
129    /// Explicit namespace names.
130    List(Vec<String>),
131    /// Match namespaces by label.
132    Selector(LabelSelector),
133    /// Allow all namespaces (must be `true`).
134    All(bool),
135}
136
137impl AllowedNamespaces {
138    /// Stable discriminant string for status/metrics.
139    ///
140    /// ```
141    /// use kopiur_api::cluster_repository::AllowedNamespaces;
142    ///
143    /// let ns = AllowedNamespaces::List(vec!["production".into(), "staging".into()]);
144    /// assert_eq!(ns.kind_str(), "List");
145    /// assert_eq!(AllowedNamespaces::All(true).kind_str(), "All");
146    /// ```
147    pub fn kind_str(&self) -> &'static str {
148        match self {
149            AllowedNamespaces::List(_) => "List",
150            AllowedNamespaces::Selector(_) => "Selector",
151            AllowedNamespaces::All(_) => "All",
152        }
153    }
154}
155
156/// Observed state of a `ClusterRepository`; mirrors `RepositoryStatus` plus `allowedNamespaceCount`.
157#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
158#[serde(rename_all = "camelCase")]
159pub struct ClusterRepositoryStatus {
160    /// Current lifecycle phase (shared with `Repository`).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub phase: Option<RepositoryPhase>,
163    /// `metadata.generation` of the `spec` last reconciled; drives staleness detection.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub observed_generation: Option<i64>,
166    /// `resourceVersion` of the password Secret observed at the last connect attempt.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub resolved_credential_version: Option<String>,
169    /// Kopia repository unique ID.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub unique_id: Option<String>,
172    /// Mirror of `spec.backend` discriminant for the print column.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub backend: Option<String>,
175    /// Number of namespaces currently resolved by `spec.allowedNamespaces`.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub allowed_namespace_count: Option<i64>,
178    /// Repository size and snapshot counts from the last catalog scan.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub storage_stats: Option<StorageStats>,
181    /// Catalog-materialization status (discovered-backup count, last refresh).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub catalog: Option<CatalogStatus>,
184    /// Resolved kopia server endpoint/auth, pinned by the reconciler.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub server: Option<ServerStatus>,
187    /// Last reverify-request token honored from a `Snapshot`'s re-probe nudge
188    /// (RFC3339); the loop guard that keeps each request a one-shot.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub last_reverify_at: Option<String>,
191    /// Backend health-probe state (`spec.health.probe`), when enabled.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub health: Option<RepositoryHealthStatus>,
194    /// The kopia repository parameters actually observed at the last bootstrap. Compare
195    /// against `spec.parameters` to see whether a declared value landed.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub parameters: Option<ObservedRepositoryParameters>,
198    /// Standard Kubernetes conditions (e.g. `Connected`, `MaintenanceOwned`).
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub conditions: Vec<Condition>,
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::testutil::from_yaml;
207    use kube::core::CustomResourceExt;
208
209    #[test]
210    fn cluster_repository_crd_metadata_is_correct() {
211        // `crd()` exercises schema generation; mis-encoded enums panic here.
212        let crd = ClusterRepository::crd();
213        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
214        assert_eq!(crd.spec.names.kind, "ClusterRepository");
215        // Cluster-scoped: this is the load-bearing assertion vs. namespaced CRDs.
216        assert_eq!(crd.spec.scope, "Cluster");
217        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
218    }
219
220    #[test]
221    fn cluster_repository_roundtrip_matches_adr_shape() {
222        // Mirrors ADR-0001 §3.2 / §5.2.
223        let yaml = r#"
224backend:
225  s3:
226    bucket: org-kopia-repo
227    prefix: ""
228    endpoint: s3.us-east-1.amazonaws.com
229    region: us-east-1
230    auth:
231      secretRef:
232        name: kopia-platform-creds
233        namespace: kopia-system
234encryption:
235  passwordSecretRef:
236    name: kopia-platform-creds
237    namespace: kopia-system
238    key: KOPIA_PASSWORD
239create:
240  enabled: true
241  encryption: AES256-GCM-HMAC-SHA256
242allowedNamespaces:
243  list: [production, staging, billing]
244identityDefaults:
245  hostnameExpr: "namespace"
246  usernameExpr: "namespace + '-' + policyName"
247catalog:
248  retain:
249    perIdentity: 50
250    maxAgeDays: 60
251  refreshInterval: 5m
252  fallbackNamespace: kopia-system
253"#;
254        let spec: ClusterRepositorySpec = from_yaml(yaml);
255        match &spec.backend {
256            Backend::S3(s3) => assert_eq!(s3.bucket, "org-kopia-repo"),
257            other => panic!("expected S3 backend, got {}", other.kind_str()),
258        }
259        match &spec.allowed_namespaces {
260            AllowedNamespaces::List(ns) => {
261                assert_eq!(ns, &["production", "staging", "billing"]);
262            }
263            other => panic!("expected List, got {}", other.kind_str()),
264        }
265        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
266        assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
267        assert_eq!(
268            id.username_expr.as_deref(),
269            Some("namespace + '-' + policyName")
270        );
271        assert_eq!(
272            spec.catalog.as_ref().unwrap().fallback_namespace.as_deref(),
273            Some("kopia-system")
274        );
275
276        let json = serde_json::to_value(&spec).expect("serialize");
277        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
278        assert_eq!(spec, reparsed);
279    }
280
281    #[test]
282    fn allowed_namespaces_selector_variant() {
283        let v: AllowedNamespaces = from_yaml(
284            "selector:\n  matchLabels: { kopiur.home-operations.com/tier: enterprise }\n",
285        );
286        assert_eq!(v.kind_str(), "Selector");
287        let json = serde_json::to_value(&v).unwrap();
288        assert_eq!(
289            json["selector"]["matchLabels"]["kopiur.home-operations.com/tier"],
290            "enterprise"
291        );
292    }
293
294    #[test]
295    fn allowed_namespaces_all_variant() {
296        let v: AllowedNamespaces = from_yaml("all: true\n");
297        assert_eq!(v.kind_str(), "All");
298        assert_eq!(serde_json::to_value(&v).unwrap()["all"], true);
299    }
300
301    #[test]
302    fn allowed_namespaces_unknown_variant_is_rejected() {
303        let value: serde_json::Value = serde_yaml::from_str("everyone: true\n").unwrap();
304        assert!(serde_json::from_value::<AllowedNamespaces>(value).is_err());
305    }
306
307    #[test]
308    fn schedule_defaults_timezone_round_trips() {
309        let yaml = r#"
310backend: { filesystem: { path: /repo } }
311encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
312allowedNamespaces: { all: true }
313scheduleDefaults:
314  timezone: America/New_York
315"#;
316        let spec: ClusterRepositorySpec = from_yaml(yaml);
317        assert_eq!(
318            spec.schedule_defaults
319                .as_ref()
320                .and_then(|d| d.timezone.as_deref()),
321            Some("America/New_York")
322        );
323        let json = serde_json::to_value(&spec).expect("serialize");
324        assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
325        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
326        assert_eq!(spec, reparsed);
327
328        // Absent scheduleDefaults stays None and is elided (no stored-object churn).
329        let bare: ClusterRepositorySpec = from_yaml(
330            "backend: { filesystem: { path: /repo } }\n\
331             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
332             allowedNamespaces: { all: true }\n",
333        );
334        assert!(bare.schedule_defaults.is_none());
335        assert!(
336            serde_json::to_value(&bare)
337                .unwrap()
338                .get("scheduleDefaults")
339                .is_none(),
340            "absent scheduleDefaults must be elided"
341        );
342    }
343
344    #[test]
345    fn identity_defaults_cluster_round_trips() {
346        // `identityDefaults.cluster` is the multi-cluster shared-repo identity
347        // suffix (M1): present, it round-trips through serde like any other field.
348        let yaml = r#"
349backend: { filesystem: { path: /repo } }
350encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
351allowedNamespaces: { all: true }
352identityDefaults:
353  cluster: east
354"#;
355        let spec: ClusterRepositorySpec = from_yaml(yaml);
356        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
357        assert_eq!(id.cluster.as_deref(), Some("east"));
358        assert!(id.hostname_expr.is_none());
359        assert!(id.username_expr.is_none());
360
361        let json = serde_json::to_value(&spec).expect("serialize");
362        assert_eq!(json["identityDefaults"]["cluster"], "east");
363        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
364        assert_eq!(spec, reparsed);
365
366        // Absent `cluster` stays None and is elided (no stored-object churn) —
367        // exercised independently of the existing `identityDefaults` back-compat
368        // fixture in `cluster_repository_roundtrip_matches_adr_shape`, which is
369        // left untouched.
370        let bare: ClusterRepositorySpec = from_yaml(
371            "backend: { filesystem: { path: /repo } }\n\
372             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
373             allowedNamespaces: { all: true }\n\
374             identityDefaults:\n  hostnameExpr: namespace\n",
375        );
376        let id = bare.identity_defaults.as_ref().expect("identityDefaults");
377        assert!(id.cluster.is_none());
378        assert!(
379            serde_json::to_value(&bare).unwrap()["identityDefaults"]
380                .get("cluster")
381                .is_none(),
382            "absent identityDefaults.cluster must be elided"
383        );
384    }
385
386    #[test]
387    fn deletion_protection_threshold_schema_default_matches_the_constant() {
388        let crd = ClusterRepository::crd();
389        let json = serde_json::to_value(&crd).unwrap();
390        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
391        assert_eq!(
392            spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
393            serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
394        );
395        assert_eq!(
396            crate::consts::effective_mass_deletion_threshold(None),
397            crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
398        );
399    }
400
401    #[test]
402    fn deletion_protection_round_trips_on_cluster_repository() {
403        let yaml = r#"
404backend: { filesystem: { path: /repo } }
405encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
406allowedNamespaces: { all: true }
407deletionProtection:
408  threshold: 0
409"#;
410        let spec: ClusterRepositorySpec = from_yaml(yaml);
411        assert_eq!(
412            spec.deletion_protection.as_ref().and_then(|d| d.threshold),
413            Some(0)
414        );
415        assert_eq!(
416            crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
417            0,
418            "Some(0) must pass through as the disable sentinel"
419        );
420        let json = serde_json::to_value(&spec).expect("serialize");
421        assert_eq!(json["deletionProtection"]["threshold"], 0);
422        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
423        assert_eq!(spec, reparsed);
424
425        // Absent stays None and is elided.
426        let bare: ClusterRepositorySpec = from_yaml(
427            "backend: { filesystem: { path: /repo } }\n\
428             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
429             allowedNamespaces: { all: true }\n",
430        );
431        assert!(bare.deletion_protection.is_none());
432        assert!(
433            serde_json::to_value(&bare)
434                .unwrap()
435                .get("deletionProtection")
436                .is_none(),
437            "absent deletionProtection must be elided"
438        );
439    }
440
441    #[test]
442    fn catalog_foreign_snapshots_round_trips_on_cluster_repository() {
443        use crate::common::ForeignSnapshots;
444
445        let yaml = r#"
446backend: { filesystem: { path: /repo } }
447encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
448allowedNamespaces: { all: true }
449identityDefaults:
450  cluster: east
451catalog:
452  fallbackNamespace: kopia-system
453  foreignSnapshots: Fallback
454"#;
455        let spec: ClusterRepositorySpec = from_yaml(yaml);
456        assert_eq!(
457            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
458            Some(ForeignSnapshots::Fallback)
459        );
460        let json = serde_json::to_value(&spec).expect("serialize");
461        assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
462        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
463        assert_eq!(spec, reparsed);
464
465        let yaml_ignore = r#"
466backend: { filesystem: { path: /repo } }
467encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
468allowedNamespaces: { all: true }
469identityDefaults:
470  cluster: east
471catalog:
472  foreignSnapshots: Ignore
473"#;
474        let spec: ClusterRepositorySpec = from_yaml(yaml_ignore);
475        assert_eq!(
476            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
477            Some(ForeignSnapshots::Ignore)
478        );
479
480        // Absent stays None and is elided.
481        let bare: ClusterRepositorySpec = from_yaml(
482            "backend: { filesystem: { path: /repo } }\n\
483             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
484             allowedNamespaces: { all: true }\n\
485             catalog: {}\n",
486        );
487        assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
488        assert!(
489            serde_json::to_value(&bare).unwrap()["catalog"]
490                .get("foreignSnapshots")
491                .is_none(),
492            "absent catalog.foreignSnapshots must be elided"
493        );
494    }
495
496    #[test]
497    fn catalog_adoption_round_trips_on_cluster_repository() {
498        use crate::common::SnapshotAdoption;
499
500        let yaml = r#"
501backend: { filesystem: { path: /repo } }
502encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
503allowedNamespaces: { all: true }
504catalog:
505  adoption: Ignore
506"#;
507        let spec: ClusterRepositorySpec = from_yaml(yaml);
508        assert_eq!(
509            spec.catalog.as_ref().and_then(|c| c.adoption),
510            Some(SnapshotAdoption::Ignore)
511        );
512        let json = serde_json::to_value(&spec).expect("serialize");
513        assert_eq!(json["catalog"]["adoption"], "Ignore");
514        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
515        assert_eq!(spec, reparsed);
516
517        // Absent stays None and is elided.
518        let bare: ClusterRepositorySpec = from_yaml(
519            "backend: { filesystem: { path: /repo } }\n\
520             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
521             allowedNamespaces: { all: true }\n\
522             catalog: {}\n",
523        );
524        assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
525        assert!(
526            serde_json::to_value(&bare).unwrap()["catalog"]
527                .get("adoption")
528                .is_none(),
529            "absent catalog.adoption must be elided"
530        );
531    }
532
533    #[test]
534    fn catalog_foreign_snapshots_unknown_variant_is_rejected() {
535        let value: serde_json::Value = serde_yaml::from_str("foreignSnapshots: Delete\n").unwrap();
536        assert!(serde_json::from_value::<crate::common::CatalogBounds>(value).is_err());
537    }
538
539    #[test]
540    fn catalog_foreign_snapshots_schema_carries_no_default() {
541        // Per the conventions doc (§4a): the effective default (`Ignore`) is
542        // context-dependent (coupled to identityDefaults.cluster), so no
543        // schemars `default` is emitted — the field must stay `—` in the
544        // generated field reference, not silently materialize `Ignore` for
545        // every repository regardless of whether it has a cluster identity.
546        let crd = ClusterRepository::crd();
547        let json = serde_json::to_value(&crd).unwrap();
548        let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
549            ["properties"]["catalog"]["properties"]["foreignSnapshots"];
550        assert!(
551            prop.get("default").is_none(),
552            "catalog.foreignSnapshots must NOT carry a schema default: {prop}"
553        );
554        // Sanity: the property itself is present, with the expected enum values.
555        assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
556    }
557}