Skip to main content

kopiur_api/
snapshot_replication.rs

1//! The `SnapshotReplication` CRD — copy snapshot **manifests** (and the content
2//! they reference) from one repository to another on a schedule, wrapping
3//! `kopia snapshot migrate` (issue #368).
4//!
5//! Where [`RepositoryReplication`](crate::repository_replication) mirrors a
6//! repository's **blobs** verbatim (`kopia repository sync-to` — same format,
7//! same password, all-or-nothing), `SnapshotReplication` copies at the
8//! **snapshot** level: source and destination are BOTH real repository CRs with
9//! their own passwords/formats, and a selector chooses which kopia identities
10//! (and optionally only their latest snapshots) get copied. It is
11//! **namespaced** (it lives alongside its source, like `Maintenance` and
12//! `RepositoryReplication`) and references its repositories via
13//! [`RepositoryRef`] — a cluster-scoped `ClusterRepository` destination is the
14//! flagship consolidation use case.
15//!
16//! Each copied snapshot is materialized as a `Snapshot` CR
17//! (`origin: replicated`) in this CR's namespace by the replication mover, with
18//! **no ownerReference back to this CR**: deleting the `SnapshotReplication`
19//! never deletes the copies. Only `spec.pruning` (or deleting the copy
20//! `Snapshot` CRs themselves) removes replicated data.
21
22use crate::common::{
23    CredentialProjection, CronSpec, MigrateThrottle, MoverSpec, ReplicationManualRunStatus,
24    RepositoryRef, Retention,
25};
26use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
27use kube::CustomResource;
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30
31/// Copy selected snapshots from a source repository into a destination repository on a schedule (`kopia snapshot migrate`).
32///
33/// Not `Eq`: `mover` transitively embeds k8s-openapi types.
34#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
35#[kube(
36    group = "kopiur.home-operations.com",
37    version = "v1alpha1",
38    kind = "SnapshotReplication",
39    plural = "snapshotreplications",
40    namespaced,
41    status = "SnapshotReplicationStatus",
42    shortname = "kopiasrepl",
43    category = "kopiur",
44    printcolumn = r#"{"name":"Source","type":"string","jsonPath":".spec.sourceRef.name"}"#,
45    printcolumn = r#"{"name":"Destination","type":"string","jsonPath":".spec.destinationRef.name"}"#,
46    printcolumn = r#"{"name":"Schedule","type":"string","jsonPath":".spec.schedule.cron"}"#,
47    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
48    printcolumn = r#"{"name":"Last","type":"date","jsonPath":".status.lastReplicated"}"#,
49    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
50)]
51#[serde(rename_all = "camelCase")]
52pub struct SnapshotReplicationSpec {
53    /// The `Repository` or `ClusterRepository` snapshots are copied FROM. Opened
54    /// read-only by the replication mover — a replication never writes to its source.
55    pub source_ref: RepositoryRef,
56    /// The `Repository` or `ClusterRepository` snapshots are copied INTO. A real
57    /// repository CR with its own password and format (unlike a
58    /// `RepositoryReplication` destination, which is a bare backend reusing the
59    /// source's password). Must not resolve to the same repository as `sourceRef`
60    /// (webhook-enforced structurally; the shared validator catches the literal
61    /// same-ref case at admission).
62    pub destination_ref: RepositoryRef,
63    /// Cron and deterministic jitter for the replication runs.
64    pub schedule: CronSpec,
65    /// Which snapshots to copy. Absent: every identity in the source repository,
66    /// full history (`kopia snapshot migrate --all`).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub selection: Option<SelectionSpec>,
69    /// Tuning for the underlying `kopia snapshot migrate` invocation. Absent:
70    /// sequential copy, and NO policy copying (see [`PolicyCopyMode`]).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub migrate: Option<MigrateOptions>,
73    /// What happens to already-replicated copies at the destination on later runs.
74    /// Absent = `none`: copies accumulate forever and — like every mode here —
75    /// **survive deletion of this CR** (copy `Snapshot` CRs carry no
76    /// ownerReference to it). Pruning only ever considers snapshots this CR
77    /// replicated (labeled at birth); it never touches the destination's own
78    /// directly-written snapshots.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub pruning: Option<Pruning>,
81    /// Mover (Job pod) overrides for the replication run.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub mover: Option<MoverSpec>,
84    /// Opt-in projection of BOTH repositories' credential Secrets into the mover
85    /// Job's namespace (required when the destination is a `ClusterRepository`
86    /// whose Secrets live elsewhere).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub credential_projection: Option<CredentialProjection>,
89    /// Pause this replication; a suspended replication runs no copies (default `false`).
90    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
91    pub suspend: bool,
92}
93
94/// Which snapshots a replication copies. Pure scalars, so `Eq` (unlike the parent spec).
95#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
96#[serde(rename_all = "camelCase")]
97pub struct SelectionSpec {
98    /// Select source identities (`username@hostname:path`) to copy. Absent: all
99    /// identities in the source repository.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub identities: Option<IdentitySelection>,
102    /// Copy only each selected identity's most recent snapshot instead of its
103    /// full history (`kopia snapshot migrate --latest`). Default `false`.
104    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
105    pub latest_only: bool,
106}
107
108/// Include/exclude lists of identity matchers. An identity is selected when it
109/// matches at least one `include` entry (an empty/absent `include` list means
110/// "everything") AND matches no `exclude` entry — exclude always wins.
111#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
112#[serde(rename_all = "camelCase")]
113pub struct IdentitySelection {
114    /// Identities to copy. Empty/absent: every identity in the source.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub include: Vec<IdentityMatcher>,
117    /// Identities to skip, applied after `include`. Exclude wins on overlap.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub exclude: Vec<IdentityMatcher>,
120}
121
122/// One glob match against a kopia identity's structured
123/// `(username, hostname, sourcePath)` triple. Every set component must match
124/// (AND); an unset component matches anything — but at least one component must
125/// be set (webhook-refused otherwise: a fully-empty matcher constrains nothing).
126///
127/// Glob semantics, **per component** (non-crossing — a `*` in `username` can
128/// never reach into `hostname`): `*` matches any run of characters (including
129/// none), `?` matches exactly one character, everything else matches literally,
130/// and the pattern is anchored (it must cover the WHOLE component — `pg` does
131/// not match `pg-main`; `pg*` does). Matching is always on the structured
132/// triple, never on a joined `username@hostname:path` string, so literal `@`/`:`
133/// inside a component cannot confuse it. No character classes (`[...]`) or brace
134/// expansion — those are rejected at admission rather than silently matched
135/// literally. See [`component_glob_matches`].
136#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
137#[serde(rename_all = "camelCase")]
138pub struct IdentityMatcher {
139    /// Glob for the `username` component; absent matches any username.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub username: Option<String>,
142    /// Glob for the `hostname` component; absent matches any hostname.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub hostname: Option<String>,
145    /// Glob for the `sourcePath` component; absent matches any path.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub source_path: Option<String>,
148}
149
150/// Tuning for `kopia snapshot migrate`. Pure scalars + an optional throttle
151/// sub-object, so `Eq` but NOT `Copy` (`Throttle` isn't).
152#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct MigrateOptions {
155    /// `--parallel`: number of snapshots migrated concurrently (kopia default
156    /// `1` — sequential). Must be >= 1 when set.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub parallel: Option<u32>,
159    /// Whether kopia **policies** attached to the copied sources are also copied
160    /// to the destination. Defaults to [`PolicyCopyMode::None`].
161    #[serde(default)]
162    pub policies: PolicyCopyMode,
163    /// Bandwidth/ops caps for THIS replication's runs, per side. `snapshot
164    /// migrate` has no speed flags, so each side is applied as `kopia repository
165    /// throttle set` on that side's connection; `source` overrides the source
166    /// repository's `moverDefaults.throttle` and `destination` the destination
167    /// repository's, field by field (a field left unset keeps that repository's
168    /// default). Absent: both sides use their repository's defaults.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub throttle: Option<MigrateThrottle>,
171}
172
173/// How `kopia snapshot migrate` treats the source's kopia policies.
174///
175/// kopia's own default is to copy policies, but Kopiur defaults to `none`: in a
176/// Kopiur-managed destination, retention is driven by `Snapshot` CRs (GFS over
177/// CRs, ADR §4.4) and kopia-side policies are deliberately inert — importing the
178/// source's policies could re-introduce kopia-side retention that deletes
179/// destination manifests behind the operator's back. `none` maps to an explicit
180/// `--no-policies`.
181///
182/// ```
183/// use kopiur_api::snapshot_replication::PolicyCopyMode;
184///
185/// // Plain camelCase string enum on the wire.
186/// assert_eq!(serde_json::to_value(PolicyCopyMode::CopyOverwrite).unwrap(), "copyOverwrite");
187/// assert_eq!(PolicyCopyMode::default(), PolicyCopyMode::None);
188/// ```
189#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
190#[serde(rename_all = "camelCase")]
191pub enum PolicyCopyMode {
192    /// Do not copy kopia policies (`--no-policies`) — the default, so a
193    /// Kopiur-managed destination's retention stays CR-driven.
194    #[default]
195    None,
196    /// Copy policies for the migrated sources, keeping any policy that already
197    /// exists at the destination (kopia's migrate default).
198    Copy,
199    /// Copy policies and overwrite same-named policies already at the
200    /// destination (`--overwrite-policies`).
201    CopyOverwrite,
202}
203
204/// Exactly one pruning mode for already-replicated copies (externally tagged:
205/// `pruning: { retention: {...} }`). Absent `spec.pruning` = [`Pruning::None`].
206///
207/// Whatever the mode, pruning only ever considers snapshots THIS replication
208/// created (selected by the labels stamped at copy-CR birth); the destination's
209/// own directly-written snapshots are structurally out of reach. And because
210/// copy `Snapshot` CRs carry no ownerReference, deleting the
211/// `SnapshotReplication` CR itself never deletes any copy — the modes below are
212/// the only operator-driven way replicated data goes away.
213#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
214#[serde(rename_all = "camelCase")]
215pub enum Pruning {
216    /// Never prune: copies accumulate until deleted by hand (the default when
217    /// `spec.pruning` is absent).
218    None(NoPruning),
219    /// Mirror the source: delete a copy when its `(identity, startTime)` has
220    /// vanished from the source repository.
221    MirrorSource(MirrorSourcePruning),
222    /// Keep copies under an independent GFS retention at the destination,
223    /// regardless of what the source still holds.
224    Retention(Retention),
225}
226
227impl Pruning {
228    /// Stable lowercase label for status/metrics/log fields. Exhaustive so a new
229    /// mode cannot compile without naming itself.
230    pub fn kind_str(&self) -> &'static str {
231        match self {
232            Pruning::None(_) => "none",
233            Pruning::MirrorSource(_) => "mirrorSource",
234            Pruning::Retention(_) => "retention",
235        }
236    }
237}
238
239/// Marker for [`Pruning::None`] — no fields today; a sub-object so future knobs
240/// slot in without API breakage (ADR §4.11).
241#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
242#[serde(rename_all = "camelCase")]
243pub struct NoPruning {}
244
245/// Marker for [`Pruning::MirrorSource`] — no fields today (sub-object for
246/// forward-compat, ADR §4.11).
247///
248/// Mirror-source deletes are deliberately NOT stamped as operator prunes, so
249/// they classify as EXTERNAL deletions and the destination repository's
250/// mass-deletion breaker (`deletionProtection.threshold`) **holds a bulk
251/// source-side vanish**: ransomware emptying the source cannot cascade into
252/// emptying the off-site copy in one wave. The hold surfaces through the
253/// breaker's normal machinery — a `DeletionHeld` condition on each held copy
254/// `Snapshot` and `MassDeletionHeld` on the destination repository — and is
255/// released with the breaker's normal timestamp acknowledgement.
256#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
257#[serde(rename_all = "camelCase")]
258pub struct MirrorSourcePruning {}
259
260/// Anchored per-component glob match: `*` matches any run of characters
261/// (including none), `?` matches exactly one, everything else is literal. This
262/// is the ONE matcher both admission (via
263/// [`validate_component_glob`]) and the replication mover use, so what the
264/// webhook admitted and what the mover selects cannot fork. Deliberately
265/// dependency-free — no regex, no glob crate.
266///
267/// Matching is per identity **component** ([`IdentityMatcher`]): there is no
268/// separator to cross, so `*` spanning the whole component is exactly the
269/// intended "match anything here" semantics.
270///
271/// ```
272/// use kopiur_api::snapshot_replication::component_glob_matches;
273///
274/// assert!(component_glob_matches("pg-*", "pg-main"));
275/// assert!(component_glob_matches("*", ""));
276/// assert!(component_glob_matches("data-?", "data-1"));
277/// assert!(!component_glob_matches("pg", "pg-main"));   // anchored: whole component
278/// assert!(!component_glob_matches("data-?", "data-12"));
279/// assert!(component_glob_matches("*.internal", "billing.svc.internal"));
280/// ```
281pub fn component_glob_matches(pattern: &str, value: &str) -> bool {
282    let p: Vec<char> = pattern.chars().collect();
283    let v: Vec<char> = value.chars().collect();
284    let (mut pi, mut vi) = (0usize, 0usize);
285    // Last `*` seen and the value position to retry from (classic backtracking
286    // wildcard match, linear in practice).
287    let mut star: Option<(usize, usize)> = None;
288    while vi < v.len() {
289        if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
290            pi += 1;
291            vi += 1;
292        } else if pi < p.len() && p[pi] == '*' {
293            star = Some((pi, vi));
294            pi += 1;
295        } else if let Some((sp, sv)) = star {
296            // Let the last `*` swallow one more character and retry.
297            pi = sp + 1;
298            vi = sv + 1;
299            star = Some((sp, sv + 1));
300        } else {
301            return false;
302        }
303    }
304    while pi < p.len() && p[pi] == '*' {
305        pi += 1;
306    }
307    pi == p.len()
308}
309
310/// Admission-time check that an [`IdentityMatcher`] component pattern is one
311/// [`component_glob_matches`] can honor: non-empty, no control characters, and
312/// none of the glob syntax this matcher deliberately does NOT support (`[...]`
313/// character classes, `{...}` brace expansion). Rejecting those up front beats
314/// silently matching them as literal characters — a `data[0-9]` that never
315/// matches anything is the silent-no-op failure mode this repo designs out.
316///
317/// Returns the human-readable reason on failure (the caller wraps it in
318/// [`ValidationError::InvalidFieldValue`](crate::ValidationError::InvalidFieldValue)
319/// with the field path).
320///
321/// ```
322/// use kopiur_api::snapshot_replication::validate_component_glob;
323///
324/// assert!(validate_component_glob("pg-*").is_ok());
325/// assert!(validate_component_glob("*").is_ok());
326/// assert!(validate_component_glob("").is_err());
327/// assert!(validate_component_glob("data[0-9]").is_err());
328/// assert!(validate_component_glob("a{b,c}").is_err());
329/// ```
330pub fn validate_component_glob(pattern: &str) -> Result<(), String> {
331    if pattern.is_empty() {
332        return Err(
333            "must not be empty — omit the component to match anything, or use \"*\"".to_string(),
334        );
335    }
336    for c in pattern.chars() {
337        match c {
338            '[' | ']' | '{' | '}' => {
339                return Err(format!(
340                    "contains {c:?}; only the \"*\" and \"?\" wildcards are supported"
341                ));
342            }
343            c if c.is_control() => {
344                return Err("must not contain control characters".to_string());
345            }
346            _ => {}
347        }
348    }
349    Ok(())
350}
351
352/// Lifecycle phase of a snapshot replication.
353///
354/// ```
355/// use kopiur_api::snapshot_replication::SnapshotReplicationPhase as P;
356///
357/// assert_eq!(serde_json::to_value(P::Suspended).unwrap(), "Suspended");
358/// // An unrecognized phase from a newer operator decodes instead of erroring.
359/// let p: P = serde_json::from_value(serde_json::json!("Verifying")).unwrap();
360/// assert_eq!(p, P::Unknown("Verifying".into()));
361/// assert_eq!(serde_json::to_value(&p).unwrap(), "Verifying");
362/// ```
363#[derive(Clone, Debug, PartialEq, Eq, Default)]
364pub enum SnapshotReplicationPhase {
365    /// Admitted, not yet run (also the default).
366    #[default]
367    Pending,
368    /// A replication mover Job is in flight.
369    Replicating,
370    /// The most recent replication completed successfully (idle until the next slot).
371    Succeeded,
372    /// The most recent replication run failed; see conditions.
373    Failed,
374    /// Suspended via `spec.suspend`.
375    Suspended,
376    /// A phase string this build does not recognize (newer operator, or legacy
377    /// stored data). Decode-compat only — hidden from the CRD schema, never
378    /// produced by this build, never a success.
379    Unknown(String),
380}
381
382impl SnapshotReplicationPhase {
383    /// Whether this phase is the **decode sentinel** — a value the running build
384    /// cannot interpret, kept verbatim by [`Unknown`](Self::Unknown) instead of
385    /// failing the whole typed `list()`/watch (#359, defect 3).
386    ///
387    /// Same narrow contract as
388    /// [`RepositoryReplicationPhase::is_unknown`](crate::RepositoryReplicationPhase::is_unknown):
389    /// `true` means only "this string is not a phase this binary knows", never
390    /// "unusual" or "not one I handle". A canonical variant added to this enum
391    /// later is by definition **not** the sentinel, which is why the `match` is
392    /// written out exhaustively rather than left as a `matches!` — the compiler,
393    /// not a reviewer, is what forces the new variant to answer.
394    ///
395    /// ```
396    /// use kopiur_api::SnapshotReplicationPhase;
397    ///
398    /// assert!(SnapshotReplicationPhase::Unknown("Verifying".into()).is_unknown());
399    /// assert!(!SnapshotReplicationPhase::Pending.is_unknown());
400    /// assert!(!SnapshotReplicationPhase::Replicating.is_unknown());
401    /// assert!(!SnapshotReplicationPhase::Suspended.is_unknown());
402    /// ```
403    pub fn is_unknown(&self) -> bool {
404        match self {
405            Self::Unknown(_) => true,
406            Self::Pending
407            | Self::Replicating
408            | Self::Succeeded
409            | Self::Failed
410            | Self::Suspended => false,
411        }
412    }
413}
414
415crate::common::phase_serde!(
416    SnapshotReplicationPhase,
417    "Lifecycle phase of a snapshot replication."
418);
419
420impl crate::common::PhaseLabel for SnapshotReplicationPhase {
421    const ALL: &'static [Self] = &[
422        Self::Pending,
423        Self::Replicating,
424        Self::Succeeded,
425        Self::Failed,
426        Self::Suspended,
427    ];
428    fn label(&self) -> &str {
429        match self {
430            Self::Pending => "Pending",
431            Self::Replicating => "Replicating",
432            Self::Succeeded => "Succeeded",
433            Self::Failed => "Failed",
434            Self::Suspended => "Suspended",
435            Self::Unknown(s) => s,
436        }
437    }
438    fn unknown(raw: String) -> Self {
439        Self::Unknown(raw)
440    }
441}
442
443/// Counters from the most recent replication run, written by the mover's
444/// terminal status patch. Pure scalars, so `Eq`.
445#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
446#[serde(rename_all = "camelCase")]
447pub struct SnapshotReplicationRunStats {
448    /// Source identities the selector matched this run.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub identities_selected: Option<u32>,
451    /// Snapshots newly copied to the destination this run.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub snapshots_copied: Option<u32>,
454    /// Selected snapshots that were already present at the destination (skipped).
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub already_present: Option<u32>,
457    /// Selected snapshots that failed to copy (kopia migrate exits 0 on
458    /// per-source failures; the mover's post-verify counts them here).
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub failed: Option<u32>,
461    /// Copies pruned this run per `spec.pruning`.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub pruned: Option<u32>,
464    /// Incomplete source manifests of selected identities — checkpoints an
465    /// interrupted `kopia snapshot create` left behind — that this run did not
466    /// replicate. kopia can never migrate a checkpoint as a complete snapshot,
467    /// so they are skipped rather than failing the post-verify. Non-zero
468    /// usually means an abandoned manual backup worth cleaning up with
469    /// `kopia snapshot delete <id> --delete` (the mover log names the ids).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub incomplete_skipped: Option<u32>,
472}
473
474/// Observed state of a `SnapshotReplication`.
475#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
476#[serde(rename_all = "camelCase")]
477pub struct SnapshotReplicationStatus {
478    /// Current lifecycle phase.
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub phase: Option<SnapshotReplicationPhase>,
481    /// `metadata.generation` last reconciled, for staleness detection / kstatus.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub observed_generation: Option<i64>,
484    /// RFC3339 timestamp of the most recent successful replication run (also the
485    /// scheduling anchor for the next slot).
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub last_replicated: Option<String>,
488    /// Counters from the most recent run.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub last_run: Option<SnapshotReplicationRunStats>,
491    /// Standard Kubernetes conditions (`Ready`, `Reconciling`, `Stalled`).
492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493    pub conditions: Vec<Condition>,
494    /// State of the most recent annotation-requested out-of-band run
495    /// (`kopiur.home-operations.com/run-requested`); absent until one is
496    /// requested.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub manual_run: Option<ReplicationManualRunStatus>,
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::common::RepositoryKind;
505    use crate::testutil::from_yaml;
506    use kube::core::CustomResourceExt;
507
508    #[test]
509    fn snapshot_replication_crd_metadata_is_correct() {
510        let crd = SnapshotReplication::crd();
511        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
512        assert_eq!(crd.spec.names.kind, "SnapshotReplication");
513        assert_eq!(crd.spec.names.plural, "snapshotreplications");
514        assert_eq!(crd.spec.scope, "Namespaced");
515        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
516        assert_eq!(
517            crd.spec.names.short_names,
518            Some(vec!["kopiasrepl".to_string()])
519        );
520        assert_eq!(crd.spec.names.categories, Some(vec!["kopiur".to_string()]));
521    }
522
523    #[test]
524    fn snapshot_replication_full_roundtrip() {
525        let yaml = r#"
526sourceRef:
527  kind: Repository
528  name: nas-primary
529destinationRef:
530  kind: ClusterRepository
531  name: offsite-shared
532schedule:
533  cron: "0 6 * * *"
534  jitter: 30m
535  timezone: America/Chicago
536selection:
537  identities:
538    include:
539      - username: pg-*
540        hostname: billing.talos
541    exclude:
542      - sourcePath: "/scratch/*"
543  latestOnly: true
544migrate:
545  parallel: 4
546  policies: copyOverwrite
547pruning:
548  retention:
549    keepDaily: 7
550    keepWeekly: 4
551credentialProjection:
552  enabled: true
553suspend: false
554"#;
555        let spec: SnapshotReplicationSpec = from_yaml(yaml);
556        assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
557        assert_eq!(spec.source_ref.name, "nas-primary");
558        assert_eq!(spec.destination_ref.kind, RepositoryKind::ClusterRepository);
559        assert_eq!(spec.destination_ref.name, "offsite-shared");
560        assert_eq!(spec.schedule.cron, "0 6 * * *");
561        assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
562
563        let sel = spec.selection.as_ref().expect("selection set");
564        assert!(sel.latest_only);
565        let ids = sel.identities.as_ref().expect("identities set");
566        assert_eq!(ids.include.len(), 1);
567        assert_eq!(ids.include[0].username.as_deref(), Some("pg-*"));
568        assert_eq!(ids.include[0].hostname.as_deref(), Some("billing.talos"));
569        assert!(ids.include[0].source_path.is_none());
570        assert_eq!(ids.exclude[0].source_path.as_deref(), Some("/scratch/*"));
571
572        let migrate = spec.migrate.clone().expect("migrate set");
573        assert_eq!(migrate.parallel, Some(4));
574        assert_eq!(migrate.policies, PolicyCopyMode::CopyOverwrite);
575
576        // Pruning is exactly one externally-tagged variant.
577        match spec.pruning.as_ref().expect("pruning set") {
578            Pruning::Retention(r) => {
579                assert_eq!(r.keep_daily, Some(7));
580                assert_eq!(r.keep_weekly, Some(4));
581            }
582            other => panic!("expected retention pruning, got {}", other.kind_str()),
583        }
584        assert!(
585            spec.credential_projection
586                .as_ref()
587                .is_some_and(|c| c.enabled)
588        );
589        assert!(!spec.suspend);
590
591        let json = serde_json::to_value(&spec).expect("serialize");
592        // Externally tagged pruning + camelCase policies string.
593        assert_eq!(json["pruning"]["retention"]["keepDaily"], 7);
594        assert_eq!(json["migrate"]["policies"], "copyOverwrite");
595        let reparsed: SnapshotReplicationSpec = serde_json::from_value(json).expect("reparse");
596        assert_eq!(spec, reparsed);
597    }
598
599    #[test]
600    fn minimal_spec_omits_optionals() {
601        let yaml = r#"
602sourceRef: { name: nas-primary }
603destinationRef: { name: offsite }
604schedule: { cron: "0 6 * * 0" }
605"#;
606        let spec: SnapshotReplicationSpec = from_yaml(yaml);
607        assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
608        assert!(spec.selection.is_none());
609        assert!(spec.migrate.is_none());
610        assert!(spec.pruning.is_none());
611        assert!(spec.mover.is_none());
612        assert!(spec.credential_projection.is_none());
613        assert!(!spec.suspend);
614        let json = serde_json::to_value(&spec).unwrap();
615        for absent in [
616            "selection",
617            "migrate",
618            "pruning",
619            "mover",
620            "credentialProjection",
621            "suspend",
622        ] {
623            assert!(json.get(absent).is_none(), "{absent} must not serialize");
624        }
625    }
626
627    #[test]
628    fn all_three_pruning_variants_roundtrip_under_their_external_tags() {
629        for (yaml, want_kind, want_key) in [
630            ("pruning: { none: {} }", "none", "none"),
631            (
632                "pruning: { mirrorSource: {} }",
633                "mirrorSource",
634                "mirrorSource",
635            ),
636            (
637                "pruning: { retention: { keepLatest: 5 } }",
638                "retention",
639                "retention",
640            ),
641        ] {
642            let full = format!(
643                "sourceRef: {{ name: a }}\ndestinationRef: {{ name: b }}\nschedule: {{ cron: \"0 6 * * *\" }}\n{yaml}\n"
644            );
645            let spec: SnapshotReplicationSpec = from_yaml(&full);
646            let pruning = spec.pruning.as_ref().expect("pruning set");
647            assert_eq!(pruning.kind_str(), want_kind);
648            let json = serde_json::to_value(&spec).unwrap();
649            assert!(
650                json["pruning"].get(want_key).is_some(),
651                "pruning must serialize under the {want_key} tag: {json}"
652            );
653            let reparsed: SnapshotReplicationSpec = serde_json::from_value(json).expect("reparse");
654            assert_eq!(spec, reparsed);
655        }
656    }
657
658    #[test]
659    fn unknown_pruning_variant_is_rejected() {
660        let value: serde_json::Value =
661            serde_saphyr::from_str("keepEverything: {}\n").expect("yaml value");
662        assert!(
663            serde_json::from_value::<Pruning>(value).is_err(),
664            "unknown pruning variant must fail to deserialize"
665        );
666    }
667
668    #[test]
669    fn unknown_policy_copy_mode_is_rejected() {
670        assert!(
671            serde_json::from_value::<PolicyCopyMode>(serde_json::json!("merge")).is_err(),
672            "unknown policies mode must fail to deserialize"
673        );
674    }
675
676    #[test]
677    fn replication_phase_all_covers_every_variant() {
678        use crate::common::PhaseLabel;
679        let labels: Vec<&str> = SnapshotReplicationPhase::ALL
680            .iter()
681            .map(|p| p.label())
682            .collect();
683        assert_eq!(SnapshotReplicationPhase::ALL.len(), 5);
684        assert!(labels.iter().all(|l| !l.is_empty()));
685    }
686
687    #[test]
688    fn status_roundtrips_with_run_stats() {
689        let status: SnapshotReplicationStatus = from_yaml(
690            "phase: Succeeded\nobservedGeneration: 3\nlastReplicated: 2026-08-01T06:00:00Z\nlastRun:\n  identitiesSelected: 4\n  snapshotsCopied: 12\n  alreadyPresent: 88\n  failed: 0\n  pruned: 2\n  incompleteSkipped: 1\n",
691        );
692        assert_eq!(status.phase, Some(SnapshotReplicationPhase::Succeeded));
693        let run = status.last_run.expect("lastRun set");
694        assert_eq!(run.identities_selected, Some(4));
695        assert_eq!(run.snapshots_copied, Some(12));
696        assert_eq!(run.incomplete_skipped, Some(1));
697        assert_eq!(run.already_present, Some(88));
698        assert_eq!(run.failed, Some(0));
699        assert_eq!(run.pruned, Some(2));
700        let json = serde_json::to_value(&status).unwrap();
701        let reparsed: SnapshotReplicationStatus = serde_json::from_value(json).unwrap();
702        assert_eq!(status, reparsed);
703    }
704
705    #[test]
706    fn component_glob_matches_covers_the_matrix() {
707        // (pattern, value, matches)
708        for (p, v, want) in [
709            ("*", "", true),
710            ("*", "anything", true),
711            ("pg-*", "pg-main", true),
712            ("pg-*", "pg-", true),
713            ("pg-*", "mysql", false),
714            ("pg", "pg-main", false), // anchored: must cover the whole component
715            ("?", "a", true),
716            ("?", "", false),
717            ("data-?", "data-1", true),
718            ("data-?", "data-12", false),
719            ("*-main", "pg-main", true),
720            ("*-main", "pg-main-old", false),
721            ("a*b*c", "aXXbYYc", true),
722            ("a*b*c", "aXXcYYb", false),
723            // Backtracking: the first `*` must not greedily eat the only `b`.
724            ("*b", "abab", true),
725            ("*.internal", "billing.svc.internal", true),
726            // Literal dots are literal, not regex.
727            ("a.b", "aXb", false),
728            ("", "", true),
729            ("", "x", false),
730        ] {
731            assert_eq!(
732                component_glob_matches(p, v),
733                want,
734                "glob {p:?} vs {v:?} must be {want}"
735            );
736        }
737    }
738
739    #[test]
740    fn manual_run_status_roundtrips_the_apiserver_way() {
741        use crate::common::ReplicationManualRunPhase;
742        // Parsed the cluster's way (YAML -> serde_json::Value -> typed).
743        let status: SnapshotReplicationStatus = from_yaml(
744            "phase: Succeeded\nmanualRun:\n  requestedAt: 2026-06-11T12:00:00Z\n  phase: Running\n",
745        );
746        let manual = status.manual_run.as_ref().expect("manualRun decodes");
747        assert_eq!(manual.requested_at.as_deref(), Some("2026-06-11T12:00:00Z"));
748        assert_eq!(manual.phase, Some(ReplicationManualRunPhase::Running));
749        assert!(
750            !manual.answers("2026-06-11T12:00:00Z"),
751            "an in-flight run does not answer its request"
752        );
753        let reparsed: SnapshotReplicationStatus =
754            serde_json::from_value(serde_json::to_value(&status).unwrap()).unwrap();
755        assert_eq!(status, reparsed);
756
757        // A suspended replication records the request as Pending — visible,
758        // not silently queued.
759        let pending: SnapshotReplicationStatus =
760            from_yaml("manualRun:\n  requestedAt: 2026-06-11T12:00:00Z\n  phase: Pending\n");
761        assert_eq!(
762            pending.manual_run.and_then(|m| m.phase),
763            Some(ReplicationManualRunPhase::Pending)
764        );
765    }
766
767    #[test]
768    fn manual_run_is_absent_from_a_status_that_never_requested_one() {
769        let status: SnapshotReplicationStatus = from_yaml("phase: Succeeded\n");
770        assert!(status.manual_run.is_none());
771        let json = serde_json::to_value(&status).unwrap();
772        assert!(json.get("manualRun").is_none(), "{json}");
773    }
774
775    #[test]
776    fn validate_component_glob_rejects_unsupported_syntax() {
777        assert!(validate_component_glob("pg-*").is_ok());
778        assert!(validate_component_glob("?").is_ok());
779        assert!(validate_component_glob("/data/pg").is_ok());
780        for bad in ["", "data[0-9]", "a]b", "a{b,c}", "a}b", "a\tb", "a\nb"] {
781            assert!(
782                validate_component_glob(bad).is_err(),
783                "{bad:?} must be rejected"
784            );
785        }
786    }
787}