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; preserves UID/GID on restore.
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub privileged_mode: Option<bool>,
35 /// Copy the UID/GID security context from a live workload rather than hard-coding it.
36 ///
37 /// Requires the workload to pin `runAsUser` (container or pod level): a UID that comes
38 /// from the container image's `USER` line is invisible in the pod spec and cannot be
39 /// inherited — the mover would silently run as its own image's UID instead.
40 ///
41 /// May be combined with `securityContext`/`podSecurityContext`, which override it
42 /// field-wise and act as the fallback when no workload pod can be resolved.
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub inherit_security_context_from: Option<InheritSecurityContextFrom>,
45 /// Per-recipe override of `Job.spec.ttlSecondsAfterFinished` so finished Jobs self-GC.
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub ttl_seconds_after_finished: Option<i64>,
48}
49
50impl MoverSpec {
51 /// Whether this mover requests **elevated privileges** that the workload
52 /// namespace must explicitly opt into (ADR §4.11/§G16). True when
53 /// `privilegedMode` is set, or the `securityContext` runs as root / privileged
54 /// / with escalation / with added Linux capabilities.
55 ///
56 /// The rationale is the same as VolSync's `privileged-movers` model: the
57 /// controller mints a mover `ServiceAccount` in the workload namespace, and a
58 /// tenant with access there could reuse it to run pods at the mover's privilege.
59 /// Granting an elevated mover is therefore a per-namespace admin decision, gated
60 /// by a namespace annotation rather than allowed implicitly. Pure + exhaustive
61 /// so the definition of "privileged" lives in one tested place.
62 pub fn requires_privilege(&self) -> bool {
63 requires_privilege_resolved(
64 self.security_context.as_ref(),
65 self.pod_security_context.as_ref(),
66 self.privileged_mode,
67 )
68 }
69}
70
71/// How the mover co-locates with the node an RWO source/destination PVC is attached to.
72#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
73pub enum SourceColocationMode {
74 /// Pin to the attached node when discoverable, else schedule freely; the default.
75 #[default]
76 Auto,
77 /// Like `Auto`, but fail the run when an RWO PVC's node cannot be determined.
78 Required,
79 /// Never compute a node pin; use only the explicit `nodeSelector`/`affinity`/`tolerations`.
80 Disabled,
81}
82
83/// Controls mover/source-PVC node co-location (RWO Multi-Attach avoidance).
84#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct SourceColocation {
87 /// The co-location strategy. Defaults to [`SourceColocationMode::Auto`].
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub mode: Option<SourceColocationMode>,
90}
91
92/// Repository-wide mover defaults inherited by every mover, overridable per-recipe via `mover`.
93#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
94#[serde(rename_all = "camelCase")]
95pub struct MoverDefaults {
96 /// Container security-context base for every mover.
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub security_context: Option<SecurityContext>,
99 /// Pod security-context base (notably `fsGroup`) for every mover.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub pod_security_context: Option<PodSecurityContext>,
102 /// Resource requests/limits base for the mover container.
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub resources: Option<ResourceRequirements>,
105 /// kopia cache defaults for every mover.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub cache: Option<CacheDefaults>,
108 /// Defaults for the deep-verification scratch (restore-test) volume.
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub scratch: Option<ScratchDefaults>,
111 /// Pod `nodeSelector` for every mover.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub node_selector: Option<BTreeMap<String, String>>,
114 /// Pod tolerations for every mover.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub tolerations: Option<Vec<Toleration>>,
117 /// Pod affinity for every mover.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub affinity: Option<Affinity>,
120 /// How a mover co-locates with its RWO PVC's node; defaults to [`SourceColocationMode::Auto`].
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub source_colocation: Option<SourceColocation>,
123 /// `Job.spec.ttlSecondsAfterFinished` for every mover Job so finished Jobs self-GC.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub ttl_seconds_after_finished: Option<i64>,
126 /// Repository throttle limits applied by every mover after it connects.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub throttle: Option<Throttle>,
129}
130
131/// Built-in default `Job.spec.ttlSecondsAfterFinished` (1h) applied to a mover Job
132/// when neither `moverDefaults.ttlSecondsAfterFinished` nor the recipe's
133/// `mover.ttlSecondsAfterFinished` sets one, so finished backup/restore Jobs and
134/// their pods self-GC instead of lingering (ADR-0005 §12).
135pub const DEFAULT_JOB_TTL_SECONDS: i64 = 3600;
136
137/// Repository-wide throttling for a mover's kopia connection; each `None` leaves kopia's current limit.
138#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
139#[serde(rename_all = "camelCase")]
140pub struct Throttle {
141 /// Cap upload throughput in bytes/sec (`--upload-bytes-per-second`).
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub upload_bytes_per_second: Option<i64>,
144 /// Cap download throughput in bytes/sec (`--download-bytes-per-second`).
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub download_bytes_per_second: Option<i64>,
147 /// Cap read/list ops/sec (`--read-requests-per-second`).
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub read_ops_per_second: Option<i64>,
150 /// Cap write ops/sec (`--write-requests-per-second`).
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub write_ops_per_second: Option<i64>,
153}
154
155/// The fully-resolved mover configuration for a single run, after the 3-layer
156/// field-wise merge `hardened ⊂ repo.moverDefaults ⊂ recipe.mover` (ADR-0004 §1/§2).
157/// `security_context` is ALWAYS present (the hardened base guarantees it); the rest are
158/// `Some` only when some layer set them. The privileged-mover gate (§4.11/§G16) runs on
159/// `security_context`/`pod_security_context` *here* — the merged result — not on the raw
160/// recipe, so an elevation introduced by `moverDefaults` is still gated.
161pub struct ResolvedMover {
162 /// Merged container security context — always present (hardened base).
163 pub security_context: SecurityContext,
164 /// Merged pod security context, if any layer set one.
165 pub pod_security_context: Option<PodSecurityContext>,
166 /// Merged resource requirements, if any layer set them.
167 pub resources: Option<ResourceRequirements>,
168 /// Merged cache config, if any layer set it.
169 pub cache: Option<CacheDefaults>,
170 /// Pod node selector from `moverDefaults` (no per-recipe override surface today).
171 pub node_selector: Option<BTreeMap<String, String>>,
172 /// Pod tolerations from `moverDefaults`.
173 pub tolerations: Option<Vec<Toleration>>,
174 /// Pod affinity from `moverDefaults`.
175 pub affinity: Option<Affinity>,
176 /// Resolved RWO source/destination co-location mode (`moverDefaults.sourceColocation.mode`),
177 /// defaulting to [`SourceColocationMode::Auto`]. Always `Some` so the reconciler
178 /// has a concrete strategy. RWO multi-attach fix.
179 pub source_colocation: SourceColocationMode,
180 /// Resolved Job TTL (recipe `mover.ttlSecondsAfterFinished` wins over
181 /// `moverDefaults.ttlSecondsAfterFinished`, falling back to
182 /// [`DEFAULT_JOB_TTL_SECONDS`]). Always `Some` so finished Jobs self-GC. §12.
183 pub ttl_seconds_after_finished: Option<i64>,
184 /// Resolved repository throttle (`moverDefaults.throttle`), if any. §13(e).
185 pub throttle: Option<Throttle>,
186}
187
188/// Resolve the effective mover configuration via the layer merge
189/// `hardened ⊂ moverDefaults ⊂ recipe` (ADR-0004 §1/§2).
190///
191/// - `defaults`: the repository's `moverDefaults` (None when the repo sets none).
192/// - `recipe_sc`/`recipe_psc`: the recipe's **effective** container/pod context, which the
193/// controller has already resolved — the explicit `mover.securityContext`/
194/// `podSecurityContext` overlaid on top of any context inherited from a workload via
195/// `inheritSecurityContextFrom` (they combine; explicit wins). The full ladder is
196/// therefore `hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit`. Layers merge as
197/// `(container, pod)` **pairs** via [`merge_context_pair`]: field-wise per dimension,
198/// plus identity promotion so the effective UID/GID belongs to the **highest layer that
199/// pins one, regardless of which dimension it wrote** — a `moverDefaults` container-level
200/// `runAsUser` can never shadow an inherited pod-level one. The pair merge is
201/// associative, so folding the inner two layers before this call is identical to a flat
202/// four-layer merge. The recipe layer enters here as a *layer*, NOT a whole-chain
203/// replacement — the hardened base + `moverDefaults` still supply `drop:[ALL]`/seccomp
204/// and a partial recipe context can only tighten.
205/// - `recipe_resources`/`recipe_cache`: from `mover.resources` / `mover.cache`.
206///
207/// `node_selector`/`tolerations`/`affinity`/`ttl` flow from `moverDefaults` (no per-recipe
208/// surface for the first three today; TTL is overridable by the caller post-resolve).
209pub fn resolve_mover(
210 defaults: Option<&MoverDefaults>,
211 recipe_sc: Option<&SecurityContext>,
212 recipe_psc: Option<&PodSecurityContext>,
213 recipe_resources: Option<&ResourceRequirements>,
214 recipe_cache: Option<&CacheDefaults>,
215 recipe_ttl_seconds_after_finished: Option<i64>,
216) -> ResolvedMover {
217 // The hardened pair is the lowest layer: the container hardening plus the pod-level
218 // fsGroup that makes the cache writable. Both are always present, so every mover pod
219 // — bootstrap, backup, restore, maintenance, verification, replication — carries the
220 // hardened defaults unless a higher layer overrides them.
221 let hardened_sc = hardened_security_context();
222 let hardened_psc = hardened_pod_security_context();
223 // hardened ⊂ moverDefaults, as one (container, pod) layer pair.
224 let (base_sc, base_psc) = merge_context_pair(
225 Some(&hardened_sc),
226 Some(&hardened_psc),
227 defaults.and_then(|d| d.security_context.as_ref()),
228 defaults.and_then(|d| d.pod_security_context.as_ref()),
229 );
230 // (hardened ⊂ moverDefaults) ⊂ recipe.
231 let (security_context, pod_security_context) =
232 merge_context_pair(base_sc.as_ref(), base_psc.as_ref(), recipe_sc, recipe_psc);
233 let security_context =
234 security_context.expect("the hardened container base layer is always present");
235 // Normalize the merged result against every kubelet/apiserver security-context invariant
236 // (see `crate::invariants`) so a contradiction the field-wise merge can assemble — most
237 // importantly an inherited-root `runAsUser: 0` left under the hardened `runAsNonRoot:
238 // true` — becomes a VALID (privileged-gated) mover rather than a pod wedged forever in
239 // `CreateContainerConfigError`.
240 let (security_context, pod_security_context) =
241 crate::invariants::enforce_security_context_invariants(
242 security_context,
243 pod_security_context,
244 );
245 ResolvedMover {
246 security_context,
247 pod_security_context,
248 resources: merge_resources_opt(
249 defaults.and_then(|d| d.resources.as_ref()),
250 recipe_resources,
251 ),
252 cache: CacheDefaults::merge(defaults.and_then(|d| d.cache.as_ref()), recipe_cache),
253 node_selector: defaults.and_then(|d| d.node_selector.clone()),
254 tolerations: defaults.and_then(|d| d.tolerations.clone()),
255 affinity: defaults.and_then(|d| d.affinity.clone()),
256 // `moverDefaults.sourceColocation.mode`, defaulting to `Auto` so RWO movers
257 // co-locate with their source PVC's node out of the box (RWO multi-attach fix).
258 source_colocation: defaults
259 .and_then(|d| d.source_colocation.as_ref())
260 .and_then(|c| c.mode)
261 .unwrap_or_default(),
262 // Recipe TTL wins over the repo default; a built-in default applies when
263 // neither sets one so every finished Job self-GCs (ADR-0005 §12).
264 ttl_seconds_after_finished: Some(
265 recipe_ttl_seconds_after_finished
266 .or_else(|| defaults.and_then(|d| d.ttl_seconds_after_finished))
267 .unwrap_or(DEFAULT_JOB_TTL_SECONDS),
268 ),
269 throttle: defaults.and_then(|d| d.throttle.clone()),
270 }
271}
272
273/// Selects workload pods by label.
274#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
275#[serde(rename_all = "camelCase")]
276pub struct PodSelector {
277 /// Label selector matching the workload pod(s) to read context/hooks from.
278 pub pod_selector: LabelSelector,
279 /// Which container within the matched pod; absent uses the first/only container.
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub container: Option<String>,
282}
283
284/// Where the mover copies its security context from instead of an explicit context.
285#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
286#[serde(rename_all = "camelCase")]
287pub enum InheritSecurityContextFrom {
288 /// Inherit from workload pod(s) matched by an explicit label selector (backup or restore).
289 WorkloadSelector(PodSelector),
290 /// Backup sources only: auto-derive the workload from the PVC this snapshot backs up.
291 PvcConsumer(PvcConsumerInherit),
292 /// Restores only: inherit the identity RECORDED on the backup itself
293 /// (`Snapshot.status.recorded`, decoded from the `kopiur-meta` kopia tag) —
294 /// uid/gid/fsGroup the backup mover actually ran as. Needs no live workload
295 /// pod, so it works on a rebuilt cluster and with `target.populator`.
296 /// Rejected at admission on SnapshotPolicy/Maintenance (backups read the
297 /// live workload; maintenance has no snapshot). Write it as `snapshot: {}`
298 /// (an empty sub-object) — a bare `snapshot:` is null and rejected.
299 Snapshot(SnapshotInherit),
300}
301
302/// Tuning for [`InheritSecurityContextFrom::Snapshot`]. Empty today; a
303/// sub-object (like [`PopulatorTarget`](crate::restore::PopulatorTarget)) so
304/// future knobs slot in without API breakage.
305#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
306#[serde(rename_all = "camelCase")]
307pub struct SnapshotInherit {}
308
309/// Tuning for [`InheritSecurityContextFrom::PvcConsumer`].
310#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
311#[serde(rename_all = "camelCase")]
312pub struct PvcConsumerInherit {
313 /// Which container within the matched consumer pod to inherit from; absent uses the first/only.
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub container: Option<String>,
316}