1use crate::common::{
5 CredentialProjection, FailurePolicy, MoverSpec, ObjectRef, PvcAccessMode, RepositoryRef,
6 ResolvedIdentity,
7};
8use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
9use kube::CustomResource;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
15#[kube(
16 group = "kopiur.home-operations.com",
17 version = "v1alpha1",
18 kind = "Restore",
19 namespaced,
20 status = "RestoreStatus",
21 shortname = "kopiarestore",
22 category = "kopiur",
23 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
24 printcolumn = r#"{"name":"Source","type":"string","jsonPath":".status.sourceKind"}"#,
25 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
26)]
27#[schemars(extend("x-kubernetes-validations" = [{
32 "rule": "[has(self.target.pvc), has(self.target.pvcRef), has(self.target.populator)].filter(x, x).size() == 1",
33 "message": "exactly one of target.pvc, target.pvcRef, target.populator"
34}]))]
35#[serde(rename_all = "camelCase")]
36pub struct RestoreSpec {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub repository: Option<RepositoryRef>,
41 pub source: RestoreSource,
43 pub target: RestoreTarget,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub options: Option<RestoreOptions>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub policy: Option<RestorePolicy>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub credential_projection: Option<CredentialProjection>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub mover: Option<MoverSpec>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub failure_policy: Option<FailurePolicy>,
60}
61
62#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
64#[serde(rename_all = "camelCase")]
65pub enum RestoreSource {
66 SnapshotRef(ObjectRef),
68 FromPolicy(FromPolicy),
70 Identity(IdentitySource),
72}
73
74impl RestoreSource {
75 pub fn kind_str(&self) -> &'static str {
90 match self {
91 RestoreSource::SnapshotRef(_) => "SnapshotRef",
92 RestoreSource::FromPolicy(_) => "FromPolicy",
93 RestoreSource::Identity(_) => "Identity",
94 }
95 }
96}
97
98#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
100#[serde(rename_all = "camelCase")]
101pub struct FromPolicy {
102 pub name: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub namespace: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub as_of: Option<String>,
110 #[serde(default = "default_offset")]
112 #[schemars(default = "default_offset")]
113 pub offset: i64,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
133 #[schemars(length(max = 4096))]
134 pub source_path: Option<String>,
135}
136
137fn default_offset() -> i64 {
142 0
143}
144
145#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
147#[serde(rename_all = "camelCase")]
148pub struct IdentitySource {
149 pub username: String,
151 pub hostname: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub source_path: Option<String>,
156 #[serde(
158 default,
159 rename = "snapshotID",
160 skip_serializing_if = "Option::is_none"
161 )]
162 pub snapshot_id: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub as_of: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub offset: Option<i64>,
169}
170
171#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
173#[serde(rename_all = "camelCase")]
174pub enum RestoreTarget {
175 Pvc(PvcTemplate),
177 PvcRef(ObjectRef),
179 Populator(PopulatorTarget),
181}
182
183impl RestoreTarget {
184 pub fn kind_str(&self) -> &'static str {
199 match self {
200 RestoreTarget::Pvc(_) => "Pvc",
201 RestoreTarget::PvcRef(_) => "PvcRef",
202 RestoreTarget::Populator(_) => "Populator",
203 }
204 }
205}
206
207#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct PopulatorTarget {}
211
212#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
214#[serde(rename_all = "camelCase")]
215pub struct PvcTemplate {
216 pub name: String,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub storage_class_name: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub capacity: Option<String>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub access_modes: Vec<PvcAccessMode>,
231}
232
233#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct RestoreOptions {
241 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
244 pub enable_file_deletion: bool,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub ignore_permission_errors: Option<bool>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub write_files_atomically: Option<bool>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub parallel: Option<u32>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub write_sparse_files: Option<bool>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub skip_owners: Option<bool>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub skip_permissions: Option<bool>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub skip_times: Option<bool>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub overwrite_files: Option<bool>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub overwrite_directories: Option<bool>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub overwrite_symlinks: Option<bool>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub ignore_errors: Option<bool>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub skip_existing: Option<bool>,
285}
286
287#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
289#[serde(rename_all = "camelCase")]
290pub struct RestorePolicy {
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub on_missing_snapshot: Option<OnMissingSnapshot>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub wait_timeout: Option<String>,
297}
298
299#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
303pub enum OnMissingSnapshot {
304 #[default]
306 Fail,
307 Continue,
309}
310
311#[derive(Clone, Debug, PartialEq, Eq, Default)]
324pub enum RestorePhase {
325 #[default]
327 Pending,
328 Resolving,
330 Restoring,
332 Completed,
334 Failed,
336 Unknown(String),
340}
341
342crate::common::phase_serde!(RestorePhase, "Lifecycle phase of a restore.");
343
344impl RestorePhase {
345 pub fn is_terminal(&self) -> bool {
367 match self {
368 Self::Completed | Self::Failed => true,
369 Self::Pending | Self::Resolving | Self::Restoring => false,
370 Self::Unknown(_) => false,
374 }
375 }
376
377 pub fn is_unknown(&self) -> bool {
394 match self {
395 Self::Unknown(_) => true,
396 Self::Pending | Self::Resolving | Self::Restoring | Self::Completed | Self::Failed => {
397 false
398 }
399 }
400 }
401}
402
403impl crate::common::PhaseLabel for RestorePhase {
404 const ALL: &'static [Self] = &[
405 Self::Pending,
406 Self::Resolving,
407 Self::Restoring,
408 Self::Completed,
409 Self::Failed,
410 ];
411 fn label(&self) -> &str {
412 match self {
413 Self::Pending => "Pending",
414 Self::Resolving => "Resolving",
415 Self::Restoring => "Restoring",
416 Self::Completed => "Completed",
417 Self::Failed => "Failed",
418 Self::Unknown(s) => s,
419 }
420 }
421 fn unknown(raw: String) -> Self {
422 Self::Unknown(raw)
423 }
424}
425
426#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
428#[serde(rename_all = "camelCase")]
429pub struct RestoreStatus {
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub phase: Option<RestorePhase>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub source_kind: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub observed_generation: Option<i64>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub resolved: Option<ResolvedRestore>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub target: Option<RestoreTargetStatus>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub timing: Option<RestoreTiming>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub wait_started_at: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub progress: Option<RestoreProgress>,
466 #[serde(default, skip_serializing_if = "Vec::is_empty")]
468 pub conditions: Vec<Condition>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub log_tail: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub failure: Option<crate::common::FailureBlock>,
475 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
493 pub claims: std::collections::BTreeMap<String, RestoreClaimStatus>,
494}
495
496#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
503#[serde(rename_all = "camelCase")]
504pub struct RestoreClaimStatus {
505 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub uid: Option<String>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub phase: Option<RestoreClaimPhase>,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub reason: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub message: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub source_path: Option<String>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub resolved: Option<ResolvedRestore>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub pvc_prime: Option<String>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub job: Option<String>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub wait_started_at: Option<String>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub observed_at: Option<String>,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub log_tail: Option<String>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub failure: Option<crate::common::FailureBlock>,
554}
555
556#[derive(Clone, Debug, PartialEq, Eq, Default)]
574pub enum RestoreClaimPhase {
575 #[default]
578 Pending,
579 Populating,
581 Rebinding,
584 Populated,
586 AlreadyBound,
590 Failed,
594 Unknown(String),
598}
599
600crate::common::phase_serde!(
601 RestoreClaimPhase,
602 "Lifecycle phase of one claiming PVC of a populator restore."
603);
604
605impl RestoreClaimPhase {
606 pub fn is_terminal(&self) -> bool {
629 match self {
630 Self::Populated | Self::AlreadyBound | Self::Failed => true,
631 Self::Pending | Self::Populating | Self::Rebinding => false,
632 Self::Unknown(_) => false,
635 }
636 }
637
638 pub fn is_unknown(&self) -> bool {
649 match self {
650 Self::Unknown(_) => true,
651 Self::Pending
652 | Self::Populating
653 | Self::Rebinding
654 | Self::Populated
655 | Self::AlreadyBound
656 | Self::Failed => false,
657 }
658 }
659}
660
661impl crate::common::PhaseLabel for RestoreClaimPhase {
662 const ALL: &'static [Self] = &[
663 Self::Pending,
664 Self::Populating,
665 Self::Rebinding,
666 Self::Populated,
667 Self::AlreadyBound,
668 Self::Failed,
669 ];
670 fn label(&self) -> &str {
671 match self {
672 Self::Pending => "Pending",
673 Self::Populating => "Populating",
674 Self::Rebinding => "Rebinding",
675 Self::Populated => "Populated",
676 Self::AlreadyBound => "AlreadyBound",
677 Self::Failed => "Failed",
678 Self::Unknown(s) => s,
679 }
680 }
681 fn unknown(raw: String) -> Self {
682 Self::Unknown(raw)
683 }
684}
685
686#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
688pub enum ResolutionOutcome {
689 Snapshot,
691 NoSnapshot,
693}
694
695#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
697#[serde(rename_all = "camelCase")]
698pub struct ResolvedRestore {
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub resolution: Option<ResolutionOutcome>,
702 #[serde(
704 default,
705 rename = "kopiaSnapshotID",
706 skip_serializing_if = "Option::is_none"
707 )]
708 pub kopia_snapshot_id: Option<String>,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub snapshot_ref: Option<ObjectRef>,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub repository: Option<RepositoryRef>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub pinned_at: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub identity: Option<ResolvedIdentity>,
721}
722
723#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
725#[serde(rename_all = "camelCase")]
726pub struct RestoreTargetStatus {
727 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub pvc_prime: Option<String>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub pvc_ref: Option<ObjectRef>,
733}
734
735#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
737#[serde(rename_all = "camelCase")]
738pub struct RestoreTiming {
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub start_time: Option<String>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub end_time: Option<String>,
745}
746
747#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
749#[serde(rename_all = "camelCase")]
750pub struct RestoreProgress {
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub bytes_restored: Option<i64>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub files_restored: Option<i64>,
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use crate::common::PhaseLabel;
763 use crate::testutil::from_yaml;
764 use kube::core::CustomResourceExt;
765
766 #[test]
767 fn restore_phase_all_covers_every_variant_uniquely() {
768 let labels: Vec<&str> = RestorePhase::ALL.iter().map(|p| p.label()).collect();
772 assert_eq!(RestorePhase::ALL.len(), 5);
773 assert!(labels.iter().all(|l| !l.is_empty()));
774 let mut sorted = labels.clone();
775 sorted.sort_unstable();
776 sorted.dedup();
777 assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
778 assert!(RestorePhase::ALL.contains(&RestorePhase::default()));
779 }
780
781 #[test]
782 fn restore_terminal_set_is_pinned() {
783 let terminal: Vec<&str> = RestorePhase::ALL
785 .iter()
786 .filter(|p| p.is_terminal())
787 .map(|p| p.label())
788 .collect();
789 assert_eq!(terminal, ["Completed", "Failed"]);
790 let in_flight: Vec<&str> = RestorePhase::ALL
791 .iter()
792 .filter(|p| !p.is_terminal())
793 .map(|p| p.label())
794 .collect();
795 assert_eq!(in_flight, ["Pending", "Resolving", "Restoring"]);
796 }
797
798 #[test]
799 fn restore_crd_metadata_is_correct() {
800 let crd = Restore::crd();
801 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
802 assert_eq!(crd.spec.names.kind, "Restore");
803 assert_eq!(crd.spec.scope, "Namespaced");
804 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
805 }
806
807 #[test]
808 fn restore_crd_carries_target_xor_x_kubernetes_validation() {
809 let crd = Restore::crd();
813 let json = serde_json::to_value(&crd).expect("serialize CRD");
814 let spec_schema =
815 &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
816 let rules = spec_schema["x-kubernetes-validations"]
817 .as_array()
818 .expect("spec.x-kubernetes-validations present");
819 assert!(
820 rules.iter().any(|r| r["rule"]
821 .as_str()
822 .is_some_and(|s| s.contains("target.populator"))),
823 "expected the target XOR rule; got {rules:?}"
824 );
825 }
826
827 #[test]
828 fn from_policy_offset_carries_static_openapi_default_in_crd() {
829 let crd = Restore::crd();
831 let json = serde_json::to_value(&crd).expect("serialize CRD");
832 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
833 ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["offset"]["default"];
834 assert_eq!(
835 default, 0,
836 "fromPolicy.offset must emit `default: 0` in the CRD schema; got {default:?}"
837 );
838 }
839
840 #[test]
841 fn from_policy_offset_defaults_to_zero_when_absent() {
842 let spec: RestoreSpec =
843 from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
844 match &spec.source {
845 RestoreSource::FromPolicy(c) => assert_eq!(c.offset, 0),
846 other => panic!("expected FromPolicy, got {}", other.kind_str()),
847 }
848 }
849
850 #[test]
851 fn restore_backup_ref_roundtrip_matches_adr_shape() {
852 let yaml = r#"
854source:
855 snapshotRef: { name: postgres-data-20260524-021300, namespace: billing }
856target:
857 pvc:
858 name: postgres-data-restored
859 storageClassName: fast-ssd
860 capacity: 100Gi
861 accessModes: [ReadWriteOnce]
862options:
863 enableFileDeletion: false
864 ignorePermissionErrors: true
865 writeFilesAtomically: true
866policy:
867 onMissingSnapshot: Fail
868 waitTimeout: 5m
869"#;
870 let spec: RestoreSpec = from_yaml(yaml);
871 assert_eq!(spec.source.kind_str(), "SnapshotRef");
872 match &spec.source {
873 RestoreSource::SnapshotRef(r) => {
874 assert_eq!(r.name, "postgres-data-20260524-021300");
875 assert_eq!(r.namespace.as_deref(), Some("billing"));
876 }
877 other => panic!("expected SnapshotRef, got {}", other.kind_str()),
878 }
879 let target = &spec.target;
880 assert_eq!(target.kind_str(), "Pvc");
881 match target {
882 RestoreTarget::Pvc(t) => {
883 assert_eq!(t.name, "postgres-data-restored");
884 assert_eq!(t.access_modes, vec![PvcAccessMode::ReadWriteOnce]);
885 }
886 other => panic!("expected Pvc, got {}", other.kind_str()),
887 }
888 assert_eq!(
889 spec.policy.as_ref().unwrap().on_missing_snapshot,
890 Some(OnMissingSnapshot::Fail)
891 );
892
893 let json = serde_json::to_value(&spec).expect("serialize");
894 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
895 assert_eq!(spec, reparsed);
896 }
897
898 #[test]
899 fn restore_pvc_access_modes_render_a_closed_enum_in_the_crd_schema() {
900 let crd = Restore::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"]["target"]["properties"]["pvc"]["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 restore_legacy_access_mode_decodes_to_unknown_not_an_error() {
925 let spec: RestoreSpec = from_yaml(
930 "source: { snapshotRef: { name: b } }\n\
931 target: { pvc: { name: restored, capacity: 10Gi, accessModes: [ReadWriteOnze] } }\n",
932 );
933 match &spec.target {
934 RestoreTarget::Pvc(t) => assert_eq!(
935 t.access_modes,
936 vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
937 ),
938 other => panic!("expected Pvc, got {}", other.kind_str()),
939 }
940 let json = serde_json::to_value(&spec).unwrap();
942 assert_eq!(
943 json["target"]["pvc"]["accessModes"],
944 serde_json::json!(["ReadWriteOnze"])
945 );
946 }
947
948 #[test]
949 fn restore_options_full_flag_sweep_roundtrip() {
950 let yaml = r#"
953source: { snapshotRef: { name: b } }
954target: { pvcRef: { name: d } }
955options:
956 enableFileDeletion: true
957 ignorePermissionErrors: false
958 writeFilesAtomically: true
959 parallel: 4
960 writeSparseFiles: true
961 skipOwners: true
962 skipPermissions: false
963 skipTimes: true
964 overwriteFiles: false
965 overwriteDirectories: false
966 overwriteSymlinks: true
967 ignoreErrors: false
968 skipExisting: true
969"#;
970 let spec: RestoreSpec = from_yaml(yaml);
971 let o = spec.options.as_ref().expect("options set");
972 assert!(o.enable_file_deletion);
973 assert_eq!(o.ignore_permission_errors, Some(false));
974 assert_eq!(o.write_files_atomically, Some(true));
975 assert_eq!(o.parallel, Some(4));
976 assert_eq!(o.write_sparse_files, Some(true));
977 assert_eq!(o.skip_owners, Some(true));
978 assert_eq!(o.skip_permissions, Some(false));
979 assert_eq!(o.skip_times, Some(true));
980 assert_eq!(o.overwrite_files, Some(false));
981 assert_eq!(o.overwrite_directories, Some(false));
982 assert_eq!(o.overwrite_symlinks, Some(true));
983 assert_eq!(o.ignore_errors, Some(false));
984 assert_eq!(o.skip_existing, Some(true));
985
986 let json = serde_json::to_value(&spec).expect("serialize");
987 assert_eq!(json["options"]["parallel"], 4);
988 assert_eq!(json["options"]["skipExisting"], true);
989 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
990 assert_eq!(spec, reparsed);
991 }
992
993 #[test]
994 fn restore_options_omits_unset_leaf_fields() {
995 let yaml = r#"
998source: { snapshotRef: { name: b } }
999target: { pvcRef: { name: d } }
1000options:
1001 enableFileDeletion: true
1002"#;
1003 let spec: RestoreSpec = from_yaml(yaml);
1004 let json = serde_json::to_value(&spec).unwrap();
1005 let opts_json = &json["options"];
1006 assert_eq!(opts_json["enableFileDeletion"], true);
1007 for key in [
1008 "ignorePermissionErrors",
1009 "writeFilesAtomically",
1010 "parallel",
1011 "writeSparseFiles",
1012 "skipOwners",
1013 "skipPermissions",
1014 "skipTimes",
1015 "overwriteFiles",
1016 "overwriteDirectories",
1017 "overwriteSymlinks",
1018 "ignoreErrors",
1019 "skipExisting",
1020 ] {
1021 assert!(opts_json.get(key).is_none(), "{key} should be absent");
1022 }
1023 }
1024
1025 #[test]
1026 fn restore_passive_populator_mode_uses_explicit_populator_target() {
1027 let yaml = r#"
1031source: { fromPolicy: { name: postgres-data, offset: 0 } }
1032target: { populator: {} }
1033policy: { onMissingSnapshot: Continue }
1034"#;
1035 let spec: RestoreSpec = from_yaml(yaml);
1036 assert_eq!(spec.source.kind_str(), "FromPolicy");
1037 assert_eq!(spec.target.kind_str(), "Populator");
1038 assert!(matches!(spec.target, RestoreTarget::Populator(_)));
1039 match &spec.source {
1040 RestoreSource::FromPolicy(c) => {
1041 assert_eq!(c.name, "postgres-data");
1042 assert_eq!(c.offset, 0);
1043 }
1044 other => panic!("expected FromPolicy, got {}", other.kind_str()),
1045 }
1046
1047 let json = serde_json::to_value(&spec).unwrap();
1049 assert!(json["target"]["populator"].is_object());
1050 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
1051 assert_eq!(spec, reparsed);
1052 }
1053
1054 #[test]
1055 fn restore_without_target_fails_to_deserialize() {
1056 let value: serde_json::Value =
1058 serde_yaml::from_str("source: { snapshotRef: { name: b } }\n").unwrap();
1059 assert!(
1060 serde_json::from_value::<RestoreSpec>(value).is_err(),
1061 "an absent target must be rejected (no empty-target form, ADR-0005 §9)"
1062 );
1063 }
1064
1065 #[test]
1066 fn restore_populator_rejects_inherit_security_context() {
1067 use crate::common::{InheritSecurityContextFrom, MoverSpec, PodSelector};
1070 use crate::validate::validate_restore;
1071 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
1072 let spec = RestoreSpec {
1073 repository: None,
1074 source: RestoreSource::FromPolicy(FromPolicy {
1075 name: "pg".into(),
1076 namespace: None,
1077 as_of: None,
1078 offset: 0,
1079 source_path: None,
1080 }),
1081 target: RestoreTarget::Populator(PopulatorTarget {}),
1082 options: None,
1083 policy: None,
1084 credential_projection: None,
1085 mover: Some(MoverSpec {
1086 inherit_security_context_from: Some(InheritSecurityContextFrom::WorkloadSelector(
1087 PodSelector {
1088 pod_selector: LabelSelector::default(),
1089 container: None,
1090 },
1091 )),
1092 ..Default::default()
1093 }),
1094 failure_policy: None,
1095 };
1096 assert!(matches!(
1097 validate_restore(&spec),
1098 Err(crate::error::ValidationError::InvalidFieldValue { .. })
1099 ));
1100
1101 let ok = RestoreSpec {
1103 mover: None,
1104 ..spec
1105 };
1106 assert!(validate_restore(&ok).is_ok());
1107 }
1108
1109 #[test]
1110 fn restore_identity_source_requires_repository_in_practice() {
1111 let yaml = r#"
1113repository: { kind: Repository, name: nas-primary, namespace: backups }
1114source:
1115 identity:
1116 username: postgres-data
1117 hostname: billing
1118 sourcePath: /data
1119 snapshotID: k1f1ec0a8
1120target:
1121 pvcRef: { name: postgres-data-restored }
1122"#;
1123 let spec: RestoreSpec = from_yaml(yaml);
1124 assert_eq!(spec.source.kind_str(), "Identity");
1125 assert!(spec.repository.is_some());
1126 match &spec.source {
1127 RestoreSource::Identity(i) => {
1128 assert_eq!(i.username, "postgres-data");
1129 assert_eq!(i.snapshot_id.as_deref(), Some("k1f1ec0a8"));
1130 }
1131 other => panic!("expected Identity, got {}", other.kind_str()),
1132 }
1133 assert_eq!(spec.target.kind_str(), "PvcRef");
1134
1135 let json = serde_json::to_value(&spec).unwrap();
1136 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
1137 assert_eq!(spec, reparsed);
1138 }
1139
1140 #[test]
1141 fn restore_mover_and_failure_policy_roundtrip() {
1142 let yaml = r#"
1145source: { snapshotRef: { name: app-data-backup } }
1146target: { pvcRef: { name: app-data-restored } }
1147mover:
1148 resources:
1149 requests: { cpu: 250m, memory: 512Mi }
1150 limits: { cpu: "2", memory: 4Gi }
1151 cache:
1152 capacity: 16Gi
1153 storageClassName: fast-ssd
1154 securityContext:
1155 runAsUser: 1000
1156 runAsGroup: 1000
1157 runAsNonRoot: true
1158 allowPrivilegeEscalation: false
1159 capabilities: { drop: ["ALL"] }
1160 seccompProfile: { type: RuntimeDefault }
1161 podSecurityContext:
1162 fsGroup: 1000
1163 fsGroupChangePolicy: OnRootMismatch
1164failurePolicy:
1165 backoffLimit: 4
1166 activeDeadlineSeconds: 3600
1167"#;
1168 let spec: RestoreSpec = from_yaml(yaml);
1169 let mover = spec.mover.as_ref().expect("mover");
1170 assert!(mover.resources.is_some());
1171 assert_eq!(
1172 mover.cache.as_ref().and_then(|c| c.capacity.as_deref()),
1173 Some("16Gi")
1174 );
1175 assert_eq!(
1176 mover.security_context.as_ref().and_then(|s| s.run_as_user),
1177 Some(1000)
1178 );
1179 assert_eq!(
1182 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1183 Some(1000)
1184 );
1185 assert!(!mover.requires_privilege());
1187 let fp = spec.failure_policy.as_ref().expect("failurePolicy");
1188 assert_eq!(fp.backoff_limit, Some(4));
1189 assert_eq!(fp.active_deadline_seconds, Some(3600));
1190
1191 let json = serde_json::to_value(&spec).expect("serialize");
1192 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1193 assert_eq!(spec, reparsed);
1194 }
1195
1196 #[test]
1197 fn restore_mover_root_context_is_privileged() {
1198 let yaml = r#"
1201source: { snapshotRef: { name: app-data-backup } }
1202target: { pvcRef: { name: app-data-restored } }
1203mover:
1204 securityContext:
1205 runAsUser: 0
1206 runAsNonRoot: false
1207"#;
1208 let spec: RestoreSpec = from_yaml(yaml);
1209 assert!(spec.mover.as_ref().unwrap().requires_privilege());
1210 }
1211
1212 #[test]
1213 fn restore_crd_schema_carries_the_snapshot_inherit_variant() {
1214 let crd = Restore::crd();
1220 let json = serde_json::to_value(&crd).expect("serialize CRD");
1221 let inherit = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1222 ["properties"]["mover"]["properties"]["inheritSecurityContextFrom"]["properties"];
1223 assert!(
1224 inherit["snapshot"].is_object(),
1225 "inheritSecurityContextFrom must carry the `snapshot` variant; got {inherit}"
1226 );
1227 assert_eq!(inherit["snapshot"]["type"], "object");
1228 assert!(inherit["workloadSelector"].is_object());
1230 assert!(inherit["pvcConsumer"].is_object());
1231 let _ = crate::SnapshotPolicy::crd();
1232 }
1233
1234 #[test]
1235 fn restore_snapshot_inherit_mover_roundtrip() {
1236 use crate::common::{InheritSecurityContextFrom, SnapshotInherit};
1238 let yaml = r#"
1239source: { snapshotRef: { name: app-data-backup } }
1240target: { pvcRef: { name: app-data-restored } }
1241mover:
1242 inheritSecurityContextFrom:
1243 snapshot: {}
1244"#;
1245 let spec: RestoreSpec = from_yaml(yaml);
1246 assert!(matches!(
1247 spec.mover
1248 .as_ref()
1249 .and_then(|m| m.inherit_security_context_from.as_ref()),
1250 Some(InheritSecurityContextFrom::Snapshot(SnapshotInherit {})),
1251 ));
1252 let json = serde_json::to_value(&spec).expect("serialize");
1253 assert!(json["mover"]["inheritSecurityContextFrom"]["snapshot"].is_object());
1254 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1255 assert_eq!(spec, reparsed);
1256 }
1257
1258 #[test]
1259 fn restore_source_unknown_variant_is_rejected() {
1260 let value: serde_json::Value = serde_yaml::from_str("snapshotUrl:\n url: x\n").unwrap();
1261 assert!(serde_json::from_value::<RestoreSource>(value).is_err());
1262 }
1263
1264 #[test]
1265 fn on_missing_snapshot_and_phase_serialize_to_expected_strings() {
1266 assert_eq!(
1267 serde_json::to_value(OnMissingSnapshot::Fail).unwrap(),
1268 "Fail"
1269 );
1270 assert_eq!(
1271 serde_json::to_value(OnMissingSnapshot::Continue).unwrap(),
1272 "Continue"
1273 );
1274 assert_eq!(
1275 serde_json::to_value(RestorePhase::Restoring).unwrap(),
1276 "Restoring"
1277 );
1278 assert_eq!(
1279 serde_json::to_value(RestorePhase::Completed).unwrap(),
1280 "Completed"
1281 );
1282 }
1283
1284 #[test]
1287 fn restore_claim_phase_all_covers_every_variant_uniquely() {
1288 let labels: Vec<&str> = RestoreClaimPhase::ALL.iter().map(|p| p.label()).collect();
1292 assert_eq!(RestoreClaimPhase::ALL.len(), 6);
1293 assert!(labels.iter().all(|l| !l.is_empty()));
1294 let mut sorted = labels.clone();
1295 sorted.sort_unstable();
1296 sorted.dedup();
1297 assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
1298 assert!(RestoreClaimPhase::ALL.contains(&RestoreClaimPhase::default()));
1299 }
1300
1301 #[test]
1302 fn restore_claim_terminal_set_is_pinned() {
1303 let terminal: Vec<&str> = RestoreClaimPhase::ALL
1305 .iter()
1306 .filter(|p| p.is_terminal())
1307 .map(|p| p.label())
1308 .collect();
1309 assert_eq!(terminal, ["Populated", "AlreadyBound", "Failed"]);
1310 let in_flight: Vec<&str> = RestoreClaimPhase::ALL
1311 .iter()
1312 .filter(|p| !p.is_terminal())
1313 .map(|p| p.label())
1314 .collect();
1315 assert_eq!(in_flight, ["Pending", "Populating", "Rebinding"]);
1316 assert!(!RestoreClaimPhase::ALL.iter().any(|p| p.is_unknown()));
1318 }
1319
1320 #[test]
1321 fn restore_claim_phase_round_trips_and_tolerates_an_unknown_value() {
1322 for p in RestoreClaimPhase::ALL {
1323 let json = serde_json::to_value(p).expect("serialize");
1324 assert_eq!(json, p.label());
1325 let back: RestoreClaimPhase = serde_json::from_value(json).expect("decode");
1326 assert_eq!(&back, p);
1327 }
1328 let unknown: RestoreClaimPhase =
1330 serde_json::from_value(serde_json::json!("Quiescing")).expect("decodes");
1331 assert_eq!(unknown, RestoreClaimPhase::Unknown("Quiescing".into()));
1332 assert!(unknown.is_unknown());
1333 assert!(!unknown.is_terminal());
1334 assert_eq!(serde_json::to_value(&unknown).unwrap(), "Quiescing");
1336 }
1337
1338 #[test]
1339 fn restore_claim_phase_schema_publishes_only_canonical_values() {
1340 let crd = Restore::crd();
1342 let json = serde_json::to_value(&crd).expect("serialize CRD");
1343 let phase = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
1344 ["properties"]["claims"]["additionalProperties"]["properties"]["phase"];
1345 let values: Vec<String> = phase["enum"]
1346 .as_array()
1347 .map(|a| {
1348 a.iter()
1349 .map(|v| v.as_str().unwrap_or_default().to_string())
1350 .collect()
1351 })
1352 .unwrap_or_default();
1353 assert_eq!(
1354 values,
1355 RestoreClaimPhase::canonical(),
1356 "claim phase schema must be exactly the canonical set; got {phase}"
1357 );
1358 }
1359
1360 #[test]
1361 fn restore_status_claims_render_as_an_additional_properties_map() {
1362 let crd = Restore::crd();
1368 let json = serde_json::to_value(&crd).expect("serialize CRD");
1369 let claims = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
1370 ["properties"]["claims"];
1371 assert_eq!(claims["type"], "object", "got {claims}");
1372 let props = claims["additionalProperties"]["properties"]
1373 .as_object()
1374 .expect("per-claim properties");
1375 for key in [
1376 "uid",
1377 "phase",
1378 "reason",
1379 "message",
1380 "sourcePath",
1381 "resolved",
1382 "pvcPrime",
1383 "job",
1384 "waitStartedAt",
1385 "observedAt",
1386 "logTail",
1387 "failure",
1388 ] {
1389 assert!(props.contains_key(key), "missing `{key}` in {claims}");
1390 }
1391 }
1392
1393 #[test]
1394 fn restore_status_claims_round_trip_through_the_apiserver_shape() {
1395 let status: RestoreStatus = from_yaml(
1396 r#"
1397phase: Restoring
1398claims:
1399 data-0:
1400 uid: 11111111-2222-3333-4444-555555555555
1401 phase: Populated
1402 reason: RestoreSucceeded
1403 sourcePath: /pvc/data-0
1404 pvcPrime: prime-1111
1405 job: r-populate-deadbeef
1406 observedAt: "2026-09-01T00:00:00Z"
1407 resolved:
1408 resolution: Snapshot
1409 kopiaSnapshotID: k1
1410 data-1:
1411 uid: 66666666-2222-3333-4444-555555555555
1412 phase: Failed
1413 reason: MoverJobFailed
1414"#,
1415 );
1416 assert_eq!(status.claims.len(), 2);
1417 let a = &status.claims["data-0"];
1418 assert_eq!(a.phase, Some(RestoreClaimPhase::Populated));
1419 assert_eq!(a.source_path.as_deref(), Some("/pvc/data-0"));
1420 assert_eq!(a.job.as_deref(), Some("r-populate-deadbeef"));
1421 assert_eq!(
1422 a.resolved
1423 .as_ref()
1424 .and_then(|r| r.kopia_snapshot_id.as_deref()),
1425 Some("k1")
1426 );
1427 assert_eq!(
1428 status.claims["data-1"].phase,
1429 Some(RestoreClaimPhase::Failed)
1430 );
1431
1432 let json = serde_json::to_value(&status).expect("serialize");
1434 let reparsed: RestoreStatus = serde_json::from_value(json).expect("reparse");
1435 assert_eq!(status, reparsed);
1436
1437 let direct = RestoreStatus::default();
1440 let json = serde_json::to_value(&direct).expect("serialize");
1441 assert!(
1442 json.get("claims").is_none(),
1443 "an empty claims map must not be written: {json}"
1444 );
1445 }
1446
1447 #[test]
1448 fn from_policy_round_trips_with_and_without_source_path() {
1449 let with: RestoreSpec = from_yaml(
1450 "source: { fromPolicy: { name: pg, sourcePath: /pvc/pgdata } }\n\
1451 target: { populator: {} }\n",
1452 );
1453 match &with.source {
1454 RestoreSource::FromPolicy(c) => {
1455 assert_eq!(c.source_path.as_deref(), Some("/pvc/pgdata"));
1456 assert_eq!(c.offset, 0);
1457 }
1458 other => panic!("expected FromPolicy, got {}", other.kind_str()),
1459 }
1460 let json = serde_json::to_value(&with).expect("serialize");
1461 assert_eq!(json["source"]["fromPolicy"]["sourcePath"], "/pvc/pgdata");
1462 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1463 assert_eq!(with, reparsed);
1464
1465 let without: RestoreSpec =
1467 from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
1468 match &without.source {
1469 RestoreSource::FromPolicy(c) => assert_eq!(c.source_path, None),
1470 other => panic!("expected FromPolicy, got {}", other.kind_str()),
1471 }
1472 let json = serde_json::to_value(&without).expect("serialize");
1473 assert!(
1474 json["source"]["fromPolicy"].get("sourcePath").is_none(),
1475 "{json}"
1476 );
1477 }
1478
1479 #[test]
1480 fn from_policy_source_path_is_a_bounded_string_in_the_crd_schema() {
1481 let crd = Restore::crd();
1482 let json = serde_json::to_value(&crd).expect("serialize CRD");
1483 let field = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1484 ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["sourcePath"];
1485 assert_eq!(field["type"], "string", "got {field}");
1486 assert_eq!(field["maxLength"], 4096, "got {field}");
1487 }
1488}