Skip to main content

kopiur_api/
backup.rs

1//! The `Backup` CRD — a single kopia snapshot as a Kubernetes object.
2//! ADR-0001 §3.4, ADR-0003 §4.5.
3//!
4//! Three origins (canonical value lives in `status.origin`):
5//! - `scheduled` — created by a `BackupSchedule`; spec carries `configRef`.
6//! - `manual`    — created by `kubectl create` / external automation; spec carries `configRef`.
7//! - `discovered`— materialized by the catalog scan; spec is empty/absent.
8
9use crate::common::{ConfigRef, DeletionPolicy, FailurePolicy, RepositoryRef, ResolvedIdentity};
10use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
11use kube::CustomResource;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// A single kopia snapshot represented as a Kubernetes object. ADR §3.4.
17///
18/// For `scheduled`/`manual` backups the spec carries `configRef` (+ optional
19/// overrides). For `discovered` backups the spec is empty — every field is optional.
20#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
21#[kube(
22    group = "kopiur.home-operations.com",
23    version = "v1alpha1",
24    kind = "Backup",
25    namespaced,
26    status = "BackupStatus",
27    shortname = "kopiabak",
28    category = "kopiur",
29    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
30    printcolumn = r#"{"name":"Origin","type":"string","jsonPath":".status.origin"}"#,
31    printcolumn = r#"{"name":"Snapshot","type":"string","jsonPath":".status.snapshot.kopiaSnapshotID"}"#,
32    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
33)]
34#[serde(rename_all = "camelCase")]
35pub struct BackupSpec {
36    /// The recipe to run. Absent for `discovered` backups. ADR §3.4.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub config_ref: Option<ConfigRef>,
39    /// Arbitrary kopia snapshot tags (e.g. `reason: scheduled-nightly`). ADR §3.4.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub tags: Option<BTreeMap<String, String>>,
42    /// Per-run failure controls passed to the mover `Job`. ADR §3.4 (G6).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub failure_policy: Option<FailurePolicy>,
45    /// Lifecycle of the underlying snapshot when this CR is deleted. Origin-aware
46    /// default (§4.5): `Delete` for scheduled/manual, forced `Retain` for discovered.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub deletion_policy: Option<DeletionPolicy>,
49}
50
51/// How a `Backup` came to exist. Canonical value mirrored from the `kopiur.home-operations.com/origin`
52/// label. Closed enum. ADR §3.4.
53///
54/// Origin drives the deletion-policy default (ADR §4.5): `discovered` backups are
55/// forced to `Retain` because the operator did not create those snapshots.
56///
57/// ```
58/// use kopiur_api::Origin;
59///
60/// assert_eq!(Origin::default(), Origin::Scheduled);
61/// // Serializes camelCase, matching the `origin` label/status value.
62/// assert_eq!(serde_json::to_value(Origin::Discovered).unwrap(), "discovered");
63/// ```
64#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
65#[serde(rename_all = "camelCase")]
66pub enum Origin {
67    /// Created by a `BackupSchedule`; spec carries `configRef`. ADR §3.4.
68    #[default]
69    Scheduled,
70    /// Created by `kubectl create` / external automation; spec carries `configRef`. ADR §3.4.
71    Manual,
72    /// Materialized by the catalog scan for a snapshot kopiur didn't produce;
73    /// spec is empty and `deletionPolicy` is forced to `Retain`. ADR §3.4/§4.5.
74    Discovered,
75}
76
77/// Lifecycle phase of a `Backup`. Closed enum. ADR §3.4 status.
78///
79/// ```
80/// use kopiur_api::{BackupPhase, PhaseLabel};
81///
82/// assert_eq!(BackupPhase::default(), BackupPhase::Pending);
83/// // `PhaseLabel::label` gives the stable string used in status/metrics.
84/// assert_eq!(BackupPhase::Succeeded.label(), "Succeeded");
85/// // Every variant is enumerated for metric reset.
86/// assert_eq!(BackupPhase::ALL.len(), 6);
87/// ```
88#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
89pub enum BackupPhase {
90    /// Admitted, not yet started (also the default). ADR §3.4 status.
91    #[default]
92    Pending,
93    /// Mover Job is in flight. ADR §3.4 status.
94    Running,
95    /// Snapshot created successfully. ADR §3.4 status.
96    Succeeded,
97    /// Mover Job exhausted its retries. ADR §3.4 status.
98    Failed,
99    /// CR is being deleted; finalizer is reclaiming the snapshot. ADR §3.4 status/§4.5.
100    Deleting,
101    /// Catalog-materialized backup kopiur didn't produce. ADR §3.4 status.
102    Discovered,
103}
104
105impl crate::common::PhaseLabel for BackupPhase {
106    const ALL: &'static [Self] = &[
107        Self::Pending,
108        Self::Running,
109        Self::Succeeded,
110        Self::Failed,
111        Self::Deleting,
112        Self::Discovered,
113    ];
114    fn label(&self) -> &'static str {
115        match self {
116            Self::Pending => "Pending",
117            Self::Running => "Running",
118            Self::Succeeded => "Succeeded",
119            Self::Failed => "Failed",
120            Self::Deleting => "Deleting",
121            Self::Discovered => "Discovered",
122        }
123    }
124}
125
126/// Observed state of a [`Backup`]. ADR §3.4 status.
127#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
128#[serde(rename_all = "camelCase")]
129pub struct BackupStatus {
130    /// Current lifecycle phase. ADR §3.4 status.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub phase: Option<BackupPhase>,
133    /// Canonical origin (also mirrored to the `origin` label). ADR §3.4 status.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub origin: Option<Origin>,
136    /// `metadata.generation` last reconciled, for staleness detection. ADR §3.4 status.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub observed_generation: Option<i64>,
139    /// The kopia artifact this CR represents. ADR §3.4.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub snapshot: Option<SnapshotInfo>,
142    /// Start/end/duration of the snapshot run. ADR §3.4 status.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub timing: Option<BackupTiming>,
145    /// Byte/file counts parsed from kopia's JSON output. ADR §3.4 status.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub stats: Option<BackupStats>,
148    /// Present for scheduled/manual; absent for discovered. ADR §3.4.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub job: Option<JobStatus>,
151    /// Frozen recipe values at run time (scheduled/manual). ADR §3.4.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub resolved: Option<ResolvedBackup>,
154    /// Standard Kubernetes conditions (e.g. `SourcesQuiesced`, `SnapshotCreated`).
155    /// ADR §3.4 status.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub conditions: Vec<Condition>,
158    /// Capped at ~4KB; full logs live in the Job pod. ADR §3.4/§4.10.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub log_tail: Option<String>,
161}
162
163/// Identifies the kopia snapshot a [`Backup`] CR owns. ADR §3.4.
164#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
165#[serde(rename_all = "camelCase")]
166pub struct SnapshotInfo {
167    /// kopia's snapshot ID — the handle the finalizer uses to delete content.
168    ///
169    /// Renamed to match the ADR wire shape exactly (`kopiaSnapshotID`, capital `ID`);
170    /// serde's `camelCase` would otherwise produce `kopiaSnapshotId`.
171    #[serde(rename = "kopiaSnapshotID")]
172    pub kopia_snapshot_id: String,
173    /// The `username@hostname:path` identity recorded for this snapshot. ADR §3.4/§4.2.
174    pub identity: ResolvedIdentity,
175}
176
177/// Timing of a snapshot run. ADR §3.4 status.
178#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
179#[serde(rename_all = "camelCase")]
180pub struct BackupTiming {
181    /// RFC3339 start time of the run. ADR §3.4 status.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub start_time: Option<String>,
184    /// RFC3339 end time of the run. ADR §3.4 status.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub end_time: Option<String>,
187    /// Wall-clock duration in seconds. ADR §3.4 status.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub duration_seconds: Option<i64>,
190}
191
192/// Stats populated from kopia's JSON output. ADR §3.4.
193#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
194#[serde(rename_all = "camelCase")]
195pub struct BackupStats {
196    /// Total logical size of the snapshot in bytes. ADR §3.4 status.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub size_bytes: Option<i64>,
199    /// Bytes newly uploaded this run (after dedup/compression). ADR §3.4 status.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub bytes_new: Option<i64>,
202    /// Count of files new since the previous snapshot. ADR §3.4 status.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub files_new: Option<i64>,
205    /// Count of files changed since the previous snapshot. ADR §3.4 status.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub files_modified: Option<i64>,
208    /// Count of files unchanged since the previous snapshot. ADR §3.4 status.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub files_unchanged: Option<i64>,
211}
212
213/// The mover Job backing a scheduled/manual `Backup`; absent for discovered. ADR §3.4 status.
214#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
215#[serde(rename_all = "camelCase")]
216pub struct JobStatus {
217    /// Name of the mover `Job`. ADR §3.4 status.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub name: Option<String>,
220    /// Number of attempts so far (bounded by `failurePolicy.backoffLimit`). ADR §3.4 status.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub attempts: Option<i32>,
223}
224
225/// Frozen recipe values pinned at run time. ADR §3.4.
226#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct ResolvedBackup {
229    /// The repository this run targeted, frozen at run time. ADR §3.4 status.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub repository: Option<RepositoryRef>,
232    /// The concrete PVCs + source paths backed up this run. ADR §3.4 status.
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub sources: Vec<ResolvedSource>,
235}
236
237/// One resolved source backed up by a run — a concrete PVC and its kopia path. ADR §3.4 status.
238#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct ResolvedSource {
241    /// `namespace/name` of the PVC, as kopia sees it. ADR §3.4.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub pvc: Option<String>,
244    /// The source path kopia recorded for this PVC. ADR §3.4/§4.2.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub source_path: Option<String>,
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::common::PhaseLabel;
253    use crate::testutil::from_yaml;
254    use kube::core::CustomResourceExt;
255
256    #[test]
257    fn backup_phase_all_covers_every_variant_uniquely() {
258        // Guards the enumerate-and-reset contract: every variant is in ALL with
259        // a unique, non-empty label. A new variant added without updating ALL
260        // makes this fail (and `label`'s exhaustive match won't compile at all).
261        let labels: Vec<&str> = BackupPhase::ALL.iter().map(|p| p.label()).collect();
262        assert_eq!(BackupPhase::ALL.len(), 6);
263        assert!(labels.iter().all(|l| !l.is_empty()));
264        let mut sorted = labels.clone();
265        sorted.sort_unstable();
266        sorted.dedup();
267        assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
268        // Default is reachable through ALL.
269        assert!(BackupPhase::ALL.contains(&BackupPhase::default()));
270    }
271
272    #[test]
273    fn backup_crd_metadata_is_correct() {
274        let crd = Backup::crd();
275        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
276        assert_eq!(crd.spec.names.kind, "Backup");
277        assert_eq!(crd.spec.scope, "Namespaced");
278        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
279    }
280
281    #[test]
282    fn backup_manual_roundtrip_matches_adr_shape() {
283        // Mirrors ADR-0001 §3.4 spec block + §5.6.
284        let yaml = r#"
285configRef: { name: postgres-data }
286tags:
287  reason: "scheduled-nightly"
288failurePolicy:
289  backoffLimit: 2
290  activeDeadlineSeconds: 7200
291deletionPolicy: Delete
292"#;
293        let spec: BackupSpec = from_yaml(yaml);
294        assert_eq!(spec.config_ref.as_ref().unwrap().name, "postgres-data");
295        assert_eq!(spec.tags.as_ref().unwrap()["reason"], "scheduled-nightly");
296        assert_eq!(spec.failure_policy.as_ref().unwrap().backoff_limit, Some(2));
297        assert_eq!(spec.deletion_policy, Some(DeletionPolicy::Delete));
298
299        let json = serde_json::to_value(&spec).expect("serialize");
300        let reparsed: BackupSpec = serde_json::from_value(json).expect("reparse");
301        assert_eq!(spec, reparsed);
302    }
303
304    #[test]
305    fn backup_discovered_spec_is_empty() {
306        // Discovered backups carry no spec fields.
307        let spec: BackupSpec = from_yaml("{}\n");
308        assert!(spec.config_ref.is_none());
309        assert!(spec.deletion_policy.is_none());
310        // Empty spec serializes to an empty object (all fields skip).
311        assert_eq!(serde_json::to_value(&spec).unwrap(), serde_json::json!({}));
312    }
313
314    #[test]
315    fn deletion_policy_serializes_to_expected_strings() {
316        assert_eq!(
317            serde_json::to_value(DeletionPolicy::Delete).unwrap(),
318            "Delete"
319        );
320        assert_eq!(
321            serde_json::to_value(DeletionPolicy::Retain).unwrap(),
322            "Retain"
323        );
324        assert_eq!(
325            serde_json::to_value(DeletionPolicy::Orphan).unwrap(),
326            "Orphan"
327        );
328        // DeletionPolicy is Copy (ADR-0003 §4.5).
329        let p = DeletionPolicy::Retain;
330        let _copy = p;
331        assert_eq!(p, DeletionPolicy::Retain);
332    }
333
334    #[test]
335    fn origin_and_phase_serialize_to_expected_strings() {
336        assert_eq!(
337            serde_json::to_value(Origin::Scheduled).unwrap(),
338            "scheduled"
339        );
340        assert_eq!(serde_json::to_value(Origin::Manual).unwrap(), "manual");
341        assert_eq!(
342            serde_json::to_value(Origin::Discovered).unwrap(),
343            "discovered"
344        );
345        assert_eq!(
346            serde_json::to_value(BackupPhase::Succeeded).unwrap(),
347            "Succeeded"
348        );
349        assert_eq!(
350            serde_json::to_value(BackupPhase::Deleting).unwrap(),
351            "Deleting"
352        );
353    }
354
355    #[test]
356    fn backup_status_roundtrips() {
357        // Mirrors ADR-0001 §3.4 status block.
358        let yaml = r#"
359phase: Succeeded
360origin: scheduled
361snapshot:
362  kopiaSnapshotID: k1f1ec0a8
363  identity:
364    username: postgres-data
365    hostname: billing
366    sourcePath: /data
367timing:
368  startTime: 2026-05-24T02:13:00Z
369  endTime: 2026-05-24T02:18:42Z
370  durationSeconds: 342
371stats:
372  sizeBytes: 4321098765
373  bytesNew: 12345678
374  filesNew: 1233
375resolved:
376  repository: { kind: Repository, name: nas-primary, namespace: backups }
377  sources:
378    - pvc: billing/postgres-data
379      sourcePath: /data
380logTail: "Snapshot created: k1f1ec0a8"
381"#;
382        let status: BackupStatus = from_yaml(yaml);
383        assert_eq!(status.phase, Some(BackupPhase::Succeeded));
384        assert_eq!(status.origin, Some(Origin::Scheduled));
385        assert_eq!(
386            status.snapshot.as_ref().unwrap().kopia_snapshot_id,
387            "k1f1ec0a8"
388        );
389        assert_eq!(status.stats.as_ref().unwrap().size_bytes, Some(4321098765));
390
391        let json = serde_json::to_value(&status).unwrap();
392        let reparsed: BackupStatus = serde_json::from_value(json).unwrap();
393        assert_eq!(status, reparsed);
394    }
395}