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 /// Extra labels for every mover POD (and the `Job` that owns it), merged
130 /// UNDER kopiur's own labels — a key kopiur sets always wins, so a
131 /// user-supplied value can never break the selectors the controller counts
132 /// and reaps by.
133 ///
134 /// This is the hook for cluster machinery that keys off pod labels and that
135 /// kopiur has no field of its own for: a Kueue `kueue.x-k8s.io/queue-name` to
136 /// put movers under a cluster queue, a monitoring/`NetworkPolicy` selector, a
137 /// service-mesh exclusion label.
138 ///
139 /// Keys under `kopiur.home-operations.com/` and the exact key
140 /// `app.kubernetes.io/managed-by` are reserved and rejected at admission.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub pod_labels: Option<BTreeMap<String, String>>,
143 /// Extra annotations for every mover pod. Unlike `podLabels`
144 /// these are **pod-template-only** — they are not mirrored onto the `Job`
145 /// object, because the common case is a sidecar-injection opt-out
146 /// (`sidecar.istio.io/inject: "false"`, `linkerd.io/inject: disabled`,
147 /// `vault.hashicorp.com/agent-inject: "false"`) that only means anything on
148 /// the pod a mesh webhook actually sees. A mover is a short-lived batch pod;
149 /// an injected sidecar that never exits keeps its Job running forever.
150 ///
151 /// Same reserved keys as `podLabels`, rejected at admission.
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub pod_annotations: Option<BTreeMap<String, String>>,
154}
155
156/// Built-in default `Job.spec.ttlSecondsAfterFinished` (1h) applied to a mover Job
157/// when neither `moverDefaults.ttlSecondsAfterFinished` nor the recipe's
158/// `mover.ttlSecondsAfterFinished` sets one, so finished backup/restore Jobs and
159/// their pods self-GC instead of lingering (ADR-0005 §12).
160pub const DEFAULT_JOB_TTL_SECONDS: i64 = 3600;
161
162/// Per-side throttle overrides for a `kopia snapshot migrate` run, which reads
163/// from a SOURCE repository and writes into a DESTINATION repository under two
164/// separate kopia connections.
165///
166/// `snapshot migrate` has **no speed flags of its own** — the only lever is
167/// `kopia repository throttle set` on each side's connection (kopia persists the
168/// limits in that connection's client config, and the migrate honors them when it
169/// reopens it). So a cap here is expressed per side, and each side overrides
170/// **that side's repository's** `moverDefaults.throttle` field by field: a field
171/// set here wins, a field left unset falls back to the repository default, and
172/// all four [`Throttle`] knobs are available on each side independently.
173///
174/// Not `Copy` (neither is [`Throttle`]).
175#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
176#[serde(rename_all = "camelCase")]
177pub struct MigrateThrottle {
178 /// Caps for the SOURCE (read) side, overriding the source repository's
179 /// `moverDefaults.throttle` field by field. Applied with `repository
180 /// throttle set` on the migrate's read-only source connection — accepted
181 /// there, so a read-only source is throttled like any other.
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub source: Option<Throttle>,
184 /// Caps for the DESTINATION (write) side, overriding the destination
185 /// repository's `moverDefaults.throttle` field by field. Applied with
186 /// `repository throttle set` on the destination connection the migrate
187 /// writes through.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub destination: Option<Throttle>,
190}
191
192/// Repository-wide throttling for a mover's kopia connection; each `None` leaves kopia's current limit.
193#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
194#[serde(rename_all = "camelCase")]
195pub struct Throttle {
196 /// Cap upload throughput in bytes/sec (`--upload-bytes-per-second`).
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub upload_bytes_per_second: Option<i64>,
199 /// Cap download throughput in bytes/sec (`--download-bytes-per-second`).
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub download_bytes_per_second: Option<i64>,
202 /// Cap read/list ops/sec (`--read-requests-per-second`).
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub read_ops_per_second: Option<i64>,
205 /// Cap write ops/sec (`--write-requests-per-second`).
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub write_ops_per_second: Option<i64>,
208}
209
210/// The fully-resolved mover configuration for a single run, after the 3-layer
211/// field-wise merge `hardened ⊂ repo.moverDefaults ⊂ recipe.mover` (ADR-0004 §1/§2).
212/// `security_context` is ALWAYS present (the hardened base guarantees it); the rest are
213/// `Some` only when some layer set them. The privileged-mover gate (§4.11/§G16) runs on
214/// `security_context`/`pod_security_context` *here* — the merged result — not on the raw
215/// recipe, so an elevation introduced by `moverDefaults` is still gated.
216pub struct ResolvedMover {
217 /// Merged container security context — always present (hardened base).
218 pub security_context: SecurityContext,
219 /// Merged pod security context, if any layer set one.
220 pub pod_security_context: Option<PodSecurityContext>,
221 /// Merged resource requirements, if any layer set them.
222 pub resources: Option<ResourceRequirements>,
223 /// Merged cache config, if any layer set it.
224 pub cache: Option<CacheDefaults>,
225 /// Pod node selector from `moverDefaults` (no per-recipe override surface today).
226 pub node_selector: Option<BTreeMap<String, String>>,
227 /// Pod tolerations from `moverDefaults`.
228 pub tolerations: Option<Vec<Toleration>>,
229 /// Pod affinity from `moverDefaults`.
230 pub affinity: Option<Affinity>,
231 /// Resolved RWO source/destination co-location mode (`moverDefaults.sourceColocation.mode`),
232 /// defaulting to [`SourceColocationMode::Auto`]. Always `Some` so the reconciler
233 /// has a concrete strategy. RWO multi-attach fix.
234 pub source_colocation: SourceColocationMode,
235 /// Resolved Job TTL (recipe `mover.ttlSecondsAfterFinished` wins over
236 /// `moverDefaults.ttlSecondsAfterFinished`, falling back to
237 /// [`DEFAULT_JOB_TTL_SECONDS`]). Always `Some` so finished Jobs self-GC. §12.
238 pub ttl_seconds_after_finished: Option<i64>,
239 /// Resolved repository throttle (`moverDefaults.throttle`), if any. §13(e).
240 pub throttle: Option<Throttle>,
241 /// Extra pod (and Job) labels from `moverDefaults.podLabels`, merged UNDER
242 /// kopiur's own labels by the caller — kopiur-managed keys always win.
243 pub pod_labels: Option<BTreeMap<String, String>>,
244 /// Extra pod-template annotations from `moverDefaults.podAnnotations`
245 /// (pod-only; never mirrored onto the `Job`).
246 pub pod_annotations: Option<BTreeMap<String, String>>,
247}
248
249/// Resolve the effective mover configuration via the layer merge
250/// `hardened ⊂ moverDefaults ⊂ recipe` (ADR-0004 §1/§2).
251///
252/// - `defaults`: the repository's `moverDefaults` (None when the repo sets none).
253/// - `recipe_sc`/`recipe_psc`: the recipe's **effective** container/pod context, which the
254/// controller has already resolved — the explicit `mover.securityContext`/
255/// `podSecurityContext` overlaid on top of any context inherited from a workload via
256/// `inheritSecurityContextFrom` (they combine; explicit wins). The full ladder is
257/// therefore `hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit`. Layers merge as
258/// `(container, pod)` **pairs** via [`merge_context_pair`]: field-wise per dimension,
259/// plus identity promotion so the effective UID/GID belongs to the **highest layer that
260/// pins one, regardless of which dimension it wrote** — a `moverDefaults` container-level
261/// `runAsUser` can never shadow an inherited pod-level one. The pair merge is
262/// associative, so folding the inner two layers before this call is identical to a flat
263/// four-layer merge. The recipe layer enters here as a *layer*, NOT a whole-chain
264/// replacement — the hardened base + `moverDefaults` still supply `drop:[ALL]`/seccomp
265/// and a partial recipe context can only tighten.
266/// - `recipe_resources`/`recipe_cache`: from `mover.resources` / `mover.cache`.
267///
268/// `node_selector`/`tolerations`/`affinity`/`pod_labels`/`pod_annotations`/`ttl` flow from
269/// `moverDefaults` (no per-recipe surface for the first five today; TTL is overridable by
270/// the caller post-resolve).
271pub fn resolve_mover(
272 defaults: Option<&MoverDefaults>,
273 recipe_sc: Option<&SecurityContext>,
274 recipe_psc: Option<&PodSecurityContext>,
275 recipe_resources: Option<&ResourceRequirements>,
276 recipe_cache: Option<&CacheDefaults>,
277 recipe_ttl_seconds_after_finished: Option<i64>,
278) -> ResolvedMover {
279 // The hardened pair is the lowest layer: the container hardening plus the pod-level
280 // fsGroup that makes the cache writable. Both are always present, so every mover pod
281 // — bootstrap, backup, restore, maintenance, verification, replication — carries the
282 // hardened defaults unless a higher layer overrides them.
283 let hardened_sc = hardened_security_context();
284 let hardened_psc = hardened_pod_security_context();
285 // hardened ⊂ moverDefaults, as one (container, pod) layer pair.
286 let (base_sc, base_psc) = merge_context_pair(
287 Some(&hardened_sc),
288 Some(&hardened_psc),
289 defaults.and_then(|d| d.security_context.as_ref()),
290 defaults.and_then(|d| d.pod_security_context.as_ref()),
291 );
292 // (hardened ⊂ moverDefaults) ⊂ recipe.
293 let (security_context, pod_security_context) =
294 merge_context_pair(base_sc.as_ref(), base_psc.as_ref(), recipe_sc, recipe_psc);
295 let security_context =
296 security_context.expect("the hardened container base layer is always present");
297 // Normalize the merged result against every kubelet/apiserver security-context invariant
298 // (see `crate::invariants`) so a contradiction the field-wise merge can assemble — most
299 // importantly an inherited-root `runAsUser: 0` left under the hardened `runAsNonRoot:
300 // true` — becomes a VALID (privileged-gated) mover rather than a pod wedged forever in
301 // `CreateContainerConfigError`.
302 let (security_context, pod_security_context) =
303 crate::invariants::enforce_security_context_invariants(
304 security_context,
305 pod_security_context,
306 );
307 ResolvedMover {
308 security_context,
309 pod_security_context,
310 resources: merge_resources_opt(
311 defaults.and_then(|d| d.resources.as_ref()),
312 recipe_resources,
313 ),
314 cache: CacheDefaults::merge(defaults.and_then(|d| d.cache.as_ref()), recipe_cache),
315 node_selector: defaults.and_then(|d| d.node_selector.clone()),
316 tolerations: defaults.and_then(|d| d.tolerations.clone()),
317 affinity: defaults.and_then(|d| d.affinity.clone()),
318 // `moverDefaults.sourceColocation.mode`, defaulting to `Auto` so RWO movers
319 // co-locate with their source PVC's node out of the box (RWO multi-attach fix).
320 source_colocation: defaults
321 .and_then(|d| d.source_colocation.as_ref())
322 .and_then(|c| c.mode)
323 .unwrap_or_default(),
324 // Recipe TTL wins over the repo default; a built-in default applies when
325 // neither sets one so every finished Job self-GCs (ADR-0005 §12).
326 ttl_seconds_after_finished: Some(
327 recipe_ttl_seconds_after_finished
328 .or_else(|| defaults.and_then(|d| d.ttl_seconds_after_finished))
329 .unwrap_or(DEFAULT_JOB_TTL_SECONDS),
330 ),
331 throttle: defaults.and_then(|d| d.throttle.clone()),
332 pod_labels: defaults.and_then(|d| d.pod_labels.clone()),
333 pod_annotations: defaults.and_then(|d| d.pod_annotations.clone()),
334 }
335}
336
337/// Selects workload pods by label.
338#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
339#[serde(rename_all = "camelCase")]
340pub struct PodSelector {
341 /// Label selector matching the workload pod(s) to read context/hooks from.
342 pub pod_selector: LabelSelector,
343 /// Which container within the matched pod; absent uses the first/only container.
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub container: Option<String>,
346}
347
348/// Where the mover copies its security context from instead of an explicit context.
349#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
350#[serde(rename_all = "camelCase")]
351pub enum InheritSecurityContextFrom {
352 /// Inherit from workload pod(s) matched by an explicit label selector (backup or restore).
353 WorkloadSelector(PodSelector),
354 /// Backup sources only: auto-derive the workload from the PVC this snapshot backs up.
355 PvcConsumer(PvcConsumerInherit),
356 /// Restores only: inherit the identity RECORDED on the backup itself
357 /// (`Snapshot.status.recorded`, decoded from the `kopiur-meta` kopia tag) —
358 /// uid/gid/fsGroup the backup mover actually ran as. Needs no live workload
359 /// pod, so it works on a rebuilt cluster and with `target.populator`.
360 /// Rejected at admission on SnapshotPolicy/Maintenance (backups read the
361 /// live workload; maintenance has no snapshot). Write it as `snapshot: {}`
362 /// (an empty sub-object) — a bare `snapshot:` is null and rejected.
363 Snapshot(SnapshotInherit),
364}
365
366/// Tuning for [`InheritSecurityContextFrom::Snapshot`]. Empty today; a
367/// sub-object (like [`PopulatorTarget`](crate::restore::PopulatorTarget)) so
368/// future knobs slot in without API breakage.
369#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
370#[serde(rename_all = "camelCase")]
371pub struct SnapshotInherit {}
372
373/// Tuning for [`InheritSecurityContextFrom::PvcConsumer`].
374#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
375#[serde(rename_all = "camelCase")]
376pub struct PvcConsumerInherit {
377 /// Which container within the matched consumer pod to inherit from; absent uses the first/only.
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub container: Option<String>,
380}