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// `PartialEq` only, never `Eq`: `stream` carries a `StreamExec`, which embeds
71// k8s-openapi's `LabelSelector` — a `PartialEq`-only type (api-conventions §3).
72#[derive(Debug, Clone, PartialEq)]
73pub struct EffectiveSource {
74 /// Index into `policy.spec.sources` these knobs came from.
75 pub index: usize,
76 /// The concrete PVC, when this is a PVC-shaped source.
77 pub pvc: Option<PvcTargetRef>,
78 /// The NFS export path, when this is an NFS source.
79 pub nfs_path: Option<String>,
80 /// The stream producer, when this is a `stream` source. Mounts nothing: the mover
81 /// execs a command and pipes its stdout into kopia, so no `pvc`/`nfs_path` is set
82 /// and `read_only` is meaningless (see [`EffectiveSource::read_only`]).
83 pub stream: Option<crate::snapshot_policy::StreamSource>,
84 /// `sourcePathOverride` from the governing source.
85 pub source_path_override: Option<String>,
86 /// Whether the mount is read-only.
87 pub read_only: bool,
88}
89
90impl EffectiveSource {
91 /// The kopia source path (and mount path) for this run.
92 ///
93 /// `sourcePathOverride` wins. Otherwise a PVC yields `/pvc/<name>` or
94 /// `/pvc/<namespace>/<name>` depending on the governing source's
95 /// [`SourcePathStrategy`], and an NFS source yields its export path.
96 ///
97 /// The `pvc`-before-`stream` order is safe because the combination is
98 /// UNREPRESENTABLE, not because PVC is the better guess: a `Source` is
99 /// exactly-one-of, and [`effective_source_in`] refuses a PVC pin against a
100 /// source that is not PVC-shaped (see the comment there for what the divergence
101 /// used to cost). Every other constructor sets `stream: None` wherever it sets
102 /// `pvc`. Anything that starts building an `EffectiveSource` with both must
103 /// decide which path is authoritative FIRST — the two consumers
104 /// ([`identity_source_path`], which records the manifest, and
105 /// `snapshot::build`, which fills the work spec) do not read this function the
106 /// same way.
107 pub fn kopia_source_path(&self, strategy: SourcePathStrategy) -> Option<String> {
108 if let Some(o) = &self.source_path_override {
109 return Some(o.clone());
110 }
111 if let Some(p) = &self.pvc {
112 return Some(match strategy {
113 SourcePathStrategy::PvcName => format!("/pvc/{}", p.name),
114 SourcePathStrategy::PvcNamespacedName => {
115 format!("/pvc/{}/{}", p.namespace, p.name)
116 }
117 });
118 }
119 // A stream source records `/stream/<fileName>` — deliberately a different
120 // root from `/pvc/...` so a streamed artifact can never share a kopia
121 // identity with a volume backup.
122 if let Some(stream) = &self.stream {
123 return Some(crate::snapshot_policy::stream_source_path(stream));
124 }
125 self.nfs_path.clone()
126 }
127}
128
129/// Resolve which source this `Snapshot` covers.
130///
131/// * `pin: None` — the ordinary single-source case: `sources[0]` governs, and
132/// its own `pvc`/`nfs` is the target. Byte-for-byte the old behavior.
133/// * `pin: Some(_)` — a fanned-out child: `sourceIndex` selects the governing
134/// source's knobs, and the pinned `target` is the concrete PVC.
135///
136/// An out-of-range `sourceIndex` (the policy shrank mid-run) is a named,
137/// terminal error. It is emphatically NOT a fallback to `sources[0]`: silently
138/// backing up a different volume than the one the CR names is the worst
139/// possible failure mode for a backup operator.
140pub fn effective_source(
141 policy: &SnapshotPolicy,
142 pin: Option<&SnapshotSourceRef>,
143) -> Result<EffectiveSource, ValidationError> {
144 effective_source_in(
145 &policy.spec.sources,
146 &policy.namespace().unwrap_or_default(),
147 &policy.name_any(),
148 pin,
149 )
150}
151
152/// [`effective_source`] over a bare `sources[]` + namespace + name.
153///
154/// The webhook validates a `SnapshotPolicySpec` it has no full object for, so the
155/// derivation has to be reachable without one. `effective_source` is a thin
156/// wrapper over this, which keeps the two on ONE code path — a spec-level
157/// derivation that drifted from the object-level one is exactly the class of bug
158/// [`identity_source_path_in`] exists to make impossible.
159pub fn effective_source_in(
160 sources: &[Source],
161 policy_namespace: &str,
162 policy_name: &str,
163 pin: Option<&SnapshotSourceRef>,
164) -> Result<EffectiveSource, ValidationError> {
165 let index = pin.map(|p| p.source_index as usize).unwrap_or(0);
166 let Some(source) = sources.get(index) else {
167 return Err(ValidationError::InvalidFieldValue {
168 field: "spec.source.sourceIndex".to_string(),
169 reason: format!(
170 "is {index} but SnapshotPolicy `{policy_name}` now has {} source(s) (it was \
171 edited). Fix: delete this Snapshot and let the schedule re-fire",
172 sources.len()
173 ),
174 });
175 };
176 let read_only = snapshot_policy::source_read_only(source);
177 let common = |pvc: Option<PvcTargetRef>| EffectiveSource {
178 index,
179 pvc,
180 nfs_path: source.nfs.as_ref().map(|n| n.path.clone()),
181 stream: source.stream.clone(),
182 source_path_override: source.source_path_override.clone(),
183 read_only,
184 };
185 match pin.map(|p| &p.target) {
186 // A fanned-out child names its own PVC; the policy source it came from
187 // is a selector and has no `pvc` of its own.
188 //
189 // The governing source must be PVC-SHAPED for that to mean anything, and the
190 // check is here rather than downstream so the bad state is unrepresentable:
191 // an `EffectiveSource` carrying BOTH a `pvc` target and a `stream` producer
192 // has two different kopia paths, and the two consumers pick different ones.
193 // `kopia_source_path` checks `pvc` first, so `identity_source_path` (hence the
194 // mover's `--override-source`, hence the RECORDED manifest) said `/pvc/data`,
195 // while `snapshot::build`'s stream arm derives `/stream/<fileName>` for the
196 // work spec — and verification and every `fromPolicy` restore look at the
197 // stream path, which no snapshot was ever written under. The quick verify then
198 // fails TERMINALLY on a policy whose snapshots are perfectly real.
199 //
200 // Nothing upstream rejects the input: `validate::snapshot` does not validate
201 // `Snapshot.spec.source` at all, and the webhook's pin refusals only fire for a
202 // selector policy MISSING a pin and for multi-repo membership. So a
203 // hand-written `spec.source: {sourceIndex: 0, target: {pvc: …}}` against a
204 // `stream` (or `nfs`) policy is admitted, and this is where it stops.
205 //
206 // Exhaustive over the wire form rather than an `is_some()` test, so a fifth
207 // source form must decide whether a PVC pin means anything for it before it
208 // compiles. A malformed source propagates its own error instead of silently
209 // accepting the pin.
210 Some(SnapshotSourceTarget::Pvc(t)) => match snapshot_policy::source_shape(source)? {
211 // Both PVC-shaped: a selector's fanned-out member (the ordinary case), and
212 // a plain `pvc:` source addressed by an explicit pin (the pre-existing
213 // one-shot shape, unchanged).
214 snapshot_policy::SourceShape::PvcSelector(_) | snapshot_policy::SourceShape::Pvc(_) => {
215 Ok(common(Some(t.clone())))
216 }
217 // A stream source's bytes come from a command's stdout and an NFS source's
218 // from an export; neither has a PVC to pin, and neither can be MOUNTED as
219 // the pinned claim either.
220 shape @ (snapshot_policy::SourceShape::Stream(_)
221 | snapshot_policy::SourceShape::Nfs(_)) => Err(ValidationError::InvalidFieldValue {
222 field: "spec.source.target.pvc".to_string(),
223 reason: format!(
224 "pins PVC `{}/{}`, but SnapshotPolicy `{policy_name}` sources[{index}] is a \
225 `{}` source with no PVC. Fix: drop `spec.source`, or point this Snapshot at \
226 a policy whose sources[{index}] is a PVC source",
227 t.namespace,
228 t.name,
229 shape.kind_str(),
230 ),
231 }),
232 },
233 None => Ok(common(source.pvc.as_ref().map(|p| PvcTargetRef {
234 // A non-selector `pvc:` source is always same-namespace.
235 namespace: policy_namespace.to_string(),
236 name: p.name.clone(),
237 }))),
238 }
239}
240
241/// **THE** kopia source path a `SnapshotPolicy`'s governing source records —
242/// the single derivation every identity site must use (#451).
243///
244/// # Why this function exists
245///
246/// Six places resolve a policy's kopia identity: the backup mover
247/// (`snapshot::build::resolve_identity_for`), the policy reconciler's
248/// `config_identity` / `config_identity_for_path` / `resolve_config_identity`,
249/// the verification reconciler's `verify_identity`, and the webhook's
250/// `identity_collision::resolve_policy_identity`. Each of them used to re-derive
251/// the path inline from `sources.first()`'s `pvc`/`nfs` — a pattern that is
252/// *correct only for the two source kinds that existed when it was written*.
253///
254/// A `stream` source has neither a `pvc` nor an `nfs`, so five of the six
255/// resolved a PATHLESS (or `/data`) identity while the backup side recorded
256/// `/stream/<fileName>`. Every consequence was silent: a `fromPolicy` `Restore`
257/// looked for `user@host:/data` and reported `SnapshotNotFound`; quick
258/// verification verified a path with no manifests and **exited 0**; the
259/// collision webhook collapsed every stream policy onto one pathless identity,
260/// raising false collisions and missing real ones; and `identities_match`
261/// compares source paths field-by-field, so a discovered stream snapshot could
262/// never be adopted.
263///
264/// Routing every site through here — and removing the raw `pvc`/`nfs` pieces
265/// from [`crate::IdentityInputs`], so no caller *can* re-derive a path — makes a
266/// seventh site structurally unable to drift.
267///
268/// # Semantics
269///
270/// Exactly [`EffectiveSource::kopia_source_path`] under the governing source's
271/// [`strategy_for`], which is byte-identical to the old inline derivation for
272/// every `pvc`/`nfs`/selector/override shape and additionally correct for
273/// `stream`. `Ok(None)` is kopia's identity-only `username@hostname` form:
274/// a selector source with no fan-out pin, or a **zero-source** legacy policy,
275/// which is deliberately tolerated rather than turned into a terminal error
276/// (admission forbids it; a hand-patched object may still carry it, and a
277/// working restore must not break on upgrade).
278pub fn identity_source_path(
279 policy: &SnapshotPolicy,
280 pin: Option<&SnapshotSourceRef>,
281) -> Result<Option<String>, ValidationError> {
282 identity_source_path_in(
283 &policy.spec.sources,
284 &policy.namespace().unwrap_or_default(),
285 &policy.name_any(),
286 pin,
287 )
288}
289
290/// The kopia source path ONE policy source records, by index — the per-source
291/// form of [`identity_source_path`], for the `status.resolved.sources[]` mirror.
292///
293/// Same derivation, so a human reading status sees exactly what kopia stores:
294/// `/pvc/<name>`, the NFS export path, `/stream/<fileName>`, or an explicit
295/// `sourcePathOverride`. `None` only for a selector source, whose path is
296/// per-member and therefore not a property of the source alone.
297pub fn source_kopia_path(source: &Source, index: usize, policy_namespace: &str) -> Option<String> {
298 let eff = EffectiveSource {
299 index,
300 pvc: source.pvc.as_ref().map(|p| PvcTargetRef {
301 // A non-selector `pvc:` source is always same-namespace.
302 namespace: policy_namespace.to_string(),
303 name: p.name.clone(),
304 }),
305 nfs_path: source.nfs.as_ref().map(|n| n.path.clone()),
306 stream: source.stream.clone(),
307 source_path_override: source.source_path_override.clone(),
308 read_only: snapshot_policy::source_read_only(source),
309 };
310 eff.kopia_source_path(strategy_for(source))
311}
312
313/// [`identity_source_path`] over a bare `sources[]` (the webhook's spec-only shape).
314pub fn identity_source_path_in(
315 sources: &[Source],
316 policy_namespace: &str,
317 policy_name: &str,
318 pin: Option<&SnapshotSourceRef>,
319) -> Result<Option<String>, ValidationError> {
320 // Zero-source legacy tolerance, checked BEFORE `effective_source_in` so it
321 // stays a pathless identity rather than the out-of-range error. A pin on a
322 // zero-source policy is still the real error it has always been.
323 if sources.is_empty() && pin.is_none() {
324 return Ok(None);
325 }
326 let eff = effective_source_in(sources, policy_namespace, policy_name, pin)?;
327 let strategy = sources.get(eff.index).map(strategy_for).unwrap_or_default();
328 Ok(eff.kopia_source_path(strategy))
329}
330
331/// The `sourcePathStrategy` governing a source.
332///
333/// Deliberately only consulted for **selector-expanded** sources. A plain
334/// `pvc:` source keeps `/pvc/<name>` unconditionally: changing the path of an
335/// existing single-PVC policy would re-identify its kopia source and orphan
336/// every manifest it has ever taken.
337pub fn strategy_for(source: &Source) -> SourcePathStrategy {
338 if source.pvc_selector.is_some() {
339 source
340 .source_path_strategy
341 .unwrap_or(SourcePathStrategy::PvcName)
342 } else {
343 SourcePathStrategy::PvcName
344 }
345}
346
347// --- the restore side: which member path fills THIS pvc (#443) ---------------
348
349/// Where a restore's kopia source path came from — the result of
350/// [`restore_source_path`].
351///
352/// The provenance is kept (rather than collapsed to an `Option<String>`) because
353/// the three cases mean different things to a human reading `status`, and to the
354/// reconciler: an [`Override`](Self::Override) is what the user asked for and is
355/// never second-guessed; a [`PolicySource`](Self::PolicySource) reproduces the
356/// pre-#443 behavior byte-for-byte; and a
357/// [`DerivedFromTarget`](Self::DerivedFromTarget) is the new per-PVC derivation
358/// that makes a fan-out restore read the right member.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub enum RestoreSourcePath {
361 /// `source.fromPolicy.sourcePath` — the user named the path explicitly.
362 Override(String),
363 /// The governing plain (`pvc:`/`nfs`) source's own path.
364 ///
365 /// `None` is the identity-only form (kopia `username@hostname`, matching any
366 /// path): a policy with **zero** sources, which admission forbids but a
367 /// hand-patched legacy object may still carry. Tolerated exactly as
368 /// `config_identity` tolerates it — a working restore must not become a
369 /// terminal error on an upgrade.
370 PolicySource(Option<String>),
371 /// The selector `sourcePathStrategy` applied to the PVC being restored — the
372 /// same code path the backup used, so the strings cannot drift.
373 DerivedFromTarget(String),
374}
375
376impl RestoreSourcePath {
377 /// The path to filter kopia snapshots by, if any. Exhaustive.
378 pub fn path(&self) -> Option<&str> {
379 match self {
380 Self::Override(p) | Self::DerivedFromTarget(p) => Some(p),
381 Self::PolicySource(p) => p.as_deref(),
382 }
383 }
384
385 /// Consume into the owned path, if any. Exhaustive.
386 pub fn into_path(self) -> Option<String> {
387 match self {
388 Self::Override(p) | Self::DerivedFromTarget(p) => Some(p),
389 Self::PolicySource(p) => p,
390 }
391 }
392}
393
394/// A total classification of one `SnapshotPolicy` source, **for the restore-path
395/// derivation only**.
396///
397/// Deliberately a DIFFERENT type from the wire-form resolver
398/// [`snapshot_policy::SourceShape`](crate::snapshot_policy::SourceShape), which
399/// answers "which one of the mutually-exclusive keys is set?" and borrows each
400/// form's whole sub-object for the backup side. This one answers the narrower
401/// restore question "can this source's path be derived from the target PVC?",
402/// so it carries only the fields that decision needs and adds an `Invalid` arm
403/// the wire-form resolver reports as an error instead. Keep the two in step:
404/// every form added there needs an arm here.
405///
406/// The point of the enum is that "not a selector" is not one thing: a plain
407/// `pvc:` source can match the restore target by NAME (and then contributes its
408/// own path, override included), while `nfs`, `stream` and a malformed source
409/// contribute nothing at all. Matched exhaustively, so a fifth source shape has
410/// to decide what it means for a restore before it compiles.
411#[derive(Debug, Clone, PartialEq, Eq)]
412enum SourceShape<'a> {
413 /// A plain `pvc:` source, with the PVC name it addresses (always in the
414 /// policy's own namespace).
415 Pvc { name: &'a str },
416 /// A `pvcSelector` source: the knobs that decide each member's path.
417 Selector {
418 /// The strategy this selector derives member paths with.
419 strategy: SourcePathStrategy,
420 /// A `sourcePathOverride` that would apply to EVERY member — which is
421 /// exactly what makes the selector non-per-PVC.
422 source_path_override: Option<&'a str>,
423 },
424 /// An `nfs` export: never addressed by a PVC target.
425 Nfs,
426 /// A `stream` source: its bytes come from a command's stdout and its kopia
427 /// path is `/stream/<fileName>`, which no PVC target can derive. Never
428 /// addressed by a PVC target, exactly like [`SourceShape::Nfs`] — but named
429 /// rather than folded into [`SourceShape::Invalid`], because a stream source
430 /// is perfectly VALID and reporting it as malformed would send a future
431 /// reader hunting a spec bug that isn't there.
432 Stream,
433 /// The source is not a singular, recognized wire form: none of
434 /// `pvc`/`pvcSelector`/`nfs`/`stream` is set, or several are — i.e. exactly the
435 /// cases [`snapshot_policy::source_shape`] reports as a
436 /// [`ValidationError`]. Admission (the CRD's own CEL rule AND the webhook)
437 /// forbids both; a hand-patched object, or one written against an older CRD
438 /// schema, may carry either.
439 ///
440 /// Contributes nothing to a derivation, and fails the derivation CLOSED when it is
441 /// the governing source — see [`restore_source_path`] step (3). Guessing which of
442 /// several set forms "wins" is how a malformed policy came to resolve a real path
443 /// holding ANOTHER volume's data.
444 Invalid,
445}
446
447/// Classify one source. Pure and total.
448///
449/// **Driven off [`snapshot_policy::source_shape`], not off the raw `Option` fields.**
450/// That is what makes the two classifiers compile-coupled: a fifth wire form adds a
451/// [`snapshot_policy::SourceShape`] variant, this `match` stops being exhaustive, and
452/// the build fails until someone decides what the new form means for a restore. The
453/// old `match (&source.pvc, &source.nfs, &source.stream)` could not do that — a fifth
454/// `Source` FIELD simply folded into [`SourceShape::Invalid`], which
455/// [`restore_source_path`] then answered for by accident. (A hardcoded list in a test
456/// cannot close that gap either: the test's inputs would just never name the new form.)
457///
458/// A malformed source — no form set, or several — becomes [`SourceShape::Invalid`]
459/// rather than whichever field an ad-hoc precedence happened to check first.
460fn source_shape(source: &Source) -> SourceShape<'_> {
461 match snapshot_policy::source_shape(source) {
462 Ok(snapshot_policy::SourceShape::PvcSelector(_)) => SourceShape::Selector {
463 strategy: strategy_for(source),
464 source_path_override: source.source_path_override.as_deref(),
465 },
466 Ok(snapshot_policy::SourceShape::Pvc(p)) => SourceShape::Pvc { name: &p.name },
467 Ok(snapshot_policy::SourceShape::Nfs(_)) => SourceShape::Nfs,
468 Ok(snapshot_policy::SourceShape::Stream(_)) => SourceShape::Stream,
469 // Nothing set, or several set. Either way there is no honest answer, and
470 // `restore_source_path` step (3) fails closed rather than deriving from it.
471 Err(_) => SourceShape::Invalid,
472 }
473}
474
475/// The kopia source path a restore of `target` should read from `policy` (#443).
476///
477/// This is the cross-volume fix. `RestoreSelector.source_path: None` becomes the
478/// kopia filter `username@hostname:` — an EMPTY path, which matches every member
479/// path of a selector policy — so before this, restoring one PVC of a multi-PVC
480/// policy took the newest snapshot of *any* member and could fill a volume with
481/// another volume's data.
482///
483/// The rule, in order:
484///
485/// 1. `override_` (`source.fromPolicy.sourcePath`) wins outright.
486/// 2. A plain `pvc:` source addressing EXACTLY this target (same name, same
487/// namespace) ⇒ that source's own path. An exact match beats every
488/// derivation AND the first-source fallback.
489/// 3. The policy has **no selector sources** ⇒ [`RestoreSourcePath::PolicySource`]
490/// of `sources[0]`'s own path — byte-identical to what `config_identity` +
491/// `resolve_identity` produced before this function existed, for the plain
492/// `pvc:`, `nfs` and `sourcePathOverride` shapes alike (and `None` for a
493/// zero-source legacy object).
494/// 4. Every selector source agrees on `(sourcePathStrategy, sourcePathOverride)`
495/// **and that override is `None`** ⇒
496/// [`RestoreSourcePath::DerivedFromTarget`], built through
497/// [`EffectiveSource::kopia_source_path`] — the same call the backup side
498/// makes, so the two strings cannot drift.
499/// 5. Anything else ⇒ a named error telling the user to set
500/// `fromPolicy.sourcePath`.
501///
502/// **(2) is deliberately ahead of (3).** Validation admits N plain `pvc:`
503/// sources on one policy (`validate::snapshot` only requires "at least one"),
504/// but today only the FIRST is ever captured: [`expand_sources`] returns `None`
505/// unless some source carries a `pvcSelector`, so a selector-free policy mints
506/// one unpinned child and `effective_source(policy, None)` resolves index 0.
507/// (A pre-existing backup-side limitation, tracked separately — not something
508/// this function can fix.) That is precisely why the fallback must not answer
509/// for every target: `sources: [pvc: a, pvc: b]` restoring into PVC `b` used to
510/// resolve `/pvc/a`, a path that is real but holds ANOTHER volume's data, and
511/// filled `b` with it under a green `Completed`. With the exact match first, `b`
512/// resolves `/pvc/b` — never written — so the restore fails honestly with
513/// `SnapshotNotFound`, or comes up empty under `Continue`. The same applies to
514/// `[nfs, pvc: a]` restoring `a`, which used to read the NFS export path.
515///
516/// Putting the exact match first is byte-identical for every SINGLE-source
517/// shape: a lone plain `pvc:` source whose name equals the target builds the
518/// same `EffectiveSource` (same index, same `PvcTargetRef`, same override, same
519/// strategy — `strategy_for` is `PvcName` for any non-selector source) and so
520/// the same string; an `nfs` source, a differently-named target and a
521/// cross-namespace target all miss (2) and fall through to (3) untouched.
522///
523/// **A target matching NO plain source still falls back to `sources[0]`** under
524/// (3) — e.g. `[pvc: a, pvc: b]` restoring into a PVC named `c` reads `/pvc/a`.
525/// That is deliberate, not an oversight: it is the pre-#443 answer, it is what
526/// makes "restore this policy's data into a differently-named scratch volume"
527/// keep working, and (5) is reserved for the shapes where a path genuinely
528/// cannot be derived (disagreeing selectors, a flattening selector override) —
529/// not for a multi-source policy where `sources[0]` is a defined, if arbitrary,
530/// answer. Set `fromPolicy.sourcePath` to name the member you want.
531///
532/// A selector carrying `sourcePathOverride: Some(o)` is deliberately NOT
533/// per-PVC: `kopia_source_path` returns the override before it ever looks at the
534/// PVC, so every member was backed up under the one path `o` and no derivation
535/// can tell them apart. That falls to (5) rather than silently returning `o`,
536/// because "which member is this?" genuinely has no answer.
537///
538/// Matching is by **strategy rule, not by claimant labels**: a `target.pvc` has
539/// no labels to match against, and a claimant's labels may have changed since the
540/// backup was taken. This function is therefore pure over the policy + target
541/// alone.
542///
543/// The namespace in `target` is the TARGET's namespace. For a cross-namespace
544/// `target.pvcRef` under a `pvcNamespacedName` strategy that derives
545/// `/pvc/<target-ns>/<name>`, which may be a path the repository never saw — use
546/// the override there.
547///
548/// ```
549/// # use kopiur_api::expand::{restore_source_path, RestoreSourcePath};
550/// # use kopiur_api::snapshot::PvcTargetRef;
551/// # use kopiur_api::SnapshotPolicy;
552/// let policy: SnapshotPolicy = serde_json::from_value(serde_json::json!({
553/// "apiVersion": "kopiur.home-operations.com/v1alpha1",
554/// "kind": "SnapshotPolicy",
555/// "metadata": { "name": "app", "namespace": "db" },
556/// "spec": {
557/// "repository": { "name": "r" },
558/// "sources": [{
559/// "pvcSelector": { "matchLabels": { "app": "web" } },
560/// "sourcePathStrategy": "PvcName",
561/// }],
562/// },
563/// }))
564/// .unwrap();
565/// let target = PvcTargetRef { namespace: "db".into(), name: "data-1".into() };
566/// assert_eq!(
567/// restore_source_path(&policy, None, &target).unwrap(),
568/// RestoreSourcePath::DerivedFromTarget("/pvc/data-1".into())
569/// );
570/// ```
571pub fn restore_source_path(
572 policy: &SnapshotPolicy,
573 override_: Option<&str>,
574 target: &PvcTargetRef,
575) -> Result<RestoreSourcePath, ValidationError> {
576 // (1) An explicit override is never second-guessed.
577 if let Some(o) = override_ {
578 return Ok(RestoreSourcePath::Override(o.to_string()));
579 }
580
581 let policy_ns = policy.namespace().unwrap_or_default();
582 let shapes: Vec<SourceShape<'_>> = policy.spec.sources.iter().map(source_shape).collect();
583
584 // (2) An exact plain-`pvc:` match, BEFORE the first-source fallback: a policy
585 // may carry several plain `pvc:` sources, and `sources[0]` would then be
586 // another volume's path — wrong-but-real data, rather than the honest
587 // `SnapshotNotFound` the unwritten path yields. Same namespace is required:
588 // a plain source always addresses the POLICY's namespace, so a same-named
589 // PVC in another namespace is a different volume.
590 if policy_ns == target.namespace
591 && let Some(index) = shapes.iter().position(|s| match s {
592 SourceShape::Pvc { name } => *name == target.name,
593 SourceShape::Selector { .. }
594 | SourceShape::Nfs
595 | SourceShape::Stream
596 | SourceShape::Invalid => false,
597 })
598 {
599 let source = &policy.spec.sources[index];
600 let eff = EffectiveSource {
601 index,
602 pvc: Some(target.clone()),
603 nfs_path: None,
604 // Matched by its own plain `pvc:` source, so PVC-shaped by construction.
605 stream: None,
606 source_path_override: source.source_path_override.clone(),
607 read_only: snapshot_policy::source_read_only(source),
608 };
609 return Ok(RestoreSourcePath::PolicySource(
610 eff.kopia_source_path(strategy_for(source)),
611 ));
612 }
613
614 // (3) No selector anywhere: the pre-#443 answer, verbatim (including the
615 // zero-source legacy tolerance, where `sources.first()` is `None`). Reached
616 // only when (2) did not match, so a target that names one of the policy's
617 // plain sources never lands here.
618 let has_selector = shapes
619 .iter()
620 .any(|s| matches!(s, SourceShape::Selector { .. }));
621 if !has_selector {
622 let Some(first) = policy.spec.sources.first() else {
623 return Ok(RestoreSourcePath::PolicySource(None));
624 };
625 // …but only when `sources[0]` is a source this build can classify. A
626 // MALFORMED governing source is the #451 failure mode reached from the
627 // other end: it is not a selector, so it lands here, and
628 // `effective_source` hands back an all-`None` source whose
629 // `kopia_source_path` is `None` — the PATHLESS identity `user@host:`,
630 // which kopia reads as a relative filesystem path and matches against
631 // EVERY member of the repository. A multi-set source (say `pvc` beside
632 // `pvcSelector`) was worse still under the old field-order precedence:
633 // it answered with `sources[0]`'s own `/pvc/<name>`, a real path holding
634 // another volume's data, under a green `Completed`. Fail closed instead.
635 //
636 // Deliberately scoped to the GOVERNING source, not to any source: a
637 // malformed sibling alongside a working selector is irrelevant to (4)'s
638 // derivation and must not break a restore that has an honest answer
639 // (`restore_path_ignores_nfs_and_malformed_siblings_of_a_selector`). And
640 // a ZERO-source legacy policy keeps its tolerance above — it has no
641 // source to be malformed.
642 if shapes.first() == Some(&SourceShape::Invalid) {
643 return Err(malformed_source_path(policy));
644 }
645 let eff = effective_source(policy, None)?;
646 return Ok(RestoreSourcePath::PolicySource(
647 eff.kopia_source_path(strategy_for(first)),
648 ));
649 }
650
651 // (4) Every selector agrees, and none of them flattens its members onto one
652 // shared path.
653 let mut agreed: Option<(SourcePathStrategy, Option<&str>)> = None;
654 for shape in &shapes {
655 let (strategy, over) = match shape {
656 SourceShape::Selector {
657 strategy,
658 source_path_override,
659 } => (*strategy, *source_path_override),
660 SourceShape::Pvc { .. }
661 | SourceShape::Nfs
662 | SourceShape::Stream
663 | SourceShape::Invalid => continue,
664 };
665 match agreed {
666 None => agreed = Some((strategy, over)),
667 Some(prev) if prev == (strategy, over) => {}
668 Some(_) => return Err(ambiguous_source_path(policy)),
669 }
670 }
671 match agreed {
672 Some((strategy, None)) => {
673 let eff = EffectiveSource {
674 // The index is inert here: the path comes from the target PVC and
675 // the agreed strategy, not from the source's own `pvc`/`nfs`.
676 index: 0,
677 pvc: Some(target.clone()),
678 nfs_path: None,
679 // A selector-derived path is PVC-shaped; a stream source never
680 // participates in a selector derivation.
681 stream: None,
682 source_path_override: None,
683 read_only: true,
684 };
685 // A PVC-bearing `EffectiveSource` with no override always yields a
686 // path, so the `None` arm is unreachable — but returning the fail-
687 // closed error rather than unwrapping keeps the function total.
688 eff.kopia_source_path(strategy)
689 .map(RestoreSourcePath::DerivedFromTarget)
690 .ok_or_else(|| ambiguous_source_path(policy))
691 }
692 // (5) A shared `sourcePathOverride`, or (defensively) no selector at all
693 // after the `has_selector` check.
694 Some((_, Some(_))) | None => Err(ambiguous_source_path(policy)),
695 }
696}
697
698/// The fail-closed error for the malformed-governing-source case of
699/// [`restore_source_path`] step (3): the source does not set exactly one of the four
700/// forms, so no path can be derived from it — what / why / fix.
701fn malformed_source_path(policy: &SnapshotPolicy) -> ValidationError {
702 ValidationError::InvalidFieldValue {
703 field: "spec.source.fromPolicy.sourcePath".to_string(),
704 reason: format!(
705 "SnapshotPolicy `{}` has a source that does not set exactly one of \
706 pvc/pvcSelector/nfs/stream, so the path to restore is unknown. Fix: repair the \
707 policy's sources[], or set source.fromPolicy.sourcePath",
708 policy.name_any()
709 ),
710 }
711}
712
713/// The fail-closed error for (5) of [`restore_source_path`] — what / why / fix.
714fn ambiguous_source_path(policy: &SnapshotPolicy) -> ValidationError {
715 ValidationError::InvalidFieldValue {
716 field: "spec.source.fromPolicy.sourcePath".to_string(),
717 reason: format!(
718 "SnapshotPolicy `{}` selector sources have no single per-PVC path (they differ in \
719 sourcePathStrategy/sourcePathOverride, or share one sourcePathOverride), so \
720 kopiur cannot tell which volume to restore. Fix: set source.fromPolicy.sourcePath \
721 (e.g. /pvc/<name>)",
722 policy.name_any()
723 ),
724 }
725}
726
727/// The deterministic name of the populate mover `Job` for one claiming PVC.
728///
729/// `<restore>-populate-<h8>`, capped at [`MAX_CHILD_NAME`] (63) — a Job name
730/// becomes a `batch.kubernetes.io/job-name` label value, which Kubernetes caps
731/// at 63 bytes.
732///
733/// `<h8>` is 8 hex of FNV-1a over the claiming PVC's `metadata.uid` and is
734/// **never** clipped: it is what makes N claimants of one `Restore` produce N
735/// distinct Jobs, and what makes a DELETED-and-re-created claim (a new uid) get a
736/// fresh Job instead of adopting the dead claim's. The pre-#443 name was the
737/// bare `<restore>-populate`, which is why only one claimant could ever be
738/// populated; that legacy name is still driven for an adopted in-flight claim
739/// (see the controller's `JobNameReuse`), never minted anew.
740///
741/// ```
742/// # use kopiur_api::expand::populate_job_name;
743/// let n = populate_job_name("restore-pg", "9f1c-uid");
744/// assert!(n.starts_with("restore-pg-populate-"));
745/// assert!(n.len() <= 63);
746/// // Distinct claimants never collide.
747/// assert_ne!(n, populate_job_name("restore-pg", "other-uid"));
748/// ```
749pub fn populate_job_name(restore: &str, consumer_uid: &str) -> String {
750 const MARKER: &str = "-populate-";
751 let tag = fnv8(consumer_uid);
752 let keep = MAX_CHILD_NAME.saturating_sub(MARKER.len() + tag.len());
753 format!("{}{MARKER}{tag}", clip(restore, keep).trim_end_matches('-'))
754 .trim_matches('-')
755 .to_string()
756}
757
758/// Render a `LabelSelector` as the API server's selector string.
759///
760/// Lives here so the controller and the CLI build byte-identical queries: a
761/// divergence would make `kubectl kopiur snapshot now` and the schedule expand
762/// to DIFFERENT PVC sets for the same recipe, which is the kind of discrepancy
763/// nobody notices until a restore is missing a volume.
764pub fn label_selector_string(
765 sel: &k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector,
766) -> String {
767 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelectorRequirement;
768 let mut terms: Vec<String> = Vec::new();
769 if let Some(labels) = &sel.match_labels {
770 for (k, v) in labels {
771 terms.push(format!("{k}={v}"));
772 }
773 }
774 if let Some(exprs) = &sel.match_expressions {
775 for LabelSelectorRequirement {
776 key,
777 operator,
778 values,
779 } in exprs
780 {
781 let vals = values.clone().unwrap_or_default().join(",");
782 match operator.as_str() {
783 "In" => terms.push(format!("{key} in ({vals})")),
784 "NotIn" => terms.push(format!("{key} notin ({vals})")),
785 "Exists" => terms.push(key.clone()),
786 "DoesNotExist" => terms.push(format!("!{key}")),
787 // Unknown operator: skip (the webhook/schema constrain the set).
788 _ => {}
789 }
790 }
791 }
792 terms.join(",")
793}
794
795/// Whether an object with these `labels` matches `selector` — the client-side
796/// counterpart to [`label_selector_string`].
797///
798/// Implements `matchLabels` (every key present with the required value) plus the
799/// `matchExpressions` operators `In`/`NotIn`/`Exists`/`DoesNotExist`. An empty
800/// selector matches everything, as Kubernetes defines it.
801///
802/// Lives here rather than in a consumer because three of them need the same
803/// answer and a partial re-implementation is worse than none: the schedule
804/// reconciler decides which policies to fire, its watch mapper decides which
805/// schedules a policy edit wakes, and the web UI shows a policy which schedules
806/// fire it. A copy that handled only `matchLabels` would silently claim a
807/// `matchExpressions` selector matched every policy in the namespace.
808///
809/// An unknown operator is treated as *no constraint* rather than as no match:
810/// the CRD schema constrains the set, so an unrecognized one can only come from
811/// a newer API than this build, and refusing to match would silently drop a
812/// policy the operator itself is scheduling.
813///
814/// ```
815/// use std::collections::BTreeMap;
816/// use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
817/// use kopiur_api::expand::labels_match_selector;
818///
819/// let labels = BTreeMap::from([("tier".to_string(), "gold".to_string())]);
820/// // An empty selector selects everything.
821/// assert!(labels_match_selector(&labels, &LabelSelector::default()));
822///
823/// let gold: LabelSelector = serde_json::from_value(serde_json::json!({
824/// "matchLabels": { "tier": "gold" }
825/// }))
826/// .unwrap();
827/// assert!(labels_match_selector(&labels, &gold));
828/// ```
829pub fn labels_match_selector(
830 labels: &std::collections::BTreeMap<String, String>,
831 selector: &k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector,
832) -> bool {
833 use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelectorRequirement;
834 if let Some(ml) = &selector.match_labels {
835 for (k, v) in ml {
836 if labels.get(k) != Some(v) {
837 return false;
838 }
839 }
840 }
841 if let Some(exprs) = &selector.match_expressions {
842 for LabelSelectorRequirement {
843 key,
844 operator,
845 values,
846 } in exprs
847 {
848 let vals = values.clone().unwrap_or_default();
849 let present = labels.get(key);
850 let ok = match operator.as_str() {
851 "In" => present.is_some_and(|v| vals.iter().any(|x| x == v)),
852 "NotIn" => present.is_none_or(|v| !vals.iter().any(|x| x == v)),
853 "Exists" => present.is_some(),
854 "DoesNotExist" => present.is_none(),
855 // Unknown operator: the schema constrains the set; treat as no constraint.
856 _ => true,
857 };
858 if !ok {
859 return false;
860 }
861 }
862 }
863 true
864}
865
866// --- naming -----------------------------------------------------------------
867
868/// The deterministic name of the fanned-out `Snapshot` for one PVC.
869///
870/// `<base>-pvc-<slug>-<h8>`, capped at [`MAX_CHILD_NAME`].
871///
872/// * `<slug>` is human-legible (`<name>`, or `<namespace>-<name>` when the PVC
873/// is outside the policy's namespace) and may be clipped.
874/// * `<h8>` is 8 hex of FNV-1a over the exact string `"<namespace>/<name>"` and
875/// is **never** clipped — it is the injectivity guarantee.
876///
877/// Collision-free against the three existing schemes (`<schedule>-<slot>`,
878/// `<schedule>-<policy>-<slot>`, `<policy>-manual-<slot>`): each ends in a
879/// dash-free 14-digit slot stamp, while a fanned name's tail after `-pvc-`
880/// always contains a `-`.
881///
882/// This does NOT delegate to `io::staging::staged_child_name`, whose
883/// `MAX_NAME_LEN - tag.len() - suffix.len() - 2` is unchecked `usize`
884/// subtraction: its callers pass 4-char suffixes (`snap`, `src`), and a PVC
885/// name up to 253 chars would underflow it.
886///
887/// ```
888/// # use kopiur_api::expand::fanout_child_name;
889/// let n = fanout_child_name("nightly-20260805020000", "db", "db", "pgdata");
890/// assert!(n.starts_with("nightly-20260805020000-pvc-pgdata-"));
891/// assert!(n.len() <= 63);
892/// ```
893pub fn fanout_child_name(base: &str, policy_ns: &str, pvc_ns: &str, pvc_name: &str) -> String {
894 fanout_child_name_for(base, policy_ns, Some((pvc_ns, pvc_name)), None)
895}
896
897/// The marker segment naming the target repository in a multi-repo child name.
898const REPO_MARKER: &str = "-repo-";
899
900/// 8 hex chars of FNV-1a over `input` — the never-clipped injectivity tag
901/// shared by every fan-out naming scheme in this module. One definition so
902/// the hash function can never drift between the schemes.
903fn fnv8(input: &str) -> String {
904 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
905 for b in input.as_bytes() {
906 hash ^= *b as u64;
907 hash = hash.wrapping_mul(0x100_0000_01b3);
908 }
909 format!("{:08x}", (hash & 0xffff_ffff) as u32)
910}
911
912/// The deterministic child `Snapshot` name for one (source-member, repository)
913/// cell of a fan-out — the general form behind [`fanout_child_name`].
914///
915/// * `member`: `Some((pvc_ns, pvc_name))` for a selector-expanded member,
916/// `None` for a policy whose single source needs no per-PVC expansion.
917/// * `repo`: `Some(ref)` for a multi-repository fan-out child, `None` for the
918/// classic single-repo shapes.
919///
920/// The four combinations produce:
921///
922/// | member | repo | name |
923/// |--------|------|------|
924/// | `None` | `None` | `<base>` — the legacy non-fanned name, byte-identical |
925/// | `Some` | `None` | `<base>-pvc-<pslug>-<h8>` — byte-identical to the legacy [`fanout_child_name`] |
926/// | `None` | `Some` | `<base>-repo-<rslug>-<h8>` |
927/// | `Some` | `Some` | `<base>-pvc-<pslug>-repo-<rslug>-<h8>` |
928///
929/// `<h8>` is FNV-1a over the newline-framed FULL unclipped tuple — `base`,
930/// then (when present) `"<pvc_ns>/<pvc_name>"`, then (when present) the
931/// normalized [`repo_key`](crate::common::repo_key) — so clipping any legible
932/// segment never merges two distinct cells, and a policy slug that happens to
933/// contain `-repo-` (pslug `x-repo-y` vs pslug `x` + rslug `y`) still yields
934/// distinct names because the hash inputs differ. Markers and the tag are
935/// NEVER clipped; when over budget the clip order is base → pslug → rslug.
936/// Always ≤ [`MAX_CHILD_NAME`] (63).
937///
938/// The legacy hash input for `repo: None` is exactly the pre-multi-repo
939/// `"{base}\n{pvc_ns}/{pvc_name}"` — pinned by golden tests.
940pub fn fanout_child_name_for(
941 base: &str,
942 policy_ns: &str,
943 member: Option<(&str, &str)>,
944 repo: Option<&crate::common::RepositoryRef>,
945) -> String {
946 // (normalized repo_key for the hash, legible slug for the name).
947 let repo = repo.map(|r| (crate::common::repo_key(r, policy_ns), r.name.as_str()));
948 match (member, repo) {
949 // Legacy single child: the name IS the base (already ≤63 for every
950 // caller-produced base; clipped defensively, identical when in budget).
951 (None, None) => clip(base, MAX_CHILD_NAME).trim_matches('-').to_string(),
952 (Some((pvc_ns, pvc_name)), repo) => {
953 member_child_name(base, policy_ns, pvc_ns, pvc_name, repo.as_ref())
954 }
955 (None, Some((rkey, repo_name))) => {
956 // <base>-repo-<rslug>-<h8>
957 let tag = fnv8(&format!("{base}\n{rkey}"));
958 let rslug = sanitize_dns1123(repo_name);
959 let fixed = REPO_MARKER.len() + 1 + tag.len();
960 let mut base_keep = base.len();
961 let mut rslug_keep = rslug.len();
962 if fixed + base_keep + rslug_keep > MAX_CHILD_NAME {
963 let room = MAX_CHILD_NAME.saturating_sub(fixed);
964 // Clip base first (every sibling shares it), rslug second.
965 rslug_keep = rslug_keep.min(room / 3);
966 base_keep = base_keep.min(room.saturating_sub(rslug_keep));
967 }
968 format!(
969 "{}{REPO_MARKER}{}-{tag}",
970 clip(base, base_keep),
971 clip(&rslug, rslug_keep)
972 )
973 .trim_matches('-')
974 .to_string()
975 }
976 }
977}
978
979/// The `member: Some(..)` arm of [`fanout_child_name_for`]: the legacy
980/// `-pvc-` form (`rkey: None`, byte-identical to the pre-multi-repo
981/// [`fanout_child_name`]) and the combined `-pvc-…-repo-…` form.
982fn member_child_name(
983 base: &str,
984 policy_ns: &str,
985 pvc_ns: &str,
986 pvc_name: &str,
987 repo: Option<&(String, &str)>,
988) -> String {
989 // The tag hashes the BASE as well as the PVC (and, for a multi-repo child,
990 // the repo key). That is not belt-and-braces: `base` is
991 // `<schedule>-<YYYYMMDDHHMMSS>` and it is the part that gets CLIPPED when
992 // the name is too long, so a tag over the PVC alone leaves two different
993 // slots of the same schedule+PVC with byte-identical names. The schedule
994 // server-side-applies with force and only skips *terminating* twins, so
995 // the second fire would re-apply onto the already-`Succeeded` first
996 // Snapshot, `run_decision` would return `SucceededSteadyState`, and no
997 // mover Job would ever launch — a whole backup slot vanishing with no
998 // error anywhere.
999 let tag = match repo {
1000 None => fnv8(&format!("{base}\n{pvc_ns}/{pvc_name}")),
1001 Some((rkey, _)) => fnv8(&format!("{base}\n{pvc_ns}/{pvc_name}\n{rkey}")),
1002 };
1003
1004 let slug_full = if pvc_ns == policy_ns {
1005 pvc_name.to_string()
1006 } else {
1007 format!("{pvc_ns}-{pvc_name}")
1008 };
1009 let slug = sanitize_dns1123(&slug_full);
1010
1011 match repo {
1012 None => {
1013 // Budget: base + "-pvc-" + slug + "-" + tag. Clip `base` first (it
1014 // is the most redundant part — every sibling shares it), then the
1015 // slug. Never the tag or the marker.
1016 let fixed = FANOUT_MARKER.len() + 1 + tag.len();
1017 let mut base_keep = base.len();
1018 let mut slug_keep = slug.len();
1019 if fixed + base_keep + slug_keep > MAX_CHILD_NAME {
1020 let room = MAX_CHILD_NAME.saturating_sub(fixed);
1021 // Give the slug up to a third of the room, the base the rest.
1022 slug_keep = slug_keep.min(room / 3);
1023 base_keep = base_keep.min(room.saturating_sub(slug_keep));
1024 }
1025 let name = format!(
1026 "{}{FANOUT_MARKER}{}-{tag}",
1027 clip(base, base_keep),
1028 clip(&slug, slug_keep)
1029 );
1030 // A clip can leave a trailing '-', which is not a legal DNS-1123 name.
1031 name.trim_matches('-').to_string()
1032 }
1033 Some((_, repo_name)) => {
1034 // Combined form: base + "-pvc-" + pslug + "-repo-" + rslug + "-" +
1035 // tag. Overhead 5+6+1+8 = 20, room 43. Clip base → pslug → rslug.
1036 let rslug = sanitize_dns1123(repo_name);
1037 let fixed = FANOUT_MARKER.len() + REPO_MARKER.len() + 1 + tag.len();
1038 let mut base_keep = base.len();
1039 let mut pslug_keep = slug.len();
1040 let mut rslug_keep = rslug.len();
1041 if fixed + base_keep + pslug_keep + rslug_keep > MAX_CHILD_NAME {
1042 let room = MAX_CHILD_NAME.saturating_sub(fixed);
1043 rslug_keep = rslug_keep.min(room / 3);
1044 pslug_keep = pslug_keep.min(room.saturating_sub(rslug_keep) / 2);
1045 base_keep = base_keep.min(room.saturating_sub(rslug_keep + pslug_keep));
1046 }
1047 format!(
1048 "{}{FANOUT_MARKER}{}{REPO_MARKER}{}-{tag}",
1049 clip(base, base_keep),
1050 clip(&slug, pslug_keep),
1051 clip(&rslug, rslug_keep)
1052 )
1053 .trim_matches('-')
1054 .to_string()
1055 }
1056 }
1057}
1058
1059/// The kopia-cache PVC name for one (policy, repository) cell.
1060///
1061/// A kopia client cache is REPOSITORY-SPECIFIC state (indexes, metadata, owned
1062/// blobs of ONE repository), and the mover mounts it at a fixed
1063/// `KOPIA_CACHE_DIRECTORY` — so a multi-repo policy's children must not share
1064/// one PVC or they poison each other's cache. Hence:
1065///
1066/// * `repo: None` — the classic single-repo cache: `kopiur-cache-<policy>`,
1067/// byte-identical to the pre-multi-repo name (including its historical lack
1068/// of a length cap — an existing PVC must keep matching by name).
1069/// * `repo: Some(_)` — a pinned child of a multi-repo policy:
1070/// `kopiur-cache-<policy>-<rslug>-<h6>`, where `<h6>` is 6 hex of FNV-1a
1071/// over the normalized [`repo_key`](crate::common::repo_key) (never
1072/// clipped — the injectivity guarantee, same family as
1073/// [`fanout_child_name_for`]) and the whole name is capped at 63 (RFC 1123
1074/// label, the same Job-label bound child names honor). Clip order:
1075/// policy slug first, then rslug; marker dashes and the tag never.
1076///
1077/// Ownership stays the policy in both shapes (the caller's concern).
1078pub fn cache_pvc_name(
1079 policy_name: &str,
1080 policy_ns: &str,
1081 repo: Option<&crate::common::RepositoryRef>,
1082) -> String {
1083 const PREFIX: &str = "kopiur-cache-";
1084 let Some(repo) = repo else {
1085 return format!("{PREFIX}{policy_name}");
1086 };
1087 let rkey = crate::common::repo_key(repo, policy_ns);
1088 // h6: the fnv8 family hash, truncated to 6 hex — enough to disambiguate a
1089 // policy's ≤8 repositories while keeping the legible slugs roomy.
1090 let tag: String = fnv8(&rkey).chars().take(6).collect();
1091 let pslug = sanitize_dns1123(policy_name);
1092 let rslug = sanitize_dns1123(&repo.name);
1093 let fixed = PREFIX.len() + 2 + tag.len(); // two joining dashes + tag
1094 let mut pslug_keep = pslug.len();
1095 let mut rslug_keep = rslug.len();
1096 if fixed + pslug_keep + rslug_keep > MAX_CHILD_NAME {
1097 let room = MAX_CHILD_NAME.saturating_sub(fixed);
1098 rslug_keep = rslug_keep.min(room / 3);
1099 pslug_keep = pslug_keep.min(room.saturating_sub(rslug_keep));
1100 }
1101 // Trim any clip-produced trailing '-' per segment (a doubled dash is not a
1102 // legal-name problem, but keeping segments clean avoids `--` cosmetics; the
1103 // never-clipped tag carries injectivity regardless).
1104 format!(
1105 "{PREFIX}{}-{}-{tag}",
1106 clip(&pslug, pslug_keep).trim_end_matches('-'),
1107 clip(&rslug, rslug_keep).trim_end_matches('-')
1108 )
1109 .trim_matches('-')
1110 .to_string()
1111}
1112
1113/// Truncate on a char boundary.
1114fn clip(s: &str, keep: usize) -> &str {
1115 if keep >= s.len() {
1116 return s;
1117 }
1118 let mut end = keep;
1119 while end > 0 && !s.is_char_boundary(end) {
1120 end -= 1;
1121 }
1122 &s[..end]
1123}
1124
1125/// Lowercase, replacing anything outside `[a-z0-9-]` with `-`.
1126fn sanitize_dns1123(s: &str) -> String {
1127 s.chars()
1128 .map(|c| {
1129 if c.is_ascii_alphanumeric() {
1130 c.to_ascii_lowercase()
1131 } else {
1132 '-'
1133 }
1134 })
1135 .collect()
1136}
1137
1138// --- expansion ---------------------------------------------------------------
1139
1140/// One member of an expanded selector: the child's name and the `spec.source`
1141/// to stamp on it.
1142#[derive(Debug, Clone, PartialEq, Eq)]
1143pub struct ExpandedMember {
1144 /// Deterministic child `Snapshot` name.
1145 pub name: String,
1146 /// The pin recording which PVC this child covers.
1147 pub source: SnapshotSourceRef,
1148}
1149
1150/// The shared `VolumeGroupSnapshot` name for one expansion in one namespace.
1151///
1152/// Derived from the per-invocation base name, NOT from any one member: every
1153/// member must compute the identical name without talking to its siblings,
1154/// which is what makes their racing server-side-applies converge on one object
1155/// instead of N.
1156///
1157/// Bounded to 63 chars for the same reason child names are — see
1158/// [`fanout_child_name`].
1159/// `repo_key` adds the repository dimension for multi-repo fan-out — one
1160/// VolumeGroupSnapshot per (repository, slot), because each repo's members are
1161/// an independent capture wave (independent-captures semantics: the N groups
1162/// are N separate point-in-time CSI snapshots, at N× the CSI quota/load).
1163/// Existing callers pass `None` and get the byte-identical legacy name.
1164pub fn group_name(
1165 base: &str,
1166 namespace: &str,
1167 source_index: usize,
1168 repo_key: Option<&str>,
1169) -> String {
1170 let suffix = "-grp";
1171 // The SOURCE INDEX is part of the key, not just the namespace. Two selector
1172 // sources in one policy have DIFFERENT label selectors, and
1173 // `resolve_group_stage` builds the VolumeGroupSnapshot's `source.selector`
1174 // from `sources[sourceIndex]`. Sharing a name across them would have the two
1175 // members force-SSA conflicting selectors onto one object, so the loser's
1176 // PVC is never captured and it fails with `GroupMemberMissing`.
1177 let tag = match repo_key {
1178 None => fnv8(&format!("{namespace}#{source_index}")),
1179 Some(k) => fnv8(&format!("{namespace}#{source_index}#{k}")),
1180 };
1181 let room = MAX_CHILD_NAME - suffix.len() - tag.len() - 1;
1182 format!("{}-{tag}{suffix}", clip(base, room))
1183 .trim_matches('-')
1184 .to_string()
1185}
1186
1187/// One `Snapshot` to mint for a slot/invocation: its deterministic name, the
1188/// source pin (for a `pvcSelector` member) and the repository pin (for a
1189/// multi-repo fan-out child, NORMALIZED via
1190/// [`normalized_repository_ref`](crate::common::normalized_repository_ref)).
1191#[derive(Debug, Clone, PartialEq, Eq)]
1192pub struct MintCell {
1193 /// Deterministic child `Snapshot` name.
1194 pub name: String,
1195 /// The `spec.source` pin recording which PVC this child covers.
1196 pub source: Option<SnapshotSourceRef>,
1197 /// The `spec.repository` pin recording which repository this child targets
1198 /// (multi-repo fan-out only; `None` keeps the legacy unpinned wire).
1199 pub repository: Option<crate::common::RepositoryRef>,
1200}
1201
1202/// **Pure.** Cross one policy's expanded source members with its repository
1203/// dimension (#368) into the exact set of `Snapshot`s to mint for one slot or
1204/// one `snapshot now` invocation. Lives here so the `SnapshotSchedule`
1205/// reconciler and `kubectl kopiur snapshot now` mint byte-identical sets — a
1206/// divergence would give the same recipe different children depending on who
1207/// fired it.
1208///
1209/// * Single-repo policy: byte-identical to the pre-multi-repo behavior — the
1210/// bare `base_name` child (no pins) for a selector-less recipe, or one
1211/// unpinned per-member child.
1212/// * Multi-repo policy: one child per (member × repository), named via
1213/// [`fanout_child_name_for`] with the repo dimension, `spec.repository`
1214/// stamped NORMALIZED; a grouped member's shared `VolumeGroupSnapshot` name
1215/// is re-derived PER REPOSITORY ([`group_name`] with the repo key) — each
1216/// repo's members are an independent capture wave, so N repos = N groups.
1217///
1218/// `members: Some(vec![])` (a selector that matched nothing) yields no cells —
1219/// the caller warns, as before.
1220pub fn mint_cells(
1221 policy: &SnapshotPolicy,
1222 base_name: &str,
1223 members: Option<Vec<ExpandedMember>>,
1224) -> Vec<MintCell> {
1225 use crate::common::{normalized_repository_ref, repo_key};
1226 let policy_ns = policy.namespace().unwrap_or_default();
1227 let repos: Option<Vec<&crate::common::RepositoryRef>> =
1228 crate::snapshot_policy::is_multi_repo(&policy.spec)
1229 .then(|| policy.spec.repositories.iter().collect());
1230 match (members, repos) {
1231 // Single-repo, no selector: the legacy bare child.
1232 (None, None) => vec![MintCell {
1233 name: base_name.to_string(),
1234 source: None,
1235 repository: None,
1236 }],
1237 // Single-repo selector fan-out: unpinned members, byte-identical.
1238 (Some(members), None) => members
1239 .into_iter()
1240 .map(|m| MintCell {
1241 name: m.name,
1242 source: Some(m.source),
1243 repository: None,
1244 })
1245 .collect(),
1246 // Multi-repo, no selector: one child per repository.
1247 (None, Some(repos)) => repos
1248 .into_iter()
1249 .map(|r| MintCell {
1250 name: fanout_child_name_for(base_name, &policy_ns, None, Some(r)),
1251 source: None,
1252 repository: Some(normalized_repository_ref(r, &policy_ns)),
1253 })
1254 .collect(),
1255 // Multi-repo selector fan-out: members × repositories.
1256 (Some(members), Some(repos)) => members
1257 .iter()
1258 .flat_map(|m| {
1259 let policy_ns = policy_ns.clone();
1260 let target = match &m.source.target {
1261 SnapshotSourceTarget::Pvc(t) => t.clone(),
1262 };
1263 repos.iter().map(move |r| {
1264 let rkey = repo_key(r, &policy_ns);
1265 let mut source = m.source.clone();
1266 if let Some(group) = source.group.as_mut() {
1267 group.volume_group_snapshot_name = group_name(
1268 base_name,
1269 &group.namespace,
1270 source.source_index as usize,
1271 Some(&rkey),
1272 );
1273 }
1274 MintCell {
1275 name: fanout_child_name_for(
1276 base_name,
1277 &policy_ns,
1278 Some((&target.namespace, &target.name)),
1279 Some(r),
1280 ),
1281 source: Some(source),
1282 repository: Some(normalized_repository_ref(r, &policy_ns)),
1283 }
1284 })
1285 })
1286 .collect(),
1287 }
1288}
1289
1290/// **Pure.** Expand one policy's sources against an already-matched PVC set.
1291///
1292/// `matched` is `(namespace, name)` per source index — the caller does the
1293/// cluster IO ([`match_pvcs`]), this decides the names and pins.
1294///
1295/// Returns `Ok(None)` when the policy has no selector source at all, meaning
1296/// "mint exactly one child with no `spec.source`", i.e. today's behavior. That
1297/// distinction matters: an empty `Vec` would mean "a selector matched nothing",
1298/// which is a different and much louder situation.
1299///
1300/// # The collision guard
1301///
1302/// `sourcePathStrategy` defaults to `PvcName` → `/pvc/<name>`. A selector with
1303/// a cross-namespace `namespaceSelector` matching a PVC called `data` in two
1304/// namespaces therefore yields **two kopia sources at the identical
1305/// `user@host:/pvc/data`**, silently merging two volumes' histories into one
1306/// stream — under a single `KOPIA_KEEP_MAX` retention pin, so they also prune
1307/// each other. `detect_identity_collision` cannot catch this: it compares
1308/// across policies and skips self. So it is caught here, before anything is
1309/// created.
1310pub fn expand_sources(
1311 policy: &SnapshotPolicy,
1312 base_name: &str,
1313 matched: &BTreeMap<usize, Vec<PvcTargetRef>>,
1314) -> Result<Option<Vec<ExpandedMember>>, ValidationError> {
1315 let policy_ns = policy.namespace().unwrap_or_default();
1316 if !policy.spec.sources.iter().any(|s| s.pvc_selector.is_some()) {
1317 return Ok(None);
1318 }
1319 let mut members: Vec<ExpandedMember> = Vec::new();
1320 // path -> (target, source index) that first produced it.
1321 let mut paths: BTreeMap<String, (PvcTargetRef, usize)> = BTreeMap::new();
1322 let grouped =
1323 policy.spec.group_by == Some(crate::snapshot_policy::GroupBy::VolumeGroupSnapshot);
1324 // How many members land in each namespace, computed up front: a
1325 // VolumeGroupSnapshot is namespaced and its `source.selector` is
1326 // namespace-local, so a selector spanning namespaces yields ONE GROUP PER
1327 // NAMESPACE — the consistency guarantee is per-namespace, not global.
1328 // Keyed by SOURCE too, not just namespace: one VolumeGroupSnapshot is built
1329 // from ONE source's label selector, so two selector sources in the same
1330 // namespace are two separate captures, and counting them together would
1331 // wrongly promote a pair of one-PVC sources into a "group".
1332 let mut per_group: BTreeMap<(&str, usize), usize> = BTreeMap::new();
1333 if grouped {
1334 for (index, source) in policy.spec.sources.iter().enumerate() {
1335 if source.pvc_selector.is_none() {
1336 continue;
1337 }
1338 for t in matched.get(&index).into_iter().flatten() {
1339 *per_group.entry((t.namespace.as_str(), index)).or_default() += 1;
1340 }
1341 }
1342 }
1343
1344 for (index, source) in policy.spec.sources.iter().enumerate() {
1345 if source.pvc_selector.is_none() {
1346 continue;
1347 }
1348 let strategy = strategy_for(source);
1349 for target in matched.get(&index).into_iter().flatten() {
1350 // Collision check against every path this expansion has produced.
1351 let eff = EffectiveSource {
1352 index,
1353 pvc: Some(target.clone()),
1354 nfs_path: None,
1355 // A selector source is always PVC-shaped; a stream source never
1356 // expands (it is one artifact per Snapshot, and admission requires
1357 // it to be its policy's only source).
1358 stream: None,
1359 source_path_override: source.source_path_override.clone(),
1360 read_only: snapshot_policy::source_read_only(source),
1361 };
1362 let path = eff
1363 .kopia_source_path(strategy)
1364 .unwrap_or_else(|| "/data".to_string());
1365 // ANY repeated path is refused, not just one produced by two
1366 // DIFFERENT PVCs. Two selector sources that both match the same PVC
1367 // land on one path AND one child name, so the second would
1368 // force-server-side-apply over the first and one backup would
1369 // vanish with no error — the same silent-overwrite class as a
1370 // clipped slot stamp.
1371 if let Some((prev, prev_index)) = paths.insert(path.clone(), (target.clone(), index)) {
1372 let same_pvc = prev.namespace == target.namespace && prev.name == target.name;
1373 // The same PVC listed twice by the SAME source is a listing
1374 // artifact (`match_pvcs` already dedupes; this keeps
1375 // `expand_sources` idempotent for any caller). Skip it rather
1376 // than reporting a configuration error that isn't one.
1377 if same_pvc && prev_index == index {
1378 continue;
1379 }
1380 let reason = if same_pvc {
1381 format!(
1382 "SnapshotPolicy `{}` has two `pvcSelector` sources that both match \
1383 `{}/{}` (path `{path}`). Fix: narrow the selectors so each PVC matches \
1384 only one source",
1385 policy.name_any(),
1386 target.namespace,
1387 target.name,
1388 )
1389 } else if let Some(i) = [index, prev_index].into_iter().find(|i| {
1390 policy
1391 .spec
1392 .sources
1393 .get(*i)
1394 .and_then(|s| s.source_path_override.as_deref())
1395 == Some(path.as_str())
1396 }) {
1397 // A `sourcePathOverride` PRODUCED this path: it wins at
1398 // `kopia_source_path`'s first branch, so the strategy is
1399 // never consulted and "set sourcePathStrategy" would be a
1400 // remedy that does nothing. Name the real cause instead.
1401 format!(
1402 "SnapshotPolicy `{}` pvcSelector matches both `{}/{}` and `{}/{}`, but \
1403 `spec.sources[{i}].sourcePathOverride` gives them the same path \
1404 `{path}`, so their backups would overwrite each other. Fix: remove \
1405 that `sourcePathOverride` and use `sourcePathStrategy` (`PvcName` or \
1406 `PvcNamespacedName`), or move the override onto its own `pvc:` \
1407 source",
1408 policy.name_any(),
1409 prev.namespace,
1410 prev.name,
1411 target.namespace,
1412 target.name,
1413 )
1414 } else {
1415 format!(
1416 "SnapshotPolicy `{}` pvcSelector matches both `{}/{}` and `{}/{}`, \
1417 which get the same path `{path}` under `sourcePathStrategy: PvcName`, \
1418 so their backups would overwrite each other. Fix: set \
1419 `sourcePathStrategy: PvcNamespacedName` on that source",
1420 policy.name_any(),
1421 prev.namespace,
1422 prev.name,
1423 target.namespace,
1424 target.name,
1425 )
1426 };
1427 return Err(ValidationError::InvalidFieldValue {
1428 field: "spec.sources[].pvcSelector".to_string(),
1429 reason,
1430 });
1431 }
1432 members.push(ExpandedMember {
1433 name: fanout_child_name(base_name, &policy_ns, &target.namespace, &target.name),
1434 source: SnapshotSourceRef {
1435 source_index: index as u32,
1436 target: SnapshotSourceTarget::Pvc(target.clone()),
1437 // A one-member "group" buys nothing and costs a
1438 // VolumeGroupSnapshotClass requirement (and a Beta API
1439 // group many clusters do not serve), so it degrades to the
1440 // ordinary per-PVC VolumeSnapshot path.
1441 group: (grouped
1442 && per_group
1443 .get(&(target.namespace.as_str(), index))
1444 .copied()
1445 .unwrap_or(0)
1446 > 1)
1447 .then(|| SnapshotSourceGroup {
1448 namespace: target.namespace.clone(),
1449 // Repo dimension: none HERE — `expand_sources` emits
1450 // repo-agnostic members, and `mint_cells` re-derives
1451 // this name with `Some(repo_key)` per repository for a
1452 // multi-repo policy. Passing None keeps single-repo
1453 // names byte-identical to the legacy form.
1454 volume_group_snapshot_name: group_name(
1455 base_name,
1456 &target.namespace,
1457 index,
1458 None,
1459 ),
1460 }),
1461 },
1462 });
1463 }
1464 }
1465 Ok(Some(members))
1466}
1467
1468#[cfg(test)]
1469mod tests;