1use crate::backup::{BackupSpec, Origin};
18use crate::backup_config::{BackupConfigSpec, Source};
19use crate::backup_schedule::BackupScheduleSpec;
20use crate::cluster_repository::{AllowedNamespaces, ClusterRepositorySpec};
21use crate::common::{DeletionPolicy, RepositoryKind, RepositoryRef};
22use crate::error::{ValidationError, ValidationResult};
23use crate::maintenance::{MaintenanceSpec, RepositoryMaintenanceSpec};
24use crate::repository::RepositorySpec;
25use crate::restore::{RestoreSource, RestoreSpec, RestoreTarget};
26use std::collections::BTreeMap;
27
28pub fn validate_repository_ref(r: &RepositoryRef) -> ValidationResult {
55 match r.kind {
56 RepositoryKind::ClusterRepository => match &r.namespace {
57 Some(ns) => Err(ValidationError::ClusterRepoNamespaceForbidden {
58 namespace: ns.clone(),
59 }),
60 None => Ok(()),
61 },
62 RepositoryKind::Repository => Ok(()),
63 }
64}
65
66pub fn validate_consumer_against_cluster_repo(
79 consumer_namespace: &str,
80 repo_name: &str,
81 allowed: &AllowedNamespaces,
82 labels: Option<&BTreeMap<String, String>>,
83) -> ValidationResult {
84 match allowed {
85 AllowedNamespaces::All(true) => Ok(()),
86 AllowedNamespaces::All(false) => Err(ValidationError::ConsumerNamespaceNotAllowed {
87 namespace: consumer_namespace.to_string(),
88 repo: repo_name.to_string(),
89 }),
90 AllowedNamespaces::List(names) => {
91 if names.iter().any(|n| n == consumer_namespace) {
92 Ok(())
93 } else {
94 Err(ValidationError::ConsumerNamespaceNotAllowed {
95 namespace: consumer_namespace.to_string(),
96 repo: repo_name.to_string(),
97 })
98 }
99 }
100 AllowedNamespaces::Selector(sel) => {
101 let Some(labels) = labels else {
102 return Err(ValidationError::SelectorLabelsUnavailable {
103 namespace: consumer_namespace.to_string(),
104 repo: repo_name.to_string(),
105 });
106 };
107 let match_labels = sel.match_labels.clone().unwrap_or_default();
108 let matches = match_labels
110 .iter()
111 .all(|(k, v)| labels.get(k).map(|got| got == v).unwrap_or(false));
112 if matches {
113 Ok(())
114 } else {
115 Err(ValidationError::ConsumerNamespaceNotAllowed {
116 namespace: consumer_namespace.to_string(),
117 repo: repo_name.to_string(),
118 })
119 }
120 }
121 }
122}
123
124pub fn validate_backup_deletion_policy(
130 origin: Origin,
131 policy: Option<DeletionPolicy>,
132) -> ValidationResult {
133 if origin != Origin::Discovered {
134 return Ok(());
135 }
136 match policy {
137 None | Some(DeletionPolicy::Retain) => Ok(()),
138 Some(other) => Err(ValidationError::DiscoveredMustRetain {
139 got: format!("{other:?}"),
140 }),
141 }
142}
143
144pub fn validate_source(source: &Source) -> ValidationResult {
149 match (source.pvc.is_some(), source.pvc_selector.is_some()) {
150 (true, true) => Err(ValidationError::MutuallyExclusive {
151 a: "pvc".to_string(),
152 b: "pvcSelector".to_string(),
153 context: "backup source".to_string(),
154 }),
155 (false, false) => Err(ValidationError::MissingRequiredField {
156 field: "source.pvc or source.pvcSelector".to_string(),
157 }),
158 _ => Ok(()),
159 }
160}
161
162pub fn validate_restore(spec: &RestoreSpec) -> ValidationResult {
170 if matches!(spec.source, RestoreSource::Identity(_)) && spec.repository.is_none() {
173 return Err(ValidationError::RestoreSourceRepositoryRequired);
174 }
175 if let Some(RestoreTarget::Pvc(t)) = &spec.target
176 && t.name.trim().is_empty()
177 {
178 return Err(ValidationError::MissingRequiredField {
179 field: "restore.target.pvc.name".to_string(),
180 });
181 }
182 Ok(())
183}
184
185pub fn validate_repository_no_inline_retention(_spec: &RepositorySpec) -> ValidationResult {
194 Ok(())
197}
198
199pub fn validate_repository_maintenance(
209 maintenance: &RepositoryMaintenanceSpec,
210 cluster_scoped: bool,
211) -> Vec<ValidationError> {
212 let mut errs = Vec::new();
213 if let Some(schedule) = &maintenance.schedule {
214 if let Err(e) = validate_cron(&schedule.quick.cron) {
215 errs.push(e);
216 }
217 if let Err(e) = validate_cron(&schedule.full.cron) {
218 errs.push(e);
219 }
220 }
221 if !cluster_scoped && let Some(ns) = &maintenance.namespace {
222 errs.push(ValidationError::MaintenanceNamespaceOnNamespacedRepo {
223 namespace: ns.clone(),
224 });
225 }
226 errs
227}
228
229pub fn validate_cron(expr: &str) -> ValidationResult {
252 let probe = expr
253 .split_whitespace()
254 .map(|f| if f == "H" { "0" } else { f })
255 .collect::<Vec<_>>()
256 .join(" ");
257 match croner::Cron::new(&probe).parse() {
258 Ok(_) => Ok(()),
259 Err(e) => Err(ValidationError::InvalidCron {
260 expr: expr.to_string(),
261 reason: e.to_string(),
262 }),
263 }
264}
265
266pub fn validate_backup_config(spec: &BackupConfigSpec) -> Vec<ValidationError> {
270 let mut errs = Vec::new();
271 if let Err(e) = validate_repository_ref(&spec.repository) {
272 errs.push(e);
273 }
274 if spec.sources.is_empty() {
275 errs.push(ValidationError::MissingRequiredField {
276 field: "spec.sources (at least one source required)".to_string(),
277 });
278 }
279 for source in &spec.sources {
280 if let Err(e) = validate_source(source) {
281 errs.push(e);
282 }
283 }
284 errs
285}
286
287pub fn validate_backup(spec: &BackupSpec, origin: Origin) -> Vec<ValidationError> {
292 let mut errs = Vec::new();
293 if let Err(e) = validate_backup_deletion_policy(origin, spec.deletion_policy) {
294 errs.push(e);
295 }
296 errs
297}
298
299pub fn validate_backup_schedule(spec: &BackupScheduleSpec) -> Vec<ValidationError> {
301 let mut errs = Vec::new();
302 if let Err(e) = validate_cron(&spec.schedule.cron) {
303 errs.push(e);
304 }
305 errs
306}
307
308pub fn validate_repository(spec: &RepositorySpec) -> Vec<ValidationError> {
310 let mut errs = Vec::new();
311 if let Err(e) = validate_repository_no_inline_retention(spec) {
312 errs.push(e);
313 }
314 if let Some(m) = &spec.maintenance {
315 errs.extend(validate_repository_maintenance(m, false));
316 }
317 errs
318}
319
320pub fn validate_maintenance(spec: &MaintenanceSpec) -> Vec<ValidationError> {
322 let mut errs = Vec::new();
323 if let Err(e) = validate_repository_ref(&spec.repository) {
324 errs.push(e);
325 }
326 if let Err(e) = validate_cron(&spec.schedule.quick.cron) {
327 errs.push(e);
328 }
329 if let Err(e) = validate_cron(&spec.schedule.full.cron) {
330 errs.push(e);
331 }
332 errs
333}
334
335pub fn validate_cluster_repository(spec: &ClusterRepositorySpec) -> Vec<ValidationError> {
339 let mut errs = Vec::new();
340 if let AllowedNamespaces::All(false) = spec.allowed_namespaces {
341 errs.push(ValidationError::MissingRequiredField {
342 field: "allowedNamespaces.all must be true to grant access (false is meaningless)"
343 .to_string(),
344 });
345 }
346 if let Some(m) = &spec.maintenance {
347 errs.extend(validate_repository_maintenance(m, true));
348 }
349 errs
350}
351
352pub fn validate_restore_spec(spec: &RestoreSpec) -> Vec<ValidationError> {
355 let mut errs = Vec::new();
356 if let Some(r) = &spec.repository
357 && let Err(e) = validate_repository_ref(r)
358 {
359 errs.push(e);
360 }
361 if let Err(e) = validate_restore(spec) {
362 errs.push(e);
363 }
364 errs
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::common::Identity;
371 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
372
373 fn repo_ref(kind: RepositoryKind, ns: Option<&str>) -> RepositoryRef {
374 RepositoryRef {
375 kind,
376 name: "r".to_string(),
377 namespace: ns.map(String::from),
378 }
379 }
380
381 #[test]
384 fn cluster_repo_ref_forbids_namespace() {
385 let err = validate_repository_ref(&repo_ref(RepositoryKind::ClusterRepository, Some("x")))
386 .unwrap_err();
387 assert_eq!(
388 err,
389 ValidationError::ClusterRepoNamespaceForbidden {
390 namespace: "x".to_string()
391 }
392 );
393 }
394
395 #[test]
396 fn cluster_repo_ref_without_namespace_ok() {
397 assert!(
398 validate_repository_ref(&repo_ref(RepositoryKind::ClusterRepository, None)).is_ok()
399 );
400 }
401
402 #[test]
403 fn namespaced_repo_ref_allows_namespace() {
404 assert!(
405 validate_repository_ref(&repo_ref(RepositoryKind::Repository, Some("other"))).is_ok()
406 );
407 assert!(validate_repository_ref(&repo_ref(RepositoryKind::Repository, None)).is_ok());
408 }
409
410 #[test]
413 fn consumer_allowed_via_list() {
414 let allowed = AllowedNamespaces::List(vec!["billing".into(), "staging".into()]);
415 assert!(validate_consumer_against_cluster_repo("billing", "repo", &allowed, None).is_ok());
416 }
417
418 #[test]
419 fn consumer_denied_via_list() {
420 let allowed = AllowedNamespaces::List(vec!["billing".into()]);
421 let err =
422 validate_consumer_against_cluster_repo("evil", "repo", &allowed, None).unwrap_err();
423 assert_eq!(
424 err,
425 ValidationError::ConsumerNamespaceNotAllowed {
426 namespace: "evil".to_string(),
427 repo: "repo".to_string()
428 }
429 );
430 }
431
432 #[test]
433 fn consumer_allowed_via_all_true_denied_via_all_false() {
434 assert!(
435 validate_consumer_against_cluster_repo(
436 "any",
437 "repo",
438 &AllowedNamespaces::All(true),
439 None
440 )
441 .is_ok()
442 );
443 assert!(
444 validate_consumer_against_cluster_repo(
445 "any",
446 "repo",
447 &AllowedNamespaces::All(false),
448 None
449 )
450 .is_err()
451 );
452 }
453
454 #[test]
455 fn consumer_allowed_via_selector_match() {
456 let sel = LabelSelector {
457 match_labels: Some(BTreeMap::from([(
458 "kopiur.home-operations.com/tier".to_string(),
459 "enterprise".to_string(),
460 )])),
461 ..Default::default()
462 };
463 let allowed = AllowedNamespaces::Selector(sel);
464 let labels = BTreeMap::from([(
465 "kopiur.home-operations.com/tier".to_string(),
466 "enterprise".to_string(),
467 )]);
468 assert!(
469 validate_consumer_against_cluster_repo("ns", "repo", &allowed, Some(&labels)).is_ok()
470 );
471 }
472
473 #[test]
474 fn consumer_denied_via_selector_mismatch() {
475 let sel = LabelSelector {
476 match_labels: Some(BTreeMap::from([(
477 "kopiur.home-operations.com/tier".to_string(),
478 "enterprise".to_string(),
479 )])),
480 ..Default::default()
481 };
482 let allowed = AllowedNamespaces::Selector(sel);
483 let labels = BTreeMap::from([(
484 "kopiur.home-operations.com/tier".to_string(),
485 "free".to_string(),
486 )]);
487 assert!(
488 validate_consumer_against_cluster_repo("ns", "repo", &allowed, Some(&labels)).is_err()
489 );
490 }
491
492 #[test]
493 fn selector_without_labels_fails_closed() {
494 let allowed = AllowedNamespaces::Selector(LabelSelector::default());
495 let err = validate_consumer_against_cluster_repo("ns", "repo", &allowed, None).unwrap_err();
496 assert_eq!(
497 err,
498 ValidationError::SelectorLabelsUnavailable {
499 namespace: "ns".to_string(),
500 repo: "repo".to_string()
501 }
502 );
503 }
504
505 #[test]
508 fn discovered_accepts_none_and_retain() {
509 assert!(validate_backup_deletion_policy(Origin::Discovered, None).is_ok());
510 assert!(
511 validate_backup_deletion_policy(Origin::Discovered, Some(DeletionPolicy::Retain))
512 .is_ok()
513 );
514 }
515
516 #[test]
517 fn discovered_rejects_delete_and_orphan() {
518 assert_eq!(
519 validate_backup_deletion_policy(Origin::Discovered, Some(DeletionPolicy::Delete))
520 .unwrap_err(),
521 ValidationError::DiscoveredMustRetain {
522 got: "Delete".to_string()
523 }
524 );
525 assert!(matches!(
526 validate_backup_deletion_policy(Origin::Discovered, Some(DeletionPolicy::Orphan)),
527 Err(ValidationError::DiscoveredMustRetain { .. })
528 ));
529 }
530
531 #[test]
532 fn produced_origins_accept_any_policy() {
533 for o in [Origin::Scheduled, Origin::Manual] {
534 for p in [
535 None,
536 Some(DeletionPolicy::Delete),
537 Some(DeletionPolicy::Retain),
538 Some(DeletionPolicy::Orphan),
539 ] {
540 assert!(validate_backup_deletion_policy(o, p).is_ok());
541 }
542 }
543 }
544
545 #[test]
548 fn source_with_both_pvc_and_selector_is_rejected() {
549 use crate::backup_config::{PvcSelector, PvcSource};
550 let src = Source {
551 pvc: Some(PvcSource { name: "p".into() }),
552 pvc_selector: Some(PvcSelector {
553 namespace_selector: None,
554 label_selector: None,
555 }),
556 source_path_override: None,
557 source_path_strategy: None,
558 };
559 assert!(matches!(
560 validate_source(&src),
561 Err(ValidationError::MutuallyExclusive { .. })
562 ));
563 }
564
565 #[test]
566 fn source_with_neither_is_rejected() {
567 let src = Source {
568 pvc: None,
569 pvc_selector: None,
570 source_path_override: None,
571 source_path_strategy: None,
572 };
573 assert!(matches!(
574 validate_source(&src),
575 Err(ValidationError::MissingRequiredField { .. })
576 ));
577 }
578
579 fn restore_with(source: RestoreSource, repo: Option<RepositoryRef>) -> RestoreSpec {
582 RestoreSpec {
583 repository: repo,
584 source,
585 target: None,
586 options: None,
587 policy: None,
588 }
589 }
590
591 #[test]
592 fn restore_identity_requires_repository() {
593 use crate::restore::IdentitySource;
594 let spec = restore_with(
595 RestoreSource::Identity(IdentitySource {
596 username: "u".into(),
597 hostname: "h".into(),
598 source_path: None,
599 snapshot_id: None,
600 as_of: None,
601 offset: None,
602 }),
603 None,
604 );
605 assert_eq!(
606 validate_restore(&spec).unwrap_err(),
607 ValidationError::RestoreSourceRepositoryRequired
608 );
609 }
610
611 #[test]
612 fn restore_identity_with_repository_ok() {
613 use crate::restore::IdentitySource;
614 let spec = restore_with(
615 RestoreSource::Identity(IdentitySource {
616 username: "u".into(),
617 hostname: "h".into(),
618 source_path: None,
619 snapshot_id: None,
620 as_of: None,
621 offset: None,
622 }),
623 Some(repo_ref(RepositoryKind::Repository, Some("backups"))),
624 );
625 assert!(validate_restore(&spec).is_ok());
626 }
627
628 #[test]
629 fn restore_backup_ref_does_not_require_repository() {
630 use crate::common::ObjectRef;
631 let spec = restore_with(
632 RestoreSource::BackupRef(ObjectRef {
633 name: "b".into(),
634 namespace: None,
635 }),
636 None,
637 );
638 assert!(validate_restore(&spec).is_ok());
639 }
640
641 #[test]
642 fn restore_pvc_target_requires_name() {
643 use crate::common::ObjectRef;
644 use crate::restore::PvcTemplate;
645 let mut spec = restore_with(
646 RestoreSource::BackupRef(ObjectRef {
647 name: "b".into(),
648 namespace: None,
649 }),
650 None,
651 );
652 spec.target = Some(RestoreTarget::Pvc(PvcTemplate {
653 name: " ".into(),
654 storage_class_name: None,
655 capacity: None,
656 access_modes: vec![],
657 }));
658 assert!(matches!(
659 validate_restore(&spec),
660 Err(ValidationError::MissingRequiredField { .. })
661 ));
662 }
663
664 #[test]
667 fn valid_cron_expressions_pass() {
668 for expr in ["0 2 * * *", "*/15 * * * *", "0 0 1 1 *", "0 */6 * * *"] {
669 assert!(validate_cron(expr).is_ok(), "{expr} should be valid");
670 }
671 }
672
673 #[test]
674 fn jenkins_h_cron_passes_via_placeholder() {
675 assert!(validate_cron("H 2 * * *").is_ok());
677 assert!(validate_cron("H H * * *").is_ok());
678 }
679
680 #[test]
681 fn malformed_cron_is_rejected() {
682 for expr in ["not a cron", "99 99 99 99 99", ""] {
683 assert!(
684 matches!(
685 validate_cron(expr),
686 Err(ValidationError::InvalidCron { .. })
687 ),
688 "{expr} should be rejected"
689 );
690 }
691 }
692
693 #[test]
696 fn repository_inline_retention_hook_passes_today() {
697 use crate::backend::{Backend, FilesystemBackend};
698 use crate::common::{Encryption, SecretKeyRef};
699 let spec = RepositorySpec {
700 backend: Backend::Filesystem(FilesystemBackend {
701 path: "/repo".into(),
702 pvc_name: None,
703 }),
704 encryption: Encryption {
705 password_secret_ref: SecretKeyRef {
706 name: "s".into(),
707 namespace: None,
708 key: None,
709 },
710 },
711 create: None,
712 cache_defaults: None,
713 catalog: None,
714 maintenance: None,
715 };
716 assert!(validate_repository_no_inline_retention(&spec).is_ok());
717 }
718
719 #[test]
722 fn backup_config_aggregate_collects_multiple_errors() {
723 let spec = BackupConfigSpec {
724 repository: repo_ref(RepositoryKind::ClusterRepository, Some("forbidden")),
725 identity: Some(Identity::default()),
726 sources: vec![], copy_method: None,
728 volume_snapshot_class_name: None,
729 group_by: None,
730 retention: None,
731 default_deletion_policy: None,
732 policy: None,
733 hooks: None,
734 mover: None,
735 };
736 let errs = validate_backup_config(&spec);
737 assert_eq!(errs.len(), 2);
739 assert!(
740 errs.iter()
741 .any(|e| matches!(e, ValidationError::ClusterRepoNamespaceForbidden { .. }))
742 );
743 assert!(
744 errs.iter()
745 .any(|e| matches!(e, ValidationError::MissingRequiredField { .. }))
746 );
747 }
748
749 #[test]
750 fn backup_config_valid_spec_has_no_errors() {
751 use crate::backup_config::{PvcSource, Source};
752 let spec = BackupConfigSpec {
753 repository: repo_ref(RepositoryKind::Repository, Some("backups")),
754 identity: None,
755 sources: vec![Source {
756 pvc: Some(PvcSource {
757 name: "data".into(),
758 }),
759 pvc_selector: None,
760 source_path_override: None,
761 source_path_strategy: None,
762 }],
763 copy_method: None,
764 volume_snapshot_class_name: None,
765 group_by: None,
766 retention: None,
767 default_deletion_policy: None,
768 policy: None,
769 hooks: None,
770 mover: None,
771 };
772 assert!(validate_backup_config(&spec).is_empty());
773 }
774
775 #[test]
776 fn backup_aggregate_rejects_discovered_delete() {
777 let spec = BackupSpec {
778 config_ref: None,
779 tags: None,
780 failure_policy: None,
781 deletion_policy: Some(DeletionPolicy::Delete),
782 };
783 let errs = validate_backup(&spec, Origin::Discovered);
784 assert_eq!(errs.len(), 1);
785 assert!(matches!(
786 errs[0],
787 ValidationError::DiscoveredMustRetain { .. }
788 ));
789 }
790
791 #[test]
792 fn backup_schedule_aggregate_rejects_bad_cron() {
793 use crate::backup_schedule::ScheduleSpec;
794 use crate::common::ConfigRef;
795 let spec = BackupScheduleSpec {
796 config_ref: ConfigRef {
797 name: "c".into(),
798 namespace: None,
799 },
800 schedule: ScheduleSpec {
801 cron: "totally bad".into(),
802 jitter: None,
803 timezone: None,
804 run_on_create: false,
805 suspend: false,
806 concurrency_policy: Default::default(),
807 starting_deadline_seconds: None,
808 },
809 failed_jobs_history_limit: None,
810 };
811 let errs = validate_backup_schedule(&spec);
812 assert!(
813 errs.iter()
814 .any(|e| matches!(e, ValidationError::InvalidCron { .. }))
815 );
816 }
817
818 fn repo_spec_with_maintenance(m: Option<RepositoryMaintenanceSpec>) -> RepositorySpec {
821 use crate::backend::{Backend, FilesystemBackend};
822 use crate::common::{Encryption, SecretKeyRef};
823 RepositorySpec {
824 backend: Backend::Filesystem(FilesystemBackend {
825 path: "/repo".into(),
826 pvc_name: None,
827 }),
828 encryption: Encryption {
829 password_secret_ref: SecretKeyRef {
830 name: "s".into(),
831 namespace: None,
832 key: None,
833 },
834 },
835 create: None,
836 cache_defaults: None,
837 catalog: None,
838 maintenance: m,
839 }
840 }
841
842 #[test]
843 fn repository_default_managed_maintenance_is_valid() {
844 assert!(validate_repository(&repo_spec_with_maintenance(None)).is_empty());
846 assert!(
847 validate_repository(&repo_spec_with_maintenance(Some(
848 RepositoryMaintenanceSpec::default()
849 )))
850 .is_empty()
851 );
852 }
853
854 #[test]
855 fn repository_maintenance_namespace_rejected_on_namespaced_repo() {
856 let m = RepositoryMaintenanceSpec {
857 namespace: Some("kopia-system".into()),
858 ..Default::default()
859 };
860 let errs = validate_repository(&repo_spec_with_maintenance(Some(m)));
861 assert_eq!(
862 errs,
863 vec![ValidationError::MaintenanceNamespaceOnNamespacedRepo {
864 namespace: "kopia-system".into()
865 }]
866 );
867 }
868
869 #[test]
870 fn repository_maintenance_namespace_allowed_on_cluster_repo() {
871 let m = RepositoryMaintenanceSpec {
872 namespace: Some("kopia-system".into()),
873 ..Default::default()
874 };
875 assert!(validate_repository_maintenance(&m, true).is_empty());
877 }
878
879 #[test]
880 fn repository_maintenance_bad_override_cron_is_rejected() {
881 use crate::common::CronSpec;
882 use crate::maintenance::MaintenanceSchedule;
883 let m = RepositoryMaintenanceSpec {
884 schedule: Some(MaintenanceSchedule {
885 quick: CronSpec {
886 cron: "totally bad".into(),
887 jitter: None,
888 },
889 full: CronSpec {
890 cron: "0 3 * * *".into(),
891 jitter: None,
892 },
893 timezone: None,
894 }),
895 ..Default::default()
896 };
897 let errs = validate_repository_maintenance(&m, false);
898 assert!(
899 errs.iter()
900 .any(|e| matches!(e, ValidationError::InvalidCron { .. }))
901 );
902 }
903
904 #[test]
905 fn cluster_repository_rejects_all_false() {
906 use crate::backend::{Backend, FilesystemBackend};
907 use crate::common::{Encryption, SecretKeyRef};
908 let spec = ClusterRepositorySpec {
909 backend: Backend::Filesystem(FilesystemBackend {
910 path: "/r".into(),
911 pvc_name: None,
912 }),
913 encryption: Encryption {
914 password_secret_ref: SecretKeyRef {
915 name: "s".into(),
916 namespace: Some("kopia-system".into()),
917 key: None,
918 },
919 },
920 create: None,
921 cache_defaults: None,
922 catalog: None,
923 allowed_namespaces: AllowedNamespaces::All(false),
924 identity_defaults: None,
925 maintenance: None,
926 };
927 assert!(!validate_cluster_repository(&spec).is_empty());
928 }
929}