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:?}: character classes and brace expansion are not supported; \
341                     only \"*\" (any run) and \"?\" (one character) are glob metacharacters"
342                ));
343            }
344            c if c.is_control() => {
345                return Err("must not contain control characters".to_string());
346            }
347            _ => {}
348        }
349    }
350    Ok(())
351}
352
353/// Lifecycle phase of a snapshot replication.
354///
355/// ```
356/// use kopiur_api::snapshot_replication::SnapshotReplicationPhase as P;
357///
358/// assert_eq!(serde_json::to_value(P::Suspended).unwrap(), "Suspended");
359/// // An unrecognized phase from a newer operator decodes instead of erroring.
360/// let p: P = serde_json::from_value(serde_json::json!("Verifying")).unwrap();
361/// assert_eq!(p, P::Unknown("Verifying".into()));
362/// assert_eq!(serde_json::to_value(&p).unwrap(), "Verifying");
363/// ```
364#[derive(Clone, Debug, PartialEq, Eq, Default)]
365pub enum SnapshotReplicationPhase {
366    /// Admitted, not yet run (also the default).
367    #[default]
368    Pending,
369    /// A replication mover Job is in flight.
370    Replicating,
371    /// The most recent replication completed successfully (idle until the next slot).
372    Succeeded,
373    /// The most recent replication run failed; see conditions.
374    Failed,
375    /// Suspended via `spec.suspend`.
376    Suspended,
377    /// A phase string this build does not recognize (newer operator, or legacy
378    /// stored data). Decode-compat only — hidden from the CRD schema, never
379    /// produced by this build, never a success.
380    Unknown(String),
381}
382
383impl SnapshotReplicationPhase {
384    /// Whether this phase is the **decode sentinel** — a value the running build
385    /// cannot interpret, kept verbatim by [`Unknown`](Self::Unknown) instead of
386    /// failing the whole typed `list()`/watch (#359, defect 3).
387    ///
388    /// Same narrow contract as
389    /// [`RepositoryReplicationPhase::is_unknown`](crate::RepositoryReplicationPhase::is_unknown):
390    /// `true` means only "this string is not a phase this binary knows", never
391    /// "unusual" or "not one I handle". A canonical variant added to this enum
392    /// later is by definition **not** the sentinel, which is why the `match` is
393    /// written out exhaustively rather than left as a `matches!` — the compiler,
394    /// not a reviewer, is what forces the new variant to answer.
395    ///
396    /// ```
397    /// use kopiur_api::SnapshotReplicationPhase;
398    ///
399    /// assert!(SnapshotReplicationPhase::Unknown("Verifying".into()).is_unknown());
400    /// assert!(!SnapshotReplicationPhase::Pending.is_unknown());
401    /// assert!(!SnapshotReplicationPhase::Replicating.is_unknown());
402    /// assert!(!SnapshotReplicationPhase::Suspended.is_unknown());
403    /// ```
404    pub fn is_unknown(&self) -> bool {
405        match self {
406            Self::Unknown(_) => true,
407            Self::Pending
408            | Self::Replicating
409            | Self::Succeeded
410            | Self::Failed
411            | Self::Suspended => false,
412        }
413    }
414}
415
416crate::common::phase_serde!(
417    SnapshotReplicationPhase,
418    "Lifecycle phase of a snapshot replication."
419);
420
421impl crate::common::PhaseLabel for SnapshotReplicationPhase {
422    const ALL: &'static [Self] = &[
423        Self::Pending,
424        Self::Replicating,
425        Self::Succeeded,
426        Self::Failed,
427        Self::Suspended,
428    ];
429    fn label(&self) -> &str {
430        match self {
431            Self::Pending => "Pending",
432            Self::Replicating => "Replicating",
433            Self::Succeeded => "Succeeded",
434            Self::Failed => "Failed",
435            Self::Suspended => "Suspended",
436            Self::Unknown(s) => s,
437        }
438    }
439    fn unknown(raw: String) -> Self {
440        Self::Unknown(raw)
441    }
442}
443
444/// Counters from the most recent replication run, written by the mover's
445/// terminal status patch. Pure scalars, so `Eq`.
446#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
447#[serde(rename_all = "camelCase")]
448pub struct SnapshotReplicationRunStats {
449    /// Source identities the selector matched this run.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub identities_selected: Option<u32>,
452    /// Snapshots newly copied to the destination this run.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub snapshots_copied: Option<u32>,
455    /// Selected snapshots that were already present at the destination (skipped).
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub already_present: Option<u32>,
458    /// Selected snapshots that failed to copy (kopia migrate exits 0 on
459    /// per-source failures; the mover's post-verify counts them here).
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub failed: Option<u32>,
462    /// Copies pruned this run per `spec.pruning`.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub pruned: Option<u32>,
465}
466
467/// Observed state of a `SnapshotReplication`.
468#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
469#[serde(rename_all = "camelCase")]
470pub struct SnapshotReplicationStatus {
471    /// Current lifecycle phase.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub phase: Option<SnapshotReplicationPhase>,
474    /// `metadata.generation` last reconciled, for staleness detection / kstatus.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub observed_generation: Option<i64>,
477    /// RFC3339 timestamp of the most recent successful replication run (also the
478    /// scheduling anchor for the next slot).
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub last_replicated: Option<String>,
481    /// Counters from the most recent run.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub last_run: Option<SnapshotReplicationRunStats>,
484    /// Standard Kubernetes conditions (`Ready`, `Reconciling`, `Stalled`).
485    #[serde(default, skip_serializing_if = "Vec::is_empty")]
486    pub conditions: Vec<Condition>,
487    /// State of the most recent annotation-requested out-of-band run
488    /// (`kopiur.home-operations.com/run-requested`); absent until one is
489    /// requested.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub manual_run: Option<ReplicationManualRunStatus>,
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::common::RepositoryKind;
498    use crate::testutil::from_yaml;
499    use kube::core::CustomResourceExt;
500
501    #[test]
502    fn snapshot_replication_crd_metadata_is_correct() {
503        let crd = SnapshotReplication::crd();
504        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
505        assert_eq!(crd.spec.names.kind, "SnapshotReplication");
506        assert_eq!(crd.spec.names.plural, "snapshotreplications");
507        assert_eq!(crd.spec.scope, "Namespaced");
508        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
509        assert_eq!(
510            crd.spec.names.short_names,
511            Some(vec!["kopiasrepl".to_string()])
512        );
513        assert_eq!(crd.spec.names.categories, Some(vec!["kopiur".to_string()]));
514    }
515
516    #[test]
517    fn snapshot_replication_full_roundtrip() {
518        let yaml = r#"
519sourceRef:
520  kind: Repository
521  name: nas-primary
522destinationRef:
523  kind: ClusterRepository
524  name: offsite-shared
525schedule:
526  cron: "0 6 * * *"
527  jitter: 30m
528  timezone: America/Chicago
529selection:
530  identities:
531    include:
532      - username: pg-*
533        hostname: billing.talos
534    exclude:
535      - sourcePath: "/scratch/*"
536  latestOnly: true
537migrate:
538  parallel: 4
539  policies: copyOverwrite
540pruning:
541  retention:
542    keepDaily: 7
543    keepWeekly: 4
544credentialProjection:
545  enabled: true
546suspend: false
547"#;
548        let spec: SnapshotReplicationSpec = from_yaml(yaml);
549        assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
550        assert_eq!(spec.source_ref.name, "nas-primary");
551        assert_eq!(spec.destination_ref.kind, RepositoryKind::ClusterRepository);
552        assert_eq!(spec.destination_ref.name, "offsite-shared");
553        assert_eq!(spec.schedule.cron, "0 6 * * *");
554        assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
555
556        let sel = spec.selection.as_ref().expect("selection set");
557        assert!(sel.latest_only);
558        let ids = sel.identities.as_ref().expect("identities set");
559        assert_eq!(ids.include.len(), 1);
560        assert_eq!(ids.include[0].username.as_deref(), Some("pg-*"));
561        assert_eq!(ids.include[0].hostname.as_deref(), Some("billing.talos"));
562        assert!(ids.include[0].source_path.is_none());
563        assert_eq!(ids.exclude[0].source_path.as_deref(), Some("/scratch/*"));
564
565        let migrate = spec.migrate.clone().expect("migrate set");
566        assert_eq!(migrate.parallel, Some(4));
567        assert_eq!(migrate.policies, PolicyCopyMode::CopyOverwrite);
568
569        // Pruning is exactly one externally-tagged variant.
570        match spec.pruning.as_ref().expect("pruning set") {
571            Pruning::Retention(r) => {
572                assert_eq!(r.keep_daily, Some(7));
573                assert_eq!(r.keep_weekly, Some(4));
574            }
575            other => panic!("expected retention pruning, got {}", other.kind_str()),
576        }
577        assert!(
578            spec.credential_projection
579                .as_ref()
580                .is_some_and(|c| c.enabled)
581        );
582        assert!(!spec.suspend);
583
584        let json = serde_json::to_value(&spec).expect("serialize");
585        // Externally tagged pruning + camelCase policies string.
586        assert_eq!(json["pruning"]["retention"]["keepDaily"], 7);
587        assert_eq!(json["migrate"]["policies"], "copyOverwrite");
588        let reparsed: SnapshotReplicationSpec = serde_json::from_value(json).expect("reparse");
589        assert_eq!(spec, reparsed);
590    }
591
592    #[test]
593    fn minimal_spec_omits_optionals() {
594        let yaml = r#"
595sourceRef: { name: nas-primary }
596destinationRef: { name: offsite }
597schedule: { cron: "0 6 * * 0" }
598"#;
599        let spec: SnapshotReplicationSpec = from_yaml(yaml);
600        assert_eq!(spec.source_ref.kind, RepositoryKind::Repository);
601        assert!(spec.selection.is_none());
602        assert!(spec.migrate.is_none());
603        assert!(spec.pruning.is_none());
604        assert!(spec.mover.is_none());
605        assert!(spec.credential_projection.is_none());
606        assert!(!spec.suspend);
607        let json = serde_json::to_value(&spec).unwrap();
608        for absent in [
609            "selection",
610            "migrate",
611            "pruning",
612            "mover",
613            "credentialProjection",
614            "suspend",
615        ] {
616            assert!(json.get(absent).is_none(), "{absent} must not serialize");
617        }
618    }
619
620    #[test]
621    fn all_three_pruning_variants_roundtrip_under_their_external_tags() {
622        for (yaml, want_kind, want_key) in [
623            ("pruning: { none: {} }", "none", "none"),
624            (
625                "pruning: { mirrorSource: {} }",
626                "mirrorSource",
627                "mirrorSource",
628            ),
629            (
630                "pruning: { retention: { keepLatest: 5 } }",
631                "retention",
632                "retention",
633            ),
634        ] {
635            let full = format!(
636                "sourceRef: {{ name: a }}\ndestinationRef: {{ name: b }}\nschedule: {{ cron: \"0 6 * * *\" }}\n{yaml}\n"
637            );
638            let spec: SnapshotReplicationSpec = from_yaml(&full);
639            let pruning = spec.pruning.as_ref().expect("pruning set");
640            assert_eq!(pruning.kind_str(), want_kind);
641            let json = serde_json::to_value(&spec).unwrap();
642            assert!(
643                json["pruning"].get(want_key).is_some(),
644                "pruning must serialize under the {want_key} tag: {json}"
645            );
646            let reparsed: SnapshotReplicationSpec = serde_json::from_value(json).expect("reparse");
647            assert_eq!(spec, reparsed);
648        }
649    }
650
651    #[test]
652    fn unknown_pruning_variant_is_rejected() {
653        let value: serde_json::Value =
654            serde_yaml::from_str("keepEverything: {}\n").expect("yaml value");
655        assert!(
656            serde_json::from_value::<Pruning>(value).is_err(),
657            "unknown pruning variant must fail to deserialize"
658        );
659    }
660
661    #[test]
662    fn unknown_policy_copy_mode_is_rejected() {
663        assert!(
664            serde_json::from_value::<PolicyCopyMode>(serde_json::json!("merge")).is_err(),
665            "unknown policies mode must fail to deserialize"
666        );
667    }
668
669    #[test]
670    fn replication_phase_all_covers_every_variant() {
671        use crate::common::PhaseLabel;
672        let labels: Vec<&str> = SnapshotReplicationPhase::ALL
673            .iter()
674            .map(|p| p.label())
675            .collect();
676        assert_eq!(SnapshotReplicationPhase::ALL.len(), 5);
677        assert!(labels.iter().all(|l| !l.is_empty()));
678    }
679
680    #[test]
681    fn status_roundtrips_with_run_stats() {
682        let status: SnapshotReplicationStatus = from_yaml(
683            "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",
684        );
685        assert_eq!(status.phase, Some(SnapshotReplicationPhase::Succeeded));
686        let run = status.last_run.expect("lastRun set");
687        assert_eq!(run.identities_selected, Some(4));
688        assert_eq!(run.snapshots_copied, Some(12));
689        assert_eq!(run.already_present, Some(88));
690        assert_eq!(run.failed, Some(0));
691        assert_eq!(run.pruned, Some(2));
692        let json = serde_json::to_value(&status).unwrap();
693        let reparsed: SnapshotReplicationStatus = serde_json::from_value(json).unwrap();
694        assert_eq!(status, reparsed);
695    }
696
697    #[test]
698    fn component_glob_matches_covers_the_matrix() {
699        // (pattern, value, matches)
700        for (p, v, want) in [
701            ("*", "", true),
702            ("*", "anything", true),
703            ("pg-*", "pg-main", true),
704            ("pg-*", "pg-", true),
705            ("pg-*", "mysql", false),
706            ("pg", "pg-main", false), // anchored: must cover the whole component
707            ("?", "a", true),
708            ("?", "", false),
709            ("data-?", "data-1", true),
710            ("data-?", "data-12", false),
711            ("*-main", "pg-main", true),
712            ("*-main", "pg-main-old", false),
713            ("a*b*c", "aXXbYYc", true),
714            ("a*b*c", "aXXcYYb", false),
715            // Backtracking: the first `*` must not greedily eat the only `b`.
716            ("*b", "abab", true),
717            ("*.internal", "billing.svc.internal", true),
718            // Literal dots are literal, not regex.
719            ("a.b", "aXb", false),
720            ("", "", true),
721            ("", "x", false),
722        ] {
723            assert_eq!(
724                component_glob_matches(p, v),
725                want,
726                "glob {p:?} vs {v:?} must be {want}"
727            );
728        }
729    }
730
731    #[test]
732    fn manual_run_status_roundtrips_the_apiserver_way() {
733        use crate::common::ReplicationManualRunPhase;
734        // Parsed the cluster's way (YAML -> serde_json::Value -> typed).
735        let status: SnapshotReplicationStatus = from_yaml(
736            "phase: Succeeded\nmanualRun:\n  requestedAt: 2026-06-11T12:00:00Z\n  phase: Running\n",
737        );
738        let manual = status.manual_run.as_ref().expect("manualRun decodes");
739        assert_eq!(manual.requested_at.as_deref(), Some("2026-06-11T12:00:00Z"));
740        assert_eq!(manual.phase, Some(ReplicationManualRunPhase::Running));
741        assert!(
742            !manual.answers("2026-06-11T12:00:00Z"),
743            "an in-flight run does not answer its request"
744        );
745        let reparsed: SnapshotReplicationStatus =
746            serde_json::from_value(serde_json::to_value(&status).unwrap()).unwrap();
747        assert_eq!(status, reparsed);
748
749        // A suspended replication records the request as Pending — visible,
750        // not silently queued.
751        let pending: SnapshotReplicationStatus =
752            from_yaml("manualRun:\n  requestedAt: 2026-06-11T12:00:00Z\n  phase: Pending\n");
753        assert_eq!(
754            pending.manual_run.and_then(|m| m.phase),
755            Some(ReplicationManualRunPhase::Pending)
756        );
757    }
758
759    #[test]
760    fn manual_run_is_absent_from_a_status_that_never_requested_one() {
761        let status: SnapshotReplicationStatus = from_yaml("phase: Succeeded\n");
762        assert!(status.manual_run.is_none());
763        let json = serde_json::to_value(&status).unwrap();
764        assert!(json.get("manualRun").is_none(), "{json}");
765    }
766
767    #[test]
768    fn validate_component_glob_rejects_unsupported_syntax() {
769        assert!(validate_component_glob("pg-*").is_ok());
770        assert!(validate_component_glob("?").is_ok());
771        assert!(validate_component_glob("/data/pg").is_ok());
772        for bad in ["", "data[0-9]", "a]b", "a{b,c}", "a}b", "a\tb", "a\nb"] {
773            assert!(
774                validate_component_glob(bad).is_err(),
775                "{bad:?} must be rejected"
776            );
777        }
778    }
779}