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":"Last-Snapshot","type":"date","jsonPath":".status.lastSuccessfulSnapshot"}"#,
28 printcolumn = r#"{"name":"Last-Verified","type":"date","jsonPath":".status.lastVerified"}"#,
29 printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.suspend"}"#,
30 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
31)]
32#[serde(rename_all = "camelCase")]
33pub struct SnapshotPolicySpec {
34 pub repository: RepositoryRef,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub identity: Option<Identity>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 #[schemars(length(max = 100))]
42 pub sources: Vec<Source>,
43 #[serde(default = "default_copy_method")]
45 #[schemars(default = "default_copy_method")]
46 pub copy_method: CopyMethod,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub volume_snapshot_class_name: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub staging: Option<StagingSpec>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 #[schemars(default = "default_group_by")]
57 pub group_by: Option<GroupBy>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub retention: Option<Retention>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 #[schemars(default = "recipe_default_deletion_policy")]
64 pub default_deletion_policy: Option<DeletionPolicy>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub compression: Option<Compression>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub files: Option<Files>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub extra_args: Vec<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub error_handling: Option<ErrorHandling>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub upload: Option<Upload>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub verification: Option<Verification>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub preflight: Option<crate::preflight::PreflightSpec>,
88 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
90 pub suspend: bool,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub hooks: Option<Hooks>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub mover: Option<MoverSpec>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub credential_projection: Option<CredentialProjection>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub deletion: Option<PolicyDeletionSpec>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub adoption: Option<crate::common::SnapshotAdoption>,
108}
109
110#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
115#[serde(rename_all = "camelCase")]
116pub struct PolicyDeletionSpec {
117 #[serde(default = "default_on_policy_delete")]
120 #[schemars(default = "default_on_policy_delete")]
121 pub on_policy_delete: crate::common::PolicyDeletePolicy,
122}
123
124fn default_on_policy_delete() -> crate::common::PolicyDeletePolicy {
125 crate::common::PolicyDeletePolicy::Retain
126}
127
128pub fn effective_on_policy_delete(
133 deletion: Option<&PolicyDeletionSpec>,
134) -> crate::common::PolicyDeletePolicy {
135 deletion.map(|d| d.on_policy_delete).unwrap_or_default()
136}
137
138#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, JsonSchema)]
148#[schemars(extend("x-kubernetes-validations" = [{
149 "rule": "(has(self.pvc) ? 1 : 0) + (has(self.pvcSelector) ? 1 : 0) + (has(self.nfs) ? 1 : 0) == 1",
150 "message": "exactly one of pvc, pvcSelector, nfs"
151}]))]
152#[serde(rename_all = "camelCase")]
153pub struct Source {
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub pvc: Option<PvcSource>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub pvc_selector: Option<PvcSelector>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub nfs: Option<NfsVolume>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
176 #[schemars(default = "default_source_read_only")]
177 pub read_only: Option<bool>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub acknowledge_live_mutation: Option<bool>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 #[schemars(length(max = 4096))]
191 pub source_path_override: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 #[schemars(default = "default_source_path_strategy")]
195 pub source_path_strategy: Option<SourcePathStrategy>,
196}
197
198#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
200#[serde(rename_all = "camelCase")]
201pub struct PvcSource {
202 pub name: String,
204}
205
206#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
208#[serde(rename_all = "camelCase")]
209pub struct PvcSelector {
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub namespace_selector: Option<NamespaceSelector>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub label_selector: Option<LabelSelector>,
216}
217
218#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
220#[serde(rename_all = "camelCase")]
221pub struct NamespaceSelector {
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub match_names: Vec<String>,
225}
226
227fn default_copy_method() -> CopyMethod {
243 CopyMethod::Snapshot
244}
245
246pub fn default_ignore_rules() -> Vec<String> {
272 vec![
273 "/lost+found".to_string(),
274 "System Volume Information".to_string(),
275 "$RECYCLE.BIN".to_string(),
276 "@eaDir".to_string(),
277 ".snapshot".to_string(),
278 ]
279}
280
281#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
293pub enum CopyMethod {
294 #[default]
296 Snapshot,
297 Clone,
299 Direct,
301}
302
303#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
307#[serde(rename_all = "camelCase")]
308pub struct StagingSpec {
309 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub timeout: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub storage_class_name: Option<String>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub access_modes: Vec<PvcAccessMode>,
338}
339
340#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
344pub enum GroupBy {
345 #[default]
347 VolumeGroupSnapshot,
348 None,
350}
351
352fn default_group_by() -> Option<GroupBy> {
356 Some(GroupBy::VolumeGroupSnapshot)
357}
358
359#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
363pub enum SourcePathStrategy {
364 #[default]
366 PvcName,
367 PvcNamespacedName,
369}
370
371fn default_source_path_strategy() -> Option<SourcePathStrategy> {
374 Some(SourcePathStrategy::PvcName)
375}
376
377fn default_source_read_only() -> Option<bool> {
383 Some(true)
384}
385
386pub fn source_read_only(source: &Source) -> bool {
390 source.read_only.unwrap_or(true)
391}
392
393pub fn source_mutates_live_volume(copy_method: CopyMethod, source: &Source) -> bool {
410 matches!(copy_method, CopyMethod::Direct)
411 && !source_read_only(source)
412 && (source.pvc.is_some() || source.pvc_selector.is_some())
413}
414
415fn recipe_default_deletion_policy() -> Option<crate::common::DeletionPolicy> {
419 Some(crate::common::DeletionPolicy::Delete)
420}
421
422#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
424#[serde(rename_all = "camelCase")]
425pub struct Compression {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub compressor: Option<String>,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub never_compress: Vec<String>,
432}
433
434#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
436#[serde(rename_all = "camelCase")]
437pub struct Files {
438 #[serde(default = "default_ignore_rules")]
447 #[schemars(default = "default_ignore_rules")]
448 pub ignore_rules: Vec<String>,
449 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
451 pub ignore_cache_dirs: bool,
452 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
454 pub ignore_identical_snapshots: bool,
455}
456
457#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
459#[serde(rename_all = "camelCase")]
460pub struct ErrorHandling {
461 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
463 pub ignore_file_errors: bool,
464 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
466 pub ignore_dir_errors: bool,
467 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
469 pub ignore_unknown_types: bool,
470 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
475 pub fail_fast: bool,
476}
477
478#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
480#[serde(rename_all = "camelCase")]
481pub struct Upload {
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub max_parallel_snapshots: Option<i64>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub max_parallel_file_reads: Option<i64>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub limit_mb: Option<i64>,
495}
496
497#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
499#[serde(rename_all = "camelCase")]
500pub struct Verification {
501 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub quick: Option<QuickVerification>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub deep: Option<DeepVerification>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub success_expr: Option<String>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub verify_files_percent: Option<u8>,
514}
515
516#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
527#[serde(rename_all = "camelCase")]
528pub struct QuickVerification {
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub schedule: Option<CronSpec>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub parallel: Option<u32>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub file_parallelism: Option<u32>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub file_queue_length: Option<u32>,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub max_errors: Option<u32>,
545}
546
547#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
549#[serde(rename_all = "camelCase")]
550pub struct DeepVerification {
551 pub schedule: CronSpec,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub storage_class_name: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub capacity: Option<String>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub parallel: Option<u32>,
563}
564
565#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
567#[serde(rename_all = "camelCase")]
568pub struct Hooks {
569 #[serde(default, skip_serializing_if = "Vec::is_empty")]
571 pub before_snapshot: Vec<Hook>,
572 #[serde(default, skip_serializing_if = "Vec::is_empty")]
574 pub after_snapshot: Vec<Hook>,
575}
576
577#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
581#[serde(rename_all = "camelCase")]
582pub enum Hook {
583 WorkloadExec(WorkloadExecHook),
585 RunJob(Box<RunJobHook>),
587 HttpRequest(HttpRequestHook),
589}
590
591impl Hook {
592 pub fn kind_str(&self) -> &'static str {
609 match self {
610 Hook::WorkloadExec(_) => "WorkloadExec",
611 Hook::RunJob(_) => "RunJob",
612 Hook::HttpRequest(_) => "HttpRequest",
613 }
614 }
615}
616
617#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
619#[serde(rename_all = "camelCase")]
620pub struct WorkloadExecHook {
621 #[serde(flatten)]
623 pub selector: PodSelector,
624 #[serde(default, skip_serializing_if = "Vec::is_empty")]
626 pub command: Vec<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub timeout: Option<String>,
630 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
632 pub continue_on_failure: bool,
633}
634
635#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
637#[serde(rename_all = "camelCase")]
638pub struct RunJobHook {
639 #[schemars(schema_with = "crate::schema::preserve_unknown_object")]
641 pub job_spec: JobSpec,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub timeout: Option<String>,
645 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
647 pub continue_on_failure: bool,
648}
649
650#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
652#[serde(rename_all = "camelCase")]
653pub struct HttpHeader {
654 pub name: String,
656 pub value: String,
658}
659
660#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
662#[serde(rename_all = "camelCase")]
663pub struct HttpRequestHook {
664 pub url: String,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub method: Option<String>,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub body: Option<String>,
672 #[serde(default, skip_serializing_if = "Vec::is_empty")]
674 pub headers: Vec<HttpHeader>,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub timeout: Option<String>,
678 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
680 pub continue_on_failure: bool,
681}
682
683#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
685#[serde(rename_all = "camelCase")]
686pub struct SnapshotPolicyStatus {
687 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub observed_generation: Option<i64>,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub resolved: Option<ResolvedPolicy>,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub retention: Option<RetentionSummary>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub adoption: Option<AdoptionSummary>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub last_successful_snapshot: Option<String>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub last_verified: Option<String>,
705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 pub conditions: Vec<Condition>,
708}
709
710#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
712#[serde(rename_all = "camelCase")]
713pub struct ResolvedPolicy {
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub identity: Option<ResolvedIdentity>,
717 #[serde(default, skip_serializing_if = "Vec::is_empty")]
719 pub sources: Vec<ResolvedPolicySource>,
720}
721
722#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
724#[serde(rename_all = "camelCase")]
725pub struct ResolvedPolicySource {
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub pvc: Option<String>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub source_path: Option<String>,
732}
733
734#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
736#[serde(rename_all = "camelCase")]
737pub struct RetentionSummary {
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub active_snapshot_count: Option<i64>,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub last_prune_at: Option<String>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub last_prune_deleted: Option<i64>,
747}
748
749#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
755#[serde(rename_all = "camelCase")]
756pub struct AdoptionSummary {
757 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub last_adoption_at: Option<String>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub last_adopted_count: Option<u32>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub total_adopted: Option<u64>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub skipped_by_retention: Option<u32>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub scan_requested_at: Option<String>,
777 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub scan_requested_identity: Option<String>,
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787 use crate::common::RepositoryKind;
788 use crate::testutil::from_yaml;
789 use kube::core::CustomResourceExt;
790
791 #[test]
792 fn snapshot_policy_crd_metadata_is_correct() {
793 let crd = SnapshotPolicy::crd();
794 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
795 assert_eq!(crd.spec.names.kind, "SnapshotPolicy");
796 assert_eq!(crd.spec.names.plural, "snapshotpolicies");
797 assert_eq!(
798 crd.spec.names.short_names.as_deref(),
799 Some(&["kopiasp".to_string()][..])
800 );
801 assert_eq!(crd.spec.scope, "Namespaced");
802 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
803 }
804
805 #[test]
806 fn copy_method_carries_static_openapi_default_in_crd() {
807 let crd = SnapshotPolicy::crd();
812 let json = serde_json::to_value(&crd).expect("serialize CRD");
813 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
814 ["properties"]["copyMethod"]["default"];
815 assert_eq!(
816 default, "Snapshot",
817 "copyMethod must emit `default: Snapshot` in the CRD schema; got {default:?}"
818 );
819 }
820
821 #[test]
822 fn staging_timeout_round_trips_and_defaults_to_absent() {
823 let spec: SnapshotPolicySpec = from_yaml(
826 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
827 );
828 assert_eq!(spec.staging, None);
829 let json = serde_json::to_value(&spec).unwrap();
830 assert!(
831 json.get("staging").is_none(),
832 "absent staging must be elided"
833 );
834
835 let spec: SnapshotPolicySpec = from_yaml(
837 "repository: { kind: Repository, name: r }\n\
838 sources: [ { pvc: { name: d } } ]\n\
839 staging: { timeout: 30m }\n",
840 );
841 assert_eq!(
842 spec.staging,
843 Some(StagingSpec {
844 timeout: Some("30m".to_string()),
845 ..Default::default()
846 })
847 );
848 let json = serde_json::to_value(&spec).unwrap();
849 assert_eq!(json["staging"]["timeout"], "30m");
850 }
851
852 #[test]
853 fn staging_overrides_round_trip_and_are_elided_when_absent() {
854 let spec: SnapshotPolicySpec = from_yaml(
858 "repository: { kind: Repository, name: r }\n\
859 sources: [ { pvc: { name: d } } ]\n\
860 staging: { timeout: 30m, storageClassName: cephfs-shallow, accessModes: [ReadOnlyMany] }\n",
861 );
862 let st = spec.staging.as_ref().unwrap();
863 assert_eq!(st.storage_class_name.as_deref(), Some("cephfs-shallow"));
864 assert_eq!(st.access_modes, vec![PvcAccessMode::ReadOnlyMany]);
865 let json = serde_json::to_value(&spec).unwrap();
866 assert_eq!(json["staging"]["storageClassName"], "cephfs-shallow");
867 assert_eq!(
868 json["staging"]["accessModes"],
869 serde_json::json!(["ReadOnlyMany"])
870 );
871
872 let spec: SnapshotPolicySpec = from_yaml(
873 "repository: { kind: Repository, name: r }\n\
874 sources: [ { pvc: { name: d } } ]\n\
875 staging: { timeout: 30m }\n",
876 );
877 let json = serde_json::to_value(&spec).unwrap();
878 assert!(json["staging"].get("storageClassName").is_none());
879 assert!(json["staging"].get("accessModes").is_none());
880 }
881
882 #[test]
883 fn staging_access_modes_legacy_value_decodes_to_unknown_not_an_error() {
884 let spec: SnapshotPolicySpec = from_yaml(
888 "repository: { kind: Repository, name: r }\n\
889 sources: [ { pvc: { name: d } } ]\n\
890 staging: { accessModes: [ReadWriteOnze] }\n",
891 );
892 assert_eq!(
893 spec.staging.unwrap().access_modes,
894 vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
895 );
896 }
897
898 #[test]
899 fn staging_access_modes_render_a_closed_enum_in_the_crd_schema() {
900 let crd = SnapshotPolicy::crd();
904 let json = serde_json::to_value(&crd).expect("serialize CRD");
905 let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
906 ["properties"]["staging"]["properties"]["accessModes"]["items"];
907 assert_eq!(
908 items["type"], "string",
909 "items must be strings; got {items}"
910 );
911 assert_eq!(
912 items["enum"],
913 serde_json::json!([
914 "ReadWriteOnce",
915 "ReadOnlyMany",
916 "ReadWriteMany",
917 "ReadWriteOncePod"
918 ]),
919 "items enum must be exactly the canonical modes; got {items}"
920 );
921 }
922
923 #[test]
924 fn copy_method_defaults_to_snapshot_when_absent() {
925 let spec: SnapshotPolicySpec = from_yaml(
928 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
929 );
930 assert_eq!(spec.copy_method, CopyMethod::Snapshot);
931 let json = serde_json::to_value(&spec).unwrap();
933 assert_eq!(json["copyMethod"], "Snapshot");
934 }
935
936 fn expected_default_ignore_rules() -> Vec<String> {
940 vec![
941 "/lost+found".to_string(),
942 "System Volume Information".to_string(),
943 "$RECYCLE.BIN".to_string(),
944 "@eaDir".to_string(),
945 ".snapshot".to_string(),
946 ]
947 }
948
949 #[test]
950 fn files_ignore_rules_carries_static_openapi_default_in_crd() {
951 let crd = SnapshotPolicy::crd();
955 let json = serde_json::to_value(&crd).expect("serialize CRD");
956 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
957 ["properties"]["files"]["properties"]["ignoreRules"]["default"];
958 let want: Vec<serde_json::Value> = expected_default_ignore_rules()
959 .into_iter()
960 .map(serde_json::Value::String)
961 .collect();
962 assert_eq!(
963 default,
964 &serde_json::Value::Array(want),
965 "files.ignoreRules must emit the 5-entry OS-artifact `default:` in the CRD schema; got {default:?}"
966 );
967 }
968
969 #[test]
970 fn ignore_rules_defaults_when_files_block_absent_entirely() {
971 let spec: SnapshotPolicySpec = from_yaml(
981 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
982 );
983 assert!(
984 spec.files.is_none(),
985 "a spec omitting `files:` entirely must parse to `None`, not a defaulted `Files`"
986 );
987 }
988
989 #[test]
990 fn ignore_rules_defaults_when_files_block_present_but_empty() {
991 let spec: SnapshotPolicySpec = from_yaml(
995 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: {}\n",
996 );
997 let files = spec.files.expect("files: {} must parse to Some(Files)");
998 assert_eq!(files.ignore_rules, expected_default_ignore_rules());
999 }
1000
1001 #[test]
1002 fn ignore_rules_explicit_empty_list_opts_out_and_round_trips() {
1003 let spec: SnapshotPolicySpec = from_yaml(
1010 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [] }\n",
1011 );
1012 let files = spec
1013 .files
1014 .as_ref()
1015 .expect("files: {...} must parse to Some(Files)");
1016 assert!(
1017 files.ignore_rules.is_empty(),
1018 "explicit `ignoreRules: []` must opt fully out, got {:?}",
1019 files.ignore_rules
1020 );
1021
1022 let json = serde_json::to_value(&spec).expect("serialize");
1025 assert_eq!(
1026 json["files"]["ignoreRules"],
1027 serde_json::json!([]),
1028 "an explicit empty ignoreRules must serialize as `[]`, not be omitted \
1029 (omission would deserialize back to the 5-entry default)"
1030 );
1031
1032 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1035 assert_eq!(spec, reparsed);
1036 assert!(reparsed.files.expect("files").ignore_rules.is_empty());
1037 }
1038
1039 #[test]
1040 fn ignore_rules_explicit_custom_list_replaces_default_wholesale() {
1041 let spec: SnapshotPolicySpec = from_yaml(
1045 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [\"*.tmp\", \"lost+found\"] }\n",
1046 );
1047 let files = spec.files.expect("files");
1048 assert_eq!(
1049 files.ignore_rules,
1050 vec!["*.tmp".to_string(), "lost+found".to_string()]
1051 );
1052 }
1053
1054 #[test]
1055 fn backup_config_roundtrip_matches_adr_shape() {
1056 let yaml = r#"
1058repository:
1059 kind: Repository
1060 name: nas-primary
1061 namespace: backups
1062identity:
1063 username: "postgres-data"
1064 hostname: "billing"
1065sources:
1066 - pvc: { name: postgres-data }
1067 sourcePathOverride: /data
1068copyMethod: Snapshot
1069volumeSnapshotClassName: csi-snap-class
1070groupBy: VolumeGroupSnapshot
1071retention:
1072 keepLatest: 10
1073 keepDaily: 14
1074defaultDeletionPolicy: Delete
1075compression:
1076 compressor: zstd
1077 neverCompress: ["*.zip", "*.gz", "*.mp4"]
1078files:
1079 ignoreRules: ["*.tmp", "*/cache/*", "lost+found"]
1080 ignoreCacheDirs: true
1081 ignoreIdenticalSnapshots: true
1082extraArgs: []
1083hooks:
1084 beforeSnapshot:
1085 - workloadExec:
1086 podSelector: { matchLabels: { app: postgres } }
1087 container: postgres
1088 command: ["pg_start_backup", "snap"]
1089 timeout: 2m
1090 afterSnapshot:
1091 - workloadExec:
1092 podSelector: { matchLabels: { app: postgres } }
1093 container: postgres
1094 command: ["pg_stop_backup"]
1095 timeout: 2m
1096mover:
1097 resources:
1098 requests: { cpu: 250m, memory: 512Mi }
1099 limits: { cpu: "2", memory: 4Gi }
1100 cache:
1101 capacity: 16Gi
1102 storageClassName: fast-ssd
1103 securityContext:
1104 runAsUser: 1000
1105 runAsGroup: 1000
1106 runAsNonRoot: true
1107 allowPrivilegeEscalation: false
1108 capabilities: { drop: ["ALL"] }
1109 seccompProfile: { type: RuntimeDefault }
1110 podSecurityContext:
1111 fsGroup: 1000
1112 fsGroupChangePolicy: OnRootMismatch
1113"#;
1114 let spec: SnapshotPolicySpec = from_yaml(yaml);
1115 assert_eq!(spec.repository.kind, RepositoryKind::Repository);
1116 assert_eq!(spec.repository.name, "nas-primary");
1117 assert_eq!(spec.sources.len(), 1);
1118 assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
1119 assert_eq!(
1120 spec.sources[0].source_path_override.as_deref(),
1121 Some("/data")
1122 );
1123 assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1124 assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
1125 assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
1126 let comp = spec.compression.as_ref().unwrap();
1127 assert_eq!(comp.compressor.as_deref(), Some("zstd"));
1128 let files = spec.files.as_ref().unwrap();
1129 assert_eq!(files.ignore_rules.len(), 3);
1130 assert!(files.ignore_cache_dirs);
1131 assert!(spec.extra_args.is_empty());
1132 let hooks = spec.hooks.as_ref().unwrap();
1133 assert_eq!(hooks.before_snapshot.len(), 1);
1134 assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
1135 let mover = spec.mover.as_ref().expect("mover");
1137 assert_eq!(
1138 mover.security_context.as_ref().and_then(|s| s.run_as_user),
1139 Some(1000)
1140 );
1141 assert_eq!(
1142 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1143 Some(1000)
1144 );
1145 assert!(!mover.requires_privilege());
1147
1148 let json = serde_json::to_value(&spec).expect("serialize");
1149 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1150 assert_eq!(spec, reparsed);
1151 }
1152
1153 #[test]
1154 fn credential_projection_roundtrip() {
1155 let yaml = r#"
1158repository: { kind: ClusterRepository, name: shared }
1159sources:
1160 - pvc: { name: data }
1161retention: { keepLatest: 5 }
1162credentialProjection:
1163 enabled: true
1164"#;
1165 let spec: SnapshotPolicySpec = from_yaml(yaml);
1166 assert_eq!(
1167 spec.credential_projection.as_ref().map(|p| p.enabled),
1168 Some(true)
1169 );
1170 let json = serde_json::to_value(&spec).expect("serialize");
1171 assert_eq!(json["credentialProjection"]["enabled"], true);
1172 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1173 assert_eq!(spec, reparsed);
1174
1175 let bare: SnapshotPolicySpec = from_yaml(
1177 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1178 );
1179 assert!(bare.credential_projection.is_none());
1180 assert!(
1181 serde_json::to_value(&bare)
1182 .unwrap()
1183 .get("credentialProjection")
1184 .is_none()
1185 );
1186 let empty: SnapshotPolicySpec = from_yaml(
1188 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\ncredentialProjection: {}\n",
1189 );
1190 assert_eq!(empty.credential_projection.map(|p| p.enabled), Some(false));
1191 }
1192
1193 #[test]
1194 fn backup_config_minimal_selector_source() {
1195 let yaml = r#"
1197repository: { kind: Repository, name: nas-primary, namespace: backups }
1198identity: { username: app-bundle, hostname: billing }
1199sources:
1200 - pvcSelector:
1201 labelSelector: { matchLabels: { backup: include } }
1202 sourcePathStrategy: PvcName
1203groupBy: VolumeGroupSnapshot
1204retention: { keepDaily: 14 }
1205"#;
1206 let spec: SnapshotPolicySpec = from_yaml(yaml);
1207 let src = &spec.sources[0];
1208 assert!(src.pvc.is_none());
1209 assert!(src.pvc_selector.is_some());
1210 assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
1211
1212 let json = serde_json::to_value(&spec).unwrap();
1213 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1214 assert_eq!(spec, reparsed);
1215 }
1216
1217 #[test]
1218 fn hook_run_job_variant_with_job_spec() {
1219 let yaml = r#"
1221runJob:
1222 jobSpec:
1223 template:
1224 spec:
1225 restartPolicy: Never
1226 containers:
1227 - name: pre
1228 image: busybox
1229 command: ["sh", "-c", "echo hi"]
1230 timeout: 5m
1231 continueOnFailure: true
1232"#;
1233 let hook: Hook = from_yaml(yaml);
1234 assert_eq!(hook.kind_str(), "RunJob");
1235 match &hook {
1236 Hook::RunJob(j) => {
1237 assert!(j.continue_on_failure);
1238 assert_eq!(j.timeout.as_deref(), Some("5m"));
1239 assert_eq!(
1240 j.job_spec
1241 .template
1242 .spec
1243 .as_ref()
1244 .unwrap()
1245 .restart_policy
1246 .as_deref(),
1247 Some("Never")
1248 );
1249 }
1250 other => panic!("expected RunJob, got {}", other.kind_str()),
1251 }
1252 let json = serde_json::to_value(&hook).unwrap();
1253 assert!(json.get("runJob").is_some());
1254 }
1255
1256 #[test]
1257 fn hook_http_request_variant() {
1258 let hook: Hook = from_yaml(
1259 "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",
1260 );
1261 assert_eq!(hook.kind_str(), "HttpRequest");
1262 let v = serde_json::to_value(&hook).unwrap();
1263 assert_eq!(v["httpRequest"]["url"], "https://example/notify");
1264 assert_eq!(
1265 v.pointer("/httpRequest/headers/0/name")
1266 .and_then(|x| x.as_str()),
1267 Some("Content-Type")
1268 );
1269 assert_eq!(
1270 v.pointer("/httpRequest/headers/1/value")
1271 .and_then(|x| x.as_str()),
1272 Some("sekrit")
1273 );
1274 let bare: Hook = from_yaml("httpRequest:\n url: https://example/notify\n");
1276 let v = serde_json::to_value(&bare).unwrap();
1277 assert!(v.pointer("/httpRequest/headers").is_none());
1278 }
1279
1280 #[test]
1281 fn hook_unknown_variant_is_rejected() {
1282 let value: serde_json::Value = serde_yaml::from_str("teleport:\n url: x\n").unwrap();
1283 assert!(serde_json::from_value::<Hook>(value).is_err());
1284 }
1285
1286 #[test]
1287 fn error_handling_upload_and_suspend_roundtrip() {
1288 let yaml = r#"
1291repository: { kind: Repository, name: r }
1292sources: [ { pvc: { name: d } } ]
1293errorHandling:
1294 ignoreFileErrors: true
1295 ignoreDirErrors: false
1296 ignoreUnknownTypes: true
1297 failFast: true
1298upload:
1299 maxParallelSnapshots: 4
1300 maxParallelFileReads: 8
1301 limitMb: 100
1302suspend: true
1303"#;
1304 let spec: SnapshotPolicySpec = from_yaml(yaml);
1305 let eh = spec.error_handling.as_ref().expect("errorHandling");
1306 assert!(eh.ignore_file_errors);
1307 assert!(!eh.ignore_dir_errors);
1308 assert!(eh.ignore_unknown_types);
1309 assert!(eh.fail_fast);
1310 let up = spec.upload.as_ref().expect("upload");
1311 assert_eq!(up.max_parallel_snapshots, Some(4));
1312 assert_eq!(up.max_parallel_file_reads, Some(8));
1313 assert_eq!(up.limit_mb, Some(100));
1314 assert!(spec.suspend);
1315
1316 let json = serde_json::to_value(&spec).expect("serialize");
1317 assert_eq!(json["suspend"], true);
1318 assert_eq!(json["errorHandling"]["ignoreFileErrors"], true);
1319 assert_eq!(json["errorHandling"]["failFast"], true);
1320 assert_eq!(json["upload"]["maxParallelSnapshots"], 4);
1321 assert_eq!(json["upload"]["limitMb"], 100);
1322 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1323 assert_eq!(spec, reparsed);
1324
1325 let bare: SnapshotPolicySpec = from_yaml(
1327 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1328 );
1329 assert!(bare.error_handling.is_none());
1330 assert!(bare.upload.is_none());
1331 assert!(!bare.suspend);
1332 let bare_json = serde_json::to_value(&bare).unwrap();
1333 assert!(bare_json.get("suspend").is_none());
1334 assert!(bare_json.get("errorHandling").is_none());
1335 }
1336
1337 #[test]
1338 fn source_schema_carries_exactly_one_of_validation() {
1339 let crd = SnapshotPolicy::crd();
1343 let json = serde_json::to_value(&crd).expect("serialize CRD");
1344 let source = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1345 ["properties"]["sources"]["items"];
1346 let rules = source["x-kubernetes-validations"]
1347 .as_array()
1348 .expect("sources.items.x-kubernetes-validations present");
1349 assert!(rules.iter().any(|r| {
1350 r["rule"]
1351 .as_str()
1352 .is_some_and(|s| s.contains("pvcSelector") && s.contains("nfs"))
1353 }));
1354 }
1355
1356 #[test]
1357 fn snapshot_policy_has_last_snapshot_and_suspended_columns() {
1358 let crd = SnapshotPolicy::crd();
1361 let json = serde_json::to_value(&crd).expect("serialize CRD");
1362 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1363 .as_array()
1364 .expect("printer columns");
1365 let by_name = |name: &str| {
1366 cols.iter()
1367 .find(|c| c["name"] == name)
1368 .unwrap_or_else(|| panic!("missing column {name}"))
1369 };
1370 assert_eq!(
1371 by_name("Last-Snapshot")["jsonPath"],
1372 ".status.lastSuccessfulSnapshot"
1373 );
1374 assert_eq!(by_name("Suspended")["jsonPath"], ".spec.suspend");
1375 }
1376
1377 #[test]
1378 fn verification_roundtrip_and_opt_in() {
1379 let yaml = r#"
1382repository: { kind: Repository, name: r }
1383sources: [ { pvc: { name: d } } ]
1384verification:
1385 quick:
1386 schedule: { cron: "0 4 * * *", jitter: 30m }
1387 deep:
1388 schedule: { cron: "0 5 * * 0", jitter: 1h }
1389 capacity: 10Gi
1390 storageClassName: fast-ssd
1391 successExpr: "stats.files > 0 && stats.errors == 0"
1392 verifyFilesPercent: 10
1393"#;
1394 let spec: SnapshotPolicySpec = from_yaml(yaml);
1395 let v = spec.verification.as_ref().expect("verification");
1396 let quick = v.quick.as_ref().expect("quick");
1397 assert_eq!(quick.schedule.as_ref().unwrap().cron, "0 4 * * *");
1398 let deep = v.deep.as_ref().expect("deep");
1399 assert_eq!(deep.schedule.cron, "0 5 * * 0");
1400 assert_eq!(deep.capacity.as_deref(), Some("10Gi"));
1401 assert_eq!(
1402 v.success_expr.as_deref(),
1403 Some("stats.files > 0 && stats.errors == 0")
1404 );
1405 assert_eq!(v.verify_files_percent, Some(10));
1406
1407 let json = serde_json::to_value(&spec).expect("serialize");
1408 assert_eq!(
1409 json["verification"]["quick"]["schedule"]["cron"],
1410 "0 4 * * *"
1411 );
1412 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1413 assert_eq!(spec, reparsed);
1414
1415 let bare: SnapshotPolicySpec = from_yaml(
1417 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1418 );
1419 assert!(bare.verification.is_none());
1420 assert!(
1421 serde_json::to_value(&bare)
1422 .unwrap()
1423 .get("verification")
1424 .is_none()
1425 );
1426 }
1427
1428 #[test]
1429 fn verification_quick_old_shape_still_decodes() {
1430 let old = from_yaml::<SnapshotPolicySpec>(
1438 "repository: { kind: Repository, name: r }\n\
1439 sources: [ { pvc: { name: d } } ]\n\
1440 verification:\n quick: { cron: \"0 4 * * *\", jitter: 30m }\n",
1441 );
1442 let v = old.verification.as_ref().expect("verification");
1443 let quick = v.quick.as_ref().expect("quick present");
1444 assert!(
1445 quick.schedule.is_none(),
1446 "old flat `quick: {{cron: ...}}` must decode with schedule: None (quick disabled)"
1447 );
1448 }
1449
1450 #[test]
1451 fn verification_quick_and_deep_tuning_knobs_roundtrip() {
1452 let yaml = r#"
1456repository: { kind: Repository, name: r }
1457sources: [ { pvc: { name: d } } ]
1458verification:
1459 quick:
1460 schedule: { cron: "0 4 * * *" }
1461 parallel: 2
1462 fileParallelism: 4
1463 fileQueueLength: 100
1464 maxErrors: 1
1465 deep:
1466 schedule: { cron: "0 5 * * 0" }
1467 parallel: 2
1468"#;
1469 let spec: SnapshotPolicySpec = from_yaml(yaml);
1470 let v = spec.verification.as_ref().expect("verification");
1471 let quick = v.quick.as_ref().expect("quick");
1472 assert_eq!(quick.parallel, Some(2));
1473 assert_eq!(quick.file_parallelism, Some(4));
1474 assert_eq!(quick.file_queue_length, Some(100));
1475 assert_eq!(quick.max_errors, Some(1));
1476 let deep = v.deep.as_ref().expect("deep");
1477 assert_eq!(deep.parallel, Some(2));
1478
1479 let json = serde_json::to_value(&spec).expect("serialize");
1480 assert_eq!(json["verification"]["quick"]["parallel"], 2);
1481 assert_eq!(json["verification"]["quick"]["fileParallelism"], 4);
1482 assert_eq!(json["verification"]["quick"]["fileQueueLength"], 100);
1483 assert_eq!(json["verification"]["quick"]["maxErrors"], 1);
1484 assert_eq!(json["verification"]["deep"]["parallel"], 2);
1485 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1486 assert_eq!(spec, reparsed);
1487
1488 let bare_yaml = r#"
1490repository: { kind: Repository, name: r }
1491sources: [ { pvc: { name: d } } ]
1492verification:
1493 quick:
1494 schedule: { cron: "0 4 * * *" }
1495 deep:
1496 schedule: { cron: "0 5 * * 0" }
1497"#;
1498 let bare: SnapshotPolicySpec = from_yaml(bare_yaml);
1499 let bv = bare.verification.as_ref().expect("verification");
1500 assert!(bv.quick.as_ref().unwrap().parallel.is_none());
1501 assert!(bv.deep.as_ref().unwrap().parallel.is_none());
1502 let bare_json = serde_json::to_value(&bare).expect("serialize");
1503 assert!(bare_json["verification"]["quick"].get("parallel").is_none());
1504 assert!(bare_json["verification"]["deep"].get("parallel").is_none());
1505 }
1506
1507 #[test]
1508 fn preflight_roundtrip_and_opt_in() {
1509 let yaml = r#"
1511repository: { kind: Repository, name: r }
1512sources: [ { pvc: { name: d } } ]
1513preflight:
1514 timeout: 10m
1515 checks:
1516 - name: maintenance-fresh
1517 expr: "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
1518 message: "maintenance must have run within 7d"
1519 - name: backend-up
1520 expr: "repository.backendReachable"
1521"#;
1522 let spec: SnapshotPolicySpec = from_yaml(yaml);
1523 let pf = spec.preflight.as_ref().expect("preflight");
1524 assert_eq!(pf.timeout.as_deref(), Some("10m"));
1525 assert_eq!(pf.checks.len(), 2);
1526 assert_eq!(pf.checks[0].name, "maintenance-fresh");
1527 assert_eq!(pf.checks[1].expr, "repository.backendReachable");
1528 assert!(pf.checks[1].message.is_none());
1529
1530 let json = serde_json::to_value(&spec).expect("serialize");
1531 assert_eq!(json["preflight"]["checks"][0]["name"], "maintenance-fresh");
1532 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1533 assert_eq!(spec, reparsed);
1534
1535 let bare: SnapshotPolicySpec = from_yaml(
1537 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1538 );
1539 assert!(bare.preflight.is_none());
1540 assert!(
1541 serde_json::to_value(&bare)
1542 .unwrap()
1543 .get("preflight")
1544 .is_none()
1545 );
1546 }
1547
1548 #[test]
1549 fn snapshot_policy_has_last_verified_column() {
1550 let crd = SnapshotPolicy::crd();
1552 let json = serde_json::to_value(&crd).expect("serialize CRD");
1553 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1554 .as_array()
1555 .expect("printer columns");
1556 let col = cols
1557 .iter()
1558 .find(|c| c["name"] == "Last-Verified")
1559 .expect("Last-Verified column");
1560 assert_eq!(col["jsonPath"], ".status.lastVerified");
1561 }
1562
1563 #[test]
1564 fn status_last_successful_snapshot_roundtrips() {
1565 let status: SnapshotPolicyStatus =
1566 from_yaml("lastSuccessfulSnapshot: 2026-06-09T02:00:00Z\n");
1567 assert_eq!(
1568 status.last_successful_snapshot.as_deref(),
1569 Some("2026-06-09T02:00:00Z")
1570 );
1571 let json = serde_json::to_value(&status).unwrap();
1572 assert_eq!(json["lastSuccessfulSnapshot"], "2026-06-09T02:00:00Z");
1573 }
1574
1575 #[test]
1578 fn policy_deletion_on_policy_delete_schema_default_is_retain() {
1579 let crd = SnapshotPolicy::crd();
1583 let json = serde_json::to_value(&crd).unwrap();
1584 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1585 assert_eq!(
1586 spec["properties"]["deletion"]["properties"]["onPolicyDelete"]["default"],
1587 serde_json::json!("Retain")
1588 );
1589 assert_eq!(
1590 effective_on_policy_delete(None),
1591 crate::common::PolicyDeletePolicy::Retain
1592 );
1593 }
1594
1595 #[test]
1596 fn policy_deletion_round_trips_and_absent_stays_none() {
1597 use crate::common::PolicyDeletePolicy;
1598
1599 let spec: SnapshotPolicySpec = from_yaml(
1600 "repository: { kind: Repository, name: r }\n\
1601 sources: [ { pvc: { name: d } } ]\n\
1602 deletion: { onPolicyDelete: Delete }\n",
1603 );
1604 assert_eq!(
1605 spec.deletion.as_ref().map(|d| d.on_policy_delete),
1606 Some(PolicyDeletePolicy::Delete)
1607 );
1608 assert_eq!(
1609 effective_on_policy_delete(spec.deletion.as_ref()),
1610 PolicyDeletePolicy::Delete
1611 );
1612 let json = serde_json::to_value(&spec).unwrap();
1613 assert_eq!(json["deletion"]["onPolicyDelete"], "Delete");
1614 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1615 assert_eq!(spec, reparsed);
1616
1617 let bare: SnapshotPolicySpec = from_yaml(
1619 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1620 );
1621 assert!(bare.deletion.is_none());
1622 assert!(
1623 serde_json::to_value(&bare)
1624 .unwrap()
1625 .get("deletion")
1626 .is_none(),
1627 "absent deletion must be elided"
1628 );
1629 assert_eq!(
1630 effective_on_policy_delete(bare.deletion.as_ref()),
1631 PolicyDeletePolicy::Retain
1632 );
1633 }
1634
1635 #[test]
1636 fn policy_deletion_unknown_on_policy_delete_value_is_rejected() {
1637 let value: serde_json::Value =
1638 serde_yaml::from_str("deletion:\n onPolicyDelete: Orphan\n").unwrap();
1639 assert!(serde_json::from_value::<SnapshotPolicySpec>(value).is_err());
1640 }
1641
1642 #[test]
1643 fn policy_delete_policy_serializes_to_expected_strings() {
1644 use crate::common::PolicyDeletePolicy;
1645
1646 assert_eq!(
1647 serde_json::to_value(PolicyDeletePolicy::Retain).unwrap(),
1648 "Retain"
1649 );
1650 assert_eq!(
1651 serde_json::to_value(PolicyDeletePolicy::Delete).unwrap(),
1652 "Delete"
1653 );
1654 assert_eq!(PolicyDeletePolicy::default(), PolicyDeletePolicy::Retain);
1655 }
1656
1657 #[test]
1660 fn policy_adoption_round_trips_and_absent_stays_none() {
1661 use crate::common::SnapshotAdoption;
1662
1663 let spec: SnapshotPolicySpec = from_yaml(
1664 "repository: { kind: Repository, name: r }\n\
1665 sources: [ { pvc: { name: d } } ]\n\
1666 adoption: Ignore\n",
1667 );
1668 assert_eq!(spec.adoption, Some(SnapshotAdoption::Ignore));
1669 let json = serde_json::to_value(&spec).unwrap();
1670 assert_eq!(json["adoption"], "Ignore");
1671 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1672 assert_eq!(spec, reparsed);
1673
1674 let bare: SnapshotPolicySpec = from_yaml(
1675 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1676 );
1677 assert!(bare.adoption.is_none());
1678 assert!(
1679 serde_json::to_value(&bare)
1680 .unwrap()
1681 .get("adoption")
1682 .is_none(),
1683 "absent adoption must be elided"
1684 );
1685 }
1686
1687 #[test]
1688 fn policy_adoption_schema_carries_no_default() {
1689 let crd = SnapshotPolicy::crd();
1693 let json = serde_json::to_value(&crd).unwrap();
1694 let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1695 ["properties"]["adoption"];
1696 assert!(
1697 prop.get("default").is_none(),
1698 "spec.adoption must NOT carry a schema default: {prop}"
1699 );
1700 assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
1701 }
1702
1703 #[test]
1704 fn adoption_summary_status_roundtrips() {
1705 let status: SnapshotPolicyStatus = from_yaml(
1706 "adoption:\n \
1707 lastAdoptionAt: 2026-06-09T02:00:00Z\n \
1708 lastAdoptedCount: 3\n \
1709 totalAdopted: 42\n \
1710 scanRequestedAt: 2026-06-10T00:00:00Z\n \
1711 scanRequestedIdentity: postgres@billing\n",
1712 );
1713 let a = status.adoption.as_ref().expect("adoption");
1714 assert_eq!(a.last_adoption_at.as_deref(), Some("2026-06-09T02:00:00Z"));
1715 assert_eq!(a.last_adopted_count, Some(3));
1716 assert_eq!(a.total_adopted, Some(42));
1717 assert_eq!(a.scan_requested_at.as_deref(), Some("2026-06-10T00:00:00Z"));
1718 assert_eq!(
1719 a.scan_requested_identity.as_deref(),
1720 Some("postgres@billing")
1721 );
1722
1723 let json = serde_json::to_value(&status).unwrap();
1724 assert_eq!(json["adoption"]["lastAdoptedCount"], 3);
1725 assert_eq!(json["adoption"]["totalAdopted"], 42);
1726 let reparsed: SnapshotPolicyStatus = serde_json::from_value(json).unwrap();
1727 assert_eq!(status, reparsed);
1728
1729 let bare: SnapshotPolicyStatus = from_yaml("{}\n");
1731 assert!(bare.adoption.is_none());
1732 assert!(
1733 serde_json::to_value(&bare)
1734 .unwrap()
1735 .get("adoption")
1736 .is_none(),
1737 "absent adoption summary must be elided"
1738 );
1739 }
1740}