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; 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}