1use crate::backend::Backend;
12use crate::common::{CronSpec, MoverSpec, ReplicationManualRunStatus, RepositoryRef};
13use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
14use kube::CustomResource;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
22#[kube(
23 group = "kopiur.home-operations.com",
24 version = "v1alpha1",
25 kind = "RepositoryReplication",
26 plural = "repositoryreplications",
27 namespaced,
28 status = "RepositoryReplicationStatus",
29 shortname = "kopiarepl",
30 category = "kopiur",
31 printcolumn = r#"{"name":"Source","type":"string","jsonPath":".spec.sourceRef.name"}"#,
32 printcolumn = r#"{"name":"Destination","type":"string","jsonPath":".status.destinationBackend"}"#,
33 printcolumn = r#"{"name":"Schedule","type":"string","jsonPath":".spec.schedule.cron"}"#,
34 printcolumn = r#"{"name":"Last","type":"date","jsonPath":".status.lastReplicated"}"#,
35 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
36)]
37#[serde(rename_all = "camelCase")]
38pub struct RepositoryReplicationSpec {
39 pub source_ref: RepositoryRef,
41 pub destination: Backend,
49 pub schedule: CronSpec,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub mover: Option<MoverSpec>,
54 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56 pub suspend: bool,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub sync: Option<SyncOptions>,
63}
64
65#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct SyncOptions {
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub parallel: Option<u32>,
77 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
85 pub delete_extra: bool,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub must_exist: Option<bool>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub times: Option<bool>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub update: Option<bool>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub max_download_speed_bytes_per_second: Option<i64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub max_upload_speed_bytes_per_second: Option<i64>,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq, Default)]
121pub enum RepositoryReplicationPhase {
122 #[default]
124 Pending,
125 Replicating,
127 Succeeded,
129 Failed,
131 Suspended,
133 Unknown(String),
137}
138
139impl RepositoryReplicationPhase {
140 pub fn is_unknown(&self) -> bool {
168 match self {
169 Self::Unknown(_) => true,
170 Self::Pending
171 | Self::Replicating
172 | Self::Succeeded
173 | Self::Failed
174 | Self::Suspended => false,
175 }
176 }
177}
178
179crate::common::phase_serde!(
180 RepositoryReplicationPhase,
181 "Lifecycle phase of a replication."
182);
183
184impl crate::common::PhaseLabel for RepositoryReplicationPhase {
185 const ALL: &'static [Self] = &[
186 Self::Pending,
187 Self::Replicating,
188 Self::Succeeded,
189 Self::Failed,
190 Self::Suspended,
191 ];
192 fn label(&self) -> &str {
193 match self {
194 Self::Pending => "Pending",
195 Self::Replicating => "Replicating",
196 Self::Succeeded => "Succeeded",
197 Self::Failed => "Failed",
198 Self::Suspended => "Suspended",
199 Self::Unknown(s) => s,
200 }
201 }
202 fn unknown(raw: String) -> Self {
203 Self::Unknown(raw)
204 }
205}
206
207#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct RepositoryReplicationStatus {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub phase: Option<RepositoryReplicationPhase>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub observed_generation: Option<i64>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub destination_backend: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub last_replicated: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub next_scheduled_at: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub last_replicated_bytes: Option<i64>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub last_replicated_blobs: Option<i64>,
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub conditions: Vec<Condition>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub manual_run: Option<ReplicationManualRunStatus>,
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::common::RepositoryKind;
246 use crate::testutil::from_yaml;
247 use kube::core::CustomResourceExt;
248
249 #[test]
250 fn repository_replication_crd_metadata_is_correct() {
251 let crd = RepositoryReplication::crd();
252 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
253 assert_eq!(crd.spec.names.kind, "RepositoryReplication");
254 assert_eq!(crd.spec.names.plural, "repositoryreplications");
255 assert_eq!(crd.spec.scope, "Namespaced");
256 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
257 }
258
259 #[test]
260 fn repository_replication_roundtrip() {
261 let yaml = r#"
263sourceRef:
264 kind: Repository
265 name: nas-primary
266destination:
267 s3:
268 bucket: offsite-mirror
269 region: us-east-1
270 auth:
271 secretRef:
272 name: offsite-creds
273schedule:
274 cron: "0 5 * * *"
275 jitter: 1h
276suspend: false
277"#;
278 let spec: RepositoryReplicationSpec = from_yaml(yaml);
279 assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
280 assert_eq!(spec.source_ref.name, "nas-primary");
281 match &spec.destination {
283 Backend::S3(s3) => assert_eq!(s3.bucket, "offsite-mirror"),
284 other => panic!("expected S3 destination, got {}", other.kind_str()),
285 }
286 assert_eq!(spec.schedule.cron, "0 5 * * *");
287 assert_eq!(spec.schedule.jitter.as_deref(), Some("1h"));
288 assert!(!spec.suspend);
289
290 let json = serde_json::to_value(&spec).expect("serialize");
291 assert_eq!(json["destination"]["s3"]["bucket"], "offsite-mirror");
293 let reparsed: RepositoryReplicationSpec = serde_json::from_value(json).expect("reparse");
294 assert_eq!(spec, reparsed);
295 }
296
297 #[test]
298 fn minimal_true_mirror_spec_omits_optionals() {
299 let yaml = r#"
302sourceRef: { name: nas-primary }
303destination: { filesystem: { path: /mirror } }
304schedule: { cron: "0 6 * * 0" }
305"#;
306 let spec: RepositoryReplicationSpec = from_yaml(yaml);
307 assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
309 let json = serde_json::to_value(&spec).unwrap();
310 assert!(json.get("suspend").is_none());
311 assert!(spec.sync.is_none());
314 assert!(json.get("sync").is_none());
315 }
316
317 #[test]
318 fn sync_options_roundtrip_full_block() {
319 let yaml = r#"
322sourceRef: { name: nas-primary }
323destination: { filesystem: { path: /mirror } }
324schedule: { cron: "0 5 * * *" }
325sync:
326 parallel: 8
327 deleteExtra: true
328 mustExist: false
329 times: true
330 update: false
331 maxDownloadSpeedBytesPerSecond: 1000000
332 maxUploadSpeedBytesPerSecond: 500000
333"#;
334 let spec: RepositoryReplicationSpec = from_yaml(yaml);
335 let sync = spec.sync.expect("sync block set");
336 assert_eq!(sync.parallel, Some(8));
337 assert!(sync.delete_extra);
338 assert_eq!(sync.must_exist, Some(false));
339 assert_eq!(sync.times, Some(true));
340 assert_eq!(sync.update, Some(false));
341 assert_eq!(sync.max_download_speed_bytes_per_second, Some(1_000_000));
342 assert_eq!(sync.max_upload_speed_bytes_per_second, Some(500_000));
343
344 let json = serde_json::to_value(&spec).expect("serialize");
345 assert_eq!(json["sync"]["parallel"], 8);
346 assert_eq!(json["sync"]["deleteExtra"], true);
347 assert_eq!(json["sync"]["mustExist"], false);
348 let reparsed: RepositoryReplicationSpec = serde_json::from_value(json).expect("reparse");
349 assert_eq!(spec, reparsed);
350 }
351
352 #[test]
353 fn sync_options_omits_unset_leaf_fields() {
354 let yaml = r#"
357sourceRef: { name: nas-primary }
358destination: { filesystem: { path: /mirror } }
359schedule: { cron: "0 5 * * *" }
360sync:
361 parallel: 4
362"#;
363 let spec: RepositoryReplicationSpec = from_yaml(yaml);
364 let json = serde_json::to_value(&spec).unwrap();
365 let sync_json = &json["sync"];
366 assert_eq!(sync_json["parallel"], 4);
367 assert!(sync_json.get("deleteExtra").is_none());
368 assert!(sync_json.get("mustExist").is_none());
369 assert!(sync_json.get("times").is_none());
370 assert!(sync_json.get("update").is_none());
371 assert!(sync_json.get("maxDownloadSpeedBytesPerSecond").is_none());
372 assert!(sync_json.get("maxUploadSpeedBytesPerSecond").is_none());
373 }
374
375 #[test]
376 fn stored_cr_with_removed_destination_encryption_still_deserializes() {
377 let yaml = r#"
382sourceRef: { name: nas-primary }
383destination: { filesystem: { path: /mirror } }
384destinationEncryption:
385 passwordSecretRef: { name: legacy-creds, key: KOPIA_PASSWORD }
386schedule: { cron: "0 6 * * 0" }
387"#;
388 let spec: RepositoryReplicationSpec = from_yaml(yaml);
389 assert_eq!(spec.source_ref.name, "nas-primary");
390 assert_eq!(spec.schedule.cron, "0 6 * * 0");
391 }
392
393 #[test]
394 fn replication_phase_all_covers_every_variant() {
395 use crate::common::PhaseLabel;
396 let labels: Vec<&str> = RepositoryReplicationPhase::ALL
397 .iter()
398 .map(|p| p.label())
399 .collect();
400 assert_eq!(RepositoryReplicationPhase::ALL.len(), 5);
401 assert!(labels.iter().all(|l| !l.is_empty()));
402 }
403
404 #[test]
405 fn status_roundtrips() {
406 let status: RepositoryReplicationStatus = from_yaml(
407 "phase: Succeeded\ndestinationBackend: s3\nlastReplicated: 2026-06-09T05:00:00Z\nlastReplicatedBytes: 12345\n",
408 );
409 assert_eq!(status.phase, Some(RepositoryReplicationPhase::Succeeded));
410 assert_eq!(status.destination_backend.as_deref(), Some("s3"));
411 assert_eq!(status.last_replicated_bytes, Some(12345));
412 let json = serde_json::to_value(&status).unwrap();
413 let reparsed: RepositoryReplicationStatus = serde_json::from_value(json).unwrap();
414 assert_eq!(status, reparsed);
415 }
416
417 #[test]
418 fn manual_run_status_roundtrips_the_apiserver_way() {
419 use crate::common::ReplicationManualRunPhase;
420 let status: RepositoryReplicationStatus = from_yaml(
423 "phase: Succeeded\nmanualRun:\n requestedAt: 2026-06-11T12:00:00Z\n phase: Succeeded\n completedAt: 2026-06-11T12:01:42Z\n",
424 );
425 let manual = status.manual_run.as_ref().expect("manualRun decodes");
426 assert_eq!(manual.requested_at.as_deref(), Some("2026-06-11T12:00:00Z"));
427 assert_eq!(manual.phase, Some(ReplicationManualRunPhase::Succeeded));
428 assert_eq!(manual.completed_at.as_deref(), Some("2026-06-11T12:01:42Z"));
429 assert!(manual.answers("2026-06-11T12:00:00Z"));
430 let reparsed: RepositoryReplicationStatus =
431 serde_json::from_value(serde_json::to_value(&status).unwrap()).unwrap();
432 assert_eq!(status, reparsed);
433
434 let skewed: RepositoryReplicationStatus =
437 from_yaml("manualRun:\n requestedAt: 2026-06-11T12:00:00Z\n phase: Queued\n");
438 assert_eq!(
439 skewed.manual_run.and_then(|m| m.phase),
440 Some(ReplicationManualRunPhase::Unknown("Queued".into()))
441 );
442 }
443
444 #[test]
445 fn manual_run_is_absent_from_a_status_that_never_requested_one() {
446 let status: RepositoryReplicationStatus = from_yaml("phase: Succeeded\n");
447 assert!(status.manual_run.is_none());
448 let json = serde_json::to_value(&status).unwrap();
450 assert!(json.get("manualRun").is_none(), "{json}");
451 }
452}