Skip to main content

kopiur_api/
repository_replication.rs

1//! The `RepositoryReplication` CRD — mirror a repository's blobs to a second
2//! backend on a schedule (ADR-0005 §13(d)). The one net-new CRD: it is the "2" in
3//! 3-2-1 backup, wrapping `kopia repository sync-to`.
4//!
5//! It is **namespaced** (it lives alongside its source repository, mirroring
6//! `Maintenance`) and references either a namespaced `Repository` or a cluster-scoped
7//! `ClusterRepository` via a [`RepositoryRef`]. The controller schedules a per-slot
8//! mover Job (croner + deterministic jitter, single-flight, repo-ready gate,
9//! transition-guarded status) exactly like `Maintenance`.
10
11use 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/// Mirror a source repository's blobs to a destination backend on a schedule (`kopia repository sync-to`).
19///
20/// Not `Eq`: `mover` transitively embeds k8s-openapi types.
21#[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    /// Reference to the `Repository` or `ClusterRepository` to mirror from.
40    pub source_ref: RepositoryRef,
41    /// The backend to mirror to; must differ from the source's backend (webhook-enforced).
42    ///
43    /// `kopia repository sync-to` is a blob-level copy: the destination inherits the
44    /// source repository's format and encryption password verbatim, so there is no
45    /// separate destination password to configure. The destination backend's own
46    /// access credentials (e.g. S3 keys) ride its `auth.secretRef`, which — like the
47    /// source's — must live in this CR's namespace.
48    pub destination: Backend,
49    /// Cron and deterministic jitter for the replication runs.
50    pub schedule: CronSpec,
51    /// Mover (Job pod) overrides for the replication run.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub mover: Option<MoverSpec>,
54    /// Pause this replication; a suspended replication runs no syncs (default `false`).
55    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56    pub suspend: bool,
57    /// Tuning knobs for the underlying `kopia repository sync-to` invocation
58    /// (issue #216). `None` reproduces today's behavior: sequential copy
59    /// (`--parallel` unset), additive sync (no `--delete`), kopia's own
60    /// `--must-exist`/`--times`/`--update` defaults, and no throughput cap.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub sync: Option<SyncOptions>,
63}
64
65/// Tuning knobs for `kopia repository sync-to` (issue #216): copy parallelism,
66/// destination pruning, and the blob-sync tri-states/throughput caps kopia
67/// exposes on the command. Every field's `None`/`false` reproduces kopia's own
68/// default, so an absent `sync` block is exactly today's behavior. Pure scalars
69/// (no k8s-openapi embeds), so this derives `Eq` unlike its parent spec.
70#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct SyncOptions {
73    /// `--parallel`: number of concurrent blob-copy workers (kopia default `1` —
74    /// sequential, the root cause of #216's multi-week seed times to R2).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub parallel: Option<u32>,
77    /// `--delete`: prune destination-only blobs so the mirror is an exact copy
78    /// (kopia default `false` — additive sync, never removes destination
79    /// content). Named `deleteExtra`, not kopia's bare `delete`: a `delete: true`
80    /// key on backup-adjacent YAML is dangerously ambiguous at a glance.
81    ///
82    /// CAUTION: with this `true`, blobs present at the destination but absent
83    /// from the source are deleted on every run.
84    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
85    pub delete_extra: bool,
86    /// `--[no-]must-exist`: fail the sync instead of initializing the
87    /// destination's repository-format blob (kopia default `false` — sync-to may
88    /// create the destination layout on first run).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub must_exist: Option<bool>,
91    /// `--[no-]times`: synchronize blob modification times to the destination,
92    /// when the destination backend supports it (kopia default `true`).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub times: Option<bool>,
95    /// `--[no-]update`: update blobs already present at the destination when the
96    /// source copy is newer (kopia default `true`).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub update: Option<bool>,
99    /// `--max-download-speed`: cap read throughput from the source, in
100    /// bytes/sec (kopia default: unlimited).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub max_download_speed_bytes_per_second: Option<i64>,
103    /// `--max-upload-speed`: cap write throughput to the destination, in
104    /// bytes/sec (kopia default: unlimited).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub max_upload_speed_bytes_per_second: Option<i64>,
107}
108
109/// Lifecycle phase of a replication.
110///
111/// ```
112/// use kopiur_api::repository_replication::RepositoryReplicationPhase as P;
113///
114/// assert_eq!(serde_json::to_value(P::Suspended).unwrap(), "Suspended");
115/// // An unrecognized phase from a newer operator decodes instead of erroring.
116/// let p: P = serde_json::from_value(serde_json::json!("Verifying")).unwrap();
117/// assert_eq!(p, P::Unknown("Verifying".into()));
118/// assert_eq!(serde_json::to_value(&p).unwrap(), "Verifying");
119/// ```
120#[derive(Clone, Debug, PartialEq, Eq, Default)]
121pub enum RepositoryReplicationPhase {
122    /// Admitted, not yet run (also the default).
123    #[default]
124    Pending,
125    /// A replication mover Job is in flight.
126    Replicating,
127    /// The most recent replication completed successfully (idle until the next slot).
128    Succeeded,
129    /// The most recent replication run failed; see conditions.
130    Failed,
131    /// Suspended via `spec.suspend`.
132    Suspended,
133    /// A phase string this build does not recognize (newer operator, or legacy
134    /// stored data). Decode-compat only — hidden from the CRD schema, never
135    /// produced by this build, never a success.
136    Unknown(String),
137}
138
139impl RepositoryReplicationPhase {
140    /// Whether this phase is the **decode sentinel** — a value the running build
141    /// cannot interpret, kept verbatim by [`Unknown`](Self::Unknown) instead of
142    /// failing the whole typed `list()`/watch (#359, defect 3).
143    ///
144    /// Same narrow contract as [`SnapshotPhase::is_unknown`](crate::SnapshotPhase::is_unknown):
145    /// `true` means only "this string is not a phase this binary knows", never
146    /// "unusual" or "not one I handle". A canonical variant added to this enum
147    /// later is by definition **not** the sentinel, which is why the `match` is
148    /// written out exhaustively rather than left as a `matches!` — the compiler,
149    /// not a reviewer, is what forces the new variant to answer.
150    ///
151    /// The reconciler uses it for its entry-time version-skew warning
152    /// (`io::warn_unreadable_phase`). Unlike the other drivers, it does not
153    /// promptly overwrite what it cannot read: no branch reads this phase, and
154    /// the terminal stamp comes from the mover at the end of a run, so an
155    /// unreadable value simply persists — up to a whole schedule interval. The
156    /// warning is therefore repeated every pass rather than emitted once: the
157    /// log is where the skew is visible.
158    ///
159    /// ```
160    /// use kopiur_api::RepositoryReplicationPhase;
161    ///
162    /// assert!(RepositoryReplicationPhase::Unknown("Verifying".into()).is_unknown());
163    /// assert!(!RepositoryReplicationPhase::Pending.is_unknown());
164    /// assert!(!RepositoryReplicationPhase::Replicating.is_unknown());
165    /// assert!(!RepositoryReplicationPhase::Suspended.is_unknown());
166    /// ```
167    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/// Observed state of a `RepositoryReplication`.
208#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct RepositoryReplicationStatus {
211    /// Current lifecycle phase.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub phase: Option<RepositoryReplicationPhase>,
214    /// `metadata.generation` last reconciled, for staleness detection / kstatus.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub observed_generation: Option<i64>,
217    /// The destination backend kind, for the `DESTINATION` print column.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub destination_backend: Option<String>,
220    /// RFC3339 timestamp of the most recent successful replication run.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub last_replicated: Option<String>,
223    /// RFC3339 timestamp of the next scheduled replication run (cron + jitter, pinned).
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub next_scheduled_at: Option<String>,
226    /// Bytes replicated by the last successful run (best-effort from kopia output).
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub last_replicated_bytes: Option<i64>,
229    /// Blobs replicated by the last successful run (best-effort).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub last_replicated_blobs: Option<i64>,
232    /// Standard Kubernetes conditions (`Ready`, `Reconciling`, `Stalled`).
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub conditions: Vec<Condition>,
235    /// State of the most recent annotation-requested out-of-band run
236    /// (`kopiur.home-operations.com/run-requested`); absent until one is
237    /// requested.
238    #[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        // sourceRef + destination (externally-tagged backend) + schedule.
262        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        // Destination is exactly one backend variant (the type guarantees it).
282        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        // Externally tagged destination backend.
292        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        // A true mirror reuses the source password (sync-to is a blob copy), so there
300        // is no destination-encryption knob to set.
301        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        // sourceRef.kind defaults to Repository.
308        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        // #216: no `sync` block set → the field is entirely absent on the wire,
312        // reproducing today's argv exactly (no dormant defaults sneak in).
313        assert!(spec.sync.is_none());
314        assert!(json.get("sync").is_none());
315    }
316
317    #[test]
318    fn sync_options_roundtrip_full_block() {
319        // #216: every `spec.sync` tuning knob round-trips through the cluster's
320        // YAML → serde_json::Value → typed path.
321        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        // A `sync` block that only sets `parallel` must not serialize the other
355        // (unset) knobs — `deleteExtra`'s `false` default also skips (Not::not).
356        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        // The field was removed (sync-to is a blob copy; it never did anything). A CR
378        // stored while the field existed must still round-trip: serde silently drops
379        // the now-unknown key (no `deny_unknown_fields`), so existing objects keep
380        // reconciling instead of failing to decode.
381        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        // Parsed the cluster's way (YAML -> serde_json::Value -> typed), which
421        // is the only path that proves the camelCase wire names land.
422        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        // A manualRun phase written by a NEWER operator decodes instead of
435        // poisoning the typed watch for every RepositoryReplication.
436        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        // …and never serializes as an explicit null.
449        let json = serde_json::to_value(&status).unwrap();
450        assert!(json.get("manualRun").is_none(), "{json}");
451    }
452}