1use crate::common::{ObjectRef, RepositoryRef, ResolvedIdentity};
5use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
6use kube::CustomResource;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
13#[kube(
14 group = "kopiur.home-operations.com",
15 version = "v1alpha1",
16 kind = "Restore",
17 namespaced,
18 status = "RestoreStatus",
19 shortname = "kopiarestore",
20 category = "kopiur",
21 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
22 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
23)]
24#[serde(rename_all = "camelCase")]
25pub struct RestoreSpec {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub repository: Option<RepositoryRef>,
31 pub source: RestoreSource,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub target: Option<RestoreTarget>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub options: Option<RestoreOptions>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub policy: Option<RestorePolicy>,
42}
43
44#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
49#[serde(rename_all = "camelCase")]
50pub enum RestoreSource {
51 BackupRef(ObjectRef),
53 FromConfig(FromConfig),
56 Identity(IdentitySource),
58}
59
60impl RestoreSource {
61 pub fn kind_str(&self) -> &'static str {
76 match self {
77 RestoreSource::BackupRef(_) => "BackupRef",
78 RestoreSource::FromConfig(_) => "FromConfig",
79 RestoreSource::Identity(_) => "Identity",
80 }
81 }
82}
83
84#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
87#[serde(rename_all = "camelCase")]
88pub struct FromConfig {
89 pub name: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub namespace: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub as_of: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub offset: Option<i64>,
100}
101
102#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
105#[serde(rename_all = "camelCase")]
106pub struct IdentitySource {
107 pub username: String,
109 pub hostname: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub source_path: Option<String>,
114 #[serde(
117 default,
118 rename = "snapshotID",
119 skip_serializing_if = "Option::is_none"
120 )]
121 pub snapshot_id: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub as_of: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub offset: Option<i64>,
128}
129
130#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
135#[serde(rename_all = "camelCase")]
136pub enum RestoreTarget {
137 Pvc(PvcTemplate),
139 PvcRef(ObjectRef),
141}
142
143impl RestoreTarget {
144 pub fn kind_str(&self) -> &'static str {
159 match self {
160 RestoreTarget::Pvc(_) => "Pvc",
161 RestoreTarget::PvcRef(_) => "PvcRef",
162 }
163 }
164}
165
166#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
168#[serde(rename_all = "camelCase")]
169pub struct PvcTemplate {
170 pub name: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub storage_class_name: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub capacity: Option<String>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub access_modes: Vec<String>,
181}
182
183#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
185#[serde(rename_all = "camelCase")]
186pub struct RestoreOptions {
187 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
190 pub enable_file_deletion: bool,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub ignore_permission_errors: Option<bool>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub write_files_atomically: Option<bool>,
197}
198
199#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
201#[serde(rename_all = "camelCase")]
202pub struct RestorePolicy {
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub on_missing_snapshot: Option<OnMissingSnapshot>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub wait_timeout: Option<String>,
210}
211
212#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
222pub enum OnMissingSnapshot {
223 #[default]
225 Fail,
226 Continue,
228}
229
230#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
232pub enum RestorePhase {
233 #[default]
235 Pending,
236 Resolving,
238 Restoring,
240 Completed,
242 Failed,
244}
245
246impl crate::common::PhaseLabel for RestorePhase {
247 const ALL: &'static [Self] = &[
248 Self::Pending,
249 Self::Resolving,
250 Self::Restoring,
251 Self::Completed,
252 Self::Failed,
253 ];
254 fn label(&self) -> &'static str {
255 match self {
256 Self::Pending => "Pending",
257 Self::Resolving => "Resolving",
258 Self::Restoring => "Restoring",
259 Self::Completed => "Completed",
260 Self::Failed => "Failed",
261 }
262 }
263}
264
265#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
267#[serde(rename_all = "camelCase")]
268pub struct RestoreStatus {
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub phase: Option<RestorePhase>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub observed_generation: Option<i64>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub resolved: Option<ResolvedRestore>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub target: Option<RestoreTargetStatus>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub timing: Option<RestoreTiming>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub progress: Option<RestoreProgress>,
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub conditions: Vec<Condition>,
290}
291
292#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
294#[serde(rename_all = "camelCase")]
295pub struct ResolvedRestore {
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub backup_ref: Option<ObjectRef>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub repository: Option<RepositoryRef>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub pinned_at: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub identity: Option<ResolvedIdentity>,
308}
309
310#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
312#[serde(rename_all = "camelCase")]
313pub struct RestoreTargetStatus {
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub pvc_prime: Option<String>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub pvc_ref: Option<ObjectRef>,
320}
321
322#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
324#[serde(rename_all = "camelCase")]
325pub struct RestoreTiming {
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub start_time: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub end_time: Option<String>,
332}
333
334#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
336#[serde(rename_all = "camelCase")]
337pub struct RestoreProgress {
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub bytes_restored: Option<i64>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub files_restored: Option<i64>,
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::testutil::from_yaml;
350 use kube::core::CustomResourceExt;
351
352 #[test]
353 fn restore_crd_metadata_is_correct() {
354 let crd = Restore::crd();
355 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
356 assert_eq!(crd.spec.names.kind, "Restore");
357 assert_eq!(crd.spec.scope, "Namespaced");
358 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
359 }
360
361 #[test]
362 fn restore_backup_ref_roundtrip_matches_adr_shape() {
363 let yaml = r#"
365source:
366 backupRef: { name: postgres-data-20260524-021300, namespace: billing }
367target:
368 pvc:
369 name: postgres-data-restored
370 storageClassName: fast-ssd
371 capacity: 100Gi
372 accessModes: [ReadWriteOnce]
373options:
374 enableFileDeletion: false
375 ignorePermissionErrors: true
376 writeFilesAtomically: true
377policy:
378 onMissingSnapshot: Fail
379 waitTimeout: 5m
380"#;
381 let spec: RestoreSpec = from_yaml(yaml);
382 assert_eq!(spec.source.kind_str(), "BackupRef");
383 match &spec.source {
384 RestoreSource::BackupRef(r) => {
385 assert_eq!(r.name, "postgres-data-20260524-021300");
386 assert_eq!(r.namespace.as_deref(), Some("billing"));
387 }
388 other => panic!("expected BackupRef, got {}", other.kind_str()),
389 }
390 let target = spec.target.as_ref().expect("target");
391 assert_eq!(target.kind_str(), "Pvc");
392 match target {
393 RestoreTarget::Pvc(t) => {
394 assert_eq!(t.name, "postgres-data-restored");
395 assert_eq!(t.access_modes, vec!["ReadWriteOnce".to_string()]);
396 }
397 other => panic!("expected Pvc, got {}", other.kind_str()),
398 }
399 assert_eq!(
400 spec.policy.as_ref().unwrap().on_missing_snapshot,
401 Some(OnMissingSnapshot::Fail)
402 );
403
404 let json = serde_json::to_value(&spec).expect("serialize");
405 let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
406 assert_eq!(spec, reparsed);
407 }
408
409 #[test]
410 fn restore_passive_populator_mode_has_no_target() {
411 let yaml = r#"
413source: { fromConfig: { name: postgres-data, offset: 0 } }
414policy: { onMissingSnapshot: Continue }
415"#;
416 let spec: RestoreSpec = from_yaml(yaml);
417 assert_eq!(spec.source.kind_str(), "FromConfig");
418 assert!(spec.target.is_none(), "passive mode omits target");
419 match &spec.source {
420 RestoreSource::FromConfig(c) => {
421 assert_eq!(c.name, "postgres-data");
422 assert_eq!(c.offset, Some(0));
423 }
424 other => panic!("expected FromConfig, got {}", other.kind_str()),
425 }
426
427 let json = serde_json::to_value(&spec).unwrap();
428 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
429 assert_eq!(spec, reparsed);
430 }
431
432 #[test]
433 fn restore_identity_source_requires_repository_in_practice() {
434 let yaml = r#"
436repository: { kind: Repository, name: nas-primary, namespace: backups }
437source:
438 identity:
439 username: postgres-data
440 hostname: billing
441 sourcePath: /data
442 snapshotID: k1f1ec0a8
443target:
444 pvcRef: { name: postgres-data-restored }
445"#;
446 let spec: RestoreSpec = from_yaml(yaml);
447 assert_eq!(spec.source.kind_str(), "Identity");
448 assert!(spec.repository.is_some());
449 match &spec.source {
450 RestoreSource::Identity(i) => {
451 assert_eq!(i.username, "postgres-data");
452 assert_eq!(i.snapshot_id.as_deref(), Some("k1f1ec0a8"));
453 }
454 other => panic!("expected Identity, got {}", other.kind_str()),
455 }
456 assert_eq!(spec.target.as_ref().unwrap().kind_str(), "PvcRef");
457
458 let json = serde_json::to_value(&spec).unwrap();
459 let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
460 assert_eq!(spec, reparsed);
461 }
462
463 #[test]
464 fn restore_source_unknown_variant_is_rejected() {
465 let value: serde_json::Value = serde_yaml::from_str("snapshotUrl:\n url: x\n").unwrap();
466 assert!(serde_json::from_value::<RestoreSource>(value).is_err());
467 }
468
469 #[test]
470 fn on_missing_snapshot_and_phase_serialize_to_expected_strings() {
471 assert_eq!(
472 serde_json::to_value(OnMissingSnapshot::Fail).unwrap(),
473 "Fail"
474 );
475 assert_eq!(
476 serde_json::to_value(OnMissingSnapshot::Continue).unwrap(),
477 "Continue"
478 );
479 assert_eq!(
480 serde_json::to_value(RestorePhase::Restoring).unwrap(),
481 "Restoring"
482 );
483 assert_eq!(
484 serde_json::to_value(RestorePhase::Completed).unwrap(),
485 "Completed"
486 );
487 }
488}