kopiur_api/expand.rs
1//! Selector expansion: turning one `SnapshotPolicy` source into N concrete
2//! per-PVC backups (#346).
3//!
4//! # The model
5//!
6//! A `SnapshotPolicy` source is exactly one of `pvc`, `nfs`, or `pvcSelector`.
7//! The first two name a single thing; the third matches many. Kopiur's whole
8//! data model is built on **one `Snapshot` CR = one mover Job = one kopia
9//! source path = one kopia manifest**, owned via a finalizer — retention,
10//! restore, the catalog and the deletion policy all rest on that 1:1. So a
11//! selector is expanded into N *ordinary* `Snapshot` CRs, one per matched PVC,
12//! each of which is then indistinguishable from a hand-written single-PVC
13//! backup.
14//!
15//! Expansion happens **once, at mint time**, in whichever component creates the
16//! CR — a `SnapshotSchedule` fire or `kubectl kopiur snapshot now`. A `Snapshot`
17//! never expands itself: a CR minting sibling CRs would re-enter the same
18//! reconciler for each child and break the one-shot `run_decision` discipline,
19//! and a `SnapshotPolicy` that minted invocations would collapse the
20//! recipe/invocation/schedule split the project is built around.
21//!
22//! # What was here before
23//!
24//! Nothing. `pvcSelector` was schema-valid, admission-accepted, documented on
25//! six pages and shipped as `deploy/examples/04-multi-pvc-selector.yaml` — and
26//! no expansion code existed anywhere, so `build_backup_run` hit its
27//! `_ =>` arm and returned `invariant violated … This is likely a bug in
28//! kopiur`. That is #346.
29//!
30//! # The restore side (#443)
31//!
32//! Expansion is only half the story. A selector policy writes N kopia sources,
33//! one per member path; a RESTORE against that policy must read exactly ONE of
34//! them, chosen by the PVC it is filling. [`restore_source_path`] is that
35//! inverse: it re-derives a member's path from the policy's own
36//! `sourcePathStrategy`, so the restore's kopia filter names the same path the
37//! backup wrote. Without it the filter is `user@host:` with no path, which
38//! matches EVERY member — and the newest of them wins, so one PVC could be
39//! filled with another PVC's data.
40//!
41//! It lives here, next to [`EffectiveSource::kopia_source_path`] and
42//! [`strategy_for`], on purpose: the backup path and the restore path are then
43//! literally the same code, and the two strings cannot drift.
44
45use std::collections::BTreeMap;
46
47use crate::error::ValidationError;
48use crate::snapshot::{PvcTargetRef, SnapshotSourceGroup, SnapshotSourceRef, SnapshotSourceTarget};
49use crate::snapshot_policy::SnapshotPolicy;
50use crate::snapshot_policy::{self, Source, SourcePathStrategy};
51use kube::ResourceExt;
52
53/// Max name length for a `Snapshot` CR produced by expansion.
54///
55/// **63, not 253.** The CR's name becomes the mover `Job`'s name, and
56/// `io::cleanup_staged_source` finds that Job's pods by the
57/// `batch.kubernetes.io/job-name` **label value**, which Kubernetes caps at 63
58/// bytes. A longer name silently breaks the pvc-protection release step and
59/// wedges staged-PVC teardown. Fan-out makes long names routine, so this is
60/// enforced here rather than discovered in production.
61const MAX_CHILD_NAME: usize = 63;
62
63/// The marker segment that makes a fanned-out name unambiguous.
64const FANOUT_MARKER: &str = "-pvc-";
65
66// --- the effective source for one run --------------------------------------
67
68/// The single source one `Snapshot` actually backs up, after resolving
69/// `spec.source` (a fanned-out child) against the policy's `sources[]`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct EffectiveSource {
72 /// Index into `policy.spec.sources` these knobs came from.
73 pub index: usize,
74 /// The concrete PVC, when this is a PVC-shaped source.
75 pub pvc: Option<PvcTargetRef>,
76 /// The NFS export path, when this is an NFS source.
77 pub nfs_path: Option<String>,
78 /// `sourcePathOverride` from the governing source.
79 pub source_path_override: Option<String>,
80 /// Whether the mount is read-only.
81 pub read_only: bool,
82}
83
84impl EffectiveSource {
85 /// The kopia source path (and mount path) for this run.
86 ///
87 /// `sourcePathOverride` wins. Otherwise a PVC yields `/pvc/<name>` or
88 /// `/pvc/<namespace>/<name>` depending on the governing source's
89 /// [`SourcePathStrategy`], and an NFS source yields its export path.
90 pub fn kopia_source_path(&self, strategy: SourcePathStrategy) -> Option<String> {
91 if let Some(o) = &self.source_path_override {
92 return Some(o.clone());
93 }
94 if let Some(p) = &self.pvc {
95 return Some(match strategy {
96 SourcePathStrategy::PvcName => format!("/pvc/{}", p.name),
97 SourcePathStrategy::PvcNamespacedName => {
98 format!("/pvc/{}/{}", p.namespace, p.name)
99 }
100 });
101 }
102 self.nfs_path.clone()
103 }
104}
105
106/// Resolve which source this `Snapshot` covers.
107///
108/// * `pin: None` — the ordinary single-source case: `sources[0]` governs, and
109/// its own `pvc`/`nfs` is the target. Byte-for-byte the old behavior.
110/// * `pin: Some(_)` — a fanned-out child: `sourceIndex` selects the governing
111/// source's knobs, and the pinned `target` is the concrete PVC.
112///
113/// An out-of-range `sourceIndex` (the policy shrank mid-run) is a named,
114/// terminal error. It is emphatically NOT a fallback to `sources[0]`: silently
115/// backing up a different volume than the one the CR names is the worst
116/// possible failure mode for a backup operator.
117pub fn effective_source(
118 policy: &SnapshotPolicy,
119 pin: Option<&SnapshotSourceRef>,
120) -> Result<EffectiveSource, ValidationError> {
121 let sources = &policy.spec.sources;
122 let index = pin.map(|p| p.source_index as usize).unwrap_or(0);
123 let Some(source) = sources.get(index) else {
124 return Err(ValidationError::InvalidFieldValue {
125 field: "spec.source.sourceIndex".to_string(),
126 reason: format!(
127 "`spec.source.sourceIndex` is {index} but SnapshotPolicy `{}` now has {} source(s); \
128 the recipe was edited after this Snapshot was created. Delete this Snapshot and let \
129 the schedule re-fire, or recreate it against the current recipe.",
130 policy.name_any(),
131 sources.len()
132 ),
133 });
134 };
135 let read_only = snapshot_policy::source_read_only(source);
136 let common = |pvc: Option<PvcTargetRef>| EffectiveSource {
137 index,
138 pvc,
139 nfs_path: source.nfs.as_ref().map(|n| n.path.clone()),
140 source_path_override: source.source_path_override.clone(),
141 read_only,
142 };
143 match pin.map(|p| &p.target) {
144 // A fanned-out child names its own PVC; the policy source it came from
145 // is a selector and has no `pvc` of its own.
146 Some(SnapshotSourceTarget::Pvc(t)) => Ok(common(Some(t.clone()))),
147 None => Ok(common(source.pvc.as_ref().map(|p| PvcTargetRef {
148 // A non-selector `pvc:` source is always same-namespace.
149 namespace: policy.namespace().unwrap_or_default(),
150 name: p.name.clone(),
151 }))),
152 }
153}
154
155/// The `sourcePathStrategy` governing a source.
156///
157/// Deliberately only consulted for **selector-expanded** sources. A plain
158/// `pvc:` source keeps `/pvc/<name>` unconditionally: changing the path of an
159/// existing single-PVC policy would re-identify its kopia source and orphan
160/// every manifest it has ever taken.
161pub fn strategy_for(source: &Source) -> SourcePathStrategy {
162 if source.pvc_selector.is_some() {
163 source
164 .source_path_strategy
165 .unwrap_or(SourcePathStrategy::PvcName)
166 } else {
167 SourcePathStrategy::PvcName
168 }
169}
170
171// --- the restore side: which member path fills THIS pvc (#443) ---------------
172
173/// Where a restore's kopia source path came from — the result of
174/// [`restore_source_path`].
175///
176/// The provenance is kept (rather than collapsed to an `Option<String>`) because
177/// the three cases mean different things to a human reading `status`, and to the
178/// reconciler: an [`Override`](Self::Override) is what the user asked for and is
179/// never second-guessed; a [`PolicySource`](Self::PolicySource) reproduces the
180/// pre-#443 behavior byte-for-byte; and a
181/// [`DerivedFromTarget`](Self::DerivedFromTarget) is the new per-PVC derivation
182/// that makes a fan-out restore read the right member.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum RestoreSourcePath {
185 /// `source.fromPolicy.sourcePath` — the user named the path explicitly.
186 Override(String),
187 /// The governing plain (`pvc:`/`nfs`) source's own path.
188 ///
189 /// `None` is the identity-only form (kopia `username@hostname`, matching any
190 /// path): a policy with **zero** sources, which admission forbids but a
191 /// hand-patched legacy object may still carry. Tolerated exactly as
192 /// `config_identity` tolerates it — a working restore must not become a
193 /// terminal error on an upgrade.
194 PolicySource(Option<String>),
195 /// The selector `sourcePathStrategy` applied to the PVC being restored — the
196 /// same code path the backup used, so the strings cannot drift.
197 DerivedFromTarget(String),
198}
199
200impl RestoreSourcePath {
201 /// The path to filter kopia snapshots by, if any. Exhaustive.
202 pub fn path(&self) -> Option<&str> {
203 match self {
204 Self::Override(p) | Self::DerivedFromTarget(p) => Some(p),
205 Self::PolicySource(p) => p.as_deref(),
206 }
207 }
208
209 /// Consume into the owned path, if any. Exhaustive.
210 pub fn into_path(self) -> Option<String> {
211 match self {
212 Self::Override(p) | Self::DerivedFromTarget(p) => Some(p),
213 Self::PolicySource(p) => p,
214 }
215 }
216}
217
218/// A total classification of one `SnapshotPolicy` source, for the restore-path
219/// derivation.
220///
221/// The point of the enum is that "not a selector" is not one thing: a plain
222/// `pvc:` source can match the restore target by NAME (and then contributes its
223/// own path, override included), while `nfs` and a malformed source contribute
224/// nothing at all. Matched exhaustively, so a fourth source shape has to decide
225/// what it means for a restore before it compiles.
226#[derive(Debug, Clone, PartialEq, Eq)]
227enum SourceShape<'a> {
228 /// A plain `pvc:` source, with the PVC name it addresses (always in the
229 /// policy's own namespace).
230 Pvc { name: &'a str },
231 /// A `pvcSelector` source: the knobs that decide each member's path.
232 Selector {
233 /// The strategy this selector derives member paths with.
234 strategy: SourcePathStrategy,
235 /// A `sourcePathOverride` that would apply to EVERY member — which is
236 /// exactly what makes the selector non-per-PVC.
237 source_path_override: Option<&'a str>,
238 },
239 /// An `nfs` export: never addressed by a PVC target.
240 Nfs,
241 /// None of `pvc`/`pvcSelector`/`nfs` is set. Admission forbids it; a
242 /// hand-patched object may carry it. Contributes nothing.
243 Invalid,
244}
245
246/// Classify one source. Pure and total.
247fn source_shape(source: &Source) -> SourceShape<'_> {
248 if source.pvc_selector.is_some() {
249 return SourceShape::Selector {
250 strategy: strategy_for(source),
251 source_path_override: source.source_path_override.as_deref(),
252 };
253 }
254 match (&source.pvc, &source.nfs) {
255 (Some(p), _) => SourceShape::Pvc { name: &p.name },
256 (None, Some(_)) => SourceShape::Nfs,
257 (None, None) => SourceShape::Invalid,
258 }
259}
260
261/// The kopia source path a restore of `target` should read from `policy` (#443).
262///
263/// This is the cross-volume fix. `RestoreSelector.source_path: None` becomes the
264/// kopia filter `username@hostname:` — an EMPTY path, which matches every member
265/// path of a selector policy — so before this, restoring one PVC of a multi-PVC
266/// policy took the newest snapshot of *any* member and could fill a volume with
267/// another volume's data.
268///
269/// The rule, in order:
270///
271/// 1. `override_` (`source.fromPolicy.sourcePath`) wins outright.
272/// 2. A plain `pvc:` source addressing EXACTLY this target (same name, same
273/// namespace) ⇒ that source's own path. An exact match beats every
274/// derivation AND the first-source fallback.
275/// 3. The policy has **no selector sources** ⇒ [`RestoreSourcePath::PolicySource`]
276/// of `sources[0]`'s own path — byte-identical to what `config_identity` +
277/// `resolve_identity` produced before this function existed, for the plain
278/// `pvc:`, `nfs` and `sourcePathOverride` shapes alike (and `None` for a
279/// zero-source legacy object).
280/// 4. Every selector source agrees on `(sourcePathStrategy, sourcePathOverride)`
281/// **and that override is `None`** ⇒
282/// [`RestoreSourcePath::DerivedFromTarget`], built through
283/// [`EffectiveSource::kopia_source_path`] — the same call the backup side
284/// makes, so the two strings cannot drift.
285/// 5. Anything else ⇒ a named error telling the user to set
286/// `fromPolicy.sourcePath`.
287///
288/// **(2) is deliberately ahead of (3).** Validation admits N plain `pvc:`
289/// sources on one policy (`validate::snapshot` only requires "at least one"),
290/// but today only the FIRST is ever captured: [`expand_sources`] returns `None`
291/// unless some source carries a `pvcSelector`, so a selector-free policy mints
292/// one unpinned child and `effective_source(policy, None)` resolves index 0.
293/// (A pre-existing backup-side limitation, tracked separately — not something
294/// this function can fix.) That is precisely why the fallback must not answer
295/// for every target: `sources: [pvc: a, pvc: b]` restoring into PVC `b` used to
296/// resolve `/pvc/a`, a path that is real but holds ANOTHER volume's data, and
297/// filled `b` with it under a green `Completed`. With the exact match first, `b`
298/// resolves `/pvc/b` — never written — so the restore fails honestly with
299/// `SnapshotNotFound`, or comes up empty under `Continue`. The same applies to
300/// `[nfs, pvc: a]` restoring `a`, which used to read the NFS export path.
301///
302/// Putting the exact match first is byte-identical for every SINGLE-source
303/// shape: a lone plain `pvc:` source whose name equals the target builds the
304/// same `EffectiveSource` (same index, same `PvcTargetRef`, same override, same
305/// strategy — `strategy_for` is `PvcName` for any non-selector source) and so
306/// the same string; an `nfs` source, a differently-named target and a
307/// cross-namespace target all miss (2) and fall through to (3) untouched.
308///
309/// **A target matching NO plain source still falls back to `sources[0]`** under
310/// (3) — e.g. `[pvc: a, pvc: b]` restoring into a PVC named `c` reads `/pvc/a`.
311/// That is deliberate, not an oversight: it is the pre-#443 answer, it is what
312/// makes "restore this policy's data into a differently-named scratch volume"
313/// keep working, and (5) is reserved for the shapes where a path genuinely
314/// cannot be derived (disagreeing selectors, a flattening selector override) —
315/// not for a multi-source policy where `sources[0]` is a defined, if arbitrary,
316/// answer. Set `fromPolicy.sourcePath` to name the member you want.
317///
318/// A selector carrying `sourcePathOverride: Some(o)` is deliberately NOT
319/// per-PVC: `kopia_source_path` returns the override before it ever looks at the
320/// PVC, so every member was backed up under the one path `o` and no derivation
321/// can tell them apart. That falls to (5) rather than silently returning `o`,
322/// because "which member is this?" genuinely has no answer.
323///
324/// Matching is by **strategy rule, not by claimant labels**: a `target.pvc` has
325/// no labels to match against, and a claimant's labels may have changed since the
326/// backup was taken. This function is therefore pure over the policy + target
327/// alone.
328///
329/// The namespace in `target` is the TARGET's namespace. For a cross-namespace
330/// `target.pvcRef` under a `pvcNamespacedName` strategy that derives
331/// `/pvc/<target-ns>/<name>`, which may be a path the repository never saw — use
332/// the override there.
333///
334/// ```
335/// # use kopiur_api::expand::{restore_source_path, RestoreSourcePath};
336/// # use kopiur_api::snapshot::PvcTargetRef;
337/// # use kopiur_api::SnapshotPolicy;
338/// let policy: SnapshotPolicy = serde_json::from_value(serde_json::json!({
339/// "apiVersion": "kopiur.home-operations.com/v1alpha1",
340/// "kind": "SnapshotPolicy",
341/// "metadata": { "name": "app", "namespace": "db" },
342/// "spec": {
343/// "repository": { "name": "r" },
344/// "sources": [{
345/// "pvcSelector": { "matchLabels": { "app": "web" } },
346/// "sourcePathStrategy": "PvcName",
347/// }],
348/// },
349/// }))
350/// .unwrap();
351/// let target = PvcTargetRef { namespace: "db".into(), name: "data-1".into() };
352/// assert_eq!(
353/// restore_source_path(&policy, None, &target).unwrap(),
354/// RestoreSourcePath::DerivedFromTarget("/pvc/data-1".into())
355/// );
356/// ```
357pub fn restore_source_path(
358 policy: &SnapshotPolicy,
359 override_: Option<&str>,
360 target: &PvcTargetRef,
361) -> Result<RestoreSourcePath, ValidationError> {
362 // (1) An explicit override is never second-guessed.
363 if let Some(o) = override_ {
364 return Ok(RestoreSourcePath::Override(o.to_string()));
365 }
366
367 let policy_ns = policy.namespace().unwrap_or_default();
368 let shapes: Vec<SourceShape<'_>> = policy.spec.sources.iter().map(source_shape).collect();
369
370 // (2) An exact plain-`pvc:` match, BEFORE the first-source fallback: a policy
371 // may carry several plain `pvc:` sources, and `sources[0]` would then be
372 // another volume's path — wrong-but-real data, rather than the honest
373 // `SnapshotNotFound` the unwritten path yields. Same namespace is required:
374 // a plain source always addresses the POLICY's namespace, so a same-named
375 // PVC in another namespace is a different volume.
376 if policy_ns == target.namespace
377 && let Some(index) = shapes.iter().position(|s| match s {
378 SourceShape::Pvc { name } => *name == target.name,
379 SourceShape::Selector { .. } | SourceShape::Nfs | SourceShape::Invalid => false,
380 })
381 {
382 let source = &policy.spec.sources[index];
383 let eff = EffectiveSource {
384 index,
385 pvc: Some(target.clone()),
386 nfs_path: None,
387 source_path_override: source.source_path_override.clone(),
388 read_only: snapshot_policy::source_read_only(source),
389 };
390 return Ok(RestoreSourcePath::PolicySource(
391 eff.kopia_source_path(strategy_for(source)),
392 ));
393 }
394
395 // (3) No selector anywhere: the pre-#443 answer, verbatim (including the
396 // zero-source legacy tolerance, where `sources.first()` is `None`). Reached
397 // only when (2) did not match, so a target that names one of the policy's
398 // plain sources never lands here.
399 let has_selector = shapes
400 .iter()
401 .any(|s| matches!(s, SourceShape::Selector { .. }));
402 if !has_selector {
403 let Some(first) = policy.spec.sources.first() else {
404 return Ok(RestoreSourcePath::PolicySource(None));
405 };
406 let eff = effective_source(policy, None)?;
407 return Ok(RestoreSourcePath::PolicySource(
408 eff.kopia_source_path(strategy_for(first)),
409 ));
410 }
411
412 // (4) Every selector agrees, and none of them flattens its members onto one
413 // shared path.
414 let mut agreed: Option<(SourcePathStrategy, Option<&str>)> = None;
415 for shape in &shapes {
416 let (strategy, over) = match shape {
417 SourceShape::Selector {
418 strategy,
419 source_path_override,
420 } => (*strategy, *source_path_override),
421 SourceShape::Pvc { .. } | SourceShape::Nfs | SourceShape::Invalid => continue,
422 };
423 match agreed {
424 None => agreed = Some((strategy, over)),
425 Some(prev) if prev == (strategy, over) => {}
426 Some(_) => return Err(ambiguous_source_path(policy)),
427 }
428 }
429 match agreed {
430 Some((strategy, None)) => {
431 let eff = EffectiveSource {
432 // The index is inert here: the path comes from the target PVC and
433 // the agreed strategy, not from the source's own `pvc`/`nfs`.
434 index: 0,
435 pvc: Some(target.clone()),
436 nfs_path: None,
437 source_path_override: None,
438 read_only: true,
439 };
440 // A PVC-bearing `EffectiveSource` with no override always yields a
441 // path, so the `None` arm is unreachable — but returning the fail-
442 // closed error rather than unwrapping keeps the function total.
443 eff.kopia_source_path(strategy)
444 .map(RestoreSourcePath::DerivedFromTarget)
445 .ok_or_else(|| ambiguous_source_path(policy))
446 }
447 // (5) A shared `sourcePathOverride`, or (defensively) no selector at all
448 // after the `has_selector` check.
449 Some((_, Some(_))) | None => Err(ambiguous_source_path(policy)),
450 }
451}
452
453/// The fail-closed error for (5) of [`restore_source_path`] — what / why / fix.
454fn ambiguous_source_path(policy: &SnapshotPolicy) -> ValidationError {
455 ValidationError::InvalidFieldValue {
456 field: "spec.source.fromPolicy.sourcePath".to_string(),
457 reason: format!(
458 "SnapshotPolicy `{}`'s selector sources do not yield a per-PVC kopia source path \
459 (they differ in sourcePathStrategy/sourcePathOverride, or share one \
460 sourcePathOverride under which every matched PVC was backed up). Restoring \
461 without a path would match the newest snapshot of ANY member and could fill this \
462 volume with another volume's data, so kopiur fails closed. Fix: set \
463 source.fromPolicy.sourcePath explicitly (e.g. /pvc/<name>) to name the member to \
464 restore.",
465 policy.name_any()
466 ),
467 }
468}
469
470/// The deterministic name of the populate mover `Job` for one claiming PVC.
471///
472/// `<restore>-populate-<h8>`, capped at [`MAX_CHILD_NAME`] (63) — a Job name
473/// becomes a `batch.kubernetes.io/job-name` label value, which Kubernetes caps
474/// at 63 bytes.
475///
476/// `<h8>` is 8 hex of FNV-1a over the claiming PVC's `metadata.uid` and is
477/// **never** clipped: it is what makes N claimants of one `Restore` produce N
478/// distinct Jobs, and what makes a DELETED-and-re-created claim (a new uid) get a
479/// fresh Job instead of adopting the dead claim's. The pre-#443 name was the
480/// bare `<restore>-populate`, which is why only one claimant could ever be
481/// populated; that legacy name is still driven for an adopted in-flight claim
482/// (see the controller's `JobNameReuse`), never minted anew.
483///
484/// ```
485/// # use kopiur_api::expand::populate_job_name;
486/// let n = populate_job_name("restore-pg", "9f1c-uid");
487/// assert!(n.starts_with("restore-pg-populate-"));
488/// assert!(n.len() <= 63);
489/// // Distinct claimants never collide.
490/// assert_ne!(n, populate_job_name("restore-pg", "other-uid"));
491/// ```
492pub fn populate_job_name(restore: &str, consumer_uid: &str) -> String {
493 const MARKER: &str = "-populate-";
494 let tag = fnv8(consumer_uid);
495 let keep = MAX_CHILD_NAME.saturating_sub(MARKER.len() + tag.len());
496 format!("{}{MARKER}{tag}", clip(restore, keep).trim_end_matches('-'))
497 .trim_matches('-')
498 .to_string()
499}
500
501/// Render a `LabelSelector` as the API server's selector string.
502///
503/// Lives here so the controller and the CLI build byte-identical queries: a
504/// divergence would make `kubectl kopiur snapshot now` and the schedule expand
505/// to DIFFERENT PVC sets for the same recipe, which is the kind of discrepancy
506/// nobody notices until a restore is missing a volume.
507pub fn label_selector_string(
508 sel: &k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector,
509) -> String {
510 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelectorRequirement;
511 let mut terms: Vec<String> = Vec::new();
512 if let Some(labels) = &sel.match_labels {
513 for (k, v) in labels {
514 terms.push(format!("{k}={v}"));
515 }
516 }
517 if let Some(exprs) = &sel.match_expressions {
518 for LabelSelectorRequirement {
519 key,
520 operator,
521 values,
522 } in exprs
523 {
524 let vals = values.clone().unwrap_or_default().join(",");
525 match operator.as_str() {
526 "In" => terms.push(format!("{key} in ({vals})")),
527 "NotIn" => terms.push(format!("{key} notin ({vals})")),
528 "Exists" => terms.push(key.clone()),
529 "DoesNotExist" => terms.push(format!("!{key}")),
530 // Unknown operator: skip (the webhook/schema constrain the set).
531 _ => {}
532 }
533 }
534 }
535 terms.join(",")
536}
537
538// --- naming -----------------------------------------------------------------
539
540/// The deterministic name of the fanned-out `Snapshot` for one PVC.
541///
542/// `<base>-pvc-<slug>-<h8>`, capped at [`MAX_CHILD_NAME`].
543///
544/// * `<slug>` is human-legible (`<name>`, or `<namespace>-<name>` when the PVC
545/// is outside the policy's namespace) and may be clipped.
546/// * `<h8>` is 8 hex of FNV-1a over the exact string `"<namespace>/<name>"` and
547/// is **never** clipped — it is the injectivity guarantee.
548///
549/// Collision-free against the three existing schemes (`<schedule>-<slot>`,
550/// `<schedule>-<policy>-<slot>`, `<policy>-manual-<slot>`): each ends in a
551/// dash-free 14-digit slot stamp, while a fanned name's tail after `-pvc-`
552/// always contains a `-`.
553///
554/// This does NOT delegate to `io::staging::staged_child_name`, whose
555/// `MAX_NAME_LEN - tag.len() - suffix.len() - 2` is unchecked `usize`
556/// subtraction: its callers pass 4-char suffixes (`snap`, `src`), and a PVC
557/// name up to 253 chars would underflow it.
558///
559/// ```
560/// # use kopiur_api::expand::fanout_child_name;
561/// let n = fanout_child_name("nightly-20260805020000", "db", "db", "pgdata");
562/// assert!(n.starts_with("nightly-20260805020000-pvc-pgdata-"));
563/// assert!(n.len() <= 63);
564/// ```
565pub fn fanout_child_name(base: &str, policy_ns: &str, pvc_ns: &str, pvc_name: &str) -> String {
566 fanout_child_name_for(base, policy_ns, Some((pvc_ns, pvc_name)), None)
567}
568
569/// The marker segment naming the target repository in a multi-repo child name.
570const REPO_MARKER: &str = "-repo-";
571
572/// 8 hex chars of FNV-1a over `input` — the never-clipped injectivity tag
573/// shared by every fan-out naming scheme in this module. One definition so
574/// the hash function can never drift between the schemes.
575fn fnv8(input: &str) -> String {
576 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
577 for b in input.as_bytes() {
578 hash ^= *b as u64;
579 hash = hash.wrapping_mul(0x100_0000_01b3);
580 }
581 format!("{:08x}", (hash & 0xffff_ffff) as u32)
582}
583
584/// The deterministic child `Snapshot` name for one (source-member, repository)
585/// cell of a fan-out — the general form behind [`fanout_child_name`].
586///
587/// * `member`: `Some((pvc_ns, pvc_name))` for a selector-expanded member,
588/// `None` for a policy whose single source needs no per-PVC expansion.
589/// * `repo`: `Some(ref)` for a multi-repository fan-out child, `None` for the
590/// classic single-repo shapes.
591///
592/// The four combinations produce:
593///
594/// | member | repo | name |
595/// |--------|------|------|
596/// | `None` | `None` | `<base>` — the legacy non-fanned name, byte-identical |
597/// | `Some` | `None` | `<base>-pvc-<pslug>-<h8>` — byte-identical to the legacy [`fanout_child_name`] |
598/// | `None` | `Some` | `<base>-repo-<rslug>-<h8>` |
599/// | `Some` | `Some` | `<base>-pvc-<pslug>-repo-<rslug>-<h8>` |
600///
601/// `<h8>` is FNV-1a over the newline-framed FULL unclipped tuple — `base`,
602/// then (when present) `"<pvc_ns>/<pvc_name>"`, then (when present) the
603/// normalized [`repo_key`](crate::common::repo_key) — so clipping any legible
604/// segment never merges two distinct cells, and a policy slug that happens to
605/// contain `-repo-` (pslug `x-repo-y` vs pslug `x` + rslug `y`) still yields
606/// distinct names because the hash inputs differ. Markers and the tag are
607/// NEVER clipped; when over budget the clip order is base → pslug → rslug.
608/// Always ≤ [`MAX_CHILD_NAME`] (63).
609///
610/// The legacy hash input for `repo: None` is exactly the pre-multi-repo
611/// `"{base}\n{pvc_ns}/{pvc_name}"` — pinned by golden tests.
612pub fn fanout_child_name_for(
613 base: &str,
614 policy_ns: &str,
615 member: Option<(&str, &str)>,
616 repo: Option<&crate::common::RepositoryRef>,
617) -> String {
618 // (normalized repo_key for the hash, legible slug for the name).
619 let repo = repo.map(|r| (crate::common::repo_key(r, policy_ns), r.name.as_str()));
620 match (member, repo) {
621 // Legacy single child: the name IS the base (already ≤63 for every
622 // caller-produced base; clipped defensively, identical when in budget).
623 (None, None) => clip(base, MAX_CHILD_NAME).trim_matches('-').to_string(),
624 (Some((pvc_ns, pvc_name)), repo) => {
625 member_child_name(base, policy_ns, pvc_ns, pvc_name, repo.as_ref())
626 }
627 (None, Some((rkey, repo_name))) => {
628 // <base>-repo-<rslug>-<h8>
629 let tag = fnv8(&format!("{base}\n{rkey}"));
630 let rslug = sanitize_dns1123(repo_name);
631 let fixed = REPO_MARKER.len() + 1 + tag.len();
632 let mut base_keep = base.len();
633 let mut rslug_keep = rslug.len();
634 if fixed + base_keep + rslug_keep > MAX_CHILD_NAME {
635 let room = MAX_CHILD_NAME.saturating_sub(fixed);
636 // Clip base first (every sibling shares it), rslug second.
637 rslug_keep = rslug_keep.min(room / 3);
638 base_keep = base_keep.min(room.saturating_sub(rslug_keep));
639 }
640 format!(
641 "{}{REPO_MARKER}{}-{tag}",
642 clip(base, base_keep),
643 clip(&rslug, rslug_keep)
644 )
645 .trim_matches('-')
646 .to_string()
647 }
648 }
649}
650
651/// The `member: Some(..)` arm of [`fanout_child_name_for`]: the legacy
652/// `-pvc-` form (`rkey: None`, byte-identical to the pre-multi-repo
653/// [`fanout_child_name`]) and the combined `-pvc-…-repo-…` form.
654fn member_child_name(
655 base: &str,
656 policy_ns: &str,
657 pvc_ns: &str,
658 pvc_name: &str,
659 repo: Option<&(String, &str)>,
660) -> String {
661 // The tag hashes the BASE as well as the PVC (and, for a multi-repo child,
662 // the repo key). That is not belt-and-braces: `base` is
663 // `<schedule>-<YYYYMMDDHHMMSS>` and it is the part that gets CLIPPED when
664 // the name is too long, so a tag over the PVC alone leaves two different
665 // slots of the same schedule+PVC with byte-identical names. The schedule
666 // server-side-applies with force and only skips *terminating* twins, so
667 // the second fire would re-apply onto the already-`Succeeded` first
668 // Snapshot, `run_decision` would return `SucceededSteadyState`, and no
669 // mover Job would ever launch — a whole backup slot vanishing with no
670 // error anywhere.
671 let tag = match repo {
672 None => fnv8(&format!("{base}\n{pvc_ns}/{pvc_name}")),
673 Some((rkey, _)) => fnv8(&format!("{base}\n{pvc_ns}/{pvc_name}\n{rkey}")),
674 };
675
676 let slug_full = if pvc_ns == policy_ns {
677 pvc_name.to_string()
678 } else {
679 format!("{pvc_ns}-{pvc_name}")
680 };
681 let slug = sanitize_dns1123(&slug_full);
682
683 match repo {
684 None => {
685 // Budget: base + "-pvc-" + slug + "-" + tag. Clip `base` first (it
686 // is the most redundant part — every sibling shares it), then the
687 // slug. Never the tag or the marker.
688 let fixed = FANOUT_MARKER.len() + 1 + tag.len();
689 let mut base_keep = base.len();
690 let mut slug_keep = slug.len();
691 if fixed + base_keep + slug_keep > MAX_CHILD_NAME {
692 let room = MAX_CHILD_NAME.saturating_sub(fixed);
693 // Give the slug up to a third of the room, the base the rest.
694 slug_keep = slug_keep.min(room / 3);
695 base_keep = base_keep.min(room.saturating_sub(slug_keep));
696 }
697 let name = format!(
698 "{}{FANOUT_MARKER}{}-{tag}",
699 clip(base, base_keep),
700 clip(&slug, slug_keep)
701 );
702 // A clip can leave a trailing '-', which is not a legal DNS-1123 name.
703 name.trim_matches('-').to_string()
704 }
705 Some((_, repo_name)) => {
706 // Combined form: base + "-pvc-" + pslug + "-repo-" + rslug + "-" +
707 // tag. Overhead 5+6+1+8 = 20, room 43. Clip base → pslug → rslug.
708 let rslug = sanitize_dns1123(repo_name);
709 let fixed = FANOUT_MARKER.len() + REPO_MARKER.len() + 1 + tag.len();
710 let mut base_keep = base.len();
711 let mut pslug_keep = slug.len();
712 let mut rslug_keep = rslug.len();
713 if fixed + base_keep + pslug_keep + rslug_keep > MAX_CHILD_NAME {
714 let room = MAX_CHILD_NAME.saturating_sub(fixed);
715 rslug_keep = rslug_keep.min(room / 3);
716 pslug_keep = pslug_keep.min(room.saturating_sub(rslug_keep) / 2);
717 base_keep = base_keep.min(room.saturating_sub(rslug_keep + pslug_keep));
718 }
719 format!(
720 "{}{FANOUT_MARKER}{}{REPO_MARKER}{}-{tag}",
721 clip(base, base_keep),
722 clip(&slug, pslug_keep),
723 clip(&rslug, rslug_keep)
724 )
725 .trim_matches('-')
726 .to_string()
727 }
728 }
729}
730
731/// The kopia-cache PVC name for one (policy, repository) cell.
732///
733/// A kopia client cache is REPOSITORY-SPECIFIC state (indexes, metadata, owned
734/// blobs of ONE repository), and the mover mounts it at a fixed
735/// `KOPIA_CACHE_DIRECTORY` — so a multi-repo policy's children must not share
736/// one PVC or they poison each other's cache. Hence:
737///
738/// * `repo: None` — the classic single-repo cache: `kopiur-cache-<policy>`,
739/// byte-identical to the pre-multi-repo name (including its historical lack
740/// of a length cap — an existing PVC must keep matching by name).
741/// * `repo: Some(_)` — a pinned child of a multi-repo policy:
742/// `kopiur-cache-<policy>-<rslug>-<h6>`, where `<h6>` is 6 hex of FNV-1a
743/// over the normalized [`repo_key`](crate::common::repo_key) (never
744/// clipped — the injectivity guarantee, same family as
745/// [`fanout_child_name_for`]) and the whole name is capped at 63 (RFC 1123
746/// label, the same Job-label bound child names honor). Clip order:
747/// policy slug first, then rslug; marker dashes and the tag never.
748///
749/// Ownership stays the policy in both shapes (the caller's concern).
750pub fn cache_pvc_name(
751 policy_name: &str,
752 policy_ns: &str,
753 repo: Option<&crate::common::RepositoryRef>,
754) -> String {
755 const PREFIX: &str = "kopiur-cache-";
756 let Some(repo) = repo else {
757 return format!("{PREFIX}{policy_name}");
758 };
759 let rkey = crate::common::repo_key(repo, policy_ns);
760 // h6: the fnv8 family hash, truncated to 6 hex — enough to disambiguate a
761 // policy's ≤8 repositories while keeping the legible slugs roomy.
762 let tag: String = fnv8(&rkey).chars().take(6).collect();
763 let pslug = sanitize_dns1123(policy_name);
764 let rslug = sanitize_dns1123(&repo.name);
765 let fixed = PREFIX.len() + 2 + tag.len(); // two joining dashes + tag
766 let mut pslug_keep = pslug.len();
767 let mut rslug_keep = rslug.len();
768 if fixed + pslug_keep + rslug_keep > MAX_CHILD_NAME {
769 let room = MAX_CHILD_NAME.saturating_sub(fixed);
770 rslug_keep = rslug_keep.min(room / 3);
771 pslug_keep = pslug_keep.min(room.saturating_sub(rslug_keep));
772 }
773 // Trim any clip-produced trailing '-' per segment (a doubled dash is not a
774 // legal-name problem, but keeping segments clean avoids `--` cosmetics; the
775 // never-clipped tag carries injectivity regardless).
776 format!(
777 "{PREFIX}{}-{}-{tag}",
778 clip(&pslug, pslug_keep).trim_end_matches('-'),
779 clip(&rslug, rslug_keep).trim_end_matches('-')
780 )
781 .trim_matches('-')
782 .to_string()
783}
784
785/// Truncate on a char boundary.
786fn clip(s: &str, keep: usize) -> &str {
787 if keep >= s.len() {
788 return s;
789 }
790 let mut end = keep;
791 while end > 0 && !s.is_char_boundary(end) {
792 end -= 1;
793 }
794 &s[..end]
795}
796
797/// Lowercase, replacing anything outside `[a-z0-9-]` with `-`.
798fn sanitize_dns1123(s: &str) -> String {
799 s.chars()
800 .map(|c| {
801 if c.is_ascii_alphanumeric() {
802 c.to_ascii_lowercase()
803 } else {
804 '-'
805 }
806 })
807 .collect()
808}
809
810// --- expansion ---------------------------------------------------------------
811
812/// One member of an expanded selector: the child's name and the `spec.source`
813/// to stamp on it.
814#[derive(Debug, Clone, PartialEq, Eq)]
815pub struct ExpandedMember {
816 /// Deterministic child `Snapshot` name.
817 pub name: String,
818 /// The pin recording which PVC this child covers.
819 pub source: SnapshotSourceRef,
820}
821
822/// The shared `VolumeGroupSnapshot` name for one expansion in one namespace.
823///
824/// Derived from the per-invocation base name, NOT from any one member: every
825/// member must compute the identical name without talking to its siblings,
826/// which is what makes their racing server-side-applies converge on one object
827/// instead of N.
828///
829/// Bounded to 63 chars for the same reason child names are — see
830/// [`fanout_child_name`].
831/// `repo_key` adds the repository dimension for multi-repo fan-out — one
832/// VolumeGroupSnapshot per (repository, slot), because each repo's members are
833/// an independent capture wave (independent-captures semantics: the N groups
834/// are N separate point-in-time CSI snapshots, at N× the CSI quota/load).
835/// Existing callers pass `None` and get the byte-identical legacy name.
836pub fn group_name(
837 base: &str,
838 namespace: &str,
839 source_index: usize,
840 repo_key: Option<&str>,
841) -> String {
842 let suffix = "-grp";
843 // The SOURCE INDEX is part of the key, not just the namespace. Two selector
844 // sources in one policy have DIFFERENT label selectors, and
845 // `resolve_group_stage` builds the VolumeGroupSnapshot's `source.selector`
846 // from `sources[sourceIndex]`. Sharing a name across them would have the two
847 // members force-SSA conflicting selectors onto one object, so the loser's
848 // PVC is never captured and it fails with `GroupMemberMissing`.
849 let tag = match repo_key {
850 None => fnv8(&format!("{namespace}#{source_index}")),
851 Some(k) => fnv8(&format!("{namespace}#{source_index}#{k}")),
852 };
853 let room = MAX_CHILD_NAME - suffix.len() - tag.len() - 1;
854 format!("{}-{tag}{suffix}", clip(base, room))
855 .trim_matches('-')
856 .to_string()
857}
858
859/// One `Snapshot` to mint for a slot/invocation: its deterministic name, the
860/// source pin (for a `pvcSelector` member) and the repository pin (for a
861/// multi-repo fan-out child, NORMALIZED via
862/// [`normalized_repository_ref`](crate::common::normalized_repository_ref)).
863#[derive(Debug, Clone, PartialEq, Eq)]
864pub struct MintCell {
865 /// Deterministic child `Snapshot` name.
866 pub name: String,
867 /// The `spec.source` pin recording which PVC this child covers.
868 pub source: Option<SnapshotSourceRef>,
869 /// The `spec.repository` pin recording which repository this child targets
870 /// (multi-repo fan-out only; `None` keeps the legacy unpinned wire).
871 pub repository: Option<crate::common::RepositoryRef>,
872}
873
874/// **Pure.** Cross one policy's expanded source members with its repository
875/// dimension (#368) into the exact set of `Snapshot`s to mint for one slot or
876/// one `snapshot now` invocation. Lives here so the `SnapshotSchedule`
877/// reconciler and `kubectl kopiur snapshot now` mint byte-identical sets — a
878/// divergence would give the same recipe different children depending on who
879/// fired it.
880///
881/// * Single-repo policy: byte-identical to the pre-multi-repo behavior — the
882/// bare `base_name` child (no pins) for a selector-less recipe, or one
883/// unpinned per-member child.
884/// * Multi-repo policy: one child per (member × repository), named via
885/// [`fanout_child_name_for`] with the repo dimension, `spec.repository`
886/// stamped NORMALIZED; a grouped member's shared `VolumeGroupSnapshot` name
887/// is re-derived PER REPOSITORY ([`group_name`] with the repo key) — each
888/// repo's members are an independent capture wave, so N repos = N groups.
889///
890/// `members: Some(vec![])` (a selector that matched nothing) yields no cells —
891/// the caller warns, as before.
892pub fn mint_cells(
893 policy: &SnapshotPolicy,
894 base_name: &str,
895 members: Option<Vec<ExpandedMember>>,
896) -> Vec<MintCell> {
897 use crate::common::{normalized_repository_ref, repo_key};
898 let policy_ns = policy.namespace().unwrap_or_default();
899 let repos: Option<Vec<&crate::common::RepositoryRef>> =
900 crate::snapshot_policy::is_multi_repo(&policy.spec)
901 .then(|| policy.spec.repositories.iter().collect());
902 match (members, repos) {
903 // Single-repo, no selector: the legacy bare child.
904 (None, None) => vec![MintCell {
905 name: base_name.to_string(),
906 source: None,
907 repository: None,
908 }],
909 // Single-repo selector fan-out: unpinned members, byte-identical.
910 (Some(members), None) => members
911 .into_iter()
912 .map(|m| MintCell {
913 name: m.name,
914 source: Some(m.source),
915 repository: None,
916 })
917 .collect(),
918 // Multi-repo, no selector: one child per repository.
919 (None, Some(repos)) => repos
920 .into_iter()
921 .map(|r| MintCell {
922 name: fanout_child_name_for(base_name, &policy_ns, None, Some(r)),
923 source: None,
924 repository: Some(normalized_repository_ref(r, &policy_ns)),
925 })
926 .collect(),
927 // Multi-repo selector fan-out: members × repositories.
928 (Some(members), Some(repos)) => members
929 .iter()
930 .flat_map(|m| {
931 let policy_ns = policy_ns.clone();
932 let target = match &m.source.target {
933 SnapshotSourceTarget::Pvc(t) => t.clone(),
934 };
935 repos.iter().map(move |r| {
936 let rkey = repo_key(r, &policy_ns);
937 let mut source = m.source.clone();
938 if let Some(group) = source.group.as_mut() {
939 group.volume_group_snapshot_name = group_name(
940 base_name,
941 &group.namespace,
942 source.source_index as usize,
943 Some(&rkey),
944 );
945 }
946 MintCell {
947 name: fanout_child_name_for(
948 base_name,
949 &policy_ns,
950 Some((&target.namespace, &target.name)),
951 Some(r),
952 ),
953 source: Some(source),
954 repository: Some(normalized_repository_ref(r, &policy_ns)),
955 }
956 })
957 })
958 .collect(),
959 }
960}
961
962/// **Pure.** Expand one policy's sources against an already-matched PVC set.
963///
964/// `matched` is `(namespace, name)` per source index — the caller does the
965/// cluster IO ([`match_pvcs`]), this decides the names and pins.
966///
967/// Returns `Ok(None)` when the policy has no selector source at all, meaning
968/// "mint exactly one child with no `spec.source`", i.e. today's behavior. That
969/// distinction matters: an empty `Vec` would mean "a selector matched nothing",
970/// which is a different and much louder situation.
971///
972/// # The collision guard
973///
974/// `sourcePathStrategy` defaults to `PvcName` → `/pvc/<name>`. A selector with
975/// a cross-namespace `namespaceSelector` matching a PVC called `data` in two
976/// namespaces therefore yields **two kopia sources at the identical
977/// `user@host:/pvc/data`**, silently merging two volumes' histories into one
978/// stream — under a single `KOPIA_KEEP_MAX` retention pin, so they also prune
979/// each other. `detect_identity_collision` cannot catch this: it compares
980/// across policies and skips self. So it is caught here, before anything is
981/// created.
982pub fn expand_sources(
983 policy: &SnapshotPolicy,
984 base_name: &str,
985 matched: &BTreeMap<usize, Vec<PvcTargetRef>>,
986) -> Result<Option<Vec<ExpandedMember>>, ValidationError> {
987 let policy_ns = policy.namespace().unwrap_or_default();
988 if !policy.spec.sources.iter().any(|s| s.pvc_selector.is_some()) {
989 return Ok(None);
990 }
991 let mut members: Vec<ExpandedMember> = Vec::new();
992 // path -> (target, source index) that first produced it.
993 let mut paths: BTreeMap<String, (PvcTargetRef, usize)> = BTreeMap::new();
994 let grouped =
995 policy.spec.group_by == Some(crate::snapshot_policy::GroupBy::VolumeGroupSnapshot);
996 // How many members land in each namespace, computed up front: a
997 // VolumeGroupSnapshot is namespaced and its `source.selector` is
998 // namespace-local, so a selector spanning namespaces yields ONE GROUP PER
999 // NAMESPACE — the consistency guarantee is per-namespace, not global.
1000 // Keyed by SOURCE too, not just namespace: one VolumeGroupSnapshot is built
1001 // from ONE source's label selector, so two selector sources in the same
1002 // namespace are two separate captures, and counting them together would
1003 // wrongly promote a pair of one-PVC sources into a "group".
1004 let mut per_group: BTreeMap<(&str, usize), usize> = BTreeMap::new();
1005 if grouped {
1006 for (index, source) in policy.spec.sources.iter().enumerate() {
1007 if source.pvc_selector.is_none() {
1008 continue;
1009 }
1010 for t in matched.get(&index).into_iter().flatten() {
1011 *per_group.entry((t.namespace.as_str(), index)).or_default() += 1;
1012 }
1013 }
1014 }
1015
1016 for (index, source) in policy.spec.sources.iter().enumerate() {
1017 if source.pvc_selector.is_none() {
1018 continue;
1019 }
1020 let strategy = strategy_for(source);
1021 for target in matched.get(&index).into_iter().flatten() {
1022 // Collision check against every path this expansion has produced.
1023 let eff = EffectiveSource {
1024 index,
1025 pvc: Some(target.clone()),
1026 nfs_path: None,
1027 source_path_override: source.source_path_override.clone(),
1028 read_only: snapshot_policy::source_read_only(source),
1029 };
1030 let path = eff
1031 .kopia_source_path(strategy)
1032 .unwrap_or_else(|| "/data".to_string());
1033 // ANY repeated path is refused, not just one produced by two
1034 // DIFFERENT PVCs. Two selector sources that both match the same PVC
1035 // land on one path AND one child name, so the second would
1036 // force-server-side-apply over the first and one backup would
1037 // vanish with no error — the same silent-overwrite class as a
1038 // clipped slot stamp.
1039 if let Some((prev, prev_index)) = paths.insert(path.clone(), (target.clone(), index)) {
1040 let same_pvc = prev.namespace == target.namespace && prev.name == target.name;
1041 // The same PVC listed twice by the SAME source is a listing
1042 // artifact (`match_pvcs` already dedupes; this keeps
1043 // `expand_sources` idempotent for any caller). Skip it rather
1044 // than reporting a configuration error that isn't one.
1045 if same_pvc && prev_index == index {
1046 continue;
1047 }
1048 let reason = if same_pvc {
1049 format!(
1050 "SnapshotPolicy `{}` has two `pvcSelector` sources that both match \
1051 `{}/{}`, so it would try to back that one volume up twice at the same \
1052 kopia source path `{path}`. Narrow the selectors so each PVC is matched \
1053 by exactly one source.",
1054 policy.name_any(),
1055 target.namespace,
1056 target.name,
1057 )
1058 } else {
1059 format!(
1060 "SnapshotPolicy `{}`'s pvcSelector matches both `{}/{}` and `{}/{}`, \
1061 which resolve to the SAME kopia source path `{path}` under \
1062 `sourcePathStrategy: PvcName`. Their backups would merge into one \
1063 snapshot history and prune each other. Set `sourcePathStrategy: \
1064 PvcNamespacedName` on that source.",
1065 policy.name_any(),
1066 prev.namespace,
1067 prev.name,
1068 target.namespace,
1069 target.name,
1070 )
1071 };
1072 return Err(ValidationError::InvalidFieldValue {
1073 field: "spec.sources[].pvcSelector".to_string(),
1074 reason,
1075 });
1076 }
1077 members.push(ExpandedMember {
1078 name: fanout_child_name(base_name, &policy_ns, &target.namespace, &target.name),
1079 source: SnapshotSourceRef {
1080 source_index: index as u32,
1081 target: SnapshotSourceTarget::Pvc(target.clone()),
1082 // A one-member "group" buys nothing and costs a
1083 // VolumeGroupSnapshotClass requirement (and a Beta API
1084 // group many clusters do not serve), so it degrades to the
1085 // ordinary per-PVC VolumeSnapshot path.
1086 group: (grouped
1087 && per_group
1088 .get(&(target.namespace.as_str(), index))
1089 .copied()
1090 .unwrap_or(0)
1091 > 1)
1092 .then(|| SnapshotSourceGroup {
1093 namespace: target.namespace.clone(),
1094 // Repo dimension: none HERE — `expand_sources` emits
1095 // repo-agnostic members, and `mint_cells` re-derives
1096 // this name with `Some(repo_key)` per repository for a
1097 // multi-repo policy. Passing None keeps single-repo
1098 // names byte-identical to the legacy form.
1099 volume_group_snapshot_name: group_name(
1100 base_name,
1101 &target.namespace,
1102 index,
1103 None,
1104 ),
1105 }),
1106 },
1107 });
1108 }
1109 }
1110 Ok(Some(members))
1111}
1112
1113#[cfg(test)]
1114mod tests;