1use 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub config_ref: Option<ConfigRef>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub tags: Option<BTreeMap<String, String>>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub failure_policy: Option<FailurePolicy>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub deletion_policy: Option<DeletionPolicy>,
49}
50
51#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
65#[serde(rename_all = "camelCase")]
66pub enum Origin {
67 #[default]
69 Scheduled,
70 Manual,
72 Discovered,
75}
76
77#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
89pub enum BackupPhase {
90 #[default]
92 Pending,
93 Running,
95 Succeeded,
97 Failed,
99 Deleting,
101 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#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
128#[serde(rename_all = "camelCase")]
129pub struct BackupStatus {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub phase: Option<BackupPhase>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub origin: Option<Origin>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub observed_generation: Option<i64>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub snapshot: Option<SnapshotInfo>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub timing: Option<BackupTiming>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub stats: Option<BackupStats>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub job: Option<JobStatus>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub resolved: Option<ResolvedBackup>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub conditions: Vec<Condition>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub log_tail: Option<String>,
161}
162
163#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
165#[serde(rename_all = "camelCase")]
166pub struct SnapshotInfo {
167 #[serde(rename = "kopiaSnapshotID")]
172 pub kopia_snapshot_id: String,
173 pub identity: ResolvedIdentity,
175}
176
177#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
179#[serde(rename_all = "camelCase")]
180pub struct BackupTiming {
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub start_time: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub end_time: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub duration_seconds: Option<i64>,
190}
191
192#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
194#[serde(rename_all = "camelCase")]
195pub struct BackupStats {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub size_bytes: Option<i64>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub bytes_new: Option<i64>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub files_new: Option<i64>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub files_modified: Option<i64>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub files_unchanged: Option<i64>,
211}
212
213#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
215#[serde(rename_all = "camelCase")]
216pub struct JobStatus {
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub name: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub attempts: Option<i32>,
223}
224
225#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct ResolvedBackup {
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub repository: Option<RepositoryRef>,
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub sources: Vec<ResolvedSource>,
235}
236
237#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct ResolvedSource {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub pvc: Option<String>,
244 #[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 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 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 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 let spec: BackupSpec = from_yaml("{}\n");
308 assert!(spec.config_ref.is_none());
309 assert!(spec.deletion_policy.is_none());
310 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 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 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}