1use crate::backend::Backend;
12use crate::common::{CronSpec, MoverSpec, 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(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
111pub enum RepositoryReplicationPhase {
112 #[default]
114 Pending,
115 Replicating,
117 Succeeded,
119 Failed,
121 Suspended,
123}
124
125impl crate::common::PhaseLabel for RepositoryReplicationPhase {
126 const ALL: &'static [Self] = &[
127 Self::Pending,
128 Self::Replicating,
129 Self::Succeeded,
130 Self::Failed,
131 Self::Suspended,
132 ];
133 fn label(&self) -> &'static str {
134 match self {
135 Self::Pending => "Pending",
136 Self::Replicating => "Replicating",
137 Self::Succeeded => "Succeeded",
138 Self::Failed => "Failed",
139 Self::Suspended => "Suspended",
140 }
141 }
142}
143
144#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
146#[serde(rename_all = "camelCase")]
147pub struct RepositoryReplicationStatus {
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub phase: Option<RepositoryReplicationPhase>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub observed_generation: Option<i64>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub destination_backend: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub last_replicated: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub next_scheduled_at: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub last_replicated_bytes: Option<i64>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub last_replicated_blobs: Option<i64>,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub conditions: Vec<Condition>,
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::common::RepositoryKind;
178 use crate::testutil::from_yaml;
179 use kube::core::CustomResourceExt;
180
181 #[test]
182 fn repository_replication_crd_metadata_is_correct() {
183 let crd = RepositoryReplication::crd();
184 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
185 assert_eq!(crd.spec.names.kind, "RepositoryReplication");
186 assert_eq!(crd.spec.names.plural, "repositoryreplications");
187 assert_eq!(crd.spec.scope, "Namespaced");
188 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
189 }
190
191 #[test]
192 fn repository_replication_roundtrip() {
193 let yaml = r#"
195sourceRef:
196 kind: Repository
197 name: nas-primary
198destination:
199 s3:
200 bucket: offsite-mirror
201 region: us-east-1
202 auth:
203 secretRef:
204 name: offsite-creds
205schedule:
206 cron: "0 5 * * *"
207 jitter: 1h
208suspend: false
209"#;
210 let spec: RepositoryReplicationSpec = from_yaml(yaml);
211 assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
212 assert_eq!(spec.source_ref.name, "nas-primary");
213 match &spec.destination {
215 Backend::S3(s3) => assert_eq!(s3.bucket, "offsite-mirror"),
216 other => panic!("expected S3 destination, got {}", other.kind_str()),
217 }
218 assert_eq!(spec.schedule.cron, "0 5 * * *");
219 assert_eq!(spec.schedule.jitter.as_deref(), Some("1h"));
220 assert!(!spec.suspend);
221
222 let json = serde_json::to_value(&spec).expect("serialize");
223 assert_eq!(json["destination"]["s3"]["bucket"], "offsite-mirror");
225 let reparsed: RepositoryReplicationSpec = serde_json::from_value(json).expect("reparse");
226 assert_eq!(spec, reparsed);
227 }
228
229 #[test]
230 fn minimal_true_mirror_spec_omits_optionals() {
231 let yaml = r#"
234sourceRef: { name: nas-primary }
235destination: { filesystem: { path: /mirror } }
236schedule: { cron: "0 6 * * 0" }
237"#;
238 let spec: RepositoryReplicationSpec = from_yaml(yaml);
239 assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
241 let json = serde_json::to_value(&spec).unwrap();
242 assert!(json.get("suspend").is_none());
243 assert!(spec.sync.is_none());
246 assert!(json.get("sync").is_none());
247 }
248
249 #[test]
250 fn sync_options_roundtrip_full_block() {
251 let yaml = r#"
254sourceRef: { name: nas-primary }
255destination: { filesystem: { path: /mirror } }
256schedule: { cron: "0 5 * * *" }
257sync:
258 parallel: 8
259 deleteExtra: true
260 mustExist: false
261 times: true
262 update: false
263 maxDownloadSpeedBytesPerSecond: 1000000
264 maxUploadSpeedBytesPerSecond: 500000
265"#;
266 let spec: RepositoryReplicationSpec = from_yaml(yaml);
267 let sync = spec.sync.expect("sync block set");
268 assert_eq!(sync.parallel, Some(8));
269 assert!(sync.delete_extra);
270 assert_eq!(sync.must_exist, Some(false));
271 assert_eq!(sync.times, Some(true));
272 assert_eq!(sync.update, Some(false));
273 assert_eq!(sync.max_download_speed_bytes_per_second, Some(1_000_000));
274 assert_eq!(sync.max_upload_speed_bytes_per_second, Some(500_000));
275
276 let json = serde_json::to_value(&spec).expect("serialize");
277 assert_eq!(json["sync"]["parallel"], 8);
278 assert_eq!(json["sync"]["deleteExtra"], true);
279 assert_eq!(json["sync"]["mustExist"], false);
280 let reparsed: RepositoryReplicationSpec = serde_json::from_value(json).expect("reparse");
281 assert_eq!(spec, reparsed);
282 }
283
284 #[test]
285 fn sync_options_omits_unset_leaf_fields() {
286 let yaml = r#"
289sourceRef: { name: nas-primary }
290destination: { filesystem: { path: /mirror } }
291schedule: { cron: "0 5 * * *" }
292sync:
293 parallel: 4
294"#;
295 let spec: RepositoryReplicationSpec = from_yaml(yaml);
296 let json = serde_json::to_value(&spec).unwrap();
297 let sync_json = &json["sync"];
298 assert_eq!(sync_json["parallel"], 4);
299 assert!(sync_json.get("deleteExtra").is_none());
300 assert!(sync_json.get("mustExist").is_none());
301 assert!(sync_json.get("times").is_none());
302 assert!(sync_json.get("update").is_none());
303 assert!(sync_json.get("maxDownloadSpeedBytesPerSecond").is_none());
304 assert!(sync_json.get("maxUploadSpeedBytesPerSecond").is_none());
305 }
306
307 #[test]
308 fn stored_cr_with_removed_destination_encryption_still_deserializes() {
309 let yaml = r#"
314sourceRef: { name: nas-primary }
315destination: { filesystem: { path: /mirror } }
316destinationEncryption:
317 passwordSecretRef: { name: legacy-creds, key: KOPIA_PASSWORD }
318schedule: { cron: "0 6 * * 0" }
319"#;
320 let spec: RepositoryReplicationSpec = from_yaml(yaml);
321 assert_eq!(spec.source_ref.name, "nas-primary");
322 assert_eq!(spec.schedule.cron, "0 6 * * 0");
323 }
324
325 #[test]
326 fn replication_phase_all_covers_every_variant() {
327 use crate::common::PhaseLabel;
328 let labels: Vec<&str> = RepositoryReplicationPhase::ALL
329 .iter()
330 .map(|p| p.label())
331 .collect();
332 assert_eq!(RepositoryReplicationPhase::ALL.len(), 5);
333 assert!(labels.iter().all(|l| !l.is_empty()));
334 }
335
336 #[test]
337 fn status_roundtrips() {
338 let status: RepositoryReplicationStatus = from_yaml(
339 "phase: Succeeded\ndestinationBackend: s3\nlastReplicated: 2026-06-09T05:00:00Z\nlastReplicatedBytes: 12345\n",
340 );
341 assert_eq!(status.phase, Some(RepositoryReplicationPhase::Succeeded));
342 assert_eq!(status.destination_backend.as_deref(), Some("s3"));
343 assert_eq!(status.last_replicated_bytes, Some(12345));
344 let json = serde_json::to_value(&status).unwrap();
345 let reparsed: RepositoryReplicationStatus = serde_json::from_value(json).unwrap();
346 assert_eq!(status, reparsed);
347 }
348}