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}