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}