1use crate::backend::NfsVolume;
5use crate::common::{
6 CredentialProjection, CronSpec, DeletionPolicy, Identity, MoverSpec, PodSelector,
7 PvcAccessMode, RepositoryRef, ResolvedIdentity, Retention,
8};
9use k8s_openapi::api::batch::v1::JobSpec;
10use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
11use kube::CustomResource;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
17#[kube(
18 group = "kopiur.home-operations.com",
19 version = "v1alpha1",
20 kind = "SnapshotPolicy",
21 plural = "snapshotpolicies",
22 namespaced,
23 status = "SnapshotPolicyStatus",
24 shortname = "kopiasp",
25 category = "kopiur",
26 printcolumn = r#"{"name":"Repository","type":"string","jsonPath":".spec.repository.name"}"#,
27 printcolumn = r#"{"name":"Repositories","type":"string","jsonPath":".status.repositorySummary"}"#,
28 printcolumn = r#"{"name":"Last-Snapshot","type":"date","jsonPath":".status.lastSuccessfulSnapshot"}"#,
29 printcolumn = r#"{"name":"Last-Verified","type":"date","jsonPath":".status.lastVerified"}"#,
30 printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.suspend"}"#,
31 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
32)]
33#[schemars(extend("x-kubernetes-validations" = [{
39 "rule": "(has(self.repository) ? 1 : 0) + (has(self.repositories) ? 1 : 0) == 1",
40 "message": "exactly one of repository, repositories"
41}]))]
42#[serde(rename_all = "camelCase")]
43pub struct SnapshotPolicySpec {
44 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub repository: Option<RepositoryRef>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 #[schemars(length(min = 1, max = 8))]
59 pub repositories: Vec<RepositoryRef>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub identity: Option<Identity>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 #[schemars(length(max = 100))]
66 pub sources: Vec<Source>,
67 #[serde(default = "default_copy_method")]
69 #[schemars(default = "default_copy_method")]
70 pub copy_method: CopyMethod,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub volume_snapshot_class_name: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub staging: Option<StagingSpec>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 #[schemars(default = "default_group_by")]
83 pub group_by: Option<GroupBy>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub retention: Option<Retention>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 #[schemars(default = "recipe_default_deletion_policy")]
90 pub default_deletion_policy: Option<DeletionPolicy>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub compression: Option<Compression>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub files: Option<Files>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub extra_args: Vec<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub error_handling: Option<ErrorHandling>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub upload: Option<Upload>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub verification: Option<Verification>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub preflight: Option<crate::preflight::PreflightSpec>,
114 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116 pub suspend: bool,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub hooks: Option<Hooks>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub mover: Option<MoverSpec>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub credential_projection: Option<CredentialProjection>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub deletion: Option<PolicyDeletionSpec>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub adoption: Option<crate::common::SnapshotAdoption>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum PolicyRepositories<'a> {
141 Single(&'a RepositoryRef),
143 Multi(&'a [RepositoryRef]),
145}
146
147pub fn policy_repositories(
168 spec: &SnapshotPolicySpec,
169) -> Result<PolicyRepositories<'_>, crate::error::ValidationError> {
170 match (&spec.repository, spec.repositories.is_empty()) {
171 (Some(single), true) => Ok(PolicyRepositories::Single(single)),
172 (None, false) => Ok(PolicyRepositories::Multi(&spec.repositories)),
173 (Some(_), false) => {
174 Err(crate::error::ValidationError::PolicyRepositoryExactlyOne { got: "both" })
175 }
176 (None, true) => {
177 Err(crate::error::ValidationError::PolicyRepositoryExactlyOne { got: "neither" })
178 }
179 }
180}
181
182pub fn single_repository_ref(
195 spec: &SnapshotPolicySpec,
196) -> Result<&RepositoryRef, crate::error::ValidationError> {
197 match policy_repositories(spec)? {
198 PolicyRepositories::Single(r) => Ok(r),
199 PolicyRepositories::Multi(_) => {
200 Err(crate::error::ValidationError::PolicySingleRepositoryRequired)
201 }
202 }
203}
204
205pub fn repository_refs(spec: &SnapshotPolicySpec) -> impl Iterator<Item = &RepositoryRef> {
210 spec.repository.iter().chain(spec.repositories.iter())
211}
212
213pub fn is_multi_repo(spec: &SnapshotPolicySpec) -> bool {
215 !spec.repositories.is_empty()
216}
217
218pub fn select_restore_repository(
237 policy: &SnapshotPolicySpec,
238 policy_name: &str,
239 policy_ns: &str,
240 explicit: Option<&RepositoryRef>,
241 restore_ns: &str,
242) -> Result<RepositoryRef, crate::error::ValidationError> {
243 use crate::common::repo_key;
244 let repos = policy_repositories(policy)?;
245 let members: Vec<&RepositoryRef> = match repos {
246 PolicyRepositories::Single(r) => vec![r],
247 PolicyRepositories::Multi(rs) => rs.iter().collect(),
248 };
249 let valid = || {
250 members
251 .iter()
252 .map(|m| repo_key(m, policy_ns))
253 .collect::<Vec<_>>()
254 .join(", ")
255 };
256 match explicit {
257 Some(given) => {
258 let given_key = repo_key(given, restore_ns);
259 if members.iter().any(|m| repo_key(m, policy_ns) == given_key) {
260 Ok(given.clone())
261 } else {
262 Err(
263 crate::error::ValidationError::RestoreRepositoryNotInPolicy {
264 given: given_key,
265 policy: policy_name.to_string(),
266 valid: valid(),
267 },
268 )
269 }
270 }
271 None => match repos {
272 PolicyRepositories::Single(r) => Ok(r.clone()),
273 PolicyRepositories::Multi(_) => Err(
274 crate::error::ValidationError::RestoreRepositorySelectionRequired {
275 policy: policy_name.to_string(),
276 valid: valid(),
277 },
278 ),
279 },
280 }
281}
282
283#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
288#[serde(rename_all = "camelCase")]
289pub struct PolicyDeletionSpec {
290 #[serde(default = "default_on_policy_delete")]
293 #[schemars(default = "default_on_policy_delete")]
294 pub on_policy_delete: crate::common::PolicyDeletePolicy,
295}
296
297fn default_on_policy_delete() -> crate::common::PolicyDeletePolicy {
298 crate::common::PolicyDeletePolicy::Retain
299}
300
301pub fn effective_on_policy_delete(
306 deletion: Option<&PolicyDeletionSpec>,
307) -> crate::common::PolicyDeletePolicy {
308 deletion.map(|d| d.on_policy_delete).unwrap_or_default()
309}
310
311#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, JsonSchema)]
329#[schemars(extend("x-kubernetes-validations" = [{
330 "rule": "(has(self.pvc) ? 1 : 0) + (has(self.pvcSelector) ? 1 : 0) + (has(self.nfs) ? 1 : 0) + (has(self.stream) ? 1 : 0) == 1",
331 "message": "exactly one of pvc, pvcSelector, nfs, stream"
332}]))]
333#[serde(rename_all = "camelCase")]
334pub struct Source {
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub pvc: Option<PvcSource>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub pvc_selector: Option<PvcSelector>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub nfs: Option<NfsVolume>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub stream: Option<StreamSource>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
363 #[schemars(default = "default_source_read_only")]
364 pub read_only: Option<bool>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub acknowledge_live_mutation: Option<bool>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
390 #[schemars(length(max = 4096))]
391 pub source_path_override: Option<String>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 #[schemars(default = "default_source_path_strategy")]
395 pub source_path_strategy: Option<SourcePathStrategy>,
396}
397
398pub const STREAM_SOURCE_ROOT: &str = "/stream";
401
402#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
413#[serde(rename_all = "camelCase")]
414pub struct StreamSource {
415 #[schemars(length(min = 1, max = 255))]
421 pub file_name: String,
422 pub workload_exec: StreamExec,
424}
425
426#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
434#[serde(rename_all = "camelCase")]
435pub struct StreamExec {
436 pub pod_selector: LabelSelector,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub container: Option<String>,
443 #[schemars(length(min = 1))]
450 pub command: Vec<String>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
457 #[schemars(default = "default_stream_timeout")]
458 pub timeout: Option<String>,
459}
460
461fn default_stream_timeout() -> Option<String> {
471 Some("1h".to_string())
472}
473
474#[derive(Debug, Clone, Copy, PartialEq)]
491pub enum SourceShape<'a> {
492 Pvc(&'a PvcSource),
494 PvcSelector(&'a PvcSelector),
496 Nfs(&'a NfsVolume),
498 Stream(&'a StreamSource),
500}
501
502impl SourceShape<'_> {
503 pub fn kind_str(&self) -> &'static str {
505 match self {
506 SourceShape::Pvc(_) => "pvc",
507 SourceShape::PvcSelector(_) => "pvcSelector",
508 SourceShape::Nfs(_) => "nfs",
509 SourceShape::Stream(_) => "stream",
510 }
511 }
512}
513
514pub fn source_shape(source: &Source) -> Result<SourceShape<'_>, crate::error::ValidationError> {
532 let set: Vec<&'static str> = [
533 ("pvc", source.pvc.is_some()),
534 ("pvcSelector", source.pvc_selector.is_some()),
535 ("nfs", source.nfs.is_some()),
536 ("stream", source.stream.is_some()),
537 ]
538 .into_iter()
539 .filter_map(|(name, present)| present.then_some(name))
540 .collect();
541
542 match set.as_slice() {
543 [] => Err(crate::error::ValidationError::MissingRequiredField {
544 field: "source.pvc, source.pvcSelector, source.nfs, or source.stream".to_string(),
545 }),
546 [first, second, ..] => Err(crate::error::ValidationError::MutuallyExclusive {
547 a: (*first).to_string(),
548 b: (*second).to_string(),
549 context: "snapshot source".to_string(),
550 }),
551 ["pvc"] => Ok(SourceShape::Pvc(source.pvc.as_ref().expect("pvc is set"))),
554 ["pvcSelector"] => Ok(SourceShape::PvcSelector(
555 source.pvc_selector.as_ref().expect("pvcSelector is set"),
556 )),
557 ["nfs"] => Ok(SourceShape::Nfs(source.nfs.as_ref().expect("nfs is set"))),
558 ["stream"] => Ok(SourceShape::Stream(
559 source.stream.as_ref().expect("stream is set"),
560 )),
561 [other] => Err(crate::error::ValidationError::InvalidFieldValue {
563 field: "spec.sources[]".to_string(),
564 reason: format!("unknown source form `{other}`"),
565 }),
566 }
567}
568
569pub fn stream_source_path(stream: &StreamSource) -> String {
573 format!("{STREAM_SOURCE_ROOT}/{}", stream.file_name)
574}
575
576#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
578#[serde(rename_all = "camelCase")]
579pub struct PvcSource {
580 pub name: String,
582}
583
584#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
586#[serde(rename_all = "camelCase")]
587pub struct PvcSelector {
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub namespace_selector: Option<NamespaceSelector>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub label_selector: Option<LabelSelector>,
594}
595
596#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
598#[serde(rename_all = "camelCase")]
599pub struct NamespaceSelector {
600 #[serde(default, skip_serializing_if = "Vec::is_empty")]
602 pub match_names: Vec<String>,
603}
604
605fn default_copy_method() -> CopyMethod {
621 CopyMethod::Snapshot
622}
623
624pub fn default_ignore_rules() -> Vec<String> {
650 vec![
651 "/lost+found".to_string(),
652 "System Volume Information".to_string(),
653 "$RECYCLE.BIN".to_string(),
654 "@eaDir".to_string(),
655 ".snapshot".to_string(),
656 ]
657}
658
659#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
671pub enum CopyMethod {
672 #[default]
674 Snapshot,
675 Clone,
677 Direct,
679}
680
681#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
685#[serde(rename_all = "camelCase")]
686pub struct StagingSpec {
687 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub timeout: Option<String>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub storage_class_name: Option<String>,
709 #[serde(default, skip_serializing_if = "Vec::is_empty")]
715 pub access_modes: Vec<PvcAccessMode>,
716}
717
718#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
722pub enum GroupBy {
723 #[default]
725 VolumeGroupSnapshot,
726 None,
728}
729
730fn default_group_by() -> Option<GroupBy> {
734 Some(GroupBy::VolumeGroupSnapshot)
735}
736
737#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
741pub enum SourcePathStrategy {
742 #[default]
744 PvcName,
745 PvcNamespacedName,
747}
748
749fn default_source_path_strategy() -> Option<SourcePathStrategy> {
752 Some(SourcePathStrategy::PvcName)
753}
754
755fn default_source_read_only() -> Option<bool> {
761 Some(true)
762}
763
764pub fn source_read_only(source: &Source) -> bool {
768 source.read_only.unwrap_or(true)
769}
770
771pub fn source_mutates_live_volume(copy_method: CopyMethod, source: &Source) -> bool {
788 matches!(copy_method, CopyMethod::Direct)
789 && !source_read_only(source)
790 && (source.pvc.is_some() || source.pvc_selector.is_some())
791}
792
793fn recipe_default_deletion_policy() -> Option<crate::common::DeletionPolicy> {
797 Some(crate::common::DeletionPolicy::Delete)
798}
799
800#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
802#[serde(rename_all = "camelCase")]
803pub struct Compression {
804 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub compressor: Option<String>,
807 #[serde(default, skip_serializing_if = "Vec::is_empty")]
809 pub never_compress: Vec<String>,
810}
811
812#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
814#[serde(rename_all = "camelCase")]
815pub struct Files {
816 #[serde(default = "default_ignore_rules")]
825 #[schemars(default = "default_ignore_rules")]
826 pub ignore_rules: Vec<String>,
827 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
829 pub ignore_cache_dirs: bool,
830 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
832 pub ignore_identical_snapshots: bool,
833}
834
835#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
837#[serde(rename_all = "camelCase")]
838pub struct ErrorHandling {
839 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
841 pub ignore_file_errors: bool,
842 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
844 pub ignore_dir_errors: bool,
845 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
847 pub ignore_unknown_types: bool,
848 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
853 pub fail_fast: bool,
854}
855
856#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
858#[serde(rename_all = "camelCase")]
859pub struct Upload {
860 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub max_parallel_snapshots: Option<i64>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
865 pub max_parallel_file_reads: Option<i64>,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub limit_mb: Option<i64>,
873}
874
875#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
877#[serde(rename_all = "camelCase")]
878pub struct Verification {
879 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub quick: Option<QuickVerification>,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub deep: Option<DeepVerification>,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub success_expr: Option<String>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub verify_files_percent: Option<u8>,
892}
893
894#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
905#[serde(rename_all = "camelCase")]
906pub struct QuickVerification {
907 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub schedule: Option<CronSpec>,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub parallel: Option<u32>,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub file_parallelism: Option<u32>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub file_queue_length: Option<u32>,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub max_errors: Option<u32>,
923}
924
925#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
927#[serde(rename_all = "camelCase")]
928pub struct DeepVerification {
929 pub schedule: CronSpec,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub storage_class_name: Option<String>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub capacity: Option<String>,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub parallel: Option<u32>,
941}
942
943#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
945#[serde(rename_all = "camelCase")]
946pub struct Hooks {
947 #[serde(default, skip_serializing_if = "Vec::is_empty")]
949 pub before_snapshot: Vec<Hook>,
950 #[serde(default, skip_serializing_if = "Vec::is_empty")]
952 pub after_snapshot: Vec<Hook>,
953}
954
955#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
959#[serde(rename_all = "camelCase")]
960pub enum Hook {
961 WorkloadExec(WorkloadExecHook),
963 RunJob(Box<RunJobHook>),
965 HttpRequest(HttpRequestHook),
967}
968
969impl Hook {
970 pub fn kind_str(&self) -> &'static str {
987 match self {
988 Hook::WorkloadExec(_) => "WorkloadExec",
989 Hook::RunJob(_) => "RunJob",
990 Hook::HttpRequest(_) => "HttpRequest",
991 }
992 }
993}
994
995#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
997#[serde(rename_all = "camelCase")]
998pub struct WorkloadExecHook {
999 #[serde(flatten)]
1001 pub selector: PodSelector,
1002 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1004 pub command: Vec<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub timeout: Option<String>,
1008 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1010 pub continue_on_failure: bool,
1011}
1012
1013#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
1015#[serde(rename_all = "camelCase")]
1016pub struct RunJobHook {
1017 #[schemars(schema_with = "crate::schema::preserve_unknown_object")]
1019 pub job_spec: JobSpec,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1022 pub timeout: Option<String>,
1023 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1025 pub continue_on_failure: bool,
1026}
1027
1028#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
1030#[serde(rename_all = "camelCase")]
1031pub struct HttpHeader {
1032 pub name: String,
1034 pub value: String,
1036}
1037
1038#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
1040#[serde(rename_all = "camelCase")]
1041pub struct HttpRequestHook {
1042 pub url: String,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub method: Option<String>,
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub body: Option<String>,
1050 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1052 pub headers: Vec<HttpHeader>,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1055 pub timeout: Option<String>,
1056 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1058 pub continue_on_failure: bool,
1059}
1060
1061#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
1063#[serde(rename_all = "camelCase")]
1064pub struct SnapshotPolicyStatus {
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub observed_generation: Option<i64>,
1068 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub resolved: Option<ResolvedPolicy>,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub retention: Option<RetentionSummary>,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub adoption: Option<AdoptionSummary>,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub last_successful_snapshot: Option<String>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub last_verified: Option<String>,
1097 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1103 pub verification: Vec<RepoVerification>,
1104 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1127 pub verification_stamps: std::collections::BTreeMap<String, String>,
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub repository_summary: Option<String>,
1134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1136 pub conditions: Vec<Condition>,
1137}
1138
1139#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
1143#[serde(rename_all = "camelCase")]
1144pub struct RepoVerification {
1145 pub repository: RepositoryRef,
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub last_verified: Option<String>,
1158}
1159
1160#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1162#[serde(rename_all = "camelCase")]
1163pub struct ResolvedPolicy {
1164 #[serde(default, skip_serializing_if = "Option::is_none")]
1166 pub identity: Option<ResolvedIdentity>,
1167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1169 pub sources: Vec<ResolvedPolicySource>,
1170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1178 pub repositories: Vec<ResolvedPolicyRepository>,
1179}
1180
1181#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
1188#[serde(rename_all = "camelCase")]
1189pub struct ResolvedPolicyRepository {
1190 pub repository: RepositoryRef,
1193 #[serde(default, skip_serializing_if = "Option::is_none")]
1198 pub identity: Option<ResolvedIdentity>,
1199}
1200
1201#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1203#[serde(rename_all = "camelCase")]
1204pub struct ResolvedPolicySource {
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub pvc: Option<String>,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub source_path: Option<String>,
1211}
1212
1213#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1215#[serde(rename_all = "camelCase")]
1216pub struct RetentionSummary {
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1219 pub active_snapshot_count: Option<i64>,
1220 #[serde(default, skip_serializing_if = "Option::is_none")]
1222 pub last_prune_at: Option<String>,
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1225 pub last_prune_deleted: Option<i64>,
1226}
1227
1228#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1234#[serde(rename_all = "camelCase")]
1235pub struct AdoptionSummary {
1236 #[serde(default, skip_serializing_if = "Option::is_none")]
1238 pub last_adoption_at: Option<String>,
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub last_adopted_count: Option<u32>,
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1244 pub total_adopted: Option<u64>,
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub skipped_by_retention: Option<u32>,
1252 #[serde(default, skip_serializing_if = "Option::is_none")]
1265 pub last_scan_matched: Option<u32>,
1266 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub last_scan_unmatched: Option<u32>,
1273 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub scan_requested_at: Option<String>,
1277 #[serde(default, skip_serializing_if = "Option::is_none")]
1281 pub scan_requested_identity: Option<String>,
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use crate::common::RepositoryKind;
1288 use crate::testutil::from_yaml;
1289 use kube::core::CustomResourceExt;
1290
1291 #[test]
1292 fn resolved_policy_single_repo_status_wire_is_byte_identical() {
1293 let resolved = ResolvedPolicy {
1297 identity: Some(crate::common::ResolvedIdentity {
1298 username: "pg".into(),
1299 hostname: "billing".into(),
1300 source_path: Some("/pvc/data".into()),
1301 }),
1302 sources: vec![ResolvedPolicySource {
1303 pvc: Some("billing/data".into()),
1304 source_path: Some("/pvc/data".into()),
1305 }],
1306 repositories: vec![],
1307 };
1308 let wire = serde_json::to_value(&resolved).expect("serializes");
1309 assert_eq!(
1310 wire,
1311 serde_json::json!({
1312 "identity": {
1313 "username": "pg",
1314 "hostname": "billing",
1315 "sourcePath": "/pvc/data",
1316 },
1317 "sources": [ { "pvc": "billing/data", "sourcePath": "/pvc/data" } ],
1318 })
1319 );
1320
1321 let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1324 assert!(decoded.repositories.is_empty());
1325 }
1326
1327 #[test]
1328 fn resolved_policy_per_repo_entries_round_trip() {
1329 let resolved = ResolvedPolicy {
1330 identity: None,
1331 sources: vec![],
1332 repositories: vec![
1333 ResolvedPolicyRepository {
1334 repository: RepositoryRef {
1335 kind: RepositoryKind::Repository,
1336 name: "nas".into(),
1337 namespace: None,
1338 },
1339 identity: Some(crate::common::ResolvedIdentity {
1340 username: "pg".into(),
1341 hostname: "billing".into(),
1342 source_path: None,
1343 }),
1344 },
1345 ResolvedPolicyRepository {
1346 repository: RepositoryRef {
1347 kind: RepositoryKind::ClusterRepository,
1348 name: "offsite".into(),
1349 namespace: None,
1350 },
1351 identity: None,
1353 },
1354 ],
1355 };
1356 let wire = serde_json::to_value(&resolved).expect("serializes");
1357 assert_eq!(
1358 wire,
1359 serde_json::json!({
1360 "repositories": [
1361 {
1362 "repository": { "kind": "Repository", "name": "nas" },
1363 "identity": { "username": "pg", "hostname": "billing" },
1364 },
1365 { "repository": { "kind": "ClusterRepository", "name": "offsite" } },
1366 ],
1367 })
1368 );
1369 let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1370 assert_eq!(decoded, resolved);
1371 }
1372
1373 #[test]
1374 fn snapshot_policy_crd_metadata_is_correct() {
1375 let crd = SnapshotPolicy::crd();
1376 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
1377 assert_eq!(crd.spec.names.kind, "SnapshotPolicy");
1378 assert_eq!(crd.spec.names.plural, "snapshotpolicies");
1379 assert_eq!(
1380 crd.spec.names.short_names.as_deref(),
1381 Some(&["kopiasp".to_string()][..])
1382 );
1383 assert_eq!(crd.spec.scope, "Namespaced");
1384 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
1385 }
1386
1387 #[test]
1388 fn copy_method_carries_static_openapi_default_in_crd() {
1389 let crd = SnapshotPolicy::crd();
1394 let json = serde_json::to_value(&crd).expect("serialize CRD");
1395 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1396 ["properties"]["copyMethod"]["default"];
1397 assert_eq!(
1398 default, "Snapshot",
1399 "copyMethod must emit `default: Snapshot` in the CRD schema; got {default:?}"
1400 );
1401 }
1402
1403 #[test]
1404 fn staging_timeout_round_trips_and_defaults_to_absent() {
1405 let spec: SnapshotPolicySpec = from_yaml(
1408 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1409 );
1410 assert_eq!(spec.staging, None);
1411 let json = serde_json::to_value(&spec).unwrap();
1412 assert!(
1413 json.get("staging").is_none(),
1414 "absent staging must be elided"
1415 );
1416
1417 let spec: SnapshotPolicySpec = from_yaml(
1419 "repository: { kind: Repository, name: r }\n\
1420 sources: [ { pvc: { name: d } } ]\n\
1421 staging: { timeout: 30m }\n",
1422 );
1423 assert_eq!(
1424 spec.staging,
1425 Some(StagingSpec {
1426 timeout: Some("30m".to_string()),
1427 ..Default::default()
1428 })
1429 );
1430 let json = serde_json::to_value(&spec).unwrap();
1431 assert_eq!(json["staging"]["timeout"], "30m");
1432 }
1433
1434 #[test]
1435 fn staging_overrides_round_trip_and_are_elided_when_absent() {
1436 let spec: SnapshotPolicySpec = from_yaml(
1440 "repository: { kind: Repository, name: r }\n\
1441 sources: [ { pvc: { name: d } } ]\n\
1442 staging: { timeout: 30m, storageClassName: cephfs-shallow, accessModes: [ReadOnlyMany] }\n",
1443 );
1444 let st = spec.staging.as_ref().unwrap();
1445 assert_eq!(st.storage_class_name.as_deref(), Some("cephfs-shallow"));
1446 assert_eq!(st.access_modes, vec![PvcAccessMode::ReadOnlyMany]);
1447 let json = serde_json::to_value(&spec).unwrap();
1448 assert_eq!(json["staging"]["storageClassName"], "cephfs-shallow");
1449 assert_eq!(
1450 json["staging"]["accessModes"],
1451 serde_json::json!(["ReadOnlyMany"])
1452 );
1453
1454 let spec: SnapshotPolicySpec = from_yaml(
1455 "repository: { kind: Repository, name: r }\n\
1456 sources: [ { pvc: { name: d } } ]\n\
1457 staging: { timeout: 30m }\n",
1458 );
1459 let json = serde_json::to_value(&spec).unwrap();
1460 assert!(json["staging"].get("storageClassName").is_none());
1461 assert!(json["staging"].get("accessModes").is_none());
1462 }
1463
1464 #[test]
1465 fn staging_access_modes_legacy_value_decodes_to_unknown_not_an_error() {
1466 let spec: SnapshotPolicySpec = from_yaml(
1470 "repository: { kind: Repository, name: r }\n\
1471 sources: [ { pvc: { name: d } } ]\n\
1472 staging: { accessModes: [ReadWriteOnze] }\n",
1473 );
1474 assert_eq!(
1475 spec.staging.unwrap().access_modes,
1476 vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
1477 );
1478 }
1479
1480 #[test]
1481 fn staging_access_modes_render_a_closed_enum_in_the_crd_schema() {
1482 let crd = SnapshotPolicy::crd();
1486 let json = serde_json::to_value(&crd).expect("serialize CRD");
1487 let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1488 ["properties"]["staging"]["properties"]["accessModes"]["items"];
1489 assert_eq!(
1490 items["type"], "string",
1491 "items must be strings; got {items}"
1492 );
1493 assert_eq!(
1494 items["enum"],
1495 serde_json::json!([
1496 "ReadWriteOnce",
1497 "ReadOnlyMany",
1498 "ReadWriteMany",
1499 "ReadWriteOncePod"
1500 ]),
1501 "items enum must be exactly the canonical modes; got {items}"
1502 );
1503 }
1504
1505 #[test]
1506 fn copy_method_defaults_to_snapshot_when_absent() {
1507 let spec: SnapshotPolicySpec = from_yaml(
1510 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1511 );
1512 assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1513 let json = serde_json::to_value(&spec).unwrap();
1515 assert_eq!(json["copyMethod"], "Snapshot");
1516 }
1517
1518 fn expected_default_ignore_rules() -> Vec<String> {
1522 vec![
1523 "/lost+found".to_string(),
1524 "System Volume Information".to_string(),
1525 "$RECYCLE.BIN".to_string(),
1526 "@eaDir".to_string(),
1527 ".snapshot".to_string(),
1528 ]
1529 }
1530
1531 #[test]
1532 fn files_ignore_rules_carries_static_openapi_default_in_crd() {
1533 let crd = SnapshotPolicy::crd();
1537 let json = serde_json::to_value(&crd).expect("serialize CRD");
1538 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1539 ["properties"]["files"]["properties"]["ignoreRules"]["default"];
1540 let want: Vec<serde_json::Value> = expected_default_ignore_rules()
1541 .into_iter()
1542 .map(serde_json::Value::String)
1543 .collect();
1544 assert_eq!(
1545 default,
1546 &serde_json::Value::Array(want),
1547 "files.ignoreRules must emit the 5-entry OS-artifact `default:` in the CRD schema; got {default:?}"
1548 );
1549 }
1550
1551 #[test]
1552 fn ignore_rules_defaults_when_files_block_absent_entirely() {
1553 let spec: SnapshotPolicySpec = from_yaml(
1563 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1564 );
1565 assert!(
1566 spec.files.is_none(),
1567 "a spec omitting `files:` entirely must parse to `None`, not a defaulted `Files`"
1568 );
1569 }
1570
1571 #[test]
1572 fn ignore_rules_defaults_when_files_block_present_but_empty() {
1573 let spec: SnapshotPolicySpec = from_yaml(
1577 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: {}\n",
1578 );
1579 let files = spec.files.expect("files: {} must parse to Some(Files)");
1580 assert_eq!(files.ignore_rules, expected_default_ignore_rules());
1581 }
1582
1583 #[test]
1584 fn ignore_rules_explicit_empty_list_opts_out_and_round_trips() {
1585 let spec: SnapshotPolicySpec = from_yaml(
1592 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [] }\n",
1593 );
1594 let files = spec
1595 .files
1596 .as_ref()
1597 .expect("files: {...} must parse to Some(Files)");
1598 assert!(
1599 files.ignore_rules.is_empty(),
1600 "explicit `ignoreRules: []` must opt fully out, got {:?}",
1601 files.ignore_rules
1602 );
1603
1604 let json = serde_json::to_value(&spec).expect("serialize");
1607 assert_eq!(
1608 json["files"]["ignoreRules"],
1609 serde_json::json!([]),
1610 "an explicit empty ignoreRules must serialize as `[]`, not be omitted \
1611 (omission would deserialize back to the 5-entry default)"
1612 );
1613
1614 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1617 assert_eq!(spec, reparsed);
1618 assert!(reparsed.files.expect("files").ignore_rules.is_empty());
1619 }
1620
1621 #[test]
1622 fn ignore_rules_explicit_custom_list_replaces_default_wholesale() {
1623 let spec: SnapshotPolicySpec = from_yaml(
1627 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [\"*.tmp\", \"lost+found\"] }\n",
1628 );
1629 let files = spec.files.expect("files");
1630 assert_eq!(
1631 files.ignore_rules,
1632 vec!["*.tmp".to_string(), "lost+found".to_string()]
1633 );
1634 }
1635
1636 #[test]
1637 fn backup_config_roundtrip_matches_adr_shape() {
1638 let yaml = r#"
1640repository:
1641 kind: Repository
1642 name: nas-primary
1643 namespace: backups
1644identity:
1645 username: "postgres-data"
1646 hostname: "billing"
1647sources:
1648 - pvc: { name: postgres-data }
1649 sourcePathOverride: /data
1650copyMethod: Snapshot
1651volumeSnapshotClassName: csi-snap-class
1652groupBy: VolumeGroupSnapshot
1653retention:
1654 keepLatest: 10
1655 keepDaily: 14
1656defaultDeletionPolicy: Delete
1657compression:
1658 compressor: zstd
1659 neverCompress: ["*.zip", "*.gz", "*.mp4"]
1660files:
1661 ignoreRules: ["*.tmp", "*/cache/*", "lost+found"]
1662 ignoreCacheDirs: true
1663 ignoreIdenticalSnapshots: true
1664extraArgs: []
1665hooks:
1666 beforeSnapshot:
1667 - workloadExec:
1668 podSelector: { matchLabels: { app: postgres } }
1669 container: postgres
1670 command: ["pg_start_backup", "snap"]
1671 timeout: 2m
1672 afterSnapshot:
1673 - workloadExec:
1674 podSelector: { matchLabels: { app: postgres } }
1675 container: postgres
1676 command: ["pg_stop_backup"]
1677 timeout: 2m
1678mover:
1679 resources:
1680 requests: { cpu: 250m, memory: 512Mi }
1681 limits: { cpu: "2", memory: 4Gi }
1682 cache:
1683 capacity: 16Gi
1684 storageClassName: fast-ssd
1685 securityContext:
1686 runAsUser: 1000
1687 runAsGroup: 1000
1688 runAsNonRoot: true
1689 allowPrivilegeEscalation: false
1690 capabilities: { drop: ["ALL"] }
1691 seccompProfile: { type: RuntimeDefault }
1692 podSecurityContext:
1693 fsGroup: 1000
1694 fsGroupChangePolicy: OnRootMismatch
1695"#;
1696 let spec: SnapshotPolicySpec = from_yaml(yaml);
1697 let repo = spec.repository.as_ref().expect("repository");
1698 assert_eq!(repo.kind, RepositoryKind::Repository);
1699 assert_eq!(repo.name, "nas-primary");
1700 assert_eq!(spec.sources.len(), 1);
1701 assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
1702 assert_eq!(
1703 spec.sources[0].source_path_override.as_deref(),
1704 Some("/data")
1705 );
1706 assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1707 assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
1708 assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
1709 let comp = spec.compression.as_ref().unwrap();
1710 assert_eq!(comp.compressor.as_deref(), Some("zstd"));
1711 let files = spec.files.as_ref().unwrap();
1712 assert_eq!(files.ignore_rules.len(), 3);
1713 assert!(files.ignore_cache_dirs);
1714 assert!(spec.extra_args.is_empty());
1715 let hooks = spec.hooks.as_ref().unwrap();
1716 assert_eq!(hooks.before_snapshot.len(), 1);
1717 assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
1718 let mover = spec.mover.as_ref().expect("mover");
1720 assert_eq!(
1721 mover.security_context.as_ref().and_then(|s| s.run_as_user),
1722 Some(1000)
1723 );
1724 assert_eq!(
1725 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1726 Some(1000)
1727 );
1728 assert!(!mover.requires_privilege());
1730
1731 let json = serde_json::to_value(&spec).expect("serialize");
1732 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1733 assert_eq!(spec, reparsed);
1734 }
1735
1736 #[test]
1737 fn credential_projection_roundtrip() {
1738 let yaml = r#"
1741repository: { kind: ClusterRepository, name: shared }
1742sources:
1743 - pvc: { name: data }
1744retention: { keepLatest: 5 }
1745credentialProjection:
1746 enabled: true
1747"#;
1748 let spec: SnapshotPolicySpec = from_yaml(yaml);
1749 assert_eq!(
1750 spec.credential_projection.as_ref().map(|p| p.enabled),
1751 Some(true)
1752 );
1753 let json = serde_json::to_value(&spec).expect("serialize");
1754 assert_eq!(json["credentialProjection"]["enabled"], true);
1755 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1756 assert_eq!(spec, reparsed);
1757
1758 let bare: SnapshotPolicySpec = from_yaml(
1760 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1761 );
1762 assert!(bare.credential_projection.is_none());
1763 assert!(
1764 serde_json::to_value(&bare)
1765 .unwrap()
1766 .get("credentialProjection")
1767 .is_none()
1768 );
1769 let empty: SnapshotPolicySpec = from_yaml(
1771 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\ncredentialProjection: {}\n",
1772 );
1773 assert_eq!(empty.credential_projection.map(|p| p.enabled), Some(false));
1774 }
1775
1776 #[test]
1777 fn backup_config_minimal_selector_source() {
1778 let yaml = r#"
1780repository: { kind: Repository, name: nas-primary, namespace: backups }
1781identity: { username: app-bundle, hostname: billing }
1782sources:
1783 - pvcSelector:
1784 labelSelector: { matchLabels: { backup: include } }
1785 sourcePathStrategy: PvcName
1786groupBy: VolumeGroupSnapshot
1787retention: { keepDaily: 14 }
1788"#;
1789 let spec: SnapshotPolicySpec = from_yaml(yaml);
1790 let src = &spec.sources[0];
1791 assert!(src.pvc.is_none());
1792 assert!(src.pvc_selector.is_some());
1793 assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
1794
1795 let json = serde_json::to_value(&spec).unwrap();
1796 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1797 assert_eq!(spec, reparsed);
1798 }
1799
1800 #[test]
1801 fn hook_run_job_variant_with_job_spec() {
1802 let yaml = r#"
1804runJob:
1805 jobSpec:
1806 template:
1807 spec:
1808 restartPolicy: Never
1809 containers:
1810 - name: pre
1811 image: busybox
1812 command: ["sh", "-c", "echo hi"]
1813 timeout: 5m
1814 continueOnFailure: true
1815"#;
1816 let hook: Hook = from_yaml(yaml);
1817 assert_eq!(hook.kind_str(), "RunJob");
1818 match &hook {
1819 Hook::RunJob(j) => {
1820 assert!(j.continue_on_failure);
1821 assert_eq!(j.timeout.as_deref(), Some("5m"));
1822 assert_eq!(
1823 j.job_spec
1824 .template
1825 .spec
1826 .as_ref()
1827 .unwrap()
1828 .restart_policy
1829 .as_deref(),
1830 Some("Never")
1831 );
1832 }
1833 other => panic!("expected RunJob, got {}", other.kind_str()),
1834 }
1835 let json = serde_json::to_value(&hook).unwrap();
1836 assert!(json.get("runJob").is_some());
1837 }
1838
1839 #[test]
1840 fn hook_http_request_variant() {
1841 let hook: Hook = from_yaml(
1842 "httpRequest:\n url: https://example/notify\n method: POST\n headers:\n - name: Content-Type\n value: application/json\n - name: X-Api-Key\n value: sekrit\n",
1843 );
1844 assert_eq!(hook.kind_str(), "HttpRequest");
1845 let v = serde_json::to_value(&hook).unwrap();
1846 assert_eq!(v["httpRequest"]["url"], "https://example/notify");
1847 assert_eq!(
1848 v.pointer("/httpRequest/headers/0/name")
1849 .and_then(|x| x.as_str()),
1850 Some("Content-Type")
1851 );
1852 assert_eq!(
1853 v.pointer("/httpRequest/headers/1/value")
1854 .and_then(|x| x.as_str()),
1855 Some("sekrit")
1856 );
1857 let bare: Hook = from_yaml("httpRequest:\n url: https://example/notify\n");
1859 let v = serde_json::to_value(&bare).unwrap();
1860 assert!(v.pointer("/httpRequest/headers").is_none());
1861 }
1862
1863 #[test]
1864 fn hook_unknown_variant_is_rejected() {
1865 let value: serde_json::Value = serde_saphyr::from_str("teleport:\n url: x\n").unwrap();
1866 assert!(serde_json::from_value::<Hook>(value).is_err());
1867 }
1868
1869 #[test]
1870 fn error_handling_upload_and_suspend_roundtrip() {
1871 let yaml = r#"
1874repository: { kind: Repository, name: r }
1875sources: [ { pvc: { name: d } } ]
1876errorHandling:
1877 ignoreFileErrors: true
1878 ignoreDirErrors: false
1879 ignoreUnknownTypes: true
1880 failFast: true
1881upload:
1882 maxParallelSnapshots: 4
1883 maxParallelFileReads: 8
1884 limitMb: 100
1885suspend: true
1886"#;
1887 let spec: SnapshotPolicySpec = from_yaml(yaml);
1888 let eh = spec.error_handling.as_ref().expect("errorHandling");
1889 assert!(eh.ignore_file_errors);
1890 assert!(!eh.ignore_dir_errors);
1891 assert!(eh.ignore_unknown_types);
1892 assert!(eh.fail_fast);
1893 let up = spec.upload.as_ref().expect("upload");
1894 assert_eq!(up.max_parallel_snapshots, Some(4));
1895 assert_eq!(up.max_parallel_file_reads, Some(8));
1896 assert_eq!(up.limit_mb, Some(100));
1897 assert!(spec.suspend);
1898
1899 let json = serde_json::to_value(&spec).expect("serialize");
1900 assert_eq!(json["suspend"], true);
1901 assert_eq!(json["errorHandling"]["ignoreFileErrors"], true);
1902 assert_eq!(json["errorHandling"]["failFast"], true);
1903 assert_eq!(json["upload"]["maxParallelSnapshots"], 4);
1904 assert_eq!(json["upload"]["limitMb"], 100);
1905 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1906 assert_eq!(spec, reparsed);
1907
1908 let bare: SnapshotPolicySpec = from_yaml(
1910 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1911 );
1912 assert!(bare.error_handling.is_none());
1913 assert!(bare.upload.is_none());
1914 assert!(!bare.suspend);
1915 let bare_json = serde_json::to_value(&bare).unwrap();
1916 assert!(bare_json.get("suspend").is_none());
1917 assert!(bare_json.get("errorHandling").is_none());
1918 }
1919
1920 fn source_cel_rule() -> String {
1923 let crd = SnapshotPolicy::crd();
1924 let json = serde_json::to_value(&crd).expect("serialize CRD");
1925 let source = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1926 ["properties"]["sources"]["items"];
1927 let rules = source["x-kubernetes-validations"]
1928 .as_array()
1929 .expect("sources.items.x-kubernetes-validations present")
1930 .clone();
1931 rules
1932 .iter()
1933 .find_map(|r| r["rule"].as_str().map(str::to_string))
1934 .expect("a CEL rule is present on sources.items")
1935 }
1936
1937 #[test]
1938 fn source_schema_carries_exactly_one_of_validation() {
1939 let rule = source_cel_rule();
1943 assert!(rule.contains("pvcSelector") && rule.contains("nfs"));
1944 assert!(
1945 rule.contains("== 1"),
1946 "it must be an exactly-one-of: {rule}"
1947 );
1948 }
1949
1950 #[test]
1970 fn source_cel_rule_lists_every_variant() {
1971 let stream = StreamSource {
1972 file_name: "dump.sql".into(),
1973 workload_exec: StreamExec {
1974 pod_selector: Default::default(),
1975 container: None,
1976 command: vec!["true".into()],
1977 timeout: None,
1978 },
1979 };
1980 let pvc = PvcSource {
1981 name: "data".into(),
1982 };
1983 let selector = PvcSelector {
1984 namespace_selector: None,
1985 label_selector: None,
1986 };
1987 let nfs = NfsVolume {
1988 server: "nfs.example".into(),
1989 path: "/export/data".into(),
1990 };
1991 let all = [
1994 SourceShape::Pvc(&pvc),
1995 SourceShape::PvcSelector(&selector),
1996 SourceShape::Nfs(&nfs),
1997 SourceShape::Stream(&stream),
1998 ];
1999
2000 let rule = source_cel_rule();
2001 for shape in &all {
2002 let wire = shape.kind_str();
2004 assert!(
2005 rule.contains(&format!("has(self.{wire})")),
2006 "the Source CEL rule does not mention `{wire}`, so the API SERVER will \
2007 REJECT that source form before the webhook ever sees it. Add \
2008 `(has(self.{wire}) ? 1 : 0)` to the rule (and its message) in \
2009 snapshot_policy.rs, then re-run `cargo xtask gen-all`.\nrule: {rule}"
2010 );
2011 }
2012 }
2013
2014 #[test]
2015 fn snapshot_policy_has_last_snapshot_and_suspended_columns() {
2016 let crd = SnapshotPolicy::crd();
2019 let json = serde_json::to_value(&crd).expect("serialize CRD");
2020 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2021 .as_array()
2022 .expect("printer columns");
2023 let by_name = |name: &str| {
2024 cols.iter()
2025 .find(|c| c["name"] == name)
2026 .unwrap_or_else(|| panic!("missing column {name}"))
2027 };
2028 assert_eq!(
2029 by_name("Last-Snapshot")["jsonPath"],
2030 ".status.lastSuccessfulSnapshot"
2031 );
2032 assert_eq!(by_name("Suspended")["jsonPath"], ".spec.suspend");
2033 }
2034
2035 #[test]
2036 fn verification_roundtrip_and_opt_in() {
2037 let yaml = r#"
2040repository: { kind: Repository, name: r }
2041sources: [ { pvc: { name: d } } ]
2042verification:
2043 quick:
2044 schedule: { cron: "0 4 * * *", jitter: 30m }
2045 deep:
2046 schedule: { cron: "0 5 * * 0", jitter: 1h }
2047 capacity: 10Gi
2048 storageClassName: fast-ssd
2049 successExpr: "stats.files > 0 && stats.errors == 0"
2050 verifyFilesPercent: 10
2051"#;
2052 let spec: SnapshotPolicySpec = from_yaml(yaml);
2053 let v = spec.verification.as_ref().expect("verification");
2054 let quick = v.quick.as_ref().expect("quick");
2055 assert_eq!(quick.schedule.as_ref().unwrap().cron, "0 4 * * *");
2056 let deep = v.deep.as_ref().expect("deep");
2057 assert_eq!(deep.schedule.cron, "0 5 * * 0");
2058 assert_eq!(deep.capacity.as_deref(), Some("10Gi"));
2059 assert_eq!(
2060 v.success_expr.as_deref(),
2061 Some("stats.files > 0 && stats.errors == 0")
2062 );
2063 assert_eq!(v.verify_files_percent, Some(10));
2064
2065 let json = serde_json::to_value(&spec).expect("serialize");
2066 assert_eq!(
2067 json["verification"]["quick"]["schedule"]["cron"],
2068 "0 4 * * *"
2069 );
2070 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
2071 assert_eq!(spec, reparsed);
2072
2073 let bare: SnapshotPolicySpec = from_yaml(
2075 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2076 );
2077 assert!(bare.verification.is_none());
2078 assert!(
2079 serde_json::to_value(&bare)
2080 .unwrap()
2081 .get("verification")
2082 .is_none()
2083 );
2084 }
2085
2086 #[test]
2087 fn verification_quick_old_shape_still_decodes() {
2088 let old = from_yaml::<SnapshotPolicySpec>(
2096 "repository: { kind: Repository, name: r }\n\
2097 sources: [ { pvc: { name: d } } ]\n\
2098 verification:\n quick: { cron: \"0 4 * * *\", jitter: 30m }\n",
2099 );
2100 let v = old.verification.as_ref().expect("verification");
2101 let quick = v.quick.as_ref().expect("quick present");
2102 assert!(
2103 quick.schedule.is_none(),
2104 "old flat `quick: {{cron: ...}}` must decode with schedule: None (quick disabled)"
2105 );
2106 }
2107
2108 #[test]
2109 fn verification_quick_and_deep_tuning_knobs_roundtrip() {
2110 let yaml = r#"
2114repository: { kind: Repository, name: r }
2115sources: [ { pvc: { name: d } } ]
2116verification:
2117 quick:
2118 schedule: { cron: "0 4 * * *" }
2119 parallel: 2
2120 fileParallelism: 4
2121 fileQueueLength: 100
2122 maxErrors: 1
2123 deep:
2124 schedule: { cron: "0 5 * * 0" }
2125 parallel: 2
2126"#;
2127 let spec: SnapshotPolicySpec = from_yaml(yaml);
2128 let v = spec.verification.as_ref().expect("verification");
2129 let quick = v.quick.as_ref().expect("quick");
2130 assert_eq!(quick.parallel, Some(2));
2131 assert_eq!(quick.file_parallelism, Some(4));
2132 assert_eq!(quick.file_queue_length, Some(100));
2133 assert_eq!(quick.max_errors, Some(1));
2134 let deep = v.deep.as_ref().expect("deep");
2135 assert_eq!(deep.parallel, Some(2));
2136
2137 let json = serde_json::to_value(&spec).expect("serialize");
2138 assert_eq!(json["verification"]["quick"]["parallel"], 2);
2139 assert_eq!(json["verification"]["quick"]["fileParallelism"], 4);
2140 assert_eq!(json["verification"]["quick"]["fileQueueLength"], 100);
2141 assert_eq!(json["verification"]["quick"]["maxErrors"], 1);
2142 assert_eq!(json["verification"]["deep"]["parallel"], 2);
2143 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
2144 assert_eq!(spec, reparsed);
2145
2146 let bare_yaml = r#"
2148repository: { kind: Repository, name: r }
2149sources: [ { pvc: { name: d } } ]
2150verification:
2151 quick:
2152 schedule: { cron: "0 4 * * *" }
2153 deep:
2154 schedule: { cron: "0 5 * * 0" }
2155"#;
2156 let bare: SnapshotPolicySpec = from_yaml(bare_yaml);
2157 let bv = bare.verification.as_ref().expect("verification");
2158 assert!(bv.quick.as_ref().unwrap().parallel.is_none());
2159 assert!(bv.deep.as_ref().unwrap().parallel.is_none());
2160 let bare_json = serde_json::to_value(&bare).expect("serialize");
2161 assert!(bare_json["verification"]["quick"].get("parallel").is_none());
2162 assert!(bare_json["verification"]["deep"].get("parallel").is_none());
2163 }
2164
2165 #[test]
2166 fn preflight_roundtrip_and_opt_in() {
2167 let yaml = r#"
2169repository: { kind: Repository, name: r }
2170sources: [ { pvc: { name: d } } ]
2171preflight:
2172 timeout: 10m
2173 checks:
2174 - name: maintenance-fresh
2175 expr: "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
2176 message: "maintenance must have run within 7d"
2177 - name: backend-up
2178 expr: "repository.backendReachable"
2179"#;
2180 let spec: SnapshotPolicySpec = from_yaml(yaml);
2181 let pf = spec.preflight.as_ref().expect("preflight");
2182 assert_eq!(pf.timeout.as_deref(), Some("10m"));
2183 assert_eq!(pf.checks.len(), 2);
2184 assert_eq!(pf.checks[0].name, "maintenance-fresh");
2185 assert_eq!(pf.checks[1].expr, "repository.backendReachable");
2186 assert!(pf.checks[1].message.is_none());
2187
2188 let json = serde_json::to_value(&spec).expect("serialize");
2189 assert_eq!(json["preflight"]["checks"][0]["name"], "maintenance-fresh");
2190 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
2191 assert_eq!(spec, reparsed);
2192
2193 let bare: SnapshotPolicySpec = from_yaml(
2195 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2196 );
2197 assert!(bare.preflight.is_none());
2198 assert!(
2199 serde_json::to_value(&bare)
2200 .unwrap()
2201 .get("preflight")
2202 .is_none()
2203 );
2204 }
2205
2206 #[test]
2207 fn snapshot_policy_has_last_verified_column() {
2208 let crd = SnapshotPolicy::crd();
2210 let json = serde_json::to_value(&crd).expect("serialize CRD");
2211 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2212 .as_array()
2213 .expect("printer columns");
2214 let col = cols
2215 .iter()
2216 .find(|c| c["name"] == "Last-Verified")
2217 .expect("Last-Verified column");
2218 assert_eq!(col["jsonPath"], ".status.lastVerified");
2219 }
2220
2221 #[test]
2222 fn snapshot_policy_has_repositories_summary_column() {
2223 let crd = SnapshotPolicy::crd();
2227 let json = serde_json::to_value(&crd).expect("serialize CRD");
2228 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2229 .as_array()
2230 .expect("printer columns");
2231 let col = cols
2232 .iter()
2233 .find(|c| c["name"] == "Repositories")
2234 .expect("Repositories column");
2235 assert_eq!(col["jsonPath"], ".status.repositorySummary");
2236 }
2237
2238 #[test]
2239 fn status_verification_fields_elide_when_empty_and_roundtrip() {
2240 let bare = SnapshotPolicyStatus::default();
2245 let json = serde_json::to_value(&bare).expect("serialize");
2246 assert!(json.get("verification").is_none(), "empty vec must elide");
2247 assert!(
2248 json.get("verificationStamps").is_none(),
2249 "empty map must elide"
2250 );
2251 assert!(
2252 json.get("repositorySummary").is_none(),
2253 "absent summary must elide"
2254 );
2255
2256 let status: SnapshotPolicyStatus = serde_json::from_value(serde_json::json!({
2258 "lastVerified": "2026-08-01T00:00:00Z",
2259 "repositorySummary": "nas, offsite",
2260 "verification": [
2261 {
2262 "repository": { "kind": "Repository", "name": "nas", "namespace": "backups" },
2263 "lastVerified": "2026-08-01T00:00:00Z"
2264 },
2265 { "repository": { "kind": "ClusterRepository", "name": "offsite" } }
2266 ],
2267 "verificationStamps": {
2268 "Repository/backups/nas": "2026-08-01T00:00:00Z"
2269 }
2270 }))
2271 .expect("status parses");
2272 assert_eq!(status.verification.len(), 2);
2273 assert_eq!(
2274 status.verification[0].last_verified.as_deref(),
2275 Some("2026-08-01T00:00:00Z")
2276 );
2277 assert!(status.verification[1].last_verified.is_none());
2278 assert_eq!(
2279 status
2280 .verification_stamps
2281 .get("Repository/backups/nas")
2282 .map(String::as_str),
2283 Some("2026-08-01T00:00:00Z")
2284 );
2285 let out = serde_json::to_value(&status).expect("serialize");
2286 assert_eq!(
2287 out["verification"][0]["repository"]["name"], "nas",
2288 "camelCase wire shape"
2289 );
2290 assert_eq!(out["repositorySummary"], "nas, offsite");
2291 }
2292
2293 #[test]
2294 fn status_last_successful_snapshot_roundtrips() {
2295 let status: SnapshotPolicyStatus =
2296 from_yaml("lastSuccessfulSnapshot: 2026-06-09T02:00:00Z\n");
2297 assert_eq!(
2298 status.last_successful_snapshot.as_deref(),
2299 Some("2026-06-09T02:00:00Z")
2300 );
2301 let json = serde_json::to_value(&status).unwrap();
2302 assert_eq!(json["lastSuccessfulSnapshot"], "2026-06-09T02:00:00Z");
2303 }
2304
2305 #[test]
2308 fn stream_timeout_schema_default_is_the_shared_constant() {
2309 let policy = serde_json::to_value(SnapshotPolicy::crd()).unwrap();
2316 let src = &policy["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2317 ["properties"]["sources"]["items"]["properties"]["stream"]["properties"]["workloadExec"]
2318 ["properties"]["timeout"]["default"];
2319 assert_eq!(src, &serde_json::json!("1h"));
2320
2321 let restore = serde_json::to_value(crate::Restore::crd()).unwrap();
2322 let tgt = &restore["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2323 ["properties"]["target"]["properties"]["streamExec"]["properties"]["workloadExec"]["properties"]
2324 ["timeout"]["default"];
2325 assert_eq!(
2326 tgt, src,
2327 "the producer and consumer timeout defaults come from one constant"
2328 );
2329 }
2330
2331 #[test]
2332 fn stream_timeout_schema_default_matches_the_duration_constant() {
2333 let rendered = default_stream_timeout().expect("a default is emitted");
2338 assert_eq!(
2339 crate::duration::parse_go_duration(&rendered)
2340 .expect("the emitted default must be a parseable Go duration")
2341 .as_secs(),
2342 crate::consts::DEFAULT_STREAM_TIMEOUT_SECS
2343 );
2344 }
2345
2346 #[test]
2349 fn policy_deletion_on_policy_delete_schema_default_is_retain() {
2350 let crd = SnapshotPolicy::crd();
2354 let json = serde_json::to_value(&crd).unwrap();
2355 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2356 assert_eq!(
2357 spec["properties"]["deletion"]["properties"]["onPolicyDelete"]["default"],
2358 serde_json::json!("Retain")
2359 );
2360 assert_eq!(
2361 effective_on_policy_delete(None),
2362 crate::common::PolicyDeletePolicy::Retain
2363 );
2364 }
2365
2366 #[test]
2367 fn policy_deletion_round_trips_and_absent_stays_none() {
2368 use crate::common::PolicyDeletePolicy;
2369
2370 let spec: SnapshotPolicySpec = from_yaml(
2371 "repository: { kind: Repository, name: r }\n\
2372 sources: [ { pvc: { name: d } } ]\n\
2373 deletion: { onPolicyDelete: Delete }\n",
2374 );
2375 assert_eq!(
2376 spec.deletion.as_ref().map(|d| d.on_policy_delete),
2377 Some(PolicyDeletePolicy::Delete)
2378 );
2379 assert_eq!(
2380 effective_on_policy_delete(spec.deletion.as_ref()),
2381 PolicyDeletePolicy::Delete
2382 );
2383 let json = serde_json::to_value(&spec).unwrap();
2384 assert_eq!(json["deletion"]["onPolicyDelete"], "Delete");
2385 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2386 assert_eq!(spec, reparsed);
2387
2388 let bare: SnapshotPolicySpec = from_yaml(
2390 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2391 );
2392 assert!(bare.deletion.is_none());
2393 assert!(
2394 serde_json::to_value(&bare)
2395 .unwrap()
2396 .get("deletion")
2397 .is_none(),
2398 "absent deletion must be elided"
2399 );
2400 assert_eq!(
2401 effective_on_policy_delete(bare.deletion.as_ref()),
2402 PolicyDeletePolicy::Retain
2403 );
2404 }
2405
2406 #[test]
2407 fn policy_deletion_unknown_on_policy_delete_value_is_rejected() {
2408 let value: serde_json::Value =
2409 serde_saphyr::from_str("deletion:\n onPolicyDelete: Orphan\n").unwrap();
2410 assert!(serde_json::from_value::<SnapshotPolicySpec>(value).is_err());
2411 }
2412
2413 #[test]
2414 fn policy_delete_policy_serializes_to_expected_strings() {
2415 use crate::common::PolicyDeletePolicy;
2416
2417 assert_eq!(
2418 serde_json::to_value(PolicyDeletePolicy::Retain).unwrap(),
2419 "Retain"
2420 );
2421 assert_eq!(
2422 serde_json::to_value(PolicyDeletePolicy::Delete).unwrap(),
2423 "Delete"
2424 );
2425 assert_eq!(PolicyDeletePolicy::default(), PolicyDeletePolicy::Retain);
2426 }
2427
2428 #[test]
2431 fn policy_adoption_round_trips_and_absent_stays_none() {
2432 use crate::common::SnapshotAdoption;
2433
2434 let spec: SnapshotPolicySpec = from_yaml(
2435 "repository: { kind: Repository, name: r }\n\
2436 sources: [ { pvc: { name: d } } ]\n\
2437 adoption: Ignore\n",
2438 );
2439 assert_eq!(spec.adoption, Some(SnapshotAdoption::Ignore));
2440 let json = serde_json::to_value(&spec).unwrap();
2441 assert_eq!(json["adoption"], "Ignore");
2442 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2443 assert_eq!(spec, reparsed);
2444
2445 let bare: SnapshotPolicySpec = from_yaml(
2446 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2447 );
2448 assert!(bare.adoption.is_none());
2449 assert!(
2450 serde_json::to_value(&bare)
2451 .unwrap()
2452 .get("adoption")
2453 .is_none(),
2454 "absent adoption must be elided"
2455 );
2456 }
2457
2458 #[test]
2459 fn policy_adoption_schema_carries_no_default() {
2460 let crd = SnapshotPolicy::crd();
2464 let json = serde_json::to_value(&crd).unwrap();
2465 let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2466 ["properties"]["adoption"];
2467 assert!(
2468 prop.get("default").is_none(),
2469 "spec.adoption must NOT carry a schema default: {prop}"
2470 );
2471 assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
2472 }
2473
2474 #[test]
2481 fn legacy_single_repo_spec_wire_is_byte_identical() {
2482 const LEGACY_WIRE: &str = r#"{"repository":{"kind":"Repository","name":"nas-primary","namespace":"backups"},"sources":[{"pvc":{"name":"pgdata"}}],"copyMethod":"Snapshot","retention":{"keepLatest":3}}"#;
2483 assert!(
2484 !LEGACY_WIRE.contains("repositories"),
2485 "fixture precondition"
2486 );
2487
2488 let spec: SnapshotPolicySpec = from_yaml(
2490 "repository: { kind: Repository, name: nas-primary, namespace: backups }\n\
2491 sources: [ { pvc: { name: pgdata } } ]\n\
2492 retention: { keepLatest: 3 }\n",
2493 );
2494 assert_eq!(spec.repository.as_ref().unwrap().name, "nas-primary");
2495 assert!(spec.repositories.is_empty());
2496
2497 assert_eq!(serde_json::to_string(&spec).unwrap(), LEGACY_WIRE);
2500
2501 let reparsed: SnapshotPolicySpec = serde_json::from_str(LEGACY_WIRE).unwrap();
2503 assert_eq!(spec, reparsed);
2504 }
2505
2506 #[test]
2507 fn repositories_roundtrip_and_absent_stays_off_the_wire() {
2508 let spec: SnapshotPolicySpec = from_yaml(
2509 "repositories:\n\
2510 - { kind: Repository, name: a }\n\
2511 - { kind: ClusterRepository, name: b }\n\
2512 sources: [ { pvc: { name: d } } ]\n",
2513 );
2514 assert!(spec.repository.is_none());
2515 assert_eq!(spec.repositories.len(), 2);
2516 let json = serde_json::to_value(&spec).unwrap();
2517 assert!(json.get("repository").is_none());
2518 assert_eq!(json["repositories"][1]["kind"], "ClusterRepository");
2519 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2520 assert_eq!(spec, reparsed);
2521 }
2522
2523 #[test]
2524 fn policy_repositories_accessors_cover_all_four_shapes() {
2525 use crate::error::ValidationError;
2526
2527 let single: SnapshotPolicySpec = from_yaml(
2528 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2529 );
2530 assert!(matches!(
2531 policy_repositories(&single),
2532 Ok(PolicyRepositories::Single(r)) if r.name == "r"
2533 ));
2534 assert!(!is_multi_repo(&single));
2535 assert_eq!(repository_refs(&single).count(), 1);
2536 assert_eq!(single_repository_ref(&single).unwrap().name, "r");
2537
2538 let multi: SnapshotPolicySpec = from_yaml(
2539 "repositories: [ { name: a }, { name: b } ]\nsources: [ { pvc: { name: d } } ]\n",
2540 );
2541 assert!(matches!(
2542 policy_repositories(&multi),
2543 Ok(PolicyRepositories::Multi(refs)) if refs.len() == 2
2544 ));
2545 assert!(is_multi_repo(&multi));
2546 assert_eq!(
2547 repository_refs(&multi)
2548 .map(|r| r.name.as_str())
2549 .collect::<Vec<_>>(),
2550 vec!["a", "b"]
2551 );
2552 assert_eq!(
2555 single_repository_ref(&multi).unwrap_err(),
2556 ValidationError::PolicySingleRepositoryRequired
2557 );
2558
2559 let neither: SnapshotPolicySpec = from_yaml("sources: [ { pvc: { name: d } } ]\n");
2562 assert_eq!(
2563 policy_repositories(&neither).unwrap_err(),
2564 ValidationError::PolicyRepositoryExactlyOne { got: "neither" }
2565 );
2566 assert_eq!(repository_refs(&neither).count(), 0);
2567
2568 let both: SnapshotPolicySpec = from_yaml(
2569 "repository: { name: r }\nrepositories: [ { name: a } ]\n\
2570 sources: [ { pvc: { name: d } } ]\n",
2571 );
2572 assert_eq!(
2573 policy_repositories(&both).unwrap_err(),
2574 ValidationError::PolicyRepositoryExactlyOne { got: "both" }
2575 );
2576 assert_eq!(
2578 repository_refs(&both)
2579 .map(|r| r.name.as_str())
2580 .collect::<Vec<_>>(),
2581 vec!["r", "a"]
2582 );
2583 }
2584
2585 #[test]
2586 fn select_restore_repository_covers_every_selection_shape() {
2587 use super::select_restore_repository;
2588 use crate::common::{RepositoryKind, RepositoryRef};
2589 use crate::error::ValidationError;
2590 let single: SnapshotPolicySpec = from_yaml(
2591 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2592 );
2593 let multi: SnapshotPolicySpec = from_yaml(
2594 "repositories:\n - { kind: Repository, name: a }\n - { kind: ClusterRepository, name: b }\n\
2595 sources: [ { pvc: { name: d } } ]\n",
2596 );
2597 let rref = |kind, name: &str, ns: Option<&str>| RepositoryRef {
2598 kind,
2599 name: name.into(),
2600 namespace: ns.map(str::to_string),
2601 };
2602
2603 let r = select_restore_repository(&single, "pol", "backups", None, "apps").unwrap();
2605 assert_eq!(r.name, "r");
2606
2607 match select_restore_repository(&multi, "pol", "backups", None, "apps").unwrap_err() {
2609 ValidationError::RestoreRepositorySelectionRequired { policy, valid } => {
2610 assert_eq!(policy, "pol");
2611 assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2612 }
2613 other => panic!("expected RestoreRepositorySelectionRequired, got {other:?}"),
2614 }
2615
2616 let explicit = rref(RepositoryKind::Repository, "a", Some("backups"));
2618 let r =
2619 select_restore_repository(&multi, "pol", "backups", Some(&explicit), "apps").unwrap();
2620 assert_eq!(r.name, "a");
2621
2622 let cluster = rref(RepositoryKind::ClusterRepository, "b", None);
2625 assert!(
2626 select_restore_repository(&multi, "pol", "backups", Some(&cluster), "apps").is_ok()
2627 );
2628
2629 let typo = rref(RepositoryKind::Repository, "a", None);
2633 match select_restore_repository(&multi, "pol", "backups", Some(&typo), "apps").unwrap_err()
2634 {
2635 ValidationError::RestoreRepositoryNotInPolicy {
2636 given,
2637 policy,
2638 valid,
2639 } => {
2640 assert_eq!(given, "Repository/apps/a");
2641 assert_eq!(policy, "pol");
2642 assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2643 }
2644 other => panic!("expected RestoreRepositoryNotInPolicy, got {other:?}"),
2645 }
2646
2647 let elsewhere = rref(RepositoryKind::ClusterRepository, "offsite", None);
2650 assert!(matches!(
2651 select_restore_repository(&single, "pol", "backups", Some(&elsewhere), "apps")
2652 .unwrap_err(),
2653 ValidationError::RestoreRepositoryNotInPolicy { .. }
2654 ));
2655 }
2656
2657 #[test]
2658 fn snapshot_policy_spec_carries_exactly_one_of_repository_cel_rule() {
2659 let crd = SnapshotPolicy::crd();
2662 let json = serde_json::to_value(&crd).expect("serialize CRD");
2663 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2664 let rules = spec["x-kubernetes-validations"]
2665 .as_array()
2666 .expect("spec-level x-kubernetes-validations present");
2667 assert!(
2668 rules.iter().any(|r| {
2669 r["rule"]
2670 == "(has(self.repository) ? 1 : 0) + (has(self.repositories) ? 1 : 0) == 1"
2671 && r["message"] == "exactly one of repository, repositories"
2672 }),
2673 "missing the exactly-one-of CEL rule; got {rules:?}"
2674 );
2675 let required = spec["required"].as_array().cloned().unwrap_or_default();
2677 assert!(
2678 !required.iter().any(|f| f == "repository"),
2679 "repository must not be schema-required anymore; got {required:?}"
2680 );
2681 let repos = &spec["properties"]["repositories"];
2685 assert_eq!(repos["minItems"], 1, "{repos}");
2686 assert_eq!(repos["maxItems"], 8, "{repos}");
2687 }
2688
2689 #[test]
2690 fn snapshot_policy_repository_print_column_is_unchanged() {
2691 let crd = SnapshotPolicy::crd();
2695 let json = serde_json::to_value(&crd).expect("serialize CRD");
2696 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2697 .as_array()
2698 .expect("printer columns");
2699 let col = cols
2700 .iter()
2701 .find(|c| c["name"] == "Repository")
2702 .expect("Repository column");
2703 assert_eq!(col["jsonPath"], ".spec.repository.name");
2704 }
2705
2706 #[test]
2707 fn adoption_summary_status_roundtrips() {
2708 let status: SnapshotPolicyStatus = from_yaml(
2709 "adoption:\n \
2710 lastAdoptionAt: 2026-06-09T02:00:00Z\n \
2711 lastAdoptedCount: 3\n \
2712 totalAdopted: 42\n \
2713 lastScanMatched: 3\n \
2714 lastScanUnmatched: 7\n \
2715 scanRequestedAt: 2026-06-10T00:00:00Z\n \
2716 scanRequestedIdentity: postgres@billing\n",
2717 );
2718 let a = status.adoption.as_ref().expect("adoption");
2719 assert_eq!(a.last_adoption_at.as_deref(), Some("2026-06-09T02:00:00Z"));
2720 assert_eq!(a.last_adopted_count, Some(3));
2721 assert_eq!(a.total_adopted, Some(42));
2722 assert_eq!(a.last_scan_matched, Some(3));
2723 assert_eq!(a.last_scan_unmatched, Some(7));
2724 assert_eq!(a.scan_requested_at.as_deref(), Some("2026-06-10T00:00:00Z"));
2725 assert_eq!(
2726 a.scan_requested_identity.as_deref(),
2727 Some("postgres@billing")
2728 );
2729
2730 let json = serde_json::to_value(&status).unwrap();
2731 assert_eq!(json["adoption"]["lastAdoptedCount"], 3);
2732 assert_eq!(json["adoption"]["totalAdopted"], 42);
2733 assert_eq!(json["adoption"]["lastScanMatched"], 3);
2734 assert_eq!(json["adoption"]["lastScanUnmatched"], 7);
2735 let reparsed: SnapshotPolicyStatus = serde_json::from_value(json).unwrap();
2736 assert_eq!(status, reparsed);
2737
2738 let empty_scan: SnapshotPolicyStatus =
2741 from_yaml("adoption:\n lastScanMatched: 0\n lastScanUnmatched: 0\n");
2742 let a = empty_scan.adoption.as_ref().expect("adoption");
2743 assert_eq!(
2744 (a.last_scan_matched, a.last_scan_unmatched),
2745 (Some(0), Some(0))
2746 );
2747 let wire = serde_json::to_value(&empty_scan).unwrap();
2748 assert_eq!(wire["adoption"]["lastScanMatched"], 0);
2749 assert_eq!(wire["adoption"]["lastScanUnmatched"], 0);
2750
2751 let bare: SnapshotPolicyStatus = from_yaml("{}\n");
2753 assert!(bare.adoption.is_none());
2754 assert!(
2755 serde_json::to_value(&bare)
2756 .unwrap()
2757 .get("adoption")
2758 .is_none(),
2759 "absent adoption summary must be elided"
2760 );
2761 }
2762}