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}
115
116fn default_offset() -> i64 {
121 0
122}
123
124#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
126#[serde(rename_all = "camelCase")]
127pub struct IdentitySource {
128 pub username: String,
130 pub hostname: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub source_path: Option<String>,
135 #[serde(
137 default,
138 rename = "snapshotID",
139 skip_serializing_if = "Option::is_none"
140 )]
141 pub snapshot_id: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub as_of: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub offset: Option<i64>,
148}
149
150#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
152#[serde(rename_all = "camelCase")]
153pub enum RestoreTarget {
154 Pvc(PvcTemplate),
156 PvcRef(ObjectRef),
158 Populator(PopulatorTarget),
160}
161
162impl RestoreTarget {
163 pub fn kind_str(&self) -> &'static str {
178 match self {
179 RestoreTarget::Pvc(_) => "Pvc",
180 RestoreTarget::PvcRef(_) => "PvcRef",
181 RestoreTarget::Populator(_) => "Populator",
182 }
183 }
184}
185
186#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
188#[serde(rename_all = "camelCase")]
189pub struct PopulatorTarget {}
190
191#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
193#[serde(rename_all = "camelCase")]
194pub struct PvcTemplate {
195 pub name: String,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub storage_class_name: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub capacity: Option<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub access_modes: Vec<PvcAccessMode>,
210}
211
212#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
218#[serde(rename_all = "camelCase")]
219pub struct RestoreOptions {
220 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
223 pub enable_file_deletion: bool,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub ignore_permission_errors: Option<bool>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub write_files_atomically: Option<bool>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub parallel: Option<u32>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub write_sparse_files: Option<bool>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub skip_owners: Option<bool>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub skip_permissions: Option<bool>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub skip_times: Option<bool>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub overwrite_files: Option<bool>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub overwrite_directories: Option<bool>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub overwrite_symlinks: Option<bool>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub ignore_errors: Option<bool>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub skip_existing: Option<bool>,
264}
265
266#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
268#[serde(rename_all = "camelCase")]
269pub struct RestorePolicy {
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub on_missing_snapshot: Option<OnMissingSnapshot>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub wait_timeout: Option<String>,
276}
277
278#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
282pub enum OnMissingSnapshot {
283 #[default]
285 Fail,
286 Continue,
288}
289
290#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
292pub enum RestorePhase {
293 #[default]
295 Pending,
296 Resolving,
298 Restoring,
300 Completed,
302 Failed,
304}
305
306impl crate::common::PhaseLabel for RestorePhase {
307 const ALL: &'static [Self] = &[
308 Self::Pending,
309 Self::Resolving,
310 Self::Restoring,
311 Self::Completed,
312 Self::Failed,
313 ];
314 fn label(&self) -> &'static str {
315 match self {
316 Self::Pending => "Pending",
317 Self::Resolving => "Resolving",
318 Self::Restoring => "Restoring",
319 Self::Completed => "Completed",
320 Self::Failed => "Failed",
321 }
322 }
323}
324
325#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
327#[serde(rename_all = "camelCase")]
328pub struct RestoreStatus {
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub phase: Option<RestorePhase>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub source_kind: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub observed_generation: Option<i64>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub resolved: Option<ResolvedRestore>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub target: Option<RestoreTargetStatus>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub timing: Option<RestoreTiming>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub progress: Option<RestoreProgress>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub conditions: Vec<Condition>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub log_tail: Option<String>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub failure: Option<crate::common::FailureBlock>,
359}
360
361#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
363pub enum ResolutionOutcome {
364 Snapshot,
366 NoSnapshot,
368}
369
370#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
372#[serde(rename_all = "camelCase")]
373pub struct ResolvedRestore {
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub resolution: Option<ResolutionOutcome>,
377 #[serde(
379 default,
380 rename = "kopiaSnapshotID",
381 skip_serializing_if = "Option::is_none"
382 )]
383 pub kopia_snapshot_id: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub snapshot_ref: Option<ObjectRef>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub repository: Option<RepositoryRef>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub pinned_at: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub identity: Option<ResolvedIdentity>,
396}
397
398#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
400#[serde(rename_all = "camelCase")]
401pub struct RestoreTargetStatus {
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub pvc_prime: Option<String>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub pvc_ref: Option<ObjectRef>,
408}
409
410#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
412#[serde(rename_all = "camelCase")]
413pub struct RestoreTiming {
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub start_time: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub end_time: Option<String>,
420}
421
422#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
424#[serde(rename_all = "camelCase")]
425pub struct RestoreProgress {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub bytes_restored: Option<i64>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub files_restored: Option<i64>,
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::testutil::from_yaml;
438 use kube::core::CustomResourceExt;
439
440 #[test]
441 fn restore_crd_metadata_is_correct() {
442 let crd = Restore::crd();
443 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
444 assert_eq!(crd.spec.names.kind, "Restore");
445 assert_eq!(crd.spec.scope, "Namespaced");
446 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
447 }
448
449 #[test]
450 fn restore_crd_carries_target_xor_x_kubernetes_validation() {
451 let crd = Restore::crd();
455 let json = serde_json::to_value(&crd).expect("serialize CRD");
456 let spec_schema =
457 &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
458 let rules = spec_schema["x-kubernetes-validations"]
459 .as_array()
460 .expect("spec.x-kubernetes-validations present");
461 assert!(
462 rules.iter().any(|r| r["rule"]
463 .as_str()
464 .is_some_and(|s| s.contains("target.populator"))),
465 "expected the target XOR rule; got {rules:?}"
466 );
467 }
468
469 #[test]
470 fn from_policy_offset_carries_static_openapi_default_in_crd() {
471 let crd = Restore::crd();
473 let json = serde_json::to_value(&crd).expect("serialize CRD");
474 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
475 ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["offset"]["default"];
476 assert_eq!(
477 default, 0,
478 "fromPolicy.offset must emit `default: 0` in the CRD schema; got {default:?}"
479 );
480 }
481
482 #[test]
483 fn from_policy_offset_defaults_to_zero_when_absent() {
484 let spec: RestoreSpec =
485 from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
486 match &spec.source {
487 RestoreSource::FromPolicy(c) => assert_eq!(c.offset, 0),
488 other => panic!("expected FromPolicy, got {}", other.kind_str()),
489 }
490 }
491
492 #[test]
493 fn restore_backup_ref_roundtrip_matches_adr_shape() {
494 let yaml = r#"
496source:
497 snapshotRef: { name: postgres-data-20260524-021300, namespace: billing }
498target:
499 pvc:
500 name: postgres-data-restored
501 storageClassName: fast-ssd
502 capacity: 100Gi
503 accessModes: [ReadWriteOnce]
504options:
505 enableFileDeletion: false
506 ignorePermissionErrors: true
507 writeFilesAtomically: true
508policy:
509 onMissingSnapshot: Fail
510 waitTimeout: 5m
511"#;
512 let spec: RestoreSpec = from_yaml(yaml);
513 assert_eq!(spec.source.kind_str(), "SnapshotRef");
514 match &spec.source {
515 RestoreSource::SnapshotRef(r) => {
516 assert_eq!(r.name, "postgres-data-20260524-021300");
517 assert_eq!(r.namespace.as_deref(), Some("billing"));
518 }
519 other => panic!("expected SnapshotRef, got {}", other.kind_str()),
520 }
521 let target = &spec.target;
522 assert_eq!(target.kind_str(), "Pvc");
523 match target {
524 RestoreTarget::Pvc(t) => {
525 assert_eq!(t.name, "postgres-data-restored");
526 assert_eq!(t.access_modes, vec![PvcAccessMode::ReadWriteOnce]);
527 }
528 other => panic!("expected Pvc, got {}", other.kind_str()),
529 }
530 assert_eq!(
531 spec.policy.as_ref().unwrap().on_missing_snapshot,
532 Some(OnMissingSnapshot::Fail)
533 );
534
535 let json = serde_json::to_value(&spec).expect("serialize");
536 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
537 assert_eq!(spec, reparsed);
538 }
539
540 #[test]
541 fn restore_pvc_access_modes_render_a_closed_enum_in_the_crd_schema() {
542 let crd = Restore::crd();
546 let json = serde_json::to_value(&crd).expect("serialize CRD");
547 let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
548 ["properties"]["target"]["properties"]["pvc"]["properties"]["accessModes"]["items"];
549 assert_eq!(
550 items["type"], "string",
551 "items must be strings; got {items}"
552 );
553 assert_eq!(
554 items["enum"],
555 serde_json::json!([
556 "ReadWriteOnce",
557 "ReadOnlyMany",
558 "ReadWriteMany",
559 "ReadWriteOncePod"
560 ]),
561 "items enum must be exactly the canonical modes; got {items}"
562 );
563 }
564
565 #[test]
566 fn restore_legacy_access_mode_decodes_to_unknown_not_an_error() {
567 let spec: RestoreSpec = from_yaml(
572 "source: { snapshotRef: { name: b } }\n\
573 target: { pvc: { name: restored, capacity: 10Gi, accessModes: [ReadWriteOnze] } }\n",
574 );
575 match &spec.target {
576 RestoreTarget::Pvc(t) => assert_eq!(
577 t.access_modes,
578 vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
579 ),
580 other => panic!("expected Pvc, got {}", other.kind_str()),
581 }
582 let json = serde_json::to_value(&spec).unwrap();
584 assert_eq!(
585 json["target"]["pvc"]["accessModes"],
586 serde_json::json!(["ReadWriteOnze"])
587 );
588 }
589
590 #[test]
591 fn restore_options_full_flag_sweep_roundtrip() {
592 let yaml = r#"
595source: { snapshotRef: { name: b } }
596target: { pvcRef: { name: d } }
597options:
598 enableFileDeletion: true
599 ignorePermissionErrors: false
600 writeFilesAtomically: true
601 parallel: 4
602 writeSparseFiles: true
603 skipOwners: true
604 skipPermissions: false
605 skipTimes: true
606 overwriteFiles: false
607 overwriteDirectories: false
608 overwriteSymlinks: true
609 ignoreErrors: false
610 skipExisting: true
611"#;
612 let spec: RestoreSpec = from_yaml(yaml);
613 let o = spec.options.as_ref().expect("options set");
614 assert!(o.enable_file_deletion);
615 assert_eq!(o.ignore_permission_errors, Some(false));
616 assert_eq!(o.write_files_atomically, Some(true));
617 assert_eq!(o.parallel, Some(4));
618 assert_eq!(o.write_sparse_files, Some(true));
619 assert_eq!(o.skip_owners, Some(true));
620 assert_eq!(o.skip_permissions, Some(false));
621 assert_eq!(o.skip_times, Some(true));
622 assert_eq!(o.overwrite_files, Some(false));
623 assert_eq!(o.overwrite_directories, Some(false));
624 assert_eq!(o.overwrite_symlinks, Some(true));
625 assert_eq!(o.ignore_errors, Some(false));
626 assert_eq!(o.skip_existing, Some(true));
627
628 let json = serde_json::to_value(&spec).expect("serialize");
629 assert_eq!(json["options"]["parallel"], 4);
630 assert_eq!(json["options"]["skipExisting"], true);
631 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
632 assert_eq!(spec, reparsed);
633 }
634
635 #[test]
636 fn restore_options_omits_unset_leaf_fields() {
637 let yaml = r#"
640source: { snapshotRef: { name: b } }
641target: { pvcRef: { name: d } }
642options:
643 enableFileDeletion: true
644"#;
645 let spec: RestoreSpec = from_yaml(yaml);
646 let json = serde_json::to_value(&spec).unwrap();
647 let opts_json = &json["options"];
648 assert_eq!(opts_json["enableFileDeletion"], true);
649 for key in [
650 "ignorePermissionErrors",
651 "writeFilesAtomically",
652 "parallel",
653 "writeSparseFiles",
654 "skipOwners",
655 "skipPermissions",
656 "skipTimes",
657 "overwriteFiles",
658 "overwriteDirectories",
659 "overwriteSymlinks",
660 "ignoreErrors",
661 "skipExisting",
662 ] {
663 assert!(opts_json.get(key).is_none(), "{key} should be absent");
664 }
665 }
666
667 #[test]
668 fn restore_passive_populator_mode_uses_explicit_populator_target() {
669 let yaml = r#"
673source: { fromPolicy: { name: postgres-data, offset: 0 } }
674target: { populator: {} }
675policy: { onMissingSnapshot: Continue }
676"#;
677 let spec: RestoreSpec = from_yaml(yaml);
678 assert_eq!(spec.source.kind_str(), "FromPolicy");
679 assert_eq!(spec.target.kind_str(), "Populator");
680 assert!(matches!(spec.target, RestoreTarget::Populator(_)));
681 match &spec.source {
682 RestoreSource::FromPolicy(c) => {
683 assert_eq!(c.name, "postgres-data");
684 assert_eq!(c.offset, 0);
685 }
686 other => panic!("expected FromPolicy, got {}", other.kind_str()),
687 }
688
689 let json = serde_json::to_value(&spec).unwrap();
691 assert!(json["target"]["populator"].is_object());
692 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
693 assert_eq!(spec, reparsed);
694 }
695
696 #[test]
697 fn restore_without_target_fails_to_deserialize() {
698 let value: serde_json::Value =
700 serde_yaml::from_str("source: { snapshotRef: { name: b } }\n").unwrap();
701 assert!(
702 serde_json::from_value::<RestoreSpec>(value).is_err(),
703 "an absent target must be rejected (no empty-target form, ADR-0005 §9)"
704 );
705 }
706
707 #[test]
708 fn restore_populator_rejects_inherit_security_context() {
709 use crate::common::{InheritSecurityContextFrom, MoverSpec, PodSelector};
712 use crate::validate::validate_restore;
713 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
714 let spec = RestoreSpec {
715 repository: None,
716 source: RestoreSource::FromPolicy(FromPolicy {
717 name: "pg".into(),
718 namespace: None,
719 as_of: None,
720 offset: 0,
721 }),
722 target: RestoreTarget::Populator(PopulatorTarget {}),
723 options: None,
724 policy: None,
725 credential_projection: None,
726 mover: Some(MoverSpec {
727 inherit_security_context_from: Some(InheritSecurityContextFrom::WorkloadSelector(
728 PodSelector {
729 pod_selector: LabelSelector::default(),
730 container: None,
731 },
732 )),
733 ..Default::default()
734 }),
735 failure_policy: None,
736 };
737 assert!(matches!(
738 validate_restore(&spec),
739 Err(crate::error::ValidationError::InvalidFieldValue { .. })
740 ));
741
742 let ok = RestoreSpec {
744 mover: None,
745 ..spec
746 };
747 assert!(validate_restore(&ok).is_ok());
748 }
749
750 #[test]
751 fn restore_identity_source_requires_repository_in_practice() {
752 let yaml = r#"
754repository: { kind: Repository, name: nas-primary, namespace: backups }
755source:
756 identity:
757 username: postgres-data
758 hostname: billing
759 sourcePath: /data
760 snapshotID: k1f1ec0a8
761target:
762 pvcRef: { name: postgres-data-restored }
763"#;
764 let spec: RestoreSpec = from_yaml(yaml);
765 assert_eq!(spec.source.kind_str(), "Identity");
766 assert!(spec.repository.is_some());
767 match &spec.source {
768 RestoreSource::Identity(i) => {
769 assert_eq!(i.username, "postgres-data");
770 assert_eq!(i.snapshot_id.as_deref(), Some("k1f1ec0a8"));
771 }
772 other => panic!("expected Identity, got {}", other.kind_str()),
773 }
774 assert_eq!(spec.target.kind_str(), "PvcRef");
775
776 let json = serde_json::to_value(&spec).unwrap();
777 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
778 assert_eq!(spec, reparsed);
779 }
780
781 #[test]
782 fn restore_mover_and_failure_policy_roundtrip() {
783 let yaml = r#"
786source: { snapshotRef: { name: app-data-backup } }
787target: { pvcRef: { name: app-data-restored } }
788mover:
789 resources:
790 requests: { cpu: 250m, memory: 512Mi }
791 limits: { cpu: "2", memory: 4Gi }
792 cache:
793 capacity: 16Gi
794 storageClassName: fast-ssd
795 securityContext:
796 runAsUser: 1000
797 runAsGroup: 1000
798 runAsNonRoot: true
799 allowPrivilegeEscalation: false
800 capabilities: { drop: ["ALL"] }
801 seccompProfile: { type: RuntimeDefault }
802 podSecurityContext:
803 fsGroup: 1000
804 fsGroupChangePolicy: OnRootMismatch
805failurePolicy:
806 backoffLimit: 4
807 activeDeadlineSeconds: 3600
808"#;
809 let spec: RestoreSpec = from_yaml(yaml);
810 let mover = spec.mover.as_ref().expect("mover");
811 assert!(mover.resources.is_some());
812 assert_eq!(
813 mover.cache.as_ref().and_then(|c| c.capacity.as_deref()),
814 Some("16Gi")
815 );
816 assert_eq!(
817 mover.security_context.as_ref().and_then(|s| s.run_as_user),
818 Some(1000)
819 );
820 assert_eq!(
823 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
824 Some(1000)
825 );
826 assert!(!mover.requires_privilege());
828 let fp = spec.failure_policy.as_ref().expect("failurePolicy");
829 assert_eq!(fp.backoff_limit, Some(4));
830 assert_eq!(fp.active_deadline_seconds, Some(3600));
831
832 let json = serde_json::to_value(&spec).expect("serialize");
833 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
834 assert_eq!(spec, reparsed);
835 }
836
837 #[test]
838 fn restore_mover_root_context_is_privileged() {
839 let yaml = r#"
842source: { snapshotRef: { name: app-data-backup } }
843target: { pvcRef: { name: app-data-restored } }
844mover:
845 securityContext:
846 runAsUser: 0
847 runAsNonRoot: false
848"#;
849 let spec: RestoreSpec = from_yaml(yaml);
850 assert!(spec.mover.as_ref().unwrap().requires_privilege());
851 }
852
853 #[test]
854 fn restore_crd_schema_carries_the_snapshot_inherit_variant() {
855 let crd = Restore::crd();
861 let json = serde_json::to_value(&crd).expect("serialize CRD");
862 let inherit = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
863 ["properties"]["mover"]["properties"]["inheritSecurityContextFrom"]["properties"];
864 assert!(
865 inherit["snapshot"].is_object(),
866 "inheritSecurityContextFrom must carry the `snapshot` variant; got {inherit}"
867 );
868 assert_eq!(inherit["snapshot"]["type"], "object");
869 assert!(inherit["workloadSelector"].is_object());
871 assert!(inherit["pvcConsumer"].is_object());
872 let _ = crate::SnapshotPolicy::crd();
873 }
874
875 #[test]
876 fn restore_snapshot_inherit_mover_roundtrip() {
877 use crate::common::{InheritSecurityContextFrom, SnapshotInherit};
879 let yaml = r#"
880source: { snapshotRef: { name: app-data-backup } }
881target: { pvcRef: { name: app-data-restored } }
882mover:
883 inheritSecurityContextFrom:
884 snapshot: {}
885"#;
886 let spec: RestoreSpec = from_yaml(yaml);
887 assert!(matches!(
888 spec.mover
889 .as_ref()
890 .and_then(|m| m.inherit_security_context_from.as_ref()),
891 Some(InheritSecurityContextFrom::Snapshot(SnapshotInherit {})),
892 ));
893 let json = serde_json::to_value(&spec).expect("serialize");
894 assert!(json["mover"]["inheritSecurityContextFrom"]["snapshot"].is_object());
895 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
896 assert_eq!(spec, reparsed);
897 }
898
899 #[test]
900 fn restore_source_unknown_variant_is_rejected() {
901 let value: serde_json::Value = serde_yaml::from_str("snapshotUrl:\n url: x\n").unwrap();
902 assert!(serde_json::from_value::<RestoreSource>(value).is_err());
903 }
904
905 #[test]
906 fn on_missing_snapshot_and_phase_serialize_to_expected_strings() {
907 assert_eq!(
908 serde_json::to_value(OnMissingSnapshot::Fail).unwrap(),
909 "Fail"
910 );
911 assert_eq!(
912 serde_json::to_value(OnMissingSnapshot::Continue).unwrap(),
913 "Continue"
914 );
915 assert_eq!(
916 serde_json::to_value(RestorePhase::Restoring).unwrap(),
917 "Restoring"
918 );
919 assert_eq!(
920 serde_json::to_value(RestorePhase::Completed).unwrap(),
921 "Completed"
922 );
923 }
924}