1use crate::common::{
5 CredentialProjection, FailurePolicy, MoverSpec, ObjectRef, PvcAccessMode, RepositoryRef,
6 ResolvedIdentity,
7};
8use crate::snapshot_policy::StreamExec;
9use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
10use kube::CustomResource;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
16#[kube(
17 group = "kopiur.home-operations.com",
18 version = "v1alpha1",
19 kind = "Restore",
20 namespaced,
21 status = "RestoreStatus",
22 shortname = "kopiarestore",
23 category = "kopiur",
24 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
25 printcolumn = r#"{"name":"Source","type":"string","jsonPath":".status.sourceKind"}"#,
26 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
27)]
28#[schemars(extend("x-kubernetes-validations" = [{
39 "rule": "[has(self.target.pvc), has(self.target.pvcRef), has(self.target.populator), has(self.target.streamExec)].filter(x, x).size() == 1",
40 "message": "exactly one of target.pvc, target.pvcRef, target.populator, target.streamExec"
41}]))]
42#[serde(rename_all = "camelCase")]
43pub struct RestoreSpec {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub repository: Option<RepositoryRef>,
48 pub source: RestoreSource,
50 pub target: RestoreTarget,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub options: Option<RestoreOptions>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub policy: Option<RestorePolicy>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub credential_projection: Option<CredentialProjection>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub mover: Option<MoverSpec>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub failure_policy: Option<FailurePolicy>,
67}
68
69#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub enum RestoreSource {
73 SnapshotRef(ObjectRef),
75 FromPolicy(FromPolicy),
77 Identity(IdentitySource),
79}
80
81impl RestoreSource {
82 pub fn kind_str(&self) -> &'static str {
97 match self {
98 RestoreSource::SnapshotRef(_) => "SnapshotRef",
99 RestoreSource::FromPolicy(_) => "FromPolicy",
100 RestoreSource::Identity(_) => "Identity",
101 }
102 }
103}
104
105#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
107#[serde(rename_all = "camelCase")]
108pub struct FromPolicy {
109 pub name: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub namespace: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub as_of: Option<String>,
117 #[serde(default = "default_offset")]
119 #[schemars(default = "default_offset")]
120 pub offset: i64,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
140 #[schemars(length(max = 4096))]
141 pub source_path: Option<String>,
142}
143
144fn default_offset() -> i64 {
149 0
150}
151
152#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
154#[serde(rename_all = "camelCase")]
155pub struct IdentitySource {
156 pub username: String,
158 pub hostname: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub source_path: Option<String>,
163 #[serde(
165 default,
166 rename = "snapshotID",
167 skip_serializing_if = "Option::is_none"
168 )]
169 pub snapshot_id: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub as_of: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub offset: Option<i64>,
176}
177
178#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
180#[serde(rename_all = "camelCase")]
181pub enum RestoreTarget {
182 Pvc(PvcTemplate),
184 PvcRef(ObjectRef),
186 Populator(PopulatorTarget),
188 StreamExec(StreamExecTarget),
192}
193
194impl RestoreTarget {
195 pub fn kind_str(&self) -> &'static str {
210 match self {
211 RestoreTarget::Pvc(_) => "Pvc",
212 RestoreTarget::PvcRef(_) => "PvcRef",
213 RestoreTarget::Populator(_) => "Populator",
214 RestoreTarget::StreamExec(_) => "StreamExec",
215 }
216 }
217}
218
219#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct StreamExecTarget {
229 #[schemars(length(min = 1, max = 255))]
232 pub file_name: String,
233 pub workload_exec: StreamExec,
235}
236
237#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct PopulatorTarget {}
241
242#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct PvcTemplate {
246 pub name: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub storage_class_name: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub capacity: Option<String>,
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub access_modes: Vec<PvcAccessMode>,
261}
262
263#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
269#[serde(rename_all = "camelCase")]
270pub struct RestoreOptions {
271 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
274 pub enable_file_deletion: bool,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub ignore_permission_errors: Option<bool>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub write_files_atomically: Option<bool>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub parallel: Option<u32>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub write_sparse_files: Option<bool>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub skip_owners: Option<bool>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub skip_permissions: Option<bool>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub skip_times: Option<bool>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub overwrite_files: Option<bool>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub overwrite_directories: Option<bool>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub overwrite_symlinks: Option<bool>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub ignore_errors: Option<bool>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub skip_existing: Option<bool>,
315}
316
317#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
319#[serde(rename_all = "camelCase")]
320pub struct RestorePolicy {
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub on_missing_snapshot: Option<OnMissingSnapshot>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub wait_timeout: Option<String>,
327}
328
329#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
333pub enum OnMissingSnapshot {
334 #[default]
336 Fail,
337 Continue,
339}
340
341#[derive(Clone, Debug, PartialEq, Eq, Default)]
354pub enum RestorePhase {
355 #[default]
357 Pending,
358 Resolving,
360 Restoring,
362 Completed,
364 Failed,
366 Unknown(String),
370}
371
372crate::common::phase_serde!(RestorePhase, "Lifecycle phase of a restore.");
373
374impl RestorePhase {
375 pub fn is_terminal(&self) -> bool {
397 match self {
398 Self::Completed | Self::Failed => true,
399 Self::Pending | Self::Resolving | Self::Restoring => false,
400 Self::Unknown(_) => false,
404 }
405 }
406
407 pub fn is_unknown(&self) -> bool {
424 match self {
425 Self::Unknown(_) => true,
426 Self::Pending | Self::Resolving | Self::Restoring | Self::Completed | Self::Failed => {
427 false
428 }
429 }
430 }
431}
432
433impl crate::common::PhaseLabel for RestorePhase {
434 const ALL: &'static [Self] = &[
435 Self::Pending,
436 Self::Resolving,
437 Self::Restoring,
438 Self::Completed,
439 Self::Failed,
440 ];
441 fn label(&self) -> &str {
442 match self {
443 Self::Pending => "Pending",
444 Self::Resolving => "Resolving",
445 Self::Restoring => "Restoring",
446 Self::Completed => "Completed",
447 Self::Failed => "Failed",
448 Self::Unknown(s) => s,
449 }
450 }
451 fn unknown(raw: String) -> Self {
452 Self::Unknown(raw)
453 }
454}
455
456#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
458#[serde(rename_all = "camelCase")]
459pub struct RestoreStatus {
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub phase: Option<RestorePhase>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub source_kind: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub observed_generation: Option<i64>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub resolved: Option<ResolvedRestore>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub target: Option<RestoreTargetStatus>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub timing: Option<RestoreTiming>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub wait_started_at: Option<String>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub progress: Option<RestoreProgress>,
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub conditions: Vec<Condition>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub log_tail: Option<String>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub failure: Option<crate::common::FailureBlock>,
505 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
523 pub claims: std::collections::BTreeMap<String, RestoreClaimStatus>,
524}
525
526#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
533#[serde(rename_all = "camelCase")]
534pub struct RestoreClaimStatus {
535 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub uid: Option<String>,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub phase: Option<RestoreClaimPhase>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub reason: Option<String>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub message: Option<String>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub source_path: Option<String>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub resolved: Option<ResolvedRestore>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub pvc_prime: Option<String>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub job: Option<String>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub wait_started_at: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub observed_at: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub log_tail: Option<String>,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub failure: Option<crate::common::FailureBlock>,
584}
585
586#[derive(Clone, Debug, PartialEq, Eq, Default)]
604pub enum RestoreClaimPhase {
605 #[default]
608 Pending,
609 Populating,
611 Rebinding,
614 Populated,
616 AlreadyBound,
620 Failed,
624 Unknown(String),
628}
629
630crate::common::phase_serde!(
631 RestoreClaimPhase,
632 "Lifecycle phase of one claiming PVC of a populator restore."
633);
634
635impl RestoreClaimPhase {
636 pub fn is_terminal(&self) -> bool {
659 match self {
660 Self::Populated | Self::AlreadyBound | Self::Failed => true,
661 Self::Pending | Self::Populating | Self::Rebinding => false,
662 Self::Unknown(_) => false,
665 }
666 }
667
668 pub fn is_unknown(&self) -> bool {
679 match self {
680 Self::Unknown(_) => true,
681 Self::Pending
682 | Self::Populating
683 | Self::Rebinding
684 | Self::Populated
685 | Self::AlreadyBound
686 | Self::Failed => false,
687 }
688 }
689}
690
691impl crate::common::PhaseLabel for RestoreClaimPhase {
692 const ALL: &'static [Self] = &[
693 Self::Pending,
694 Self::Populating,
695 Self::Rebinding,
696 Self::Populated,
697 Self::AlreadyBound,
698 Self::Failed,
699 ];
700 fn label(&self) -> &str {
701 match self {
702 Self::Pending => "Pending",
703 Self::Populating => "Populating",
704 Self::Rebinding => "Rebinding",
705 Self::Populated => "Populated",
706 Self::AlreadyBound => "AlreadyBound",
707 Self::Failed => "Failed",
708 Self::Unknown(s) => s,
709 }
710 }
711 fn unknown(raw: String) -> Self {
712 Self::Unknown(raw)
713 }
714}
715
716#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
718pub enum ResolutionOutcome {
719 Snapshot,
721 NoSnapshot,
723}
724
725#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
727#[serde(rename_all = "camelCase")]
728pub struct ResolvedRestore {
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub resolution: Option<ResolutionOutcome>,
732 #[serde(
734 default,
735 rename = "kopiaSnapshotID",
736 skip_serializing_if = "Option::is_none"
737 )]
738 pub kopia_snapshot_id: Option<String>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub snapshot_ref: Option<ObjectRef>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub repository: Option<RepositoryRef>,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub pinned_at: Option<String>,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub identity: Option<ResolvedIdentity>,
751}
752
753#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
755#[serde(rename_all = "camelCase")]
756pub struct RestoreTargetStatus {
757 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub pvc_prime: Option<String>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub pvc_ref: Option<ObjectRef>,
763}
764
765#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
767#[serde(rename_all = "camelCase")]
768pub struct RestoreTiming {
769 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub start_time: Option<String>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub end_time: Option<String>,
775}
776
777#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
779#[serde(rename_all = "camelCase")]
780pub struct RestoreProgress {
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub bytes_restored: Option<i64>,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub files_restored: Option<i64>,
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::common::PhaseLabel;
793 use crate::testutil::from_yaml;
794 use kube::core::CustomResourceExt;
795
796 #[test]
797 fn restore_phase_all_covers_every_variant_uniquely() {
798 let labels: Vec<&str> = RestorePhase::ALL.iter().map(|p| p.label()).collect();
802 assert_eq!(RestorePhase::ALL.len(), 5);
803 assert!(labels.iter().all(|l| !l.is_empty()));
804 let mut sorted = labels.clone();
805 sorted.sort_unstable();
806 sorted.dedup();
807 assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
808 assert!(RestorePhase::ALL.contains(&RestorePhase::default()));
809 }
810
811 #[test]
812 fn restore_terminal_set_is_pinned() {
813 let terminal: Vec<&str> = RestorePhase::ALL
815 .iter()
816 .filter(|p| p.is_terminal())
817 .map(|p| p.label())
818 .collect();
819 assert_eq!(terminal, ["Completed", "Failed"]);
820 let in_flight: Vec<&str> = RestorePhase::ALL
821 .iter()
822 .filter(|p| !p.is_terminal())
823 .map(|p| p.label())
824 .collect();
825 assert_eq!(in_flight, ["Pending", "Resolving", "Restoring"]);
826 }
827
828 #[test]
829 fn restore_crd_metadata_is_correct() {
830 let crd = Restore::crd();
831 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
832 assert_eq!(crd.spec.names.kind, "Restore");
833 assert_eq!(crd.spec.scope, "Namespaced");
834 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
835 }
836
837 #[test]
838 fn restore_crd_carries_target_xor_x_kubernetes_validation() {
839 let crd = Restore::crd();
843 let json = serde_json::to_value(&crd).expect("serialize CRD");
844 let spec_schema =
845 &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
846 let rules = spec_schema["x-kubernetes-validations"]
847 .as_array()
848 .expect("spec.x-kubernetes-validations present");
849 assert!(
850 rules.iter().any(|r| r["rule"]
851 .as_str()
852 .is_some_and(|s| s.contains("target.populator"))),
853 "expected the target XOR rule; got {rules:?}"
854 );
855 }
856
857 #[test]
858 fn from_policy_offset_carries_static_openapi_default_in_crd() {
859 let crd = Restore::crd();
861 let json = serde_json::to_value(&crd).expect("serialize CRD");
862 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
863 ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["offset"]["default"];
864 assert_eq!(
865 default, 0,
866 "fromPolicy.offset must emit `default: 0` in the CRD schema; got {default:?}"
867 );
868 }
869
870 #[test]
871 fn from_policy_offset_defaults_to_zero_when_absent() {
872 let spec: RestoreSpec =
873 from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
874 match &spec.source {
875 RestoreSource::FromPolicy(c) => assert_eq!(c.offset, 0),
876 other => panic!("expected FromPolicy, got {}", other.kind_str()),
877 }
878 }
879
880 #[test]
881 fn restore_backup_ref_roundtrip_matches_adr_shape() {
882 let yaml = r#"
884source:
885 snapshotRef: { name: postgres-data-20260524-021300, namespace: billing }
886target:
887 pvc:
888 name: postgres-data-restored
889 storageClassName: fast-ssd
890 capacity: 100Gi
891 accessModes: [ReadWriteOnce]
892options:
893 enableFileDeletion: false
894 ignorePermissionErrors: true
895 writeFilesAtomically: true
896policy:
897 onMissingSnapshot: Fail
898 waitTimeout: 5m
899"#;
900 let spec: RestoreSpec = from_yaml(yaml);
901 assert_eq!(spec.source.kind_str(), "SnapshotRef");
902 match &spec.source {
903 RestoreSource::SnapshotRef(r) => {
904 assert_eq!(r.name, "postgres-data-20260524-021300");
905 assert_eq!(r.namespace.as_deref(), Some("billing"));
906 }
907 other => panic!("expected SnapshotRef, got {}", other.kind_str()),
908 }
909 let target = &spec.target;
910 assert_eq!(target.kind_str(), "Pvc");
911 match target {
912 RestoreTarget::Pvc(t) => {
913 assert_eq!(t.name, "postgres-data-restored");
914 assert_eq!(t.access_modes, vec![PvcAccessMode::ReadWriteOnce]);
915 }
916 other => panic!("expected Pvc, got {}", other.kind_str()),
917 }
918 assert_eq!(
919 spec.policy.as_ref().unwrap().on_missing_snapshot,
920 Some(OnMissingSnapshot::Fail)
921 );
922
923 let json = serde_json::to_value(&spec).expect("serialize");
924 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
925 assert_eq!(spec, reparsed);
926 }
927
928 #[test]
929 fn restore_pvc_access_modes_render_a_closed_enum_in_the_crd_schema() {
930 let crd = Restore::crd();
934 let json = serde_json::to_value(&crd).expect("serialize CRD");
935 let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
936 ["properties"]["target"]["properties"]["pvc"]["properties"]["accessModes"]["items"];
937 assert_eq!(
938 items["type"], "string",
939 "items must be strings; got {items}"
940 );
941 assert_eq!(
942 items["enum"],
943 serde_json::json!([
944 "ReadWriteOnce",
945 "ReadOnlyMany",
946 "ReadWriteMany",
947 "ReadWriteOncePod"
948 ]),
949 "items enum must be exactly the canonical modes; got {items}"
950 );
951 }
952
953 #[test]
954 fn restore_legacy_access_mode_decodes_to_unknown_not_an_error() {
955 let spec: RestoreSpec = from_yaml(
960 "source: { snapshotRef: { name: b } }\n\
961 target: { pvc: { name: restored, capacity: 10Gi, accessModes: [ReadWriteOnze] } }\n",
962 );
963 match &spec.target {
964 RestoreTarget::Pvc(t) => assert_eq!(
965 t.access_modes,
966 vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
967 ),
968 other => panic!("expected Pvc, got {}", other.kind_str()),
969 }
970 let json = serde_json::to_value(&spec).unwrap();
972 assert_eq!(
973 json["target"]["pvc"]["accessModes"],
974 serde_json::json!(["ReadWriteOnze"])
975 );
976 }
977
978 #[test]
979 fn restore_options_full_flag_sweep_roundtrip() {
980 let yaml = r#"
983source: { snapshotRef: { name: b } }
984target: { pvcRef: { name: d } }
985options:
986 enableFileDeletion: true
987 ignorePermissionErrors: false
988 writeFilesAtomically: true
989 parallel: 4
990 writeSparseFiles: true
991 skipOwners: true
992 skipPermissions: false
993 skipTimes: true
994 overwriteFiles: false
995 overwriteDirectories: false
996 overwriteSymlinks: true
997 ignoreErrors: false
998 skipExisting: true
999"#;
1000 let spec: RestoreSpec = from_yaml(yaml);
1001 let o = spec.options.as_ref().expect("options set");
1002 assert!(o.enable_file_deletion);
1003 assert_eq!(o.ignore_permission_errors, Some(false));
1004 assert_eq!(o.write_files_atomically, Some(true));
1005 assert_eq!(o.parallel, Some(4));
1006 assert_eq!(o.write_sparse_files, Some(true));
1007 assert_eq!(o.skip_owners, Some(true));
1008 assert_eq!(o.skip_permissions, Some(false));
1009 assert_eq!(o.skip_times, Some(true));
1010 assert_eq!(o.overwrite_files, Some(false));
1011 assert_eq!(o.overwrite_directories, Some(false));
1012 assert_eq!(o.overwrite_symlinks, Some(true));
1013 assert_eq!(o.ignore_errors, Some(false));
1014 assert_eq!(o.skip_existing, Some(true));
1015
1016 let json = serde_json::to_value(&spec).expect("serialize");
1017 assert_eq!(json["options"]["parallel"], 4);
1018 assert_eq!(json["options"]["skipExisting"], true);
1019 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1020 assert_eq!(spec, reparsed);
1021 }
1022
1023 #[test]
1024 fn restore_options_omits_unset_leaf_fields() {
1025 let yaml = r#"
1028source: { snapshotRef: { name: b } }
1029target: { pvcRef: { name: d } }
1030options:
1031 enableFileDeletion: true
1032"#;
1033 let spec: RestoreSpec = from_yaml(yaml);
1034 let json = serde_json::to_value(&spec).unwrap();
1035 let opts_json = &json["options"];
1036 assert_eq!(opts_json["enableFileDeletion"], true);
1037 for key in [
1038 "ignorePermissionErrors",
1039 "writeFilesAtomically",
1040 "parallel",
1041 "writeSparseFiles",
1042 "skipOwners",
1043 "skipPermissions",
1044 "skipTimes",
1045 "overwriteFiles",
1046 "overwriteDirectories",
1047 "overwriteSymlinks",
1048 "ignoreErrors",
1049 "skipExisting",
1050 ] {
1051 assert!(opts_json.get(key).is_none(), "{key} should be absent");
1052 }
1053 }
1054
1055 #[test]
1056 fn restore_passive_populator_mode_uses_explicit_populator_target() {
1057 let yaml = r#"
1061source: { fromPolicy: { name: postgres-data, offset: 0 } }
1062target: { populator: {} }
1063policy: { onMissingSnapshot: Continue }
1064"#;
1065 let spec: RestoreSpec = from_yaml(yaml);
1066 assert_eq!(spec.source.kind_str(), "FromPolicy");
1067 assert_eq!(spec.target.kind_str(), "Populator");
1068 assert!(matches!(spec.target, RestoreTarget::Populator(_)));
1069 match &spec.source {
1070 RestoreSource::FromPolicy(c) => {
1071 assert_eq!(c.name, "postgres-data");
1072 assert_eq!(c.offset, 0);
1073 }
1074 other => panic!("expected FromPolicy, got {}", other.kind_str()),
1075 }
1076
1077 let json = serde_json::to_value(&spec).unwrap();
1079 assert!(json["target"]["populator"].is_object());
1080 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
1081 assert_eq!(spec, reparsed);
1082 }
1083
1084 #[test]
1085 fn restore_without_target_fails_to_deserialize() {
1086 let value: serde_json::Value =
1088 serde_saphyr::from_str("source: { snapshotRef: { name: b } }\n").unwrap();
1089 assert!(
1090 serde_json::from_value::<RestoreSpec>(value).is_err(),
1091 "an absent target must be rejected (no empty-target form, ADR-0005 §9)"
1092 );
1093 }
1094
1095 #[test]
1096 fn restore_populator_rejects_inherit_security_context() {
1097 use crate::common::{InheritSecurityContextFrom, MoverSpec, PodSelector};
1100 use crate::validate::validate_restore;
1101 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
1102 let spec = RestoreSpec {
1103 repository: None,
1104 source: RestoreSource::FromPolicy(FromPolicy {
1105 name: "pg".into(),
1106 namespace: None,
1107 as_of: None,
1108 offset: 0,
1109 source_path: None,
1110 }),
1111 target: RestoreTarget::Populator(PopulatorTarget {}),
1112 options: None,
1113 policy: None,
1114 credential_projection: None,
1115 mover: Some(MoverSpec {
1116 inherit_security_context_from: Some(InheritSecurityContextFrom::WorkloadSelector(
1117 PodSelector {
1118 pod_selector: LabelSelector::default(),
1119 container: None,
1120 },
1121 )),
1122 ..Default::default()
1123 }),
1124 failure_policy: None,
1125 };
1126 assert!(matches!(
1127 validate_restore(&spec),
1128 Err(crate::error::ValidationError::InvalidFieldValue { .. })
1129 ));
1130
1131 let ok = RestoreSpec {
1133 mover: None,
1134 ..spec
1135 };
1136 assert!(validate_restore(&ok).is_ok());
1137 }
1138
1139 #[test]
1140 fn restore_identity_source_requires_repository_in_practice() {
1141 let yaml = r#"
1143repository: { kind: Repository, name: nas-primary, namespace: backups }
1144source:
1145 identity:
1146 username: postgres-data
1147 hostname: billing
1148 sourcePath: /data
1149 snapshotID: k1f1ec0a8
1150target:
1151 pvcRef: { name: postgres-data-restored }
1152"#;
1153 let spec: RestoreSpec = from_yaml(yaml);
1154 assert_eq!(spec.source.kind_str(), "Identity");
1155 assert!(spec.repository.is_some());
1156 match &spec.source {
1157 RestoreSource::Identity(i) => {
1158 assert_eq!(i.username, "postgres-data");
1159 assert_eq!(i.snapshot_id.as_deref(), Some("k1f1ec0a8"));
1160 }
1161 other => panic!("expected Identity, got {}", other.kind_str()),
1162 }
1163 assert_eq!(spec.target.kind_str(), "PvcRef");
1164
1165 let json = serde_json::to_value(&spec).unwrap();
1166 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
1167 assert_eq!(spec, reparsed);
1168 }
1169
1170 #[test]
1171 fn restore_mover_and_failure_policy_roundtrip() {
1172 let yaml = r#"
1175source: { snapshotRef: { name: app-data-backup } }
1176target: { pvcRef: { name: app-data-restored } }
1177mover:
1178 resources:
1179 requests: { cpu: 250m, memory: 512Mi }
1180 limits: { cpu: "2", memory: 4Gi }
1181 cache:
1182 capacity: 16Gi
1183 storageClassName: fast-ssd
1184 securityContext:
1185 runAsUser: 1000
1186 runAsGroup: 1000
1187 runAsNonRoot: true
1188 allowPrivilegeEscalation: false
1189 capabilities: { drop: ["ALL"] }
1190 seccompProfile: { type: RuntimeDefault }
1191 podSecurityContext:
1192 fsGroup: 1000
1193 fsGroupChangePolicy: OnRootMismatch
1194failurePolicy:
1195 backoffLimit: 4
1196 activeDeadlineSeconds: 3600
1197"#;
1198 let spec: RestoreSpec = from_yaml(yaml);
1199 let mover = spec.mover.as_ref().expect("mover");
1200 assert!(mover.resources.is_some());
1201 assert_eq!(
1202 mover.cache.as_ref().and_then(|c| c.capacity.as_deref()),
1203 Some("16Gi")
1204 );
1205 assert_eq!(
1206 mover.security_context.as_ref().and_then(|s| s.run_as_user),
1207 Some(1000)
1208 );
1209 assert_eq!(
1212 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1213 Some(1000)
1214 );
1215 assert!(!mover.requires_privilege());
1217 let fp = spec.failure_policy.as_ref().expect("failurePolicy");
1218 assert_eq!(fp.backoff_limit, Some(4));
1219 assert_eq!(fp.active_deadline_seconds, Some(3600));
1220
1221 let json = serde_json::to_value(&spec).expect("serialize");
1222 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1223 assert_eq!(spec, reparsed);
1224 }
1225
1226 #[test]
1227 fn restore_mover_root_context_is_privileged() {
1228 let yaml = r#"
1231source: { snapshotRef: { name: app-data-backup } }
1232target: { pvcRef: { name: app-data-restored } }
1233mover:
1234 securityContext:
1235 runAsUser: 0
1236 runAsNonRoot: false
1237"#;
1238 let spec: RestoreSpec = from_yaml(yaml);
1239 assert!(spec.mover.as_ref().unwrap().requires_privilege());
1240 }
1241
1242 #[test]
1243 fn restore_crd_schema_carries_the_snapshot_inherit_variant() {
1244 let crd = Restore::crd();
1250 let json = serde_json::to_value(&crd).expect("serialize CRD");
1251 let inherit = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1252 ["properties"]["mover"]["properties"]["inheritSecurityContextFrom"]["properties"];
1253 assert!(
1254 inherit["snapshot"].is_object(),
1255 "inheritSecurityContextFrom must carry the `snapshot` variant; got {inherit}"
1256 );
1257 assert_eq!(inherit["snapshot"]["type"], "object");
1258 assert!(inherit["workloadSelector"].is_object());
1260 assert!(inherit["pvcConsumer"].is_object());
1261 let _ = crate::SnapshotPolicy::crd();
1262 }
1263
1264 #[test]
1265 fn restore_snapshot_inherit_mover_roundtrip() {
1266 use crate::common::{InheritSecurityContextFrom, SnapshotInherit};
1268 let yaml = r#"
1269source: { snapshotRef: { name: app-data-backup } }
1270target: { pvcRef: { name: app-data-restored } }
1271mover:
1272 inheritSecurityContextFrom:
1273 snapshot: {}
1274"#;
1275 let spec: RestoreSpec = from_yaml(yaml);
1276 assert!(matches!(
1277 spec.mover
1278 .as_ref()
1279 .and_then(|m| m.inherit_security_context_from.as_ref()),
1280 Some(InheritSecurityContextFrom::Snapshot(SnapshotInherit {})),
1281 ));
1282 let json = serde_json::to_value(&spec).expect("serialize");
1283 assert!(json["mover"]["inheritSecurityContextFrom"]["snapshot"].is_object());
1284 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1285 assert_eq!(spec, reparsed);
1286 }
1287
1288 #[test]
1289 fn restore_source_unknown_variant_is_rejected() {
1290 let value: serde_json::Value = serde_saphyr::from_str("snapshotUrl:\n url: x\n").unwrap();
1291 assert!(serde_json::from_value::<RestoreSource>(value).is_err());
1292 }
1293
1294 #[test]
1295 fn on_missing_snapshot_and_phase_serialize_to_expected_strings() {
1296 assert_eq!(
1297 serde_json::to_value(OnMissingSnapshot::Fail).unwrap(),
1298 "Fail"
1299 );
1300 assert_eq!(
1301 serde_json::to_value(OnMissingSnapshot::Continue).unwrap(),
1302 "Continue"
1303 );
1304 assert_eq!(
1305 serde_json::to_value(RestorePhase::Restoring).unwrap(),
1306 "Restoring"
1307 );
1308 assert_eq!(
1309 serde_json::to_value(RestorePhase::Completed).unwrap(),
1310 "Completed"
1311 );
1312 }
1313
1314 #[test]
1317 fn restore_claim_phase_all_covers_every_variant_uniquely() {
1318 let labels: Vec<&str> = RestoreClaimPhase::ALL.iter().map(|p| p.label()).collect();
1322 assert_eq!(RestoreClaimPhase::ALL.len(), 6);
1323 assert!(labels.iter().all(|l| !l.is_empty()));
1324 let mut sorted = labels.clone();
1325 sorted.sort_unstable();
1326 sorted.dedup();
1327 assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
1328 assert!(RestoreClaimPhase::ALL.contains(&RestoreClaimPhase::default()));
1329 }
1330
1331 #[test]
1332 fn restore_claim_terminal_set_is_pinned() {
1333 let terminal: Vec<&str> = RestoreClaimPhase::ALL
1335 .iter()
1336 .filter(|p| p.is_terminal())
1337 .map(|p| p.label())
1338 .collect();
1339 assert_eq!(terminal, ["Populated", "AlreadyBound", "Failed"]);
1340 let in_flight: Vec<&str> = RestoreClaimPhase::ALL
1341 .iter()
1342 .filter(|p| !p.is_terminal())
1343 .map(|p| p.label())
1344 .collect();
1345 assert_eq!(in_flight, ["Pending", "Populating", "Rebinding"]);
1346 assert!(!RestoreClaimPhase::ALL.iter().any(|p| p.is_unknown()));
1348 }
1349
1350 #[test]
1351 fn restore_claim_phase_round_trips_and_tolerates_an_unknown_value() {
1352 for p in RestoreClaimPhase::ALL {
1353 let json = serde_json::to_value(p).expect("serialize");
1354 assert_eq!(json, p.label());
1355 let back: RestoreClaimPhase = serde_json::from_value(json).expect("decode");
1356 assert_eq!(&back, p);
1357 }
1358 let unknown: RestoreClaimPhase =
1360 serde_json::from_value(serde_json::json!("Quiescing")).expect("decodes");
1361 assert_eq!(unknown, RestoreClaimPhase::Unknown("Quiescing".into()));
1362 assert!(unknown.is_unknown());
1363 assert!(!unknown.is_terminal());
1364 assert_eq!(serde_json::to_value(&unknown).unwrap(), "Quiescing");
1366 }
1367
1368 #[test]
1369 fn restore_claim_phase_schema_publishes_only_canonical_values() {
1370 let crd = Restore::crd();
1372 let json = serde_json::to_value(&crd).expect("serialize CRD");
1373 let phase = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
1374 ["properties"]["claims"]["additionalProperties"]["properties"]["phase"];
1375 let values: Vec<String> = phase["enum"]
1376 .as_array()
1377 .map(|a| {
1378 a.iter()
1379 .map(|v| v.as_str().unwrap_or_default().to_string())
1380 .collect()
1381 })
1382 .unwrap_or_default();
1383 assert_eq!(
1384 values,
1385 RestoreClaimPhase::canonical(),
1386 "claim phase schema must be exactly the canonical set; got {phase}"
1387 );
1388 }
1389
1390 #[test]
1391 fn restore_status_claims_render_as_an_additional_properties_map() {
1392 let crd = Restore::crd();
1398 let json = serde_json::to_value(&crd).expect("serialize CRD");
1399 let claims = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
1400 ["properties"]["claims"];
1401 assert_eq!(claims["type"], "object", "got {claims}");
1402 let props = claims["additionalProperties"]["properties"]
1403 .as_object()
1404 .expect("per-claim properties");
1405 for key in [
1406 "uid",
1407 "phase",
1408 "reason",
1409 "message",
1410 "sourcePath",
1411 "resolved",
1412 "pvcPrime",
1413 "job",
1414 "waitStartedAt",
1415 "observedAt",
1416 "logTail",
1417 "failure",
1418 ] {
1419 assert!(props.contains_key(key), "missing `{key}` in {claims}");
1420 }
1421 }
1422
1423 #[test]
1424 fn restore_status_claims_round_trip_through_the_apiserver_shape() {
1425 let status: RestoreStatus = from_yaml(
1426 r#"
1427phase: Restoring
1428claims:
1429 data-0:
1430 uid: 11111111-2222-3333-4444-555555555555
1431 phase: Populated
1432 reason: RestoreSucceeded
1433 sourcePath: /pvc/data-0
1434 pvcPrime: prime-1111
1435 job: r-populate-deadbeef
1436 observedAt: "2026-09-01T00:00:00Z"
1437 resolved:
1438 resolution: Snapshot
1439 kopiaSnapshotID: k1
1440 data-1:
1441 uid: 66666666-2222-3333-4444-555555555555
1442 phase: Failed
1443 reason: MoverJobFailed
1444"#,
1445 );
1446 assert_eq!(status.claims.len(), 2);
1447 let a = &status.claims["data-0"];
1448 assert_eq!(a.phase, Some(RestoreClaimPhase::Populated));
1449 assert_eq!(a.source_path.as_deref(), Some("/pvc/data-0"));
1450 assert_eq!(a.job.as_deref(), Some("r-populate-deadbeef"));
1451 assert_eq!(
1452 a.resolved
1453 .as_ref()
1454 .and_then(|r| r.kopia_snapshot_id.as_deref()),
1455 Some("k1")
1456 );
1457 assert_eq!(
1458 status.claims["data-1"].phase,
1459 Some(RestoreClaimPhase::Failed)
1460 );
1461
1462 let json = serde_json::to_value(&status).expect("serialize");
1464 let reparsed: RestoreStatus = serde_json::from_value(json).expect("reparse");
1465 assert_eq!(status, reparsed);
1466
1467 let direct = RestoreStatus::default();
1470 let json = serde_json::to_value(&direct).expect("serialize");
1471 assert!(
1472 json.get("claims").is_none(),
1473 "an empty claims map must not be written: {json}"
1474 );
1475 }
1476
1477 #[test]
1478 fn from_policy_round_trips_with_and_without_source_path() {
1479 let with: RestoreSpec = from_yaml(
1480 "source: { fromPolicy: { name: pg, sourcePath: /pvc/pgdata } }\n\
1481 target: { populator: {} }\n",
1482 );
1483 match &with.source {
1484 RestoreSource::FromPolicy(c) => {
1485 assert_eq!(c.source_path.as_deref(), Some("/pvc/pgdata"));
1486 assert_eq!(c.offset, 0);
1487 }
1488 other => panic!("expected FromPolicy, got {}", other.kind_str()),
1489 }
1490 let json = serde_json::to_value(&with).expect("serialize");
1491 assert_eq!(json["source"]["fromPolicy"]["sourcePath"], "/pvc/pgdata");
1492 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1493 assert_eq!(with, reparsed);
1494
1495 let without: RestoreSpec =
1497 from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
1498 match &without.source {
1499 RestoreSource::FromPolicy(c) => assert_eq!(c.source_path, None),
1500 other => panic!("expected FromPolicy, got {}", other.kind_str()),
1501 }
1502 let json = serde_json::to_value(&without).expect("serialize");
1503 assert!(
1504 json["source"]["fromPolicy"].get("sourcePath").is_none(),
1505 "{json}"
1506 );
1507 }
1508
1509 #[test]
1510 fn from_policy_source_path_is_a_bounded_string_in_the_crd_schema() {
1511 let crd = Restore::crd();
1512 let json = serde_json::to_value(&crd).expect("serialize CRD");
1513 let field = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1514 ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["sourcePath"];
1515 assert_eq!(field["type"], "string", "got {field}");
1516 assert_eq!(field["maxLength"], 4096, "got {field}");
1517 }
1518}
1519
1520#[cfg(test)]
1521mod cel_guard_tests {
1522 use super::*;
1523 use kube::CustomResourceExt;
1524
1525 #[test]
1537 fn restore_target_cel_rule_lists_every_variant() {
1538 use crate::common::ObjectRef;
1539
1540 let all = [
1543 RestoreTarget::Pvc(PvcTemplate {
1544 name: "p".into(),
1545 capacity: None,
1546 storage_class_name: None,
1547 access_modes: Vec::new(),
1548 }),
1549 RestoreTarget::PvcRef(ObjectRef {
1550 name: "p".into(),
1551 namespace: None,
1552 }),
1553 RestoreTarget::Populator(PopulatorTarget {}),
1554 RestoreTarget::StreamExec(StreamExecTarget {
1555 file_name: "f.sql".into(),
1556 workload_exec: crate::snapshot_policy::StreamExec {
1557 pod_selector: Default::default(),
1558 container: None,
1559 command: vec!["true".into()],
1560 timeout: None,
1561 },
1562 }),
1563 ];
1564
1565 let crd = serde_json::to_value(Restore::crd()).expect("CRD serializes");
1566 let schema = &crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1567 let rules = schema["x-kubernetes-validations"]
1568 .as_array()
1569 .expect("the spec carries operator-authored CEL validations");
1570 let rule = rules
1571 .iter()
1572 .find_map(|r| r["rule"].as_str())
1573 .expect("a CEL rule is present");
1574
1575 for target in &all {
1576 let kind = target.kind_str();
1578 let mut wire = kind.to_string();
1579 wire[..1].make_ascii_lowercase();
1580 assert!(
1581 rule.contains(&format!("has(self.target.{wire})")),
1582 "the RestoreSpec CEL rule does not mention `target.{wire}`, so the API server \
1583 will REJECT that target before the webhook ever sees it. Add \
1584 `has(self.target.{wire})` to the rule (and its message) in restore.rs, then \
1585 re-run `mise run gen`.\nrule: {rule}"
1586 );
1587 }
1588 }
1589}