1use 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#[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#[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 pub backend: Backend,
60 pub encryption: Encryption,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub create: Option<CreateBehavior>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub seed: Option<SeedSpec>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub bootstrap: Option<BootstrapSpec>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub mover_defaults: Option<MoverDefaults>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub schedule_defaults: Option<ScheduleDefaults>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub catalog: Option<CatalogBounds>,
93 pub allowed_namespaces: AllowedNamespaces,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub identity_defaults: Option<IdentityDefaults>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub server: Option<ClusterServerSpec>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub maintenance: Option<RepositoryMaintenanceSpec>,
104 #[serde(default = "default_namespace_delete_policy")]
106 #[schemars(default = "default_namespace_delete_policy")]
107 pub on_namespace_delete: NamespaceDeletePolicy,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub deletion_protection: Option<DeletionProtectionSpec>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub concurrency: Option<ConcurrencySpec>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub credential_projection: Option<ClusterRepoCredentialProjection>,
117 #[serde(default = "default_repository_mode")]
119 #[schemars(default = "default_repository_mode")]
120 pub mode: RepositoryMode,
121 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123 pub suspend: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub health: Option<RepositoryHealthSpec>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub parameters: Option<RepositoryParameters>,
130}
131
132#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
134#[serde(rename_all = "camelCase")]
135pub struct ClusterRepoCredentialProjection {
136 #[serde(default)]
138 pub allowed: bool,
139}
140
141#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
143#[serde(rename_all = "camelCase")]
144pub enum AllowedNamespaces {
145 List(Vec<String>),
147 Selector(LabelSelector),
149 All(bool),
151}
152
153impl AllowedNamespaces {
154 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#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
174#[serde(rename_all = "camelCase")]
175pub struct ClusterRepositoryStatus {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub phase: Option<RepositoryPhase>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub observed_generation: Option<i64>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub resolved_credential_version: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub unique_id: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub seed: Option<SeedStatus>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub backend: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub allowed_namespace_count: Option<i64>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub storage_stats: Option<StorageStats>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub catalog: Option<CatalogStatus>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub server: Option<ServerStatus>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub last_reverify_at: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub health: Option<RepositoryHealthStatus>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub parameters: Option<ObservedRepositoryParameters>,
231 #[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 let crd = ClusterRepository::crd();
246 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
247 assert_eq!(crd.spec.names.kind, "ClusterRepository");
248 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 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_saphyr::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 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 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 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 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 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 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 max_concurrent_delete_jobs: None,
533 })
534 );
535 assert_eq!(
536 effective_max_concurrent_jobs(spec.concurrency.as_ref()).map(|n| n.get()),
537 Some(4)
538 );
539 let json = serde_json::to_value(&spec).expect("serialize");
540 assert_eq!(json["concurrency"]["maxConcurrentJobs"], 4);
541 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
542 assert_eq!(spec, reparsed);
543
544 let zero: ClusterRepositorySpec =
547 from_yaml(&format!("{head}concurrency:\n maxConcurrentJobs: 0\n"));
548 assert_eq!(
549 zero.concurrency.and_then(|c| c.max_concurrent_jobs),
550 Some(0)
551 );
552 assert_eq!(
553 effective_max_concurrent_jobs(zero.concurrency.as_ref()),
554 None
555 );
556
557 let bare: ClusterRepositorySpec = from_yaml(head);
559 assert!(bare.concurrency.is_none());
560 assert!(
561 serde_json::to_value(&bare)
562 .unwrap()
563 .get("concurrency")
564 .is_none(),
565 "absent concurrency must be elided"
566 );
567 }
568
569 #[test]
570 fn schedule_defaults_jitter_round_trips_on_cluster_repository() {
571 let head = "backend: { filesystem: { path: /repo } }\n\
572 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
573 allowedNamespaces: { all: true }\n";
574 let spec: ClusterRepositorySpec = from_yaml(&format!(
575 "{head}scheduleDefaults:\n timezone: America/New_York\n jitter: 10m\n"
576 ));
577 let sd = spec.schedule_defaults.as_ref().expect("scheduleDefaults");
578 assert_eq!(sd.jitter.as_deref(), Some("10m"));
579 assert_eq!(sd.timezone.as_deref(), Some("America/New_York"));
580 let json = serde_json::to_value(&spec).expect("serialize");
581 assert_eq!(json["scheduleDefaults"]["jitter"], "10m");
582 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
583 assert_eq!(spec, reparsed);
584
585 let tz_only: ClusterRepositorySpec = from_yaml(&format!(
587 "{head}scheduleDefaults:\n timezone: America/New_York\n"
588 ));
589 assert!(
590 tz_only
591 .schedule_defaults
592 .as_ref()
593 .and_then(|d| d.jitter.as_ref())
594 .is_none()
595 );
596 assert!(
597 serde_json::to_value(&tz_only).unwrap()["scheduleDefaults"]
598 .get("jitter")
599 .is_none(),
600 "absent jitter must be elided"
601 );
602 }
603
604 #[test]
605 fn mover_defaults_pod_metadata_round_trips_on_cluster_repository() {
606 let spec: ClusterRepositorySpec = from_yaml(
607 "backend: { filesystem: { path: /repo } }\n\
608 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
609 allowedNamespaces: { all: true }\n\
610 moverDefaults:\n\
611 \x20 podLabels: { kueue.x-k8s.io/queue-name: backups }\n\
612 \x20 podAnnotations: { sidecar.istio.io/inject: \"false\" }\n",
613 );
614 let md = spec.mover_defaults.as_ref().expect("moverDefaults");
615 assert_eq!(
616 md.pod_labels
617 .as_ref()
618 .and_then(|m| m.get("kueue.x-k8s.io/queue-name"))
619 .map(String::as_str),
620 Some("backups")
621 );
622 assert_eq!(
623 md.pod_annotations
624 .as_ref()
625 .and_then(|m| m.get("sidecar.istio.io/inject"))
626 .map(String::as_str),
627 Some("false")
628 );
629 let json = serde_json::to_value(&spec).expect("serialize");
630 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
631 assert_eq!(spec, reparsed);
632 }
633
634 #[test]
635 fn catalog_foreign_snapshots_round_trips_on_cluster_repository() {
636 use crate::common::ForeignSnapshots;
637
638 let yaml = r#"
639backend: { filesystem: { path: /repo } }
640encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
641allowedNamespaces: { all: true }
642identityDefaults:
643 cluster: east
644catalog:
645 fallbackNamespace: kopia-system
646 foreignSnapshots: Fallback
647"#;
648 let spec: ClusterRepositorySpec = from_yaml(yaml);
649 assert_eq!(
650 spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
651 Some(ForeignSnapshots::Fallback)
652 );
653 let json = serde_json::to_value(&spec).expect("serialize");
654 assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
655 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
656 assert_eq!(spec, reparsed);
657
658 let yaml_ignore = r#"
659backend: { filesystem: { path: /repo } }
660encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
661allowedNamespaces: { all: true }
662identityDefaults:
663 cluster: east
664catalog:
665 foreignSnapshots: Ignore
666"#;
667 let spec: ClusterRepositorySpec = from_yaml(yaml_ignore);
668 assert_eq!(
669 spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
670 Some(ForeignSnapshots::Ignore)
671 );
672
673 let bare: ClusterRepositorySpec = from_yaml(
675 "backend: { filesystem: { path: /repo } }\n\
676 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
677 allowedNamespaces: { all: true }\n\
678 catalog: {}\n",
679 );
680 assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
681 assert!(
682 serde_json::to_value(&bare).unwrap()["catalog"]
683 .get("foreignSnapshots")
684 .is_none(),
685 "absent catalog.foreignSnapshots must be elided"
686 );
687 }
688
689 #[test]
690 fn catalog_adoption_round_trips_on_cluster_repository() {
691 use crate::common::SnapshotAdoption;
692
693 let yaml = r#"
694backend: { filesystem: { path: /repo } }
695encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
696allowedNamespaces: { all: true }
697catalog:
698 adoption: Ignore
699"#;
700 let spec: ClusterRepositorySpec = from_yaml(yaml);
701 assert_eq!(
702 spec.catalog.as_ref().and_then(|c| c.adoption),
703 Some(SnapshotAdoption::Ignore)
704 );
705 let json = serde_json::to_value(&spec).expect("serialize");
706 assert_eq!(json["catalog"]["adoption"], "Ignore");
707 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
708 assert_eq!(spec, reparsed);
709
710 let bare: ClusterRepositorySpec = from_yaml(
712 "backend: { filesystem: { path: /repo } }\n\
713 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
714 allowedNamespaces: { all: true }\n\
715 catalog: {}\n",
716 );
717 assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
718 assert!(
719 serde_json::to_value(&bare).unwrap()["catalog"]
720 .get("adoption")
721 .is_none(),
722 "absent catalog.adoption must be elided"
723 );
724 }
725
726 #[test]
727 fn catalog_foreign_snapshots_unknown_variant_is_rejected() {
728 let value: serde_json::Value =
729 serde_saphyr::from_str("foreignSnapshots: Delete\n").unwrap();
730 assert!(serde_json::from_value::<crate::common::CatalogBounds>(value).is_err());
731 }
732
733 #[test]
734 fn catalog_foreign_snapshots_schema_carries_no_default() {
735 let crd = ClusterRepository::crd();
741 let json = serde_json::to_value(&crd).unwrap();
742 let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
743 ["properties"]["catalog"]["properties"]["foreignSnapshots"];
744 assert!(
745 prop.get("default").is_none(),
746 "catalog.foreignSnapshots must NOT carry a schema default: {prop}"
747 );
748 assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
750 }
751}