Skip to main content

kopiur_api/common/
mover.rs

1use super::*;
2use k8s_openapi::api::core::v1::{
3    Affinity, PodSecurityContext, ResourceRequirements, SecurityContext, Toleration,
4};
5use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// Per-recipe mover overrides (resources, cache, security context).
11#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
12#[serde(rename_all = "camelCase")]
13pub struct MoverSpec {
14    /// Resource requests/limits for the mover container.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub resources: Option<k8s_openapi::api::core::v1::ResourceRequirements>,
17    /// Override the repository's [`CacheDefaults`] for this recipe's movers.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub cache: Option<CacheDefaults>,
20    /// Container security context for the mover; merged field-wise over the hardened base,
21    /// `moverDefaults`, and any inherited context — this is the highest layer, so every field
22    /// set here wins. Combines with `inheritSecurityContextFrom`: fields you set override the
23    /// workload's, fields you omit are inherited, and this context stands in alone when
24    /// inheritance cannot resolve a pod.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub security_context: Option<k8s_openapi::api::core::v1::SecurityContext>,
27    /// Pod security context for the mover (notably `fsGroup` for group-writable restore
28    /// volumes). Same layering as `securityContext`: highest layer, merged field-wise, and
29    /// combinable with `inheritSecurityContextFrom`.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub pod_security_context: Option<k8s_openapi::api::core::v1::PodSecurityContext>,
32    /// Opt-in, namespace-gated privileged mode. Never changes the UID. When the mover runs
33    /// as root (`runAsUser: 0`), adds the capabilities the operation needs on top of
34    /// `drop: [ALL]`: `DAC_OVERRIDE` for a backup (read files owned by any uid/gid);
35    /// `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID` for a restore (reproduce original
36    /// ownership). Grants nothing for maintenance, or when the mover does not run as
37    /// root (capabilities are inert for non-root); set `runAsUser: 0` yourself.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub privileged_mode: Option<bool>,
40    /// Copy the UID/GID security context from a live workload rather than hard-coding it.
41    ///
42    /// Requires the workload to pin `runAsUser` (container or pod level): a UID that comes
43    /// from the container image's `USER` line is invisible in the pod spec and cannot be
44    /// inherited — the mover would silently run as its own image's UID instead.
45    ///
46    /// May be combined with `securityContext`/`podSecurityContext`, which override it
47    /// field-wise and act as the fallback when no workload pod can be resolved.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub inherit_security_context_from: Option<InheritSecurityContextFrom>,
50    /// Per-recipe override of `Job.spec.ttlSecondsAfterFinished` so finished Jobs self-GC.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub ttl_seconds_after_finished: Option<i64>,
53}
54
55impl MoverSpec {
56    /// Whether this mover requests **elevated privileges** that the workload
57    /// namespace must explicitly opt into (ADR §4.11/§G16). True when
58    /// `privilegedMode` is set, or the `securityContext` runs as root / privileged
59    /// / with escalation / with added Linux capabilities.
60    ///
61    /// The rationale is the same as VolSync's `privileged-movers` model: the
62    /// controller mints a mover `ServiceAccount` in the workload namespace, and a
63    /// tenant with access there could reuse it to run pods at the mover's privilege.
64    /// Granting an elevated mover is therefore a per-namespace admin decision, gated
65    /// by a namespace annotation rather than allowed implicitly. Pure + exhaustive
66    /// so the definition of "privileged" lives in one tested place.
67    pub fn requires_privilege(&self) -> bool {
68        requires_privilege_resolved(
69            self.security_context.as_ref(),
70            self.pod_security_context.as_ref(),
71            self.privileged_mode,
72        )
73    }
74
75    /// Admission-time warning when `privilegedMode: true` would certainly grant nothing:
76    /// this spec pins a non-zero `runAsUser` (capabilities are inert for non-root, and
77    /// `privilegedMode` never picks a UID).
78    ///
79    /// Judged on `hardened ⊂ self` only. A spec with no `runAsUser` stays silent: the
80    /// repository's `moverDefaults` may pin `runAsUser: 0` and is invisible here, so
81    /// warning would be a false positive. The controller's `PrivilegedModeInert` Event,
82    /// which sees the merged context, reports that case. An `inheritSecurityContextFrom`
83    /// identity resolves at reconcile time, so it stays silent too.
84    pub fn privileged_mode_warning(&self, op: MoverOperation) -> Option<String> {
85        if self.inherit_security_context_from.is_some() {
86            return None;
87        }
88        let resolved = resolve_mover(
89            None,
90            self.security_context.as_ref(),
91            self.pod_security_context.as_ref(),
92            None,
93            None,
94            None,
95        );
96        let effect = privileged_mode_effect(
97            &resolved.security_context,
98            resolved.pod_security_context.as_ref(),
99            self.privileged_mode,
100            op,
101        );
102        match effect {
103            // A non-root uid pinned on this object stays non-root whatever the
104            // repository's moverDefaults say, so it is certainly inert.
105            PrivilegedModeEffect::InertNonRootUid { .. } => effect.warning(),
106            // No uid here: moverDefaults (invisible at admission) may pin root. Leave
107            // it to the controller's PrivilegedModeInert Event, which sees the merge.
108            PrivilegedModeEffect::InertUnsetUid
109            | PrivilegedModeEffect::NotRequested
110            | PrivilegedModeEffect::NothingToGrant
111            | PrivilegedModeEffect::Granted => None,
112        }
113    }
114
115    /// Overlay `self` — the **HIGHER** layer — onto `base`, field by field, producing
116    /// one `MoverSpec` that stands in for both. This is what lets an invocation carry
117    /// its own mover overrides on top of a shared, GitOps-managed recipe:
118    /// `Snapshot.spec.mover` merged over `SnapshotPolicy.spec.mover` (#464).
119    ///
120    /// The merge delegates to the SAME primitives the repository-layer merge uses, so a
121    /// per-run override can never resolve differently from a per-recipe one:
122    ///
123    /// - `securityContext`/`podSecurityContext` go through [`merge_context_pair`] as one
124    ///   `(container, pod)` layer pair — field-wise per dimension **plus** identity
125    ///   promotion, so a policy's container-level `runAsUser` can never shadow a
126    ///   snapshot's pod-level one. That merge is associative, so folding these two
127    ///   layers here and then handing the result to [`resolve_mover`] is identical to a
128    ///   flat `hardened ⊂ moverDefaults ⊂ inherited ⊂ policy ⊂ snapshot` merge.
129    /// - `resources` via [`merge_resources_opt`] (per-key requests/limits).
130    /// - `cache` via [`CacheDefaults::merge`] (per-field).
131    /// - `privilegedMode`, `ttlSecondsAfterFinished` and `inheritSecurityContextFrom`
132    ///   are whole-value knobs with no inner structure to merge, so the higher layer's
133    ///   value wins outright and `None` falls through to `base`.
134    ///
135    /// `base: None` is the identity: the result is `self` unchanged. Because the layer
136    /// order is fixed by this signature, a caller cannot accidentally invert it.
137    pub fn merge_over(&self, base: Option<&MoverSpec>) -> MoverSpec {
138        let Some(base) = base else {
139            return self.clone();
140        };
141        // One layer pair, not two independent dimension merges — a lone
142        // `merge_security_context` here would reintroduce cross-dimension identity
143        // shadowing (see `merge_context_pair`'s docs).
144        let (security_context, pod_security_context) = merge_context_pair(
145            base.security_context.as_ref(),
146            base.pod_security_context.as_ref(),
147            self.security_context.as_ref(),
148            self.pod_security_context.as_ref(),
149        );
150        MoverSpec {
151            resources: merge_resources_opt(base.resources.as_ref(), self.resources.as_ref()),
152            cache: CacheDefaults::merge(base.cache.as_ref(), self.cache.as_ref()),
153            security_context,
154            pod_security_context,
155            privileged_mode: self.privileged_mode.or(base.privileged_mode),
156            // Whole-value: a higher layer that names its own inherit source replaces the
157            // lower layer's outright rather than blending two workload selectors into
158            // something neither manifest asked for.
159            inherit_security_context_from: self
160                .inherit_security_context_from
161                .clone()
162                .or_else(|| base.inherit_security_context_from.clone()),
163            ttl_seconds_after_finished: self
164                .ttl_seconds_after_finished
165                .or(base.ttl_seconds_after_finished),
166        }
167    }
168}
169
170/// How the mover co-locates with the node an RWO source/destination PVC is attached to.
171#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
172pub enum SourceColocationMode {
173    /// Pin to the attached node when discoverable, else schedule freely; the default.
174    #[default]
175    Auto,
176    /// Like `Auto`, but fail the run when an RWO PVC's node cannot be determined.
177    Required,
178    /// Never compute a node pin; use only the explicit `nodeSelector`/`affinity`/`tolerations`.
179    Disabled,
180}
181
182/// Controls mover/source-PVC node co-location (RWO Multi-Attach avoidance).
183#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
184#[serde(rename_all = "camelCase")]
185pub struct SourceColocation {
186    /// The co-location strategy. Defaults to [`SourceColocationMode::Auto`].
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub mode: Option<SourceColocationMode>,
189}
190
191/// Repository-wide mover defaults inherited by every mover, overridable per-recipe via `mover`.
192#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
193#[serde(rename_all = "camelCase")]
194pub struct MoverDefaults {
195    /// Container security-context base for every mover.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub security_context: Option<SecurityContext>,
198    /// Pod security-context base (notably `fsGroup`) for every mover.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub pod_security_context: Option<PodSecurityContext>,
201    /// Resource requests/limits base for the mover container.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub resources: Option<ResourceRequirements>,
204    /// kopia cache defaults for every mover.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub cache: Option<CacheDefaults>,
207    /// Defaults for the deep-verification scratch (restore-test) volume.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub scratch: Option<ScratchDefaults>,
210    /// Pod `nodeSelector` for every mover.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub node_selector: Option<BTreeMap<String, String>>,
213    /// Pod tolerations for every mover.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub tolerations: Option<Vec<Toleration>>,
216    /// Pod affinity for every mover.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub affinity: Option<Affinity>,
219    /// How a mover co-locates with its RWO PVC's node; defaults to [`SourceColocationMode::Auto`].
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub source_colocation: Option<SourceColocation>,
222    /// `Job.spec.ttlSecondsAfterFinished` for every mover Job so finished Jobs self-GC.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub ttl_seconds_after_finished: Option<i64>,
225    /// Repository throttle limits applied by every mover after it connects.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub throttle: Option<Throttle>,
228    /// Extra labels for every mover POD (and the `Job` that owns it), merged
229    /// UNDER kopiur's own labels — a key kopiur sets always wins, so a
230    /// user-supplied value can never break the selectors the controller counts
231    /// and reaps by.
232    ///
233    /// This is the hook for cluster machinery that keys off pod labels and that
234    /// kopiur has no field of its own for: a Kueue `kueue.x-k8s.io/queue-name` to
235    /// put movers under a cluster queue, a monitoring/`NetworkPolicy` selector, a
236    /// service-mesh exclusion label.
237    ///
238    /// Keys under `kopiur.home-operations.com/` and the exact key
239    /// `app.kubernetes.io/managed-by` are reserved and rejected at admission.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub pod_labels: Option<BTreeMap<String, String>>,
242    /// Extra annotations for every mover pod. Unlike `podLabels`
243    /// these are **pod-template-only** — they are not mirrored onto the `Job`
244    /// object, because the common case is a sidecar-injection opt-out
245    /// (`sidecar.istio.io/inject: "false"`, `linkerd.io/inject: disabled`,
246    /// `vault.hashicorp.com/agent-inject: "false"`) that only means anything on
247    /// the pod a mesh webhook actually sees. A mover is a short-lived batch pod;
248    /// an injected sidecar that never exits keeps its Job running forever.
249    ///
250    /// Same reserved keys as `podLabels`, rejected at admission.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub pod_annotations: Option<BTreeMap<String, String>>,
253}
254
255/// Built-in default `Job.spec.ttlSecondsAfterFinished` (1h) applied to a mover Job
256/// when neither `moverDefaults.ttlSecondsAfterFinished` nor the recipe's
257/// `mover.ttlSecondsAfterFinished` sets one, so finished backup/restore Jobs and
258/// their pods self-GC instead of lingering (ADR-0005 §12).
259pub const DEFAULT_JOB_TTL_SECONDS: i64 = 3600;
260
261/// Per-side throttle overrides for a `kopia snapshot migrate` run, which reads
262/// from a SOURCE repository and writes into a DESTINATION repository under two
263/// separate kopia connections.
264///
265/// `snapshot migrate` has **no speed flags of its own** — the only lever is
266/// `kopia repository throttle set` on each side's connection (kopia persists the
267/// limits in that connection's client config, and the migrate honors them when it
268/// reopens it). So a cap here is expressed per side, and each side overrides
269/// **that side's repository's** `moverDefaults.throttle` field by field: a field
270/// set here wins, a field left unset falls back to the repository default, and
271/// all four [`Throttle`] knobs are available on each side independently.
272///
273/// Not `Copy` (neither is [`Throttle`]).
274#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
275#[serde(rename_all = "camelCase")]
276pub struct MigrateThrottle {
277    /// Caps for the SOURCE (read) side, overriding the source repository's
278    /// `moverDefaults.throttle` field by field. Applied with `repository
279    /// throttle set` on the migrate's read-only source connection — accepted
280    /// there, so a read-only source is throttled like any other.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub source: Option<Throttle>,
283    /// Caps for the DESTINATION (write) side, overriding the destination
284    /// repository's `moverDefaults.throttle` field by field. Applied with
285    /// `repository throttle set` on the destination connection the migrate
286    /// writes through.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub destination: Option<Throttle>,
289}
290
291/// Repository-wide throttling for a mover's kopia connection; each `None` leaves kopia's current limit.
292#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
293#[serde(rename_all = "camelCase")]
294pub struct Throttle {
295    /// Cap upload throughput in bytes/sec (`--upload-bytes-per-second`).
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub upload_bytes_per_second: Option<i64>,
298    /// Cap download throughput in bytes/sec (`--download-bytes-per-second`).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub download_bytes_per_second: Option<i64>,
301    /// Cap read/list ops/sec (`--read-requests-per-second`).
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub read_ops_per_second: Option<i64>,
304    /// Cap write ops/sec (`--write-requests-per-second`).
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub write_ops_per_second: Option<i64>,
307}
308
309/// The fully-resolved mover configuration for a single run, after the 3-layer
310/// field-wise merge `hardened ⊂ repo.moverDefaults ⊂ recipe.mover` (ADR-0004 §1/§2).
311/// `security_context` is ALWAYS present (the hardened base guarantees it); the rest are
312/// `Some` only when some layer set them. The privileged-mover gate (§4.11/§G16) runs on
313/// `security_context`/`pod_security_context` *here* — the merged result — not on the raw
314/// recipe, so an elevation introduced by `moverDefaults` is still gated.
315#[derive(Clone, Debug, PartialEq)]
316pub struct ResolvedMover {
317    /// Merged container security context — always present (hardened base).
318    pub security_context: SecurityContext,
319    /// Merged pod security context, if any layer set one.
320    pub pod_security_context: Option<PodSecurityContext>,
321    /// Merged resource requirements, if any layer set them.
322    pub resources: Option<ResourceRequirements>,
323    /// Merged cache config, if any layer set it.
324    pub cache: Option<CacheDefaults>,
325    /// Pod node selector from `moverDefaults` (no per-recipe override surface today).
326    pub node_selector: Option<BTreeMap<String, String>>,
327    /// Pod tolerations from `moverDefaults`.
328    pub tolerations: Option<Vec<Toleration>>,
329    /// Pod affinity from `moverDefaults`.
330    pub affinity: Option<Affinity>,
331    /// Resolved RWO source/destination co-location mode (`moverDefaults.sourceColocation.mode`),
332    /// defaulting to [`SourceColocationMode::Auto`]. Always `Some` so the reconciler
333    /// has a concrete strategy. RWO multi-attach fix.
334    pub source_colocation: SourceColocationMode,
335    /// Resolved Job TTL (recipe `mover.ttlSecondsAfterFinished` wins over
336    /// `moverDefaults.ttlSecondsAfterFinished`, falling back to
337    /// [`DEFAULT_JOB_TTL_SECONDS`]). Always `Some` so finished Jobs self-GC. §12.
338    pub ttl_seconds_after_finished: Option<i64>,
339    /// Resolved repository throttle (`moverDefaults.throttle`), if any. §13(e).
340    pub throttle: Option<Throttle>,
341    /// Extra pod (and Job) labels from `moverDefaults.podLabels`, merged UNDER
342    /// kopiur's own labels by the caller — kopiur-managed keys always win.
343    pub pod_labels: Option<BTreeMap<String, String>>,
344    /// Extra pod-template annotations from `moverDefaults.podAnnotations`
345    /// (pod-only; never mirrored onto the `Job`).
346    pub pod_annotations: Option<BTreeMap<String, String>>,
347}
348
349/// Resolve the effective mover configuration via the layer merge
350/// `hardened ⊂ moverDefaults ⊂ recipe` (ADR-0004 §1/§2).
351///
352/// - `defaults`: the repository's `moverDefaults` (None when the repo sets none).
353/// - `recipe_sc`/`recipe_psc`: the recipe's **effective** container/pod context, which the
354///   controller has already resolved — the explicit `mover.securityContext`/
355///   `podSecurityContext` overlaid on top of any context inherited from a workload via
356///   `inheritSecurityContextFrom` (they combine; explicit wins). The full ladder is
357///   therefore `hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit`. Layers merge as
358///   `(container, pod)` **pairs** via [`merge_context_pair`]: field-wise per dimension,
359///   plus identity promotion so the effective UID/GID belongs to the **highest layer that
360///   pins one, regardless of which dimension it wrote** — a `moverDefaults` container-level
361///   `runAsUser` can never shadow an inherited pod-level one. The pair merge is
362///   associative, so folding the inner two layers before this call is identical to a flat
363///   four-layer merge. The recipe layer enters here as a *layer*, NOT a whole-chain
364///   replacement — the hardened base + `moverDefaults` still supply `drop:[ALL]`/seccomp
365///   and a partial recipe context can only tighten.
366/// - `recipe_resources`/`recipe_cache`: from `mover.resources` / `mover.cache`.
367///
368/// `node_selector`/`tolerations`/`affinity`/`pod_labels`/`pod_annotations`/`ttl` flow from
369/// `moverDefaults` (no per-recipe surface for the first five today; TTL is overridable by
370/// the caller post-resolve).
371pub fn resolve_mover(
372    defaults: Option<&MoverDefaults>,
373    recipe_sc: Option<&SecurityContext>,
374    recipe_psc: Option<&PodSecurityContext>,
375    recipe_resources: Option<&ResourceRequirements>,
376    recipe_cache: Option<&CacheDefaults>,
377    recipe_ttl_seconds_after_finished: Option<i64>,
378) -> ResolvedMover {
379    // The hardened pair is the lowest layer: the container hardening plus the pod-level
380    // fsGroup that makes the cache writable. Both are always present, so every mover pod
381    // — bootstrap, backup, restore, maintenance, verification, replication — carries the
382    // hardened defaults unless a higher layer overrides them.
383    let hardened_sc = hardened_security_context();
384    let hardened_psc = hardened_pod_security_context();
385    // hardened ⊂ moverDefaults, as one (container, pod) layer pair.
386    let (base_sc, base_psc) = merge_context_pair(
387        Some(&hardened_sc),
388        Some(&hardened_psc),
389        defaults.and_then(|d| d.security_context.as_ref()),
390        defaults.and_then(|d| d.pod_security_context.as_ref()),
391    );
392    // (hardened ⊂ moverDefaults) ⊂ recipe.
393    let (security_context, pod_security_context) =
394        merge_context_pair(base_sc.as_ref(), base_psc.as_ref(), recipe_sc, recipe_psc);
395    let security_context =
396        security_context.expect("the hardened container base layer is always present");
397    // Normalize the merged result against every kubelet/apiserver security-context invariant
398    // (see `crate::invariants`) so a contradiction the field-wise merge can assemble — most
399    // importantly an inherited-root `runAsUser: 0` left under the hardened `runAsNonRoot:
400    // true` — becomes a VALID (privileged-gated) mover rather than a pod wedged forever in
401    // `CreateContainerConfigError`.
402    let (security_context, pod_security_context) =
403        crate::invariants::enforce_security_context_invariants(
404            security_context,
405            pod_security_context,
406        );
407    ResolvedMover {
408        security_context,
409        pod_security_context,
410        resources: merge_resources_opt(
411            defaults.and_then(|d| d.resources.as_ref()),
412            recipe_resources,
413        ),
414        cache: CacheDefaults::merge(defaults.and_then(|d| d.cache.as_ref()), recipe_cache),
415        node_selector: defaults.and_then(|d| d.node_selector.clone()),
416        tolerations: defaults.and_then(|d| d.tolerations.clone()),
417        affinity: defaults.and_then(|d| d.affinity.clone()),
418        // `moverDefaults.sourceColocation.mode`, defaulting to `Auto` so RWO movers
419        // co-locate with their source PVC's node out of the box (RWO multi-attach fix).
420        source_colocation: defaults
421            .and_then(|d| d.source_colocation.as_ref())
422            .and_then(|c| c.mode)
423            .unwrap_or_default(),
424        // Recipe TTL wins over the repo default; a built-in default applies when
425        // neither sets one so every finished Job self-GCs (ADR-0005 §12).
426        ttl_seconds_after_finished: Some(
427            recipe_ttl_seconds_after_finished
428                .or_else(|| defaults.and_then(|d| d.ttl_seconds_after_finished))
429                .unwrap_or(DEFAULT_JOB_TTL_SECONDS),
430        ),
431        throttle: defaults.and_then(|d| d.throttle.clone()),
432        pod_labels: defaults.and_then(|d| d.pod_labels.clone()),
433        pod_annotations: defaults.and_then(|d| d.pod_annotations.clone()),
434    }
435}
436
437impl ResolvedMover {
438    /// Apply `privilegedMode` for `op` to this resolved mover (#552): a root mover gets the
439    /// operation's capabilities added on top of `drop: [ALL]`; the UID is never changed.
440    /// See [`apply_privileged_mode`] for the contract. Re-runs the (idempotent)
441    /// security-context invariants afterwards. A no-op unless `privileged_mode ==
442    /// Some(true)` and the effective UID is `0`.
443    pub fn with_privileged_mode(self, privileged_mode: Option<bool>, op: MoverOperation) -> Self {
444        self.with_privileged_mode_effect(privileged_mode, op).0
445    }
446
447    /// [`Self::with_privileged_mode`], also returning what was done, so a caller can
448    /// surface an inert grant ([`PrivilegedModeEffect::warning`]) to the user.
449    pub fn with_privileged_mode_effect(
450        self,
451        privileged_mode: Option<bool>,
452        op: MoverOperation,
453    ) -> (Self, PrivilegedModeEffect) {
454        let (sc, psc, effect) = apply_privileged_mode(
455            self.security_context,
456            self.pod_security_context,
457            privileged_mode,
458            op,
459        );
460        let (security_context, pod_security_context) =
461            crate::invariants::enforce_security_context_invariants(sc, psc);
462        (
463            Self {
464                security_context,
465                pod_security_context,
466                ..self
467            },
468            effect,
469        )
470    }
471}
472
473/// Selects workload pods by label.
474#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
475#[serde(rename_all = "camelCase")]
476pub struct PodSelector {
477    /// Label selector matching the workload pod(s) to read context/hooks from.
478    pub pod_selector: LabelSelector,
479    /// Which container within the matched pod; absent uses the first/only container.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub container: Option<String>,
482}
483
484/// Where the mover copies its security context from instead of an explicit context.
485#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
486#[serde(rename_all = "camelCase")]
487pub enum InheritSecurityContextFrom {
488    /// Inherit from workload pod(s) matched by an explicit label selector (backup or restore).
489    WorkloadSelector(PodSelector),
490    /// Backup sources only: auto-derive the workload from the PVC this snapshot backs up.
491    PvcConsumer(PvcConsumerInherit),
492    /// Restores only: inherit the identity RECORDED on the backup itself
493    /// (`Snapshot.status.recorded`, decoded from the `kopiur-meta` kopia tag) —
494    /// uid/gid/fsGroup the backup mover actually ran as. Needs no live workload
495    /// pod, so it works on a rebuilt cluster and with `target.populator`.
496    /// Rejected at admission on SnapshotPolicy/Maintenance (backups read the
497    /// live workload; maintenance has no snapshot). Write it as `snapshot: {}`
498    /// (an empty sub-object) — a bare `snapshot:` is null and rejected.
499    Snapshot(SnapshotInherit),
500}
501
502/// Tuning for [`InheritSecurityContextFrom::Snapshot`]. Empty today; a
503/// sub-object (like [`PopulatorTarget`](crate::restore::PopulatorTarget)) so
504/// future knobs slot in without API breakage.
505#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
506#[serde(rename_all = "camelCase")]
507pub struct SnapshotInherit {}
508
509/// Tuning for [`InheritSecurityContextFrom::PvcConsumer`].
510#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
511#[serde(rename_all = "camelCase")]
512pub struct PvcConsumerInherit {
513    /// Which container within the matched consumer pod to inherit from; absent uses the first/only.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub container: Option<String>,
516}