Skip to main content

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;