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