Skip to main content

kopiur_api/
restore.rs

1//! The `Restore` CRD — a restore from a snapshot/identity to a PVC, or a passive
2//! populator source. ADR-0001 §3.6, ADR-0003 §4.6.
3
4use 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/// A restore operation. `target` is optional: absence = passive populator mode,
11/// consumed by a PVC's `spec.dataSourceRef`. ADR §3.6/§4.7.
12#[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")]
25/// Desired state of a [`Restore`]: where to read from, where to write to, and how
26/// to behave when the snapshot is missing. ADR §3.6/§4.6.
27pub struct RestoreSpec {
28    /// Derived from `source` when omitted; REQUIRED only with `source.identity`. ADR §3.6/§4.6.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub repository: Option<RepositoryRef>,
31    /// Exactly one source mode; webhook-enforced. ADR §3.6.
32    pub source: RestoreSource,
33    /// Absence = passive populator mode. ADR §3.6/§4.7.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub target: Option<RestoreTarget>,
36    /// kopia restore behavior (file deletion, permission/atomicity handling). ADR §4.6.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub options: Option<RestoreOptions>,
39    /// Missing-snapshot handling and wait timeout. ADR §4.6 (G7).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub policy: Option<RestorePolicy>,
42}
43
44/// Where to restore from. Externally-tagged; exactly one variant. ADR §3.6/§4.6.
45///
46/// Wire shape: `source: { backupRef: {...} }`, `{ fromConfig: {...} }`, or
47/// `{ identity: {...} }`.
48#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
49#[serde(rename_all = "camelCase")]
50pub enum RestoreSource {
51    /// A `Backup` CR (scheduled, manual, or discovered — all the same kind). Default mode.
52    BackupRef(ObjectRef),
53    /// A `BackupConfig` CR — resolves via identity even with no `Backup` CR present
54    /// (deploy-or-restore). Default `onMissingSnapshot: Continue`. ADR §4.6.
55    FromConfig(FromConfig),
56    /// A raw kopia identity (foreign writers / aged-out catalog). Requires `spec.repository`.
57    Identity(IdentitySource),
58}
59
60impl RestoreSource {
61    /// Stable discriminant string for status/metrics.
62    ///
63    /// ```
64    /// use kopiur_api::common::ObjectRef;
65    /// use kopiur_api::restore::RestoreSource;
66    ///
67    /// let src = RestoreSource::BackupRef(ObjectRef { name: "pg-20260524".into(), namespace: None });
68    /// assert_eq!(src.kind_str(), "BackupRef");
69    ///
70    /// // Externally tagged: each variant deserializes under its own camelCase key.
71    /// let from_cfg: RestoreSource =
72    ///     serde_json::from_value(serde_json::json!({ "fromConfig": { "name": "pg" } })).unwrap();
73    /// assert_eq!(from_cfg.kind_str(), "FromConfig");
74    /// ```
75    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/// The `fromConfig` source: resolve a snapshot via a `BackupConfig`'s identity,
85/// even when no `Backup` CR exists yet (deploy-or-restore). ADR §4.6.
86#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
87#[serde(rename_all = "camelCase")]
88pub struct FromConfig {
89    /// Name of the `BackupConfig` whose identity selects the snapshot.
90    pub name: String,
91    /// Namespace of the `BackupConfig`; absent = the `Restore`'s own namespace.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub namespace: Option<String>,
94    /// Restore the newest snapshot at or before this RFC3339 timestamp (point-in-time).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub as_of: Option<String>,
97    /// 0 = latest, 1 = previous, ... ADR §3.6.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub offset: Option<i64>,
100}
101
102/// The `identity` source: a raw kopia `username@hostname:path` identity for
103/// foreign writers or snapshots aged out of the catalog. Requires `spec.repository`.
104#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
105#[serde(rename_all = "camelCase")]
106pub struct IdentitySource {
107    /// The kopia `username` to match.
108    pub username: String,
109    /// The kopia `hostname` to match.
110    pub hostname: String,
111    /// The kopia source path to match; absent matches any path for the identity.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub source_path: Option<String>,
114    /// Pin an exact kopia snapshot by ID. Renamed to match the ADR wire shape
115    /// exactly (`snapshotID`, capital `ID`).
116    #[serde(
117        default,
118        rename = "snapshotID",
119        skip_serializing_if = "Option::is_none"
120    )]
121    pub snapshot_id: Option<String>,
122    /// Restore the newest snapshot at or before this RFC3339 timestamp (point-in-time).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub as_of: Option<String>,
125    /// 0 = latest, 1 = previous, ... mutually exclusive with `snapshotID`/`asOf` in practice.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub offset: Option<i64>,
128}
129
130/// Where to restore to. Externally-tagged; exactly one variant when present. ADR §3.6.
131///
132/// Wire shape: `target: { pvc: {...} }` or `{ pvcRef: {...} }`. Omitting `target`
133/// entirely (modeled as `Option<RestoreTarget>` on the spec) is passive mode.
134#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
135#[serde(rename_all = "camelCase")]
136pub enum RestoreTarget {
137    /// Operator creates the PVC.
138    Pvc(PvcTemplate),
139    /// Write into an existing PVC.
140    PvcRef(ObjectRef),
141}
142
143impl RestoreTarget {
144    /// Stable discriminant string for status/metrics.
145    ///
146    /// ```
147    /// use kopiur_api::common::ObjectRef;
148    /// use kopiur_api::restore::RestoreTarget;
149    ///
150    /// let into_existing = RestoreTarget::PvcRef(ObjectRef { name: "data".into(), namespace: None });
151    /// assert_eq!(into_existing.kind_str(), "PvcRef");
152    ///
153    /// // Externally tagged: `{ pvc: {...} }` selects the create-PVC variant.
154    /// let created: RestoreTarget =
155    ///     serde_json::from_value(serde_json::json!({ "pvc": { "name": "restored" } })).unwrap();
156    /// assert_eq!(created.kind_str(), "Pvc");
157    /// ```
158    pub fn kind_str(&self) -> &'static str {
159        match self {
160            RestoreTarget::Pvc(_) => "Pvc",
161            RestoreTarget::PvcRef(_) => "PvcRef",
162        }
163    }
164}
165
166/// Template for a PVC the operator creates as the restore target. ADR §3.6.
167#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
168#[serde(rename_all = "camelCase")]
169pub struct PvcTemplate {
170    /// Name of the PVC to create.
171    pub name: String,
172    /// StorageClass for the new PVC; absent uses the cluster default.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub storage_class_name: Option<String>,
175    /// Requested size of the new PVC (e.g. `100Gi`).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub capacity: Option<String>,
178    /// Access modes for the new PVC (e.g. `["ReadWriteOnce"]`).
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub access_modes: Vec<String>,
181}
182
183/// kopia restore behavior knobs. ADR §4.6.
184#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
185#[serde(rename_all = "camelCase")]
186pub struct RestoreOptions {
187    /// Delete files in the target that are not present in the snapshot (make the
188    /// target an exact mirror). Off by default — additive restore is the safe default.
189    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
190    pub enable_file_deletion: bool,
191    /// Default true; surfaces a condition if any errors occurred. ADR §4.6.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub ignore_permission_errors: Option<bool>,
194    /// Default true. ADR §4.6.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub write_files_atomically: Option<bool>,
197}
198
199/// How the restore reacts to a missing snapshot and how long it waits. ADR §4.6 (G7).
200#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
201#[serde(rename_all = "camelCase")]
202pub struct RestorePolicy {
203    /// Default `Fail` for explicit sources; `Continue` for `fromConfig`. ADR §4.6 (G7).
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub on_missing_snapshot: Option<OnMissingSnapshot>,
206    /// How long to wait for the source snapshot to appear before giving up
207    /// (Go-style duration string, e.g. `5m`).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub wait_timeout: Option<String>,
210}
211
212/// What to do when the resolved source matches no snapshot. Closed enum. ADR §4.6 (G7).
213///
214/// ```
215/// use kopiur_api::restore::OnMissingSnapshot;
216///
217/// // Fail-closed is the default so an explicit restore can't silently no-op.
218/// assert_eq!(OnMissingSnapshot::default(), OnMissingSnapshot::Fail);
219/// assert_eq!(serde_json::to_value(OnMissingSnapshot::Continue).unwrap(), "Continue");
220/// ```
221#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
222pub enum OnMissingSnapshot {
223    /// Fail-closed; the default for explicit `backupRef`/`identity` sources.
224    #[default]
225    Fail,
226    /// Proceed (deploy-or-restore); the default for `fromConfig`.
227    Continue,
228}
229
230/// Lifecycle phase of a restore. Closed enum. ADR §3.6 status.
231#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
232pub enum RestorePhase {
233    /// Admitted but not yet acted on; the default initial phase.
234    #[default]
235    Pending,
236    /// Resolving the source to a concrete snapshot and pinning it to status.
237    Resolving,
238    /// The mover `Job` is actively writing data into the target.
239    Restoring,
240    /// The restore finished successfully.
241    Completed,
242    /// The restore terminally failed; see `conditions` for the reason.
243    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/// Observed state of a [`Restore`], written by the controller/mover. ADR §3.6 status.
266#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
267#[serde(rename_all = "camelCase")]
268pub struct RestoreStatus {
269    /// Current lifecycle phase.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub phase: Option<RestorePhase>,
272    /// `metadata.generation` last reconciled, so stale status is detectable.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub observed_generation: Option<i64>,
275    /// Pinned at admission; never re-resolved. ADR §3.6/§4.6.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub resolved: Option<ResolvedRestore>,
278    /// Resolved target details (the PVC written to / populator handshake).
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub target: Option<RestoreTargetStatus>,
281    /// Start/end timestamps for the restore run.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub timing: Option<RestoreTiming>,
284    /// Bytes/files restored so far, patched periodically by the mover.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub progress: Option<RestoreProgress>,
287    /// Standard Kubernetes conditions carrying the human-readable status/reason.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub conditions: Vec<Condition>,
290}
291
292/// The source resolved and pinned at admission, so a restore never silently retargets. ADR §4.6.
293#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
294#[serde(rename_all = "camelCase")]
295pub struct ResolvedRestore {
296    /// The concrete `Backup` CR the source resolved to, when applicable.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub backup_ref: Option<ObjectRef>,
299    /// The repository the snapshot lives in, resolved from the source.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub repository: Option<RepositoryRef>,
302    /// Timestamp at which the source was pinned (RFC3339).
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub pinned_at: Option<String>,
305    /// The resolved kopia identity (`username@hostname:path`) of the snapshot.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub identity: Option<ResolvedIdentity>,
308}
309
310/// Resolved restore target details written to status. ADR §3.6.
311#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
312#[serde(rename_all = "camelCase")]
313pub struct RestoreTargetStatus {
314    /// Populator handshake (passive / pvc-create modes). ADR §3.6.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub pvc_prime: Option<String>,
317    /// The PVC actually written to (created or pre-existing).
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub pvc_ref: Option<ObjectRef>,
320}
321
322/// Start/end timestamps of a restore run. ADR §3.6 status.
323#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
324#[serde(rename_all = "camelCase")]
325pub struct RestoreTiming {
326    /// When the mover began restoring (RFC3339).
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub start_time: Option<String>,
329    /// When the restore reached a terminal phase (RFC3339).
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub end_time: Option<String>,
332}
333
334/// Live progress counters patched by the mover during a restore. ADR §3.6 status.
335#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
336#[serde(rename_all = "camelCase")]
337pub struct RestoreProgress {
338    /// Total bytes restored so far.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub bytes_restored: Option<i64>,
341    /// Total files restored so far.
342    #[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        // Mirrors ADR-0001 §3.6 / §5.3.
364        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        // Mirrors ADR-0001 §5.5 deploy-or-restore: fromConfig + Continue, no target.
412        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        // The `identity` source variant; spec.repository is webhook-required (not type-required).
435        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}