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_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 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 })
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 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 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 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 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 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 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 assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
748 }
749}