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)]
321#[schemars(extend("x-kubernetes-validations" = [{
322 "rule": "(has(self.pvc) ? 1 : 0) + (has(self.pvcSelector) ? 1 : 0) + (has(self.nfs) ? 1 : 0) == 1",
323 "message": "exactly one of pvc, pvcSelector, nfs"
324}]))]
325#[serde(rename_all = "camelCase")]
326pub struct Source {
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub pvc: Option<PvcSource>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub pvc_selector: Option<PvcSelector>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub nfs: Option<NfsVolume>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
349 #[schemars(default = "default_source_read_only")]
350 pub read_only: Option<bool>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub acknowledge_live_mutation: Option<bool>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 #[schemars(length(max = 4096))]
364 pub source_path_override: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 #[schemars(default = "default_source_path_strategy")]
368 pub source_path_strategy: Option<SourcePathStrategy>,
369}
370
371#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
373#[serde(rename_all = "camelCase")]
374pub struct PvcSource {
375 pub name: String,
377}
378
379#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
381#[serde(rename_all = "camelCase")]
382pub struct PvcSelector {
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub namespace_selector: Option<NamespaceSelector>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub label_selector: Option<LabelSelector>,
389}
390
391#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
393#[serde(rename_all = "camelCase")]
394pub struct NamespaceSelector {
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub match_names: Vec<String>,
398}
399
400fn default_copy_method() -> CopyMethod {
416 CopyMethod::Snapshot
417}
418
419pub fn default_ignore_rules() -> Vec<String> {
445 vec![
446 "/lost+found".to_string(),
447 "System Volume Information".to_string(),
448 "$RECYCLE.BIN".to_string(),
449 "@eaDir".to_string(),
450 ".snapshot".to_string(),
451 ]
452}
453
454#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
466pub enum CopyMethod {
467 #[default]
469 Snapshot,
470 Clone,
472 Direct,
474}
475
476#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
480#[serde(rename_all = "camelCase")]
481pub struct StagingSpec {
482 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub timeout: Option<String>,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub storage_class_name: Option<String>,
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub access_modes: Vec<PvcAccessMode>,
511}
512
513#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
517pub enum GroupBy {
518 #[default]
520 VolumeGroupSnapshot,
521 None,
523}
524
525fn default_group_by() -> Option<GroupBy> {
529 Some(GroupBy::VolumeGroupSnapshot)
530}
531
532#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
536pub enum SourcePathStrategy {
537 #[default]
539 PvcName,
540 PvcNamespacedName,
542}
543
544fn default_source_path_strategy() -> Option<SourcePathStrategy> {
547 Some(SourcePathStrategy::PvcName)
548}
549
550fn default_source_read_only() -> Option<bool> {
556 Some(true)
557}
558
559pub fn source_read_only(source: &Source) -> bool {
563 source.read_only.unwrap_or(true)
564}
565
566pub fn source_mutates_live_volume(copy_method: CopyMethod, source: &Source) -> bool {
583 matches!(copy_method, CopyMethod::Direct)
584 && !source_read_only(source)
585 && (source.pvc.is_some() || source.pvc_selector.is_some())
586}
587
588fn recipe_default_deletion_policy() -> Option<crate::common::DeletionPolicy> {
592 Some(crate::common::DeletionPolicy::Delete)
593}
594
595#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
597#[serde(rename_all = "camelCase")]
598pub struct Compression {
599 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub compressor: Option<String>,
602 #[serde(default, skip_serializing_if = "Vec::is_empty")]
604 pub never_compress: Vec<String>,
605}
606
607#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
609#[serde(rename_all = "camelCase")]
610pub struct Files {
611 #[serde(default = "default_ignore_rules")]
620 #[schemars(default = "default_ignore_rules")]
621 pub ignore_rules: Vec<String>,
622 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
624 pub ignore_cache_dirs: bool,
625 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
627 pub ignore_identical_snapshots: bool,
628}
629
630#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
632#[serde(rename_all = "camelCase")]
633pub struct ErrorHandling {
634 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
636 pub ignore_file_errors: bool,
637 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
639 pub ignore_dir_errors: bool,
640 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
642 pub ignore_unknown_types: bool,
643 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
648 pub fail_fast: bool,
649}
650
651#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
653#[serde(rename_all = "camelCase")]
654pub struct Upload {
655 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub max_parallel_snapshots: Option<i64>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub max_parallel_file_reads: Option<i64>,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub limit_mb: Option<i64>,
668}
669
670#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
672#[serde(rename_all = "camelCase")]
673pub struct Verification {
674 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub quick: Option<QuickVerification>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub deep: Option<DeepVerification>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub success_expr: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub verify_files_percent: Option<u8>,
687}
688
689#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
700#[serde(rename_all = "camelCase")]
701pub struct QuickVerification {
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub schedule: Option<CronSpec>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub parallel: Option<u32>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub file_parallelism: Option<u32>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub file_queue_length: Option<u32>,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub max_errors: Option<u32>,
718}
719
720#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
722#[serde(rename_all = "camelCase")]
723pub struct DeepVerification {
724 pub schedule: CronSpec,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub storage_class_name: Option<String>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub capacity: Option<String>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub parallel: Option<u32>,
736}
737
738#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
740#[serde(rename_all = "camelCase")]
741pub struct Hooks {
742 #[serde(default, skip_serializing_if = "Vec::is_empty")]
744 pub before_snapshot: Vec<Hook>,
745 #[serde(default, skip_serializing_if = "Vec::is_empty")]
747 pub after_snapshot: Vec<Hook>,
748}
749
750#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
754#[serde(rename_all = "camelCase")]
755pub enum Hook {
756 WorkloadExec(WorkloadExecHook),
758 RunJob(Box<RunJobHook>),
760 HttpRequest(HttpRequestHook),
762}
763
764impl Hook {
765 pub fn kind_str(&self) -> &'static str {
782 match self {
783 Hook::WorkloadExec(_) => "WorkloadExec",
784 Hook::RunJob(_) => "RunJob",
785 Hook::HttpRequest(_) => "HttpRequest",
786 }
787 }
788}
789
790#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
792#[serde(rename_all = "camelCase")]
793pub struct WorkloadExecHook {
794 #[serde(flatten)]
796 pub selector: PodSelector,
797 #[serde(default, skip_serializing_if = "Vec::is_empty")]
799 pub command: Vec<String>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub timeout: Option<String>,
803 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
805 pub continue_on_failure: bool,
806}
807
808#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
810#[serde(rename_all = "camelCase")]
811pub struct RunJobHook {
812 #[schemars(schema_with = "crate::schema::preserve_unknown_object")]
814 pub job_spec: JobSpec,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub timeout: Option<String>,
818 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
820 pub continue_on_failure: bool,
821}
822
823#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
825#[serde(rename_all = "camelCase")]
826pub struct HttpHeader {
827 pub name: String,
829 pub value: String,
831}
832
833#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
835#[serde(rename_all = "camelCase")]
836pub struct HttpRequestHook {
837 pub url: String,
839 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub method: Option<String>,
842 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub body: Option<String>,
845 #[serde(default, skip_serializing_if = "Vec::is_empty")]
847 pub headers: Vec<HttpHeader>,
848 #[serde(default, skip_serializing_if = "Option::is_none")]
850 pub timeout: Option<String>,
851 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
853 pub continue_on_failure: bool,
854}
855
856#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
858#[serde(rename_all = "camelCase")]
859pub struct SnapshotPolicyStatus {
860 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub observed_generation: Option<i64>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
865 pub resolved: Option<ResolvedPolicy>,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub retention: Option<RetentionSummary>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub adoption: Option<AdoptionSummary>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub last_successful_snapshot: Option<String>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
881 pub last_verified: Option<String>,
882 #[serde(default, skip_serializing_if = "Vec::is_empty")]
888 pub verification: Vec<RepoVerification>,
889 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
898 pub verification_stamps: std::collections::BTreeMap<String, String>,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub repository_summary: Option<String>,
905 #[serde(default, skip_serializing_if = "Vec::is_empty")]
907 pub conditions: Vec<Condition>,
908}
909
910#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
914#[serde(rename_all = "camelCase")]
915pub struct RepoVerification {
916 pub repository: RepositoryRef,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub last_verified: Option<String>,
924}
925
926#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
928#[serde(rename_all = "camelCase")]
929pub struct ResolvedPolicy {
930 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub identity: Option<ResolvedIdentity>,
933 #[serde(default, skip_serializing_if = "Vec::is_empty")]
935 pub sources: Vec<ResolvedPolicySource>,
936 #[serde(default, skip_serializing_if = "Vec::is_empty")]
944 pub repositories: Vec<ResolvedPolicyRepository>,
945}
946
947#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
954#[serde(rename_all = "camelCase")]
955pub struct ResolvedPolicyRepository {
956 pub repository: RepositoryRef,
959 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub identity: Option<ResolvedIdentity>,
965}
966
967#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
969#[serde(rename_all = "camelCase")]
970pub struct ResolvedPolicySource {
971 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub pvc: Option<String>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub source_path: Option<String>,
977}
978
979#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
981#[serde(rename_all = "camelCase")]
982pub struct RetentionSummary {
983 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub active_snapshot_count: Option<i64>,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub last_prune_at: Option<String>,
989 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub last_prune_deleted: Option<i64>,
992}
993
994#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1000#[serde(rename_all = "camelCase")]
1001pub struct AdoptionSummary {
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub last_adoption_at: Option<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub last_adopted_count: Option<u32>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub total_adopted: Option<u64>,
1011 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub skipped_by_retention: Option<u32>,
1018 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub last_scan_matched: Option<u32>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1038 pub last_scan_unmatched: Option<u32>,
1039 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub scan_requested_at: Option<String>,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub scan_requested_identity: Option<String>,
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053 use crate::common::RepositoryKind;
1054 use crate::testutil::from_yaml;
1055 use kube::core::CustomResourceExt;
1056
1057 #[test]
1058 fn resolved_policy_single_repo_status_wire_is_byte_identical() {
1059 let resolved = ResolvedPolicy {
1063 identity: Some(crate::common::ResolvedIdentity {
1064 username: "pg".into(),
1065 hostname: "billing".into(),
1066 source_path: Some("/pvc/data".into()),
1067 }),
1068 sources: vec![ResolvedPolicySource {
1069 pvc: Some("billing/data".into()),
1070 source_path: Some("/pvc/data".into()),
1071 }],
1072 repositories: vec![],
1073 };
1074 let wire = serde_json::to_value(&resolved).expect("serializes");
1075 assert_eq!(
1076 wire,
1077 serde_json::json!({
1078 "identity": {
1079 "username": "pg",
1080 "hostname": "billing",
1081 "sourcePath": "/pvc/data",
1082 },
1083 "sources": [ { "pvc": "billing/data", "sourcePath": "/pvc/data" } ],
1084 })
1085 );
1086
1087 let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1090 assert!(decoded.repositories.is_empty());
1091 }
1092
1093 #[test]
1094 fn resolved_policy_per_repo_entries_round_trip() {
1095 let resolved = ResolvedPolicy {
1096 identity: None,
1097 sources: vec![],
1098 repositories: vec![
1099 ResolvedPolicyRepository {
1100 repository: RepositoryRef {
1101 kind: RepositoryKind::Repository,
1102 name: "nas".into(),
1103 namespace: None,
1104 },
1105 identity: Some(crate::common::ResolvedIdentity {
1106 username: "pg".into(),
1107 hostname: "billing".into(),
1108 source_path: None,
1109 }),
1110 },
1111 ResolvedPolicyRepository {
1112 repository: RepositoryRef {
1113 kind: RepositoryKind::ClusterRepository,
1114 name: "offsite".into(),
1115 namespace: None,
1116 },
1117 identity: None,
1119 },
1120 ],
1121 };
1122 let wire = serde_json::to_value(&resolved).expect("serializes");
1123 assert_eq!(
1124 wire,
1125 serde_json::json!({
1126 "repositories": [
1127 {
1128 "repository": { "kind": "Repository", "name": "nas" },
1129 "identity": { "username": "pg", "hostname": "billing" },
1130 },
1131 { "repository": { "kind": "ClusterRepository", "name": "offsite" } },
1132 ],
1133 })
1134 );
1135 let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1136 assert_eq!(decoded, resolved);
1137 }
1138
1139 #[test]
1140 fn snapshot_policy_crd_metadata_is_correct() {
1141 let crd = SnapshotPolicy::crd();
1142 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
1143 assert_eq!(crd.spec.names.kind, "SnapshotPolicy");
1144 assert_eq!(crd.spec.names.plural, "snapshotpolicies");
1145 assert_eq!(
1146 crd.spec.names.short_names.as_deref(),
1147 Some(&["kopiasp".to_string()][..])
1148 );
1149 assert_eq!(crd.spec.scope, "Namespaced");
1150 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
1151 }
1152
1153 #[test]
1154 fn copy_method_carries_static_openapi_default_in_crd() {
1155 let crd = SnapshotPolicy::crd();
1160 let json = serde_json::to_value(&crd).expect("serialize CRD");
1161 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1162 ["properties"]["copyMethod"]["default"];
1163 assert_eq!(
1164 default, "Snapshot",
1165 "copyMethod must emit `default: Snapshot` in the CRD schema; got {default:?}"
1166 );
1167 }
1168
1169 #[test]
1170 fn staging_timeout_round_trips_and_defaults_to_absent() {
1171 let spec: SnapshotPolicySpec = from_yaml(
1174 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1175 );
1176 assert_eq!(spec.staging, None);
1177 let json = serde_json::to_value(&spec).unwrap();
1178 assert!(
1179 json.get("staging").is_none(),
1180 "absent staging must be elided"
1181 );
1182
1183 let spec: SnapshotPolicySpec = from_yaml(
1185 "repository: { kind: Repository, name: r }\n\
1186 sources: [ { pvc: { name: d } } ]\n\
1187 staging: { timeout: 30m }\n",
1188 );
1189 assert_eq!(
1190 spec.staging,
1191 Some(StagingSpec {
1192 timeout: Some("30m".to_string()),
1193 ..Default::default()
1194 })
1195 );
1196 let json = serde_json::to_value(&spec).unwrap();
1197 assert_eq!(json["staging"]["timeout"], "30m");
1198 }
1199
1200 #[test]
1201 fn staging_overrides_round_trip_and_are_elided_when_absent() {
1202 let spec: SnapshotPolicySpec = from_yaml(
1206 "repository: { kind: Repository, name: r }\n\
1207 sources: [ { pvc: { name: d } } ]\n\
1208 staging: { timeout: 30m, storageClassName: cephfs-shallow, accessModes: [ReadOnlyMany] }\n",
1209 );
1210 let st = spec.staging.as_ref().unwrap();
1211 assert_eq!(st.storage_class_name.as_deref(), Some("cephfs-shallow"));
1212 assert_eq!(st.access_modes, vec![PvcAccessMode::ReadOnlyMany]);
1213 let json = serde_json::to_value(&spec).unwrap();
1214 assert_eq!(json["staging"]["storageClassName"], "cephfs-shallow");
1215 assert_eq!(
1216 json["staging"]["accessModes"],
1217 serde_json::json!(["ReadOnlyMany"])
1218 );
1219
1220 let spec: SnapshotPolicySpec = from_yaml(
1221 "repository: { kind: Repository, name: r }\n\
1222 sources: [ { pvc: { name: d } } ]\n\
1223 staging: { timeout: 30m }\n",
1224 );
1225 let json = serde_json::to_value(&spec).unwrap();
1226 assert!(json["staging"].get("storageClassName").is_none());
1227 assert!(json["staging"].get("accessModes").is_none());
1228 }
1229
1230 #[test]
1231 fn staging_access_modes_legacy_value_decodes_to_unknown_not_an_error() {
1232 let spec: SnapshotPolicySpec = from_yaml(
1236 "repository: { kind: Repository, name: r }\n\
1237 sources: [ { pvc: { name: d } } ]\n\
1238 staging: { accessModes: [ReadWriteOnze] }\n",
1239 );
1240 assert_eq!(
1241 spec.staging.unwrap().access_modes,
1242 vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
1243 );
1244 }
1245
1246 #[test]
1247 fn staging_access_modes_render_a_closed_enum_in_the_crd_schema() {
1248 let crd = SnapshotPolicy::crd();
1252 let json = serde_json::to_value(&crd).expect("serialize CRD");
1253 let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1254 ["properties"]["staging"]["properties"]["accessModes"]["items"];
1255 assert_eq!(
1256 items["type"], "string",
1257 "items must be strings; got {items}"
1258 );
1259 assert_eq!(
1260 items["enum"],
1261 serde_json::json!([
1262 "ReadWriteOnce",
1263 "ReadOnlyMany",
1264 "ReadWriteMany",
1265 "ReadWriteOncePod"
1266 ]),
1267 "items enum must be exactly the canonical modes; got {items}"
1268 );
1269 }
1270
1271 #[test]
1272 fn copy_method_defaults_to_snapshot_when_absent() {
1273 let spec: SnapshotPolicySpec = from_yaml(
1276 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1277 );
1278 assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1279 let json = serde_json::to_value(&spec).unwrap();
1281 assert_eq!(json["copyMethod"], "Snapshot");
1282 }
1283
1284 fn expected_default_ignore_rules() -> Vec<String> {
1288 vec![
1289 "/lost+found".to_string(),
1290 "System Volume Information".to_string(),
1291 "$RECYCLE.BIN".to_string(),
1292 "@eaDir".to_string(),
1293 ".snapshot".to_string(),
1294 ]
1295 }
1296
1297 #[test]
1298 fn files_ignore_rules_carries_static_openapi_default_in_crd() {
1299 let crd = SnapshotPolicy::crd();
1303 let json = serde_json::to_value(&crd).expect("serialize CRD");
1304 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1305 ["properties"]["files"]["properties"]["ignoreRules"]["default"];
1306 let want: Vec<serde_json::Value> = expected_default_ignore_rules()
1307 .into_iter()
1308 .map(serde_json::Value::String)
1309 .collect();
1310 assert_eq!(
1311 default,
1312 &serde_json::Value::Array(want),
1313 "files.ignoreRules must emit the 5-entry OS-artifact `default:` in the CRD schema; got {default:?}"
1314 );
1315 }
1316
1317 #[test]
1318 fn ignore_rules_defaults_when_files_block_absent_entirely() {
1319 let spec: SnapshotPolicySpec = from_yaml(
1329 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1330 );
1331 assert!(
1332 spec.files.is_none(),
1333 "a spec omitting `files:` entirely must parse to `None`, not a defaulted `Files`"
1334 );
1335 }
1336
1337 #[test]
1338 fn ignore_rules_defaults_when_files_block_present_but_empty() {
1339 let spec: SnapshotPolicySpec = from_yaml(
1343 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: {}\n",
1344 );
1345 let files = spec.files.expect("files: {} must parse to Some(Files)");
1346 assert_eq!(files.ignore_rules, expected_default_ignore_rules());
1347 }
1348
1349 #[test]
1350 fn ignore_rules_explicit_empty_list_opts_out_and_round_trips() {
1351 let spec: SnapshotPolicySpec = from_yaml(
1358 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [] }\n",
1359 );
1360 let files = spec
1361 .files
1362 .as_ref()
1363 .expect("files: {...} must parse to Some(Files)");
1364 assert!(
1365 files.ignore_rules.is_empty(),
1366 "explicit `ignoreRules: []` must opt fully out, got {:?}",
1367 files.ignore_rules
1368 );
1369
1370 let json = serde_json::to_value(&spec).expect("serialize");
1373 assert_eq!(
1374 json["files"]["ignoreRules"],
1375 serde_json::json!([]),
1376 "an explicit empty ignoreRules must serialize as `[]`, not be omitted \
1377 (omission would deserialize back to the 5-entry default)"
1378 );
1379
1380 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1383 assert_eq!(spec, reparsed);
1384 assert!(reparsed.files.expect("files").ignore_rules.is_empty());
1385 }
1386
1387 #[test]
1388 fn ignore_rules_explicit_custom_list_replaces_default_wholesale() {
1389 let spec: SnapshotPolicySpec = from_yaml(
1393 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [\"*.tmp\", \"lost+found\"] }\n",
1394 );
1395 let files = spec.files.expect("files");
1396 assert_eq!(
1397 files.ignore_rules,
1398 vec!["*.tmp".to_string(), "lost+found".to_string()]
1399 );
1400 }
1401
1402 #[test]
1403 fn backup_config_roundtrip_matches_adr_shape() {
1404 let yaml = r#"
1406repository:
1407 kind: Repository
1408 name: nas-primary
1409 namespace: backups
1410identity:
1411 username: "postgres-data"
1412 hostname: "billing"
1413sources:
1414 - pvc: { name: postgres-data }
1415 sourcePathOverride: /data
1416copyMethod: Snapshot
1417volumeSnapshotClassName: csi-snap-class
1418groupBy: VolumeGroupSnapshot
1419retention:
1420 keepLatest: 10
1421 keepDaily: 14
1422defaultDeletionPolicy: Delete
1423compression:
1424 compressor: zstd
1425 neverCompress: ["*.zip", "*.gz", "*.mp4"]
1426files:
1427 ignoreRules: ["*.tmp", "*/cache/*", "lost+found"]
1428 ignoreCacheDirs: true
1429 ignoreIdenticalSnapshots: true
1430extraArgs: []
1431hooks:
1432 beforeSnapshot:
1433 - workloadExec:
1434 podSelector: { matchLabels: { app: postgres } }
1435 container: postgres
1436 command: ["pg_start_backup", "snap"]
1437 timeout: 2m
1438 afterSnapshot:
1439 - workloadExec:
1440 podSelector: { matchLabels: { app: postgres } }
1441 container: postgres
1442 command: ["pg_stop_backup"]
1443 timeout: 2m
1444mover:
1445 resources:
1446 requests: { cpu: 250m, memory: 512Mi }
1447 limits: { cpu: "2", memory: 4Gi }
1448 cache:
1449 capacity: 16Gi
1450 storageClassName: fast-ssd
1451 securityContext:
1452 runAsUser: 1000
1453 runAsGroup: 1000
1454 runAsNonRoot: true
1455 allowPrivilegeEscalation: false
1456 capabilities: { drop: ["ALL"] }
1457 seccompProfile: { type: RuntimeDefault }
1458 podSecurityContext:
1459 fsGroup: 1000
1460 fsGroupChangePolicy: OnRootMismatch
1461"#;
1462 let spec: SnapshotPolicySpec = from_yaml(yaml);
1463 let repo = spec.repository.as_ref().expect("repository");
1464 assert_eq!(repo.kind, RepositoryKind::Repository);
1465 assert_eq!(repo.name, "nas-primary");
1466 assert_eq!(spec.sources.len(), 1);
1467 assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
1468 assert_eq!(
1469 spec.sources[0].source_path_override.as_deref(),
1470 Some("/data")
1471 );
1472 assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1473 assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
1474 assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
1475 let comp = spec.compression.as_ref().unwrap();
1476 assert_eq!(comp.compressor.as_deref(), Some("zstd"));
1477 let files = spec.files.as_ref().unwrap();
1478 assert_eq!(files.ignore_rules.len(), 3);
1479 assert!(files.ignore_cache_dirs);
1480 assert!(spec.extra_args.is_empty());
1481 let hooks = spec.hooks.as_ref().unwrap();
1482 assert_eq!(hooks.before_snapshot.len(), 1);
1483 assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
1484 let mover = spec.mover.as_ref().expect("mover");
1486 assert_eq!(
1487 mover.security_context.as_ref().and_then(|s| s.run_as_user),
1488 Some(1000)
1489 );
1490 assert_eq!(
1491 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1492 Some(1000)
1493 );
1494 assert!(!mover.requires_privilege());
1496
1497 let json = serde_json::to_value(&spec).expect("serialize");
1498 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1499 assert_eq!(spec, reparsed);
1500 }
1501
1502 #[test]
1503 fn credential_projection_roundtrip() {
1504 let yaml = r#"
1507repository: { kind: ClusterRepository, name: shared }
1508sources:
1509 - pvc: { name: data }
1510retention: { keepLatest: 5 }
1511credentialProjection:
1512 enabled: true
1513"#;
1514 let spec: SnapshotPolicySpec = from_yaml(yaml);
1515 assert_eq!(
1516 spec.credential_projection.as_ref().map(|p| p.enabled),
1517 Some(true)
1518 );
1519 let json = serde_json::to_value(&spec).expect("serialize");
1520 assert_eq!(json["credentialProjection"]["enabled"], true);
1521 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1522 assert_eq!(spec, reparsed);
1523
1524 let bare: SnapshotPolicySpec = from_yaml(
1526 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1527 );
1528 assert!(bare.credential_projection.is_none());
1529 assert!(
1530 serde_json::to_value(&bare)
1531 .unwrap()
1532 .get("credentialProjection")
1533 .is_none()
1534 );
1535 let empty: SnapshotPolicySpec = from_yaml(
1537 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\ncredentialProjection: {}\n",
1538 );
1539 assert_eq!(empty.credential_projection.map(|p| p.enabled), Some(false));
1540 }
1541
1542 #[test]
1543 fn backup_config_minimal_selector_source() {
1544 let yaml = r#"
1546repository: { kind: Repository, name: nas-primary, namespace: backups }
1547identity: { username: app-bundle, hostname: billing }
1548sources:
1549 - pvcSelector:
1550 labelSelector: { matchLabels: { backup: include } }
1551 sourcePathStrategy: PvcName
1552groupBy: VolumeGroupSnapshot
1553retention: { keepDaily: 14 }
1554"#;
1555 let spec: SnapshotPolicySpec = from_yaml(yaml);
1556 let src = &spec.sources[0];
1557 assert!(src.pvc.is_none());
1558 assert!(src.pvc_selector.is_some());
1559 assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
1560
1561 let json = serde_json::to_value(&spec).unwrap();
1562 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1563 assert_eq!(spec, reparsed);
1564 }
1565
1566 #[test]
1567 fn hook_run_job_variant_with_job_spec() {
1568 let yaml = r#"
1570runJob:
1571 jobSpec:
1572 template:
1573 spec:
1574 restartPolicy: Never
1575 containers:
1576 - name: pre
1577 image: busybox
1578 command: ["sh", "-c", "echo hi"]
1579 timeout: 5m
1580 continueOnFailure: true
1581"#;
1582 let hook: Hook = from_yaml(yaml);
1583 assert_eq!(hook.kind_str(), "RunJob");
1584 match &hook {
1585 Hook::RunJob(j) => {
1586 assert!(j.continue_on_failure);
1587 assert_eq!(j.timeout.as_deref(), Some("5m"));
1588 assert_eq!(
1589 j.job_spec
1590 .template
1591 .spec
1592 .as_ref()
1593 .unwrap()
1594 .restart_policy
1595 .as_deref(),
1596 Some("Never")
1597 );
1598 }
1599 other => panic!("expected RunJob, got {}", other.kind_str()),
1600 }
1601 let json = serde_json::to_value(&hook).unwrap();
1602 assert!(json.get("runJob").is_some());
1603 }
1604
1605 #[test]
1606 fn hook_http_request_variant() {
1607 let hook: Hook = from_yaml(
1608 "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",
1609 );
1610 assert_eq!(hook.kind_str(), "HttpRequest");
1611 let v = serde_json::to_value(&hook).unwrap();
1612 assert_eq!(v["httpRequest"]["url"], "https://example/notify");
1613 assert_eq!(
1614 v.pointer("/httpRequest/headers/0/name")
1615 .and_then(|x| x.as_str()),
1616 Some("Content-Type")
1617 );
1618 assert_eq!(
1619 v.pointer("/httpRequest/headers/1/value")
1620 .and_then(|x| x.as_str()),
1621 Some("sekrit")
1622 );
1623 let bare: Hook = from_yaml("httpRequest:\n url: https://example/notify\n");
1625 let v = serde_json::to_value(&bare).unwrap();
1626 assert!(v.pointer("/httpRequest/headers").is_none());
1627 }
1628
1629 #[test]
1630 fn hook_unknown_variant_is_rejected() {
1631 let value: serde_json::Value = serde_yaml::from_str("teleport:\n url: x\n").unwrap();
1632 assert!(serde_json::from_value::<Hook>(value).is_err());
1633 }
1634
1635 #[test]
1636 fn error_handling_upload_and_suspend_roundtrip() {
1637 let yaml = r#"
1640repository: { kind: Repository, name: r }
1641sources: [ { pvc: { name: d } } ]
1642errorHandling:
1643 ignoreFileErrors: true
1644 ignoreDirErrors: false
1645 ignoreUnknownTypes: true
1646 failFast: true
1647upload:
1648 maxParallelSnapshots: 4
1649 maxParallelFileReads: 8
1650 limitMb: 100
1651suspend: true
1652"#;
1653 let spec: SnapshotPolicySpec = from_yaml(yaml);
1654 let eh = spec.error_handling.as_ref().expect("errorHandling");
1655 assert!(eh.ignore_file_errors);
1656 assert!(!eh.ignore_dir_errors);
1657 assert!(eh.ignore_unknown_types);
1658 assert!(eh.fail_fast);
1659 let up = spec.upload.as_ref().expect("upload");
1660 assert_eq!(up.max_parallel_snapshots, Some(4));
1661 assert_eq!(up.max_parallel_file_reads, Some(8));
1662 assert_eq!(up.limit_mb, Some(100));
1663 assert!(spec.suspend);
1664
1665 let json = serde_json::to_value(&spec).expect("serialize");
1666 assert_eq!(json["suspend"], true);
1667 assert_eq!(json["errorHandling"]["ignoreFileErrors"], true);
1668 assert_eq!(json["errorHandling"]["failFast"], true);
1669 assert_eq!(json["upload"]["maxParallelSnapshots"], 4);
1670 assert_eq!(json["upload"]["limitMb"], 100);
1671 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1672 assert_eq!(spec, reparsed);
1673
1674 let bare: SnapshotPolicySpec = from_yaml(
1676 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1677 );
1678 assert!(bare.error_handling.is_none());
1679 assert!(bare.upload.is_none());
1680 assert!(!bare.suspend);
1681 let bare_json = serde_json::to_value(&bare).unwrap();
1682 assert!(bare_json.get("suspend").is_none());
1683 assert!(bare_json.get("errorHandling").is_none());
1684 }
1685
1686 #[test]
1687 fn source_schema_carries_exactly_one_of_validation() {
1688 let crd = SnapshotPolicy::crd();
1692 let json = serde_json::to_value(&crd).expect("serialize CRD");
1693 let source = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1694 ["properties"]["sources"]["items"];
1695 let rules = source["x-kubernetes-validations"]
1696 .as_array()
1697 .expect("sources.items.x-kubernetes-validations present");
1698 assert!(rules.iter().any(|r| {
1699 r["rule"]
1700 .as_str()
1701 .is_some_and(|s| s.contains("pvcSelector") && s.contains("nfs"))
1702 }));
1703 }
1704
1705 #[test]
1706 fn snapshot_policy_has_last_snapshot_and_suspended_columns() {
1707 let crd = SnapshotPolicy::crd();
1710 let json = serde_json::to_value(&crd).expect("serialize CRD");
1711 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1712 .as_array()
1713 .expect("printer columns");
1714 let by_name = |name: &str| {
1715 cols.iter()
1716 .find(|c| c["name"] == name)
1717 .unwrap_or_else(|| panic!("missing column {name}"))
1718 };
1719 assert_eq!(
1720 by_name("Last-Snapshot")["jsonPath"],
1721 ".status.lastSuccessfulSnapshot"
1722 );
1723 assert_eq!(by_name("Suspended")["jsonPath"], ".spec.suspend");
1724 }
1725
1726 #[test]
1727 fn verification_roundtrip_and_opt_in() {
1728 let yaml = r#"
1731repository: { kind: Repository, name: r }
1732sources: [ { pvc: { name: d } } ]
1733verification:
1734 quick:
1735 schedule: { cron: "0 4 * * *", jitter: 30m }
1736 deep:
1737 schedule: { cron: "0 5 * * 0", jitter: 1h }
1738 capacity: 10Gi
1739 storageClassName: fast-ssd
1740 successExpr: "stats.files > 0 && stats.errors == 0"
1741 verifyFilesPercent: 10
1742"#;
1743 let spec: SnapshotPolicySpec = from_yaml(yaml);
1744 let v = spec.verification.as_ref().expect("verification");
1745 let quick = v.quick.as_ref().expect("quick");
1746 assert_eq!(quick.schedule.as_ref().unwrap().cron, "0 4 * * *");
1747 let deep = v.deep.as_ref().expect("deep");
1748 assert_eq!(deep.schedule.cron, "0 5 * * 0");
1749 assert_eq!(deep.capacity.as_deref(), Some("10Gi"));
1750 assert_eq!(
1751 v.success_expr.as_deref(),
1752 Some("stats.files > 0 && stats.errors == 0")
1753 );
1754 assert_eq!(v.verify_files_percent, Some(10));
1755
1756 let json = serde_json::to_value(&spec).expect("serialize");
1757 assert_eq!(
1758 json["verification"]["quick"]["schedule"]["cron"],
1759 "0 4 * * *"
1760 );
1761 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1762 assert_eq!(spec, reparsed);
1763
1764 let bare: SnapshotPolicySpec = from_yaml(
1766 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1767 );
1768 assert!(bare.verification.is_none());
1769 assert!(
1770 serde_json::to_value(&bare)
1771 .unwrap()
1772 .get("verification")
1773 .is_none()
1774 );
1775 }
1776
1777 #[test]
1778 fn verification_quick_old_shape_still_decodes() {
1779 let old = from_yaml::<SnapshotPolicySpec>(
1787 "repository: { kind: Repository, name: r }\n\
1788 sources: [ { pvc: { name: d } } ]\n\
1789 verification:\n quick: { cron: \"0 4 * * *\", jitter: 30m }\n",
1790 );
1791 let v = old.verification.as_ref().expect("verification");
1792 let quick = v.quick.as_ref().expect("quick present");
1793 assert!(
1794 quick.schedule.is_none(),
1795 "old flat `quick: {{cron: ...}}` must decode with schedule: None (quick disabled)"
1796 );
1797 }
1798
1799 #[test]
1800 fn verification_quick_and_deep_tuning_knobs_roundtrip() {
1801 let yaml = r#"
1805repository: { kind: Repository, name: r }
1806sources: [ { pvc: { name: d } } ]
1807verification:
1808 quick:
1809 schedule: { cron: "0 4 * * *" }
1810 parallel: 2
1811 fileParallelism: 4
1812 fileQueueLength: 100
1813 maxErrors: 1
1814 deep:
1815 schedule: { cron: "0 5 * * 0" }
1816 parallel: 2
1817"#;
1818 let spec: SnapshotPolicySpec = from_yaml(yaml);
1819 let v = spec.verification.as_ref().expect("verification");
1820 let quick = v.quick.as_ref().expect("quick");
1821 assert_eq!(quick.parallel, Some(2));
1822 assert_eq!(quick.file_parallelism, Some(4));
1823 assert_eq!(quick.file_queue_length, Some(100));
1824 assert_eq!(quick.max_errors, Some(1));
1825 let deep = v.deep.as_ref().expect("deep");
1826 assert_eq!(deep.parallel, Some(2));
1827
1828 let json = serde_json::to_value(&spec).expect("serialize");
1829 assert_eq!(json["verification"]["quick"]["parallel"], 2);
1830 assert_eq!(json["verification"]["quick"]["fileParallelism"], 4);
1831 assert_eq!(json["verification"]["quick"]["fileQueueLength"], 100);
1832 assert_eq!(json["verification"]["quick"]["maxErrors"], 1);
1833 assert_eq!(json["verification"]["deep"]["parallel"], 2);
1834 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1835 assert_eq!(spec, reparsed);
1836
1837 let bare_yaml = r#"
1839repository: { kind: Repository, name: r }
1840sources: [ { pvc: { name: d } } ]
1841verification:
1842 quick:
1843 schedule: { cron: "0 4 * * *" }
1844 deep:
1845 schedule: { cron: "0 5 * * 0" }
1846"#;
1847 let bare: SnapshotPolicySpec = from_yaml(bare_yaml);
1848 let bv = bare.verification.as_ref().expect("verification");
1849 assert!(bv.quick.as_ref().unwrap().parallel.is_none());
1850 assert!(bv.deep.as_ref().unwrap().parallel.is_none());
1851 let bare_json = serde_json::to_value(&bare).expect("serialize");
1852 assert!(bare_json["verification"]["quick"].get("parallel").is_none());
1853 assert!(bare_json["verification"]["deep"].get("parallel").is_none());
1854 }
1855
1856 #[test]
1857 fn preflight_roundtrip_and_opt_in() {
1858 let yaml = r#"
1860repository: { kind: Repository, name: r }
1861sources: [ { pvc: { name: d } } ]
1862preflight:
1863 timeout: 10m
1864 checks:
1865 - name: maintenance-fresh
1866 expr: "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
1867 message: "maintenance must have run within 7d"
1868 - name: backend-up
1869 expr: "repository.backendReachable"
1870"#;
1871 let spec: SnapshotPolicySpec = from_yaml(yaml);
1872 let pf = spec.preflight.as_ref().expect("preflight");
1873 assert_eq!(pf.timeout.as_deref(), Some("10m"));
1874 assert_eq!(pf.checks.len(), 2);
1875 assert_eq!(pf.checks[0].name, "maintenance-fresh");
1876 assert_eq!(pf.checks[1].expr, "repository.backendReachable");
1877 assert!(pf.checks[1].message.is_none());
1878
1879 let json = serde_json::to_value(&spec).expect("serialize");
1880 assert_eq!(json["preflight"]["checks"][0]["name"], "maintenance-fresh");
1881 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1882 assert_eq!(spec, reparsed);
1883
1884 let bare: SnapshotPolicySpec = from_yaml(
1886 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1887 );
1888 assert!(bare.preflight.is_none());
1889 assert!(
1890 serde_json::to_value(&bare)
1891 .unwrap()
1892 .get("preflight")
1893 .is_none()
1894 );
1895 }
1896
1897 #[test]
1898 fn snapshot_policy_has_last_verified_column() {
1899 let crd = SnapshotPolicy::crd();
1901 let json = serde_json::to_value(&crd).expect("serialize CRD");
1902 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1903 .as_array()
1904 .expect("printer columns");
1905 let col = cols
1906 .iter()
1907 .find(|c| c["name"] == "Last-Verified")
1908 .expect("Last-Verified column");
1909 assert_eq!(col["jsonPath"], ".status.lastVerified");
1910 }
1911
1912 #[test]
1913 fn snapshot_policy_has_repositories_summary_column() {
1914 let crd = SnapshotPolicy::crd();
1918 let json = serde_json::to_value(&crd).expect("serialize CRD");
1919 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1920 .as_array()
1921 .expect("printer columns");
1922 let col = cols
1923 .iter()
1924 .find(|c| c["name"] == "Repositories")
1925 .expect("Repositories column");
1926 assert_eq!(col["jsonPath"], ".status.repositorySummary");
1927 }
1928
1929 #[test]
1930 fn status_verification_fields_elide_when_empty_and_roundtrip() {
1931 let bare = SnapshotPolicyStatus::default();
1936 let json = serde_json::to_value(&bare).expect("serialize");
1937 assert!(json.get("verification").is_none(), "empty vec must elide");
1938 assert!(
1939 json.get("verificationStamps").is_none(),
1940 "empty map must elide"
1941 );
1942 assert!(
1943 json.get("repositorySummary").is_none(),
1944 "absent summary must elide"
1945 );
1946
1947 let status: SnapshotPolicyStatus = serde_json::from_value(serde_json::json!({
1949 "lastVerified": "2026-08-01T00:00:00Z",
1950 "repositorySummary": "nas, offsite",
1951 "verification": [
1952 {
1953 "repository": { "kind": "Repository", "name": "nas", "namespace": "backups" },
1954 "lastVerified": "2026-08-01T00:00:00Z"
1955 },
1956 { "repository": { "kind": "ClusterRepository", "name": "offsite" } }
1957 ],
1958 "verificationStamps": {
1959 "Repository/backups/nas": "2026-08-01T00:00:00Z"
1960 }
1961 }))
1962 .expect("status parses");
1963 assert_eq!(status.verification.len(), 2);
1964 assert_eq!(
1965 status.verification[0].last_verified.as_deref(),
1966 Some("2026-08-01T00:00:00Z")
1967 );
1968 assert!(status.verification[1].last_verified.is_none());
1969 assert_eq!(
1970 status
1971 .verification_stamps
1972 .get("Repository/backups/nas")
1973 .map(String::as_str),
1974 Some("2026-08-01T00:00:00Z")
1975 );
1976 let out = serde_json::to_value(&status).expect("serialize");
1977 assert_eq!(
1978 out["verification"][0]["repository"]["name"], "nas",
1979 "camelCase wire shape"
1980 );
1981 assert_eq!(out["repositorySummary"], "nas, offsite");
1982 }
1983
1984 #[test]
1985 fn status_last_successful_snapshot_roundtrips() {
1986 let status: SnapshotPolicyStatus =
1987 from_yaml("lastSuccessfulSnapshot: 2026-06-09T02:00:00Z\n");
1988 assert_eq!(
1989 status.last_successful_snapshot.as_deref(),
1990 Some("2026-06-09T02:00:00Z")
1991 );
1992 let json = serde_json::to_value(&status).unwrap();
1993 assert_eq!(json["lastSuccessfulSnapshot"], "2026-06-09T02:00:00Z");
1994 }
1995
1996 #[test]
1999 fn policy_deletion_on_policy_delete_schema_default_is_retain() {
2000 let crd = SnapshotPolicy::crd();
2004 let json = serde_json::to_value(&crd).unwrap();
2005 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2006 assert_eq!(
2007 spec["properties"]["deletion"]["properties"]["onPolicyDelete"]["default"],
2008 serde_json::json!("Retain")
2009 );
2010 assert_eq!(
2011 effective_on_policy_delete(None),
2012 crate::common::PolicyDeletePolicy::Retain
2013 );
2014 }
2015
2016 #[test]
2017 fn policy_deletion_round_trips_and_absent_stays_none() {
2018 use crate::common::PolicyDeletePolicy;
2019
2020 let spec: SnapshotPolicySpec = from_yaml(
2021 "repository: { kind: Repository, name: r }\n\
2022 sources: [ { pvc: { name: d } } ]\n\
2023 deletion: { onPolicyDelete: Delete }\n",
2024 );
2025 assert_eq!(
2026 spec.deletion.as_ref().map(|d| d.on_policy_delete),
2027 Some(PolicyDeletePolicy::Delete)
2028 );
2029 assert_eq!(
2030 effective_on_policy_delete(spec.deletion.as_ref()),
2031 PolicyDeletePolicy::Delete
2032 );
2033 let json = serde_json::to_value(&spec).unwrap();
2034 assert_eq!(json["deletion"]["onPolicyDelete"], "Delete");
2035 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2036 assert_eq!(spec, reparsed);
2037
2038 let bare: SnapshotPolicySpec = from_yaml(
2040 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2041 );
2042 assert!(bare.deletion.is_none());
2043 assert!(
2044 serde_json::to_value(&bare)
2045 .unwrap()
2046 .get("deletion")
2047 .is_none(),
2048 "absent deletion must be elided"
2049 );
2050 assert_eq!(
2051 effective_on_policy_delete(bare.deletion.as_ref()),
2052 PolicyDeletePolicy::Retain
2053 );
2054 }
2055
2056 #[test]
2057 fn policy_deletion_unknown_on_policy_delete_value_is_rejected() {
2058 let value: serde_json::Value =
2059 serde_yaml::from_str("deletion:\n onPolicyDelete: Orphan\n").unwrap();
2060 assert!(serde_json::from_value::<SnapshotPolicySpec>(value).is_err());
2061 }
2062
2063 #[test]
2064 fn policy_delete_policy_serializes_to_expected_strings() {
2065 use crate::common::PolicyDeletePolicy;
2066
2067 assert_eq!(
2068 serde_json::to_value(PolicyDeletePolicy::Retain).unwrap(),
2069 "Retain"
2070 );
2071 assert_eq!(
2072 serde_json::to_value(PolicyDeletePolicy::Delete).unwrap(),
2073 "Delete"
2074 );
2075 assert_eq!(PolicyDeletePolicy::default(), PolicyDeletePolicy::Retain);
2076 }
2077
2078 #[test]
2081 fn policy_adoption_round_trips_and_absent_stays_none() {
2082 use crate::common::SnapshotAdoption;
2083
2084 let spec: SnapshotPolicySpec = from_yaml(
2085 "repository: { kind: Repository, name: r }\n\
2086 sources: [ { pvc: { name: d } } ]\n\
2087 adoption: Ignore\n",
2088 );
2089 assert_eq!(spec.adoption, Some(SnapshotAdoption::Ignore));
2090 let json = serde_json::to_value(&spec).unwrap();
2091 assert_eq!(json["adoption"], "Ignore");
2092 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2093 assert_eq!(spec, reparsed);
2094
2095 let bare: SnapshotPolicySpec = from_yaml(
2096 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2097 );
2098 assert!(bare.adoption.is_none());
2099 assert!(
2100 serde_json::to_value(&bare)
2101 .unwrap()
2102 .get("adoption")
2103 .is_none(),
2104 "absent adoption must be elided"
2105 );
2106 }
2107
2108 #[test]
2109 fn policy_adoption_schema_carries_no_default() {
2110 let crd = SnapshotPolicy::crd();
2114 let json = serde_json::to_value(&crd).unwrap();
2115 let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2116 ["properties"]["adoption"];
2117 assert!(
2118 prop.get("default").is_none(),
2119 "spec.adoption must NOT carry a schema default: {prop}"
2120 );
2121 assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
2122 }
2123
2124 #[test]
2131 fn legacy_single_repo_spec_wire_is_byte_identical() {
2132 const LEGACY_WIRE: &str = r#"{"repository":{"kind":"Repository","name":"nas-primary","namespace":"backups"},"sources":[{"pvc":{"name":"pgdata"}}],"copyMethod":"Snapshot","retention":{"keepLatest":3}}"#;
2133 assert!(
2134 !LEGACY_WIRE.contains("repositories"),
2135 "fixture precondition"
2136 );
2137
2138 let spec: SnapshotPolicySpec = from_yaml(
2140 "repository: { kind: Repository, name: nas-primary, namespace: backups }\n\
2141 sources: [ { pvc: { name: pgdata } } ]\n\
2142 retention: { keepLatest: 3 }\n",
2143 );
2144 assert_eq!(spec.repository.as_ref().unwrap().name, "nas-primary");
2145 assert!(spec.repositories.is_empty());
2146
2147 assert_eq!(serde_json::to_string(&spec).unwrap(), LEGACY_WIRE);
2150
2151 let reparsed: SnapshotPolicySpec = serde_json::from_str(LEGACY_WIRE).unwrap();
2153 assert_eq!(spec, reparsed);
2154 }
2155
2156 #[test]
2157 fn repositories_roundtrip_and_absent_stays_off_the_wire() {
2158 let spec: SnapshotPolicySpec = from_yaml(
2159 "repositories:\n\
2160 - { kind: Repository, name: a }\n\
2161 - { kind: ClusterRepository, name: b }\n\
2162 sources: [ { pvc: { name: d } } ]\n",
2163 );
2164 assert!(spec.repository.is_none());
2165 assert_eq!(spec.repositories.len(), 2);
2166 let json = serde_json::to_value(&spec).unwrap();
2167 assert!(json.get("repository").is_none());
2168 assert_eq!(json["repositories"][1]["kind"], "ClusterRepository");
2169 let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2170 assert_eq!(spec, reparsed);
2171 }
2172
2173 #[test]
2174 fn policy_repositories_accessors_cover_all_four_shapes() {
2175 use crate::error::ValidationError;
2176
2177 let single: SnapshotPolicySpec = from_yaml(
2178 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2179 );
2180 assert!(matches!(
2181 policy_repositories(&single),
2182 Ok(PolicyRepositories::Single(r)) if r.name == "r"
2183 ));
2184 assert!(!is_multi_repo(&single));
2185 assert_eq!(repository_refs(&single).count(), 1);
2186 assert_eq!(single_repository_ref(&single).unwrap().name, "r");
2187
2188 let multi: SnapshotPolicySpec = from_yaml(
2189 "repositories: [ { name: a }, { name: b } ]\nsources: [ { pvc: { name: d } } ]\n",
2190 );
2191 assert!(matches!(
2192 policy_repositories(&multi),
2193 Ok(PolicyRepositories::Multi(refs)) if refs.len() == 2
2194 ));
2195 assert!(is_multi_repo(&multi));
2196 assert_eq!(
2197 repository_refs(&multi)
2198 .map(|r| r.name.as_str())
2199 .collect::<Vec<_>>(),
2200 vec!["a", "b"]
2201 );
2202 assert_eq!(
2205 single_repository_ref(&multi).unwrap_err(),
2206 ValidationError::PolicySingleRepositoryRequired
2207 );
2208
2209 let neither: SnapshotPolicySpec = from_yaml("sources: [ { pvc: { name: d } } ]\n");
2212 assert_eq!(
2213 policy_repositories(&neither).unwrap_err(),
2214 ValidationError::PolicyRepositoryExactlyOne { got: "neither" }
2215 );
2216 assert_eq!(repository_refs(&neither).count(), 0);
2217
2218 let both: SnapshotPolicySpec = from_yaml(
2219 "repository: { name: r }\nrepositories: [ { name: a } ]\n\
2220 sources: [ { pvc: { name: d } } ]\n",
2221 );
2222 assert_eq!(
2223 policy_repositories(&both).unwrap_err(),
2224 ValidationError::PolicyRepositoryExactlyOne { got: "both" }
2225 );
2226 assert_eq!(
2228 repository_refs(&both)
2229 .map(|r| r.name.as_str())
2230 .collect::<Vec<_>>(),
2231 vec!["r", "a"]
2232 );
2233 }
2234
2235 #[test]
2236 fn select_restore_repository_covers_every_selection_shape() {
2237 use super::select_restore_repository;
2238 use crate::common::{RepositoryKind, RepositoryRef};
2239 use crate::error::ValidationError;
2240 let single: SnapshotPolicySpec = from_yaml(
2241 "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2242 );
2243 let multi: SnapshotPolicySpec = from_yaml(
2244 "repositories:\n - { kind: Repository, name: a }\n - { kind: ClusterRepository, name: b }\n\
2245 sources: [ { pvc: { name: d } } ]\n",
2246 );
2247 let rref = |kind, name: &str, ns: Option<&str>| RepositoryRef {
2248 kind,
2249 name: name.into(),
2250 namespace: ns.map(str::to_string),
2251 };
2252
2253 let r = select_restore_repository(&single, "pol", "backups", None, "apps").unwrap();
2255 assert_eq!(r.name, "r");
2256
2257 match select_restore_repository(&multi, "pol", "backups", None, "apps").unwrap_err() {
2259 ValidationError::RestoreRepositorySelectionRequired { policy, valid } => {
2260 assert_eq!(policy, "pol");
2261 assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2262 }
2263 other => panic!("expected RestoreRepositorySelectionRequired, got {other:?}"),
2264 }
2265
2266 let explicit = rref(RepositoryKind::Repository, "a", Some("backups"));
2268 let r =
2269 select_restore_repository(&multi, "pol", "backups", Some(&explicit), "apps").unwrap();
2270 assert_eq!(r.name, "a");
2271
2272 let cluster = rref(RepositoryKind::ClusterRepository, "b", None);
2275 assert!(
2276 select_restore_repository(&multi, "pol", "backups", Some(&cluster), "apps").is_ok()
2277 );
2278
2279 let typo = rref(RepositoryKind::Repository, "a", None);
2283 match select_restore_repository(&multi, "pol", "backups", Some(&typo), "apps").unwrap_err()
2284 {
2285 ValidationError::RestoreRepositoryNotInPolicy {
2286 given,
2287 policy,
2288 valid,
2289 } => {
2290 assert_eq!(given, "Repository/apps/a");
2291 assert_eq!(policy, "pol");
2292 assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2293 }
2294 other => panic!("expected RestoreRepositoryNotInPolicy, got {other:?}"),
2295 }
2296
2297 let elsewhere = rref(RepositoryKind::ClusterRepository, "offsite", None);
2300 assert!(matches!(
2301 select_restore_repository(&single, "pol", "backups", Some(&elsewhere), "apps")
2302 .unwrap_err(),
2303 ValidationError::RestoreRepositoryNotInPolicy { .. }
2304 ));
2305 }
2306
2307 #[test]
2308 fn snapshot_policy_spec_carries_exactly_one_of_repository_cel_rule() {
2309 let crd = SnapshotPolicy::crd();
2312 let json = serde_json::to_value(&crd).expect("serialize CRD");
2313 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2314 let rules = spec["x-kubernetes-validations"]
2315 .as_array()
2316 .expect("spec-level x-kubernetes-validations present");
2317 assert!(
2318 rules.iter().any(|r| {
2319 r["rule"]
2320 == "(has(self.repository) ? 1 : 0) + (has(self.repositories) ? 1 : 0) == 1"
2321 && r["message"] == "exactly one of repository, repositories"
2322 }),
2323 "missing the exactly-one-of CEL rule; got {rules:?}"
2324 );
2325 let required = spec["required"].as_array().cloned().unwrap_or_default();
2327 assert!(
2328 !required.iter().any(|f| f == "repository"),
2329 "repository must not be schema-required anymore; got {required:?}"
2330 );
2331 let repos = &spec["properties"]["repositories"];
2335 assert_eq!(repos["minItems"], 1, "{repos}");
2336 assert_eq!(repos["maxItems"], 8, "{repos}");
2337 }
2338
2339 #[test]
2340 fn snapshot_policy_repository_print_column_is_unchanged() {
2341 let crd = SnapshotPolicy::crd();
2345 let json = serde_json::to_value(&crd).expect("serialize CRD");
2346 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2347 .as_array()
2348 .expect("printer columns");
2349 let col = cols
2350 .iter()
2351 .find(|c| c["name"] == "Repository")
2352 .expect("Repository column");
2353 assert_eq!(col["jsonPath"], ".spec.repository.name");
2354 }
2355
2356 #[test]
2357 fn adoption_summary_status_roundtrips() {
2358 let status: SnapshotPolicyStatus = from_yaml(
2359 "adoption:\n \
2360 lastAdoptionAt: 2026-06-09T02:00:00Z\n \
2361 lastAdoptedCount: 3\n \
2362 totalAdopted: 42\n \
2363 lastScanMatched: 3\n \
2364 lastScanUnmatched: 7\n \
2365 scanRequestedAt: 2026-06-10T00:00:00Z\n \
2366 scanRequestedIdentity: postgres@billing\n",
2367 );
2368 let a = status.adoption.as_ref().expect("adoption");
2369 assert_eq!(a.last_adoption_at.as_deref(), Some("2026-06-09T02:00:00Z"));
2370 assert_eq!(a.last_adopted_count, Some(3));
2371 assert_eq!(a.total_adopted, Some(42));
2372 assert_eq!(a.last_scan_matched, Some(3));
2373 assert_eq!(a.last_scan_unmatched, Some(7));
2374 assert_eq!(a.scan_requested_at.as_deref(), Some("2026-06-10T00:00:00Z"));
2375 assert_eq!(
2376 a.scan_requested_identity.as_deref(),
2377 Some("postgres@billing")
2378 );
2379
2380 let json = serde_json::to_value(&status).unwrap();
2381 assert_eq!(json["adoption"]["lastAdoptedCount"], 3);
2382 assert_eq!(json["adoption"]["totalAdopted"], 42);
2383 assert_eq!(json["adoption"]["lastScanMatched"], 3);
2384 assert_eq!(json["adoption"]["lastScanUnmatched"], 7);
2385 let reparsed: SnapshotPolicyStatus = serde_json::from_value(json).unwrap();
2386 assert_eq!(status, reparsed);
2387
2388 let empty_scan: SnapshotPolicyStatus =
2391 from_yaml("adoption:\n lastScanMatched: 0\n lastScanUnmatched: 0\n");
2392 let a = empty_scan.adoption.as_ref().expect("adoption");
2393 assert_eq!(
2394 (a.last_scan_matched, a.last_scan_unmatched),
2395 (Some(0), Some(0))
2396 );
2397 let wire = serde_json::to_value(&empty_scan).unwrap();
2398 assert_eq!(wire["adoption"]["lastScanMatched"], 0);
2399 assert_eq!(wire["adoption"]["lastScanUnmatched"], 0);
2400
2401 let bare: SnapshotPolicyStatus = from_yaml("{}\n");
2403 assert!(bare.adoption.is_none());
2404 assert!(
2405 serde_json::to_value(&bare)
2406 .unwrap()
2407 .get("adoption")
2408 .is_none(),
2409 "absent adoption summary must be elided"
2410 );
2411 }
2412}