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, ConcurrencySpec, CreateBehavior, DeletionProtectionSpec, Encryption,
11    IdentityDefaults, 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::seed::{SeedSpec, SeedStatus};
20use crate::server::{ClusterServerSpec, ServerStatus};
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
22use kube::CustomResource;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26/// A cluster-scoped kopia repository referenceable from allow-listed namespaces.
27#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
28#[kube(
29    group = "kopiur.home-operations.com",
30    version = "v1alpha1",
31    kind = "ClusterRepository",
32    status = "ClusterRepositoryStatus",
33    shortname = "kopiacrepo",
34    category = "kopiur",
35    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
36    printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
37    printcolumn = r#"{"name":"Namespaces","type":"integer","jsonPath":".status.allowedNamespaceCount"}"#,
38    printcolumn = r#"{"name":"Server","type":"string","jsonPath":".status.server.endpoint"}"#,
39    printcolumn = r#"{"name":"IndexBlobs","type":"integer","jsonPath":".status.storageStats.indexBlobCount","priority":1}"#,
40    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
41)]
42// §7/§15: create-time-immutability transition rules (apiserver + CI), same set as
43// the namespaced Repository — and like it, `encryption` (the password Secret reference)
44// is deliberately NOT locked (kopia fixes only the resolved value; a rename with identical
45// content must pass). Each `create.*` leaf is `has()`-guarded: CEL field access on an
46// absent optional key raises a "no such key" error that fails the whole rule (→ 422 on
47// *every* update, wedging the controller's finalizer/status writes), so we compare
48// presence first and only dereference when set — see the namespaced `Repository` for the
49// full rationale.
50#[schemars(extend("x-kubernetes-validations" = [
51    {"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"},
52    {"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"},
53    {"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"},
54    {"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"}
55]))]
56#[serde(rename_all = "camelCase")]
57pub struct ClusterRepositorySpec {
58    /// Exactly one storage backend.
59    pub backend: Backend,
60    /// Repository password (a Secret reference that must carry an explicit `namespace`).
61    pub encryption: Encryption,
62    /// What to do when the repository does not yet exist (absent means it must already exist).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub create: Option<CreateBehavior>,
65    /// Initialize this repository from an existing replica on its FIRST
66    /// bootstrap (issue #380) — a disaster-recovery entry point.
67    ///
68    /// Armed only while the repository has never been initialized
69    /// (`status.uniqueId` unset) **and** the mover's connect reports the backend
70    /// uninitialized; on an already-initialized repository it is a documented
71    /// no-op (`Seeded=True`, reason `AlreadyInitialized`), so it is safe to
72    /// leave standing in a GitOps manifest. When armed it also replaces
73    /// `spec.create`'s fallback: the repository is seeded or the bootstrap
74    /// fails, never silently created empty.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub seed: Option<SeedSpec>,
77    /// Tuning for the bootstrap/discovery mover Job (`<name>-discovery`) that
78    /// connects/creates an object-store repository the operator cannot reach
79    /// in-process (and re-runs for catalog re-scans).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub bootstrap: Option<BootstrapSpec>,
82    /// Base mover configuration inherited by every mover this repository spawns.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub mover_defaults: Option<MoverDefaults>,
85    /// Scheduling defaults (`timezone`, `jitter`) inherited by consumers that don't
86    /// set their own equivalent field — backup, verification, replication, and
87    /// maintenance schedules; set once here instead of repeating it on every cron.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub schedule_defaults: Option<ScheduleDefaults>,
90    /// Bounds materialization of `origin: discovered` `Snapshot` CRs from the kopia catalog.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub catalog: Option<CatalogBounds>,
93    /// Which namespaces are permitted to reference this repository.
94    pub allowed_namespaces: AllowedNamespaces,
95    /// Identity defaults (CEL `*Expr`) applied when consumers don't override.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub identity_defaults: Option<IdentityDefaults>,
98    /// Optional kopia web-UI server (the target `namespace` is required).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub server: Option<ClusterServerSpec>,
101    /// Maintenance control; `maintenance.namespace` selects where the owned `Maintenance` CR lands.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub maintenance: Option<RepositoryMaintenanceSpec>,
104    /// What happens to this repository's snapshots when a consuming namespace is deleted.
105    #[serde(default = "default_namespace_delete_policy")]
106    #[schemars(default = "default_namespace_delete_policy")]
107    pub on_namespace_delete: NamespaceDeletePolicy,
108    /// Mass-deletion circuit breaker for this repository's Snapshots.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub deletion_protection: Option<DeletionProtectionSpec>,
111    /// Concurrency limits for mover Jobs against this repository (absent = unlimited).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub concurrency: Option<ConcurrencySpec>,
114    /// Repository-owner gate for projecting credential Secrets into a foreign consumer namespace.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub credential_projection: Option<ClusterRepoCredentialProjection>,
117    /// Access mode: `ReadWrite` (default) or `ReadOnly` (serves restores only).
118    #[serde(default = "default_repository_mode")]
119    #[schemars(default = "default_repository_mode")]
120    pub mode: RepositoryMode,
121    /// Pause this cluster repository: skip connect/bootstrap and maintenance projection.
122    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123    pub suspend: bool,
124    /// Repository health thresholds (tunes the index-blob-count warning).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub health: Option<RepositoryHealthSpec>,
127    /// Mutable kopia repository parameters, re-applied on bootstrap whenever they drift.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub parameters: Option<RepositoryParameters>,
130}
131
132/// The repository-owner side of credential projection on a `ClusterRepository`.
133#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
134#[serde(rename_all = "camelCase")]
135pub struct ClusterRepoCredentialProjection {
136    /// When `true`, the owner permits projecting this repository's credential Secret(s) into a consumer namespace.
137    #[serde(default)]
138    pub allowed: bool,
139}
140
141/// The set of namespaces permitted to reference this `ClusterRepository` (exactly one of).
142#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
143#[serde(rename_all = "camelCase")]
144pub enum AllowedNamespaces {
145    /// Explicit namespace names.
146    List(Vec<String>),
147    /// Match namespaces by label.
148    Selector(LabelSelector),
149    /// Allow all namespaces (must be `true`).
150    All(bool),
151}
152
153impl AllowedNamespaces {
154    /// Stable discriminant string for status/metrics.
155    ///
156    /// ```
157    /// use kopiur_api::cluster_repository::AllowedNamespaces;
158    ///
159    /// let ns = AllowedNamespaces::List(vec!["production".into(), "staging".into()]);
160    /// assert_eq!(ns.kind_str(), "List");
161    /// assert_eq!(AllowedNamespaces::All(true).kind_str(), "All");
162    /// ```
163    pub fn kind_str(&self) -> &'static str {
164        match self {
165            AllowedNamespaces::List(_) => "List",
166            AllowedNamespaces::Selector(_) => "Selector",
167            AllowedNamespaces::All(_) => "All",
168        }
169    }
170}
171
172/// Observed state of a `ClusterRepository`; mirrors `RepositoryStatus` plus `allowedNamespaceCount`.
173#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
174#[serde(rename_all = "camelCase")]
175pub struct ClusterRepositoryStatus {
176    /// Current lifecycle phase (shared with `Repository`).
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub phase: Option<RepositoryPhase>,
179    /// `metadata.generation` of the `spec` last reconciled; drives staleness detection.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub observed_generation: Option<i64>,
182    /// `resourceVersion` of the password Secret observed at the last connect attempt.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub resolved_credential_version: Option<String>,
185    /// Kopia repository unique ID, pinned on the first successful bootstrap.
186    ///
187    /// Its presence is the "this repository has been Ready" flag that makes
188    /// auto-create one-way in time: `spec.create.enabled` governs the FIRST
189    /// bootstrap only, and once this is set kopiur will never create a fresh
190    /// empty repository over the backend, however empty the backend goes. A
191    /// wiped backend therefore parks at `Failed` with reason
192    /// `RepositoryReinitializeBlocked` instead of being silently re-created.
193    ///
194    /// To deliberately re-initialize a wiped repository, annotate it with
195    /// `kopiur.home-operations.com/allow-reinitialize` set to THIS value; the
196    /// ack is honored only while it matches, so the new ID minted by a
197    /// successful re-initialize makes it inert. This discards the history the
198    /// old repository held.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub unique_id: Option<String>,
201    /// What the last seed attempt did (`spec.seed`); absent on a repository that
202    /// was never seeded.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub seed: Option<SeedStatus>,
205    /// Mirror of `spec.backend` discriminant for the print column.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub backend: Option<String>,
208    /// Number of namespaces currently resolved by `spec.allowedNamespaces`.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub allowed_namespace_count: Option<i64>,
211    /// Repository size and snapshot counts from the last catalog scan.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub storage_stats: Option<StorageStats>,
214    /// Catalog-materialization status (discovered-backup count, last refresh).
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub catalog: Option<CatalogStatus>,
217    /// Resolved kopia server endpoint/auth, pinned by the reconciler.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub server: Option<ServerStatus>,
220    /// Last reverify-request token honored from a `Snapshot`'s re-probe nudge
221    /// (RFC3339); the loop guard that keeps each request a one-shot.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub last_reverify_at: Option<String>,
224    /// Backend health-probe state (`spec.health.probe`), when enabled.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub health: Option<RepositoryHealthStatus>,
227    /// The kopia repository parameters actually observed at the last bootstrap. Compare
228    /// against `spec.parameters` to see whether a declared value landed.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub parameters: Option<ObservedRepositoryParameters>,
231    /// Standard Kubernetes conditions (e.g. `Connected`, `MaintenanceOwned`).
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub conditions: Vec<Condition>,
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::testutil::from_yaml;
240    use kube::core::CustomResourceExt;
241
242    #[test]
243    fn cluster_repository_crd_metadata_is_correct() {
244        // `crd()` exercises schema generation; mis-encoded enums panic here.
245        let crd = ClusterRepository::crd();
246        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
247        assert_eq!(crd.spec.names.kind, "ClusterRepository");
248        // Cluster-scoped: this is the load-bearing assertion vs. namespaced CRDs.
249        assert_eq!(crd.spec.scope, "Cluster");
250        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
251    }
252
253    #[test]
254    fn cluster_repository_roundtrip_matches_adr_shape() {
255        // Mirrors ADR-0001 §3.2 / §5.2.
256        let yaml = r#"
257backend:
258  s3:
259    bucket: org-kopia-repo
260    prefix: ""
261    endpoint: s3.us-east-1.amazonaws.com
262    region: us-east-1
263    auth:
264      secretRef:
265        name: kopia-platform-creds
266        namespace: kopia-system
267encryption:
268  passwordSecretRef:
269    name: kopia-platform-creds
270    namespace: kopia-system
271    key: KOPIA_PASSWORD
272create:
273  enabled: true
274  encryption: AES256-GCM-HMAC-SHA256
275allowedNamespaces:
276  list: [production, staging, billing]
277identityDefaults:
278  hostnameExpr: "namespace"
279  usernameExpr: "namespace + '-' + policyName"
280catalog:
281  retain:
282    perIdentity: 50
283    maxAgeDays: 60
284  refreshInterval: 5m
285  fallbackNamespace: kopia-system
286"#;
287        let spec: ClusterRepositorySpec = from_yaml(yaml);
288        match &spec.backend {
289            Backend::S3(s3) => assert_eq!(s3.bucket, "org-kopia-repo"),
290            other => panic!("expected S3 backend, got {}", other.kind_str()),
291        }
292        match &spec.allowed_namespaces {
293            AllowedNamespaces::List(ns) => {
294                assert_eq!(ns, &["production", "staging", "billing"]);
295            }
296            other => panic!("expected List, got {}", other.kind_str()),
297        }
298        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
299        assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
300        assert_eq!(
301            id.username_expr.as_deref(),
302            Some("namespace + '-' + policyName")
303        );
304        assert_eq!(
305            spec.catalog.as_ref().unwrap().fallback_namespace.as_deref(),
306            Some("kopia-system")
307        );
308
309        let json = serde_json::to_value(&spec).expect("serialize");
310        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
311        assert_eq!(spec, reparsed);
312    }
313
314    #[test]
315    fn allowed_namespaces_selector_variant() {
316        let v: AllowedNamespaces = from_yaml(
317            "selector:\n  matchLabels: { kopiur.home-operations.com/tier: enterprise }\n",
318        );
319        assert_eq!(v.kind_str(), "Selector");
320        let json = serde_json::to_value(&v).unwrap();
321        assert_eq!(
322            json["selector"]["matchLabels"]["kopiur.home-operations.com/tier"],
323            "enterprise"
324        );
325    }
326
327    #[test]
328    fn allowed_namespaces_all_variant() {
329        let v: AllowedNamespaces = from_yaml("all: true\n");
330        assert_eq!(v.kind_str(), "All");
331        assert_eq!(serde_json::to_value(&v).unwrap()["all"], true);
332    }
333
334    #[test]
335    fn allowed_namespaces_unknown_variant_is_rejected() {
336        let value: serde_json::Value = serde_yaml::from_str("everyone: true\n").unwrap();
337        assert!(serde_json::from_value::<AllowedNamespaces>(value).is_err());
338    }
339
340    #[test]
341    fn schedule_defaults_timezone_round_trips() {
342        let yaml = r#"
343backend: { filesystem: { path: /repo } }
344encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
345allowedNamespaces: { all: true }
346scheduleDefaults:
347  timezone: America/New_York
348"#;
349        let spec: ClusterRepositorySpec = from_yaml(yaml);
350        assert_eq!(
351            spec.schedule_defaults
352                .as_ref()
353                .and_then(|d| d.timezone.as_deref()),
354            Some("America/New_York")
355        );
356        let json = serde_json::to_value(&spec).expect("serialize");
357        assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
358        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
359        assert_eq!(spec, reparsed);
360
361        // Absent scheduleDefaults stays None and is elided (no stored-object churn).
362        let bare: ClusterRepositorySpec = from_yaml(
363            "backend: { filesystem: { path: /repo } }\n\
364             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
365             allowedNamespaces: { all: true }\n",
366        );
367        assert!(bare.schedule_defaults.is_none());
368        assert!(
369            serde_json::to_value(&bare)
370                .unwrap()
371                .get("scheduleDefaults")
372                .is_none(),
373            "absent scheduleDefaults must be elided"
374        );
375    }
376
377    #[test]
378    fn identity_defaults_cluster_round_trips() {
379        // `identityDefaults.cluster` is the multi-cluster shared-repo identity
380        // suffix (M1): present, it round-trips through serde like any other field.
381        let yaml = r#"
382backend: { filesystem: { path: /repo } }
383encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
384allowedNamespaces: { all: true }
385identityDefaults:
386  cluster: east
387"#;
388        let spec: ClusterRepositorySpec = from_yaml(yaml);
389        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
390        assert_eq!(id.cluster.as_deref(), Some("east"));
391        assert!(id.hostname_expr.is_none());
392        assert!(id.username_expr.is_none());
393
394        let json = serde_json::to_value(&spec).expect("serialize");
395        assert_eq!(json["identityDefaults"]["cluster"], "east");
396        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
397        assert_eq!(spec, reparsed);
398
399        // Absent `cluster` stays None and is elided (no stored-object churn) —
400        // exercised independently of the existing `identityDefaults` back-compat
401        // fixture in `cluster_repository_roundtrip_matches_adr_shape`, which is
402        // left untouched.
403        let bare: ClusterRepositorySpec = from_yaml(
404            "backend: { filesystem: { path: /repo } }\n\
405             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
406             allowedNamespaces: { all: true }\n\
407             identityDefaults:\n  hostnameExpr: namespace\n",
408        );
409        let id = bare.identity_defaults.as_ref().expect("identityDefaults");
410        assert!(id.cluster.is_none());
411        assert!(
412            serde_json::to_value(&bare).unwrap()["identityDefaults"]
413                .get("cluster")
414                .is_none(),
415            "absent identityDefaults.cluster must be elided"
416        );
417    }
418
419    #[test]
420    fn deletion_protection_threshold_schema_default_matches_the_constant() {
421        let crd = ClusterRepository::crd();
422        let json = serde_json::to_value(&crd).unwrap();
423        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
424        assert_eq!(
425            spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
426            serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
427        );
428        assert_eq!(
429            crate::consts::effective_mass_deletion_threshold(None),
430            crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
431        );
432    }
433
434    #[test]
435    fn health_probe_schema_defaults_mirror_the_repository_twin() {
436        // #345: the ClusterRepository CRD must carry the same context-free
437        // probe defaults as the namespaced Repository — default-ON and the
438        // breaker (`Degrade`) as the onFailure default. The resolvers
439        // (`RepositoryHealthProbeSpec::enabled` / `effective_on_failure`) are
440        // shared, so only the schema emission needs its own guard here.
441        let crd = ClusterRepository::crd();
442        let json = serde_json::to_value(&crd).unwrap();
443        let probe = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
444            ["properties"]["health"]["properties"]["probe"]["properties"];
445        assert_eq!(
446            probe["enabled"]["default"],
447            serde_json::json!(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
448        );
449        assert_eq!(probe["onFailure"]["default"], serde_json::json!("Degrade"));
450        assert_eq!(probe["interval"]["default"], serde_json::json!("30m"));
451        assert_eq!(probe["failureThreshold"]["default"], serde_json::json!(3));
452    }
453
454    #[test]
455    fn deletion_protection_round_trips_on_cluster_repository() {
456        let yaml = r#"
457backend: { filesystem: { path: /repo } }
458encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
459allowedNamespaces: { all: true }
460deletionProtection:
461  threshold: 0
462"#;
463        let spec: ClusterRepositorySpec = from_yaml(yaml);
464        assert_eq!(
465            spec.deletion_protection.as_ref().and_then(|d| d.threshold),
466            Some(0)
467        );
468        assert_eq!(
469            crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
470            0,
471            "Some(0) must pass through as the disable sentinel"
472        );
473        let json = serde_json::to_value(&spec).expect("serialize");
474        assert_eq!(json["deletionProtection"]["threshold"], 0);
475        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
476        assert_eq!(spec, reparsed);
477
478        // Absent stays None and is elided.
479        let bare: ClusterRepositorySpec = from_yaml(
480            "backend: { filesystem: { path: /repo } }\n\
481             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
482             allowedNamespaces: { all: true }\n",
483        );
484        assert!(bare.deletion_protection.is_none());
485        assert!(
486            serde_json::to_value(&bare)
487                .unwrap()
488                .get("deletionProtection")
489                .is_none(),
490            "absent deletionProtection must be elided"
491        );
492    }
493
494    #[test]
495    fn concurrency_max_concurrent_jobs_emits_no_schema_default() {
496        // The `ClusterRepository` mirror of the `Repository` guard. Both kinds
497        // embed the SAME `ConcurrencySpec`, but each generates its own CRD schema,
498        // so a schemars `default` added to the shared struct would materialize on
499        // both — and a guard that only watched one kind would let it through on the
500        // other. §4a: absent ≡ 0 ≡ unlimited, so a server-side default would stamp
501        // `{maxConcurrentJobs: 0}` onto every stored cluster repository for no
502        // behavior change at all.
503        let json = serde_json::to_value(ClusterRepository::crd()).unwrap();
504        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
505        let field = &spec["properties"]["concurrency"]["properties"]["maxConcurrentJobs"];
506        assert!(
507            !field.is_null(),
508            "the field itself must exist in the schema: {spec}"
509        );
510        assert!(
511            field.get("default").is_none(),
512            "maxConcurrentJobs must NOT carry a schema default: {field}"
513        );
514        assert_eq!(crate::consts::effective_max_concurrent_jobs(None), None);
515    }
516
517    #[test]
518    fn concurrency_round_trips_on_cluster_repository() {
519        use crate::common::ConcurrencySpec;
520        use crate::consts::effective_max_concurrent_jobs;
521
522        let head = "backend: { filesystem: { path: /repo } }\n\
523                    encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
524                    allowedNamespaces: { all: true }\n";
525
526        let spec: ClusterRepositorySpec =
527            from_yaml(&format!("{head}concurrency:\n  maxConcurrentJobs: 4\n"));
528        assert_eq!(
529            spec.concurrency,
530            Some(ConcurrencySpec {
531                max_concurrent_jobs: Some(4)
532            })
533        );
534        assert_eq!(
535            effective_max_concurrent_jobs(spec.concurrency.as_ref()).map(|n| n.get()),
536            Some(4)
537        );
538        let json = serde_json::to_value(&spec).expect("serialize");
539        assert_eq!(json["concurrency"]["maxConcurrentJobs"], 4);
540        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
541        assert_eq!(spec, reparsed);
542
543        // `0` is the explicit "unlimited" spelling; it round-trips as itself
544        // rather than being normalized away, and resolves to uncapped.
545        let zero: ClusterRepositorySpec =
546            from_yaml(&format!("{head}concurrency:\n  maxConcurrentJobs: 0\n"));
547        assert_eq!(
548            zero.concurrency.and_then(|c| c.max_concurrent_jobs),
549            Some(0)
550        );
551        assert_eq!(
552            effective_max_concurrent_jobs(zero.concurrency.as_ref()),
553            None
554        );
555
556        // Absent stays None and is elided (no stored-object churn).
557        let bare: ClusterRepositorySpec = from_yaml(head);
558        assert!(bare.concurrency.is_none());
559        assert!(
560            serde_json::to_value(&bare)
561                .unwrap()
562                .get("concurrency")
563                .is_none(),
564            "absent concurrency must be elided"
565        );
566    }
567
568    #[test]
569    fn schedule_defaults_jitter_round_trips_on_cluster_repository() {
570        let head = "backend: { filesystem: { path: /repo } }\n\
571                    encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
572                    allowedNamespaces: { all: true }\n";
573        let spec: ClusterRepositorySpec = from_yaml(&format!(
574            "{head}scheduleDefaults:\n  timezone: America/New_York\n  jitter: 10m\n"
575        ));
576        let sd = spec.schedule_defaults.as_ref().expect("scheduleDefaults");
577        assert_eq!(sd.jitter.as_deref(), Some("10m"));
578        assert_eq!(sd.timezone.as_deref(), Some("America/New_York"));
579        let json = serde_json::to_value(&spec).expect("serialize");
580        assert_eq!(json["scheduleDefaults"]["jitter"], "10m");
581        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
582        assert_eq!(spec, reparsed);
583
584        // A scheduleDefaults with only a timezone elides jitter entirely.
585        let tz_only: ClusterRepositorySpec = from_yaml(&format!(
586            "{head}scheduleDefaults:\n  timezone: America/New_York\n"
587        ));
588        assert!(
589            tz_only
590                .schedule_defaults
591                .as_ref()
592                .and_then(|d| d.jitter.as_ref())
593                .is_none()
594        );
595        assert!(
596            serde_json::to_value(&tz_only).unwrap()["scheduleDefaults"]
597                .get("jitter")
598                .is_none(),
599            "absent jitter must be elided"
600        );
601    }
602
603    #[test]
604    fn mover_defaults_pod_metadata_round_trips_on_cluster_repository() {
605        let spec: ClusterRepositorySpec = from_yaml(
606            "backend: { filesystem: { path: /repo } }\n\
607             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
608             allowedNamespaces: { all: true }\n\
609             moverDefaults:\n\
610             \x20 podLabels: { kueue.x-k8s.io/queue-name: backups }\n\
611             \x20 podAnnotations: { sidecar.istio.io/inject: \"false\" }\n",
612        );
613        let md = spec.mover_defaults.as_ref().expect("moverDefaults");
614        assert_eq!(
615            md.pod_labels
616                .as_ref()
617                .and_then(|m| m.get("kueue.x-k8s.io/queue-name"))
618                .map(String::as_str),
619            Some("backups")
620        );
621        assert_eq!(
622            md.pod_annotations
623                .as_ref()
624                .and_then(|m| m.get("sidecar.istio.io/inject"))
625                .map(String::as_str),
626            Some("false")
627        );
628        let json = serde_json::to_value(&spec).expect("serialize");
629        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
630        assert_eq!(spec, reparsed);
631    }
632
633    #[test]
634    fn catalog_foreign_snapshots_round_trips_on_cluster_repository() {
635        use crate::common::ForeignSnapshots;
636
637        let yaml = r#"
638backend: { filesystem: { path: /repo } }
639encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
640allowedNamespaces: { all: true }
641identityDefaults:
642  cluster: east
643catalog:
644  fallbackNamespace: kopia-system
645  foreignSnapshots: Fallback
646"#;
647        let spec: ClusterRepositorySpec = from_yaml(yaml);
648        assert_eq!(
649            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
650            Some(ForeignSnapshots::Fallback)
651        );
652        let json = serde_json::to_value(&spec).expect("serialize");
653        assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
654        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
655        assert_eq!(spec, reparsed);
656
657        let yaml_ignore = r#"
658backend: { filesystem: { path: /repo } }
659encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
660allowedNamespaces: { all: true }
661identityDefaults:
662  cluster: east
663catalog:
664  foreignSnapshots: Ignore
665"#;
666        let spec: ClusterRepositorySpec = from_yaml(yaml_ignore);
667        assert_eq!(
668            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
669            Some(ForeignSnapshots::Ignore)
670        );
671
672        // Absent stays None and is elided.
673        let bare: ClusterRepositorySpec = from_yaml(
674            "backend: { filesystem: { path: /repo } }\n\
675             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
676             allowedNamespaces: { all: true }\n\
677             catalog: {}\n",
678        );
679        assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
680        assert!(
681            serde_json::to_value(&bare).unwrap()["catalog"]
682                .get("foreignSnapshots")
683                .is_none(),
684            "absent catalog.foreignSnapshots must be elided"
685        );
686    }
687
688    #[test]
689    fn catalog_adoption_round_trips_on_cluster_repository() {
690        use crate::common::SnapshotAdoption;
691
692        let yaml = r#"
693backend: { filesystem: { path: /repo } }
694encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
695allowedNamespaces: { all: true }
696catalog:
697  adoption: Ignore
698"#;
699        let spec: ClusterRepositorySpec = from_yaml(yaml);
700        assert_eq!(
701            spec.catalog.as_ref().and_then(|c| c.adoption),
702            Some(SnapshotAdoption::Ignore)
703        );
704        let json = serde_json::to_value(&spec).expect("serialize");
705        assert_eq!(json["catalog"]["adoption"], "Ignore");
706        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
707        assert_eq!(spec, reparsed);
708
709        // Absent stays None and is elided.
710        let bare: ClusterRepositorySpec = from_yaml(
711            "backend: { filesystem: { path: /repo } }\n\
712             encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
713             allowedNamespaces: { all: true }\n\
714             catalog: {}\n",
715        );
716        assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
717        assert!(
718            serde_json::to_value(&bare).unwrap()["catalog"]
719                .get("adoption")
720                .is_none(),
721            "absent catalog.adoption must be elided"
722        );
723    }
724
725    #[test]
726    fn catalog_foreign_snapshots_unknown_variant_is_rejected() {
727        let value: serde_json::Value = serde_yaml::from_str("foreignSnapshots: Delete\n").unwrap();
728        assert!(serde_json::from_value::<crate::common::CatalogBounds>(value).is_err());
729    }
730
731    #[test]
732    fn catalog_foreign_snapshots_schema_carries_no_default() {
733        // Per the conventions doc (§4a): the effective default (`Ignore`) is
734        // context-dependent (coupled to identityDefaults.cluster), so no
735        // schemars `default` is emitted — the field must stay `—` in the
736        // generated field reference, not silently materialize `Ignore` for
737        // every repository regardless of whether it has a cluster identity.
738        let crd = ClusterRepository::crd();
739        let json = serde_json::to_value(&crd).unwrap();
740        let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
741            ["properties"]["catalog"]["properties"]["foreignSnapshots"];
742        assert!(
743            prop.get("default").is_none(),
744            "catalog.foreignSnapshots must NOT carry a schema default: {prop}"
745        );
746        // Sanity: the property itself is present, with the expected enum values.
747        assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
748    }
749}