kopiur_api/validate/backend.rs
1use super::*;
2use crate::error::{ValidationError, ValidationResult};
3
4/// A DNS-1123 subdomain (the shape of every Kubernetes object name): non-empty,
5/// ≤253 chars, lowercase alphanumerics / `-` / `.`, starting and ending
6/// alphanumeric. The structural schema can't express it, so the webhook does.
7/// `field` names where the value appears, for an actionable message.
8pub fn validate_dns1123_name(value: &str, field: &str) -> ValidationResult {
9 if value.is_empty() {
10 return Err(ValidationError::MissingRequiredField {
11 field: field.to_string(),
12 });
13 }
14 let valid_len = value.len() <= 253;
15 let valid_chars = value
16 .chars()
17 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.');
18 let valid_edges = value.starts_with(|c: char| c.is_ascii_alphanumeric())
19 && value.ends_with(|c: char| c.is_ascii_alphanumeric());
20 if valid_len && valid_chars && valid_edges {
21 Ok(())
22 } else {
23 Err(ValidationError::InvalidFieldValue {
24 field: field.to_string(),
25 reason: format!(
26 "must be a DNS-1123 subdomain: lowercase letters, digits, '-' or '.', \
27 starting and ending with a letter or digit, at most 253 characters \
28 (got {value:?})"
29 ),
30 })
31 }
32}
33
34/// A cloud-IAM backend's `auth` block is well-formed: **exactly one** of
35/// `secretRef` or `workloadIdentity` when `auth` is present (both are `Option`
36/// because the forms share the `auth` key, so it's a webhook check — the same
37/// shape as [`validate_source`]). An absent/empty `auth` is legal: the
38/// well-known keys may ride the encryption-password Secret, and an empty block
39/// means exactly that. A workload-identity `serviceAccountName` must be a valid
40/// object name, or the mover Job would be rejected by the API server later with
41/// a far less actionable message. `context` names the backend (e.g.
42/// `"s3 backend"`) for the message.
43pub fn validate_backend_auth(
44 auth: &crate::backend::BackendAuth,
45 context: &str,
46) -> ValidationResult {
47 if auth.secret_ref.is_some() && auth.workload_identity.is_some() {
48 return Err(ValidationError::MutuallyExclusive {
49 a: "auth.secretRef".to_string(),
50 b: "auth.workloadIdentity".to_string(),
51 context: context.to_string(),
52 });
53 }
54 if let Some(wi) = &auth.workload_identity {
55 validate_dns1123_name(
56 &wi.service_account_name,
57 &format!("{context} auth.workloadIdentity.serviceAccountName"),
58 )?;
59 }
60 Ok(())
61}
62
63/// A backend's `tls` block is internally consistent — every rule the structural
64/// schema can't express. A `caBundleRef` must actually name a ConfigMap
65/// (`configMapName` was `Option` for API growth, so an empty `caBundleRef: {}`
66/// parses fine but would be a silently dead reference), the name must be a
67/// valid object name (or every mover run fails at ConfigMap resolution with a
68/// far less actionable message), an explicitly-set `key` must not be blank
69/// (blank would shadow the `ca.crt` default and never match a real key), and
70/// pairing `caBundleRef` with `disableTls: true` is a contradiction: with
71/// kopia's `--disable-tls` there is no TLS handshake at all, so the CA could
72/// never be consulted. `context` names the backend (e.g. `"s3 backend"`) for
73/// the message.
74///
75/// Deliberately NOT here: `caBundleRef` + `insecureSkipVerify: true` is an
76/// admission *warning* ([`super::S3_TLS_SKIP_VERIFY_WARNING`]), never an error
77/// — see that constant's doc for why upgrades forbid hardening it.
78pub fn validate_backend_tls(tls: &crate::common::TlsConfig, context: &str) -> ValidationResult {
79 let Some(ca) = &tls.ca_bundle_ref else {
80 return Ok(());
81 };
82 if tls.disable_tls {
83 return Err(ValidationError::MutuallyExclusive {
84 a: "tls.caBundleRef".to_string(),
85 b: "tls.disableTls".to_string(),
86 context: format!(
87 "{context}: disableTls means the CA bundle is never used; remove \
88 disableTls, or remove caBundleRef for plain HTTP"
89 ),
90 });
91 }
92 match ca.config_map_name.as_deref() {
93 None | Some("") => {
94 return Err(ValidationError::InvalidFieldValue {
95 field: format!("{context} tls.caBundleRef.configMapName"),
96 reason: "is required. Fix: set configMapName to the ConfigMap holding \
97 the PEM CA bundle, or remove the caBundleRef block"
98 .to_string(),
99 });
100 }
101 Some(name) => {
102 validate_dns1123_name(name, &format!("{context} tls.caBundleRef.configMapName"))?;
103 }
104 }
105 if let Some(key) = &ca.key
106 && key.trim().is_empty()
107 {
108 return Err(ValidationError::InvalidFieldValue {
109 field: format!("{context} tls.caBundleRef.key"),
110 reason: format!(
111 "is blank ({key:?}). Fix: set it to the key holding the PEM CA \
112 bundle, or omit it to use the default \"ca.crt\""
113 ),
114 });
115 }
116 Ok(())
117}
118
119/// Validate backend *content* the structural schema can't express: the
120/// inline-NFS volume on a `Filesystem` backend, the `secretRef` XOR
121/// `workloadIdentity` rule on the cloud-IAM backends, Azure's
122/// workload-identity prerequisites, and the S3 `tls` block's consistency.
123/// Exhaustive `match` so a new `Backend` variant must be considered here
124/// before it compiles.
125pub fn validate_backend(backend: &crate::backend::Backend) -> ValidationResult {
126 use crate::backend::{Backend, RepoVolume};
127 match backend {
128 Backend::Filesystem(fs) => match &fs.volume {
129 Some(RepoVolume::Nfs(nfs)) => validate_nfs_volume(nfs, "filesystem repo"),
130 Some(RepoVolume::Pvc(_)) | None => Ok(()),
131 },
132 Backend::S3(s) => {
133 if let Some(auth) = &s.auth {
134 validate_backend_auth(auth, "s3 backend")?;
135 }
136 if let Some(tls) = &s.tls {
137 validate_backend_tls(tls, "s3 backend")?;
138 }
139 Ok(())
140 }
141 Backend::Azure(a) => match &a.auth {
142 Some(auth) => {
143 validate_backend_auth(auth, "azure backend")?;
144 // kopia's `--storage-account` is a required flag, and with
145 // workload identity there is no Secret to deliver it via the
146 // AZURE_STORAGE_ACCOUNT env var (the azure-workload-identity
147 // webhook injects only tenant/client/token-file). It must be in
148 // the spec, or every mover run fails at kopia flag parsing.
149 if auth.workload_identity.is_some() && a.storage_account.is_none() {
150 return Err(ValidationError::InvalidFieldValue {
151 field: "azure backend storageAccount".to_string(),
152 reason: "is required with auth.workloadIdentity. Fix: set \
153 spec.backend.azure.storageAccount"
154 .to_string(),
155 });
156 }
157 Ok(())
158 }
159 None => Ok(()),
160 },
161 Backend::Gcs(g) => match &g.auth {
162 Some(auth) => validate_backend_auth(auth, "gcs backend"),
163 None => Ok(()),
164 },
165 Backend::Rclone(r) => {
166 // kopia's `--rclone-startup-timeout` takes a Go duration; reject a
167 // malformed value at admission instead of failing every connect.
168 if let Some(t) = &r.startup_timeout
169 && crate::duration::parse_go_duration(t).is_none()
170 {
171 return Err(ValidationError::InvalidFieldValue {
172 field: "rclone backend startupTimeout".to_string(),
173 reason: format!("must be a Go duration like \"30s\" or \"2m\" (got {t:?})"),
174 });
175 }
176 Ok(())
177 }
178 Backend::Gdrive(g) => {
179 if g.folder_id.trim().is_empty() {
180 return Err(ValidationError::MissingRequiredField {
181 field: "gdrive backend folderId".to_string(),
182 });
183 }
184 Ok(())
185 }
186 Backend::B2(_) | Backend::Sftp(_) | Backend::WebDav(_) => Ok(()),
187 }
188}
189
190/// WHICH one-pod credential pairing [`validate_replication_auth`] is judging.
191///
192/// The rule itself is identical everywhere a single mover pod carries two
193/// backends' credentials, so exactly one implementation decides it. Only the
194/// FIELD PATHS and the prose differ, and they differ enough to matter: a
195/// repository bootstrap rejection that says "destination backend auth" and
196/// blames "the replication mover" names a field the author never wrote and a
197/// mover that never ran. An enum rather than free-form strings so a new pairing
198/// cannot be added without deciding what its rejection says.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum AuthPairKind {
201 /// A `RepositoryReplication` / `SnapshotReplication` source→destination
202 /// pair, carried by one replication mover Job.
203 Replication,
204 /// A repository's own `spec.backend` paired with its `spec.seed` source,
205 /// carried by one seeding Job at bootstrap. Note the argument order this
206 /// implies: the `source` argument is the repository being created and the
207 /// `destination` argument is the seed it reads FROM.
208 Seed,
209}
210
211impl AuthPairKind {
212 /// Field path for the both-federated, disagreeing-ServiceAccount rejection.
213 fn service_account_field(self) -> &'static str {
214 match self {
215 Self::Replication => "destination auth.workloadIdentity.serviceAccountName",
216 Self::Seed => "seed.from.backend auth.workloadIdentity.serviceAccountName",
217 }
218 }
219
220 /// Field path for the static/workload-identity mix rejection.
221 fn auth_field(self) -> &'static str {
222 match self {
223 Self::Replication => "destination backend auth",
224 Self::Seed => "seed.from.backend auth",
225 }
226 }
227
228 /// The single pod that carries both credential sets, as a noun phrase (also
229 /// used possessively, so it must read correctly with a trailing `'s`).
230 fn mover(self) -> &'static str {
231 match self {
232 Self::Replication => "the replication mover",
233 Self::Seed => "the seeding mover",
234 }
235 }
236
237 /// How to name the `source` argument's side in prose.
238 fn source_label(self) -> &'static str {
239 match self {
240 Self::Replication => "the source repository",
241 Self::Seed => "this repository",
242 }
243 }
244
245 /// How to name the `destination` argument's side in prose.
246 fn destination_label(self) -> &'static str {
247 match self {
248 Self::Replication => "the destination",
249 Self::Seed => "the seed source",
250 }
251 }
252
253 /// The pairing itself, for "a same-kind {} pair cannot mix ...".
254 fn pair_label(self) -> &'static str {
255 match self {
256 Self::Replication => "source/destination",
257 Self::Seed => "repository/seed-source",
258 }
259 }
260}
261
262/// A `RepositoryReplication`'s source/destination auth pair is safe to run in
263/// **one** mover pod. The replicate pod's environment carries the static side's
264/// credential Secret (`envFrom`); for a same-kind S3 or Azure pair where exactly
265/// one side uses workload identity, the workload-identity side's credential
266/// chain reads those same env vars (minio-go's `EnvAWS`; kopia's env-bound azure
267/// flags) and would silently authenticate as the *other* side — wrong identity,
268/// plausibly wrong permissions, no error. Rejected at admission instead. GCS
269/// mixed pairs are safe (the static side's key travels as a `--credentials-file`
270/// path, not ambient env). Both-workload-identity pairs must name the same
271/// ServiceAccount — a pod runs as exactly one.
272///
273/// A repository `spec.seed` reuses this VERDICT unchanged — one seeding pod
274/// carries both credential sets for exactly the same reason — and passes
275/// [`AuthPairKind::Seed`] so the rejection points at `spec.seed.from.backend`
276/// and speaks about the seeding Job (issue #380).
277pub fn validate_replication_auth(
278 source: &crate::backend::Backend,
279 destination: &crate::backend::Backend,
280 kind: AuthPairKind,
281) -> ValidationResult {
282 use crate::creds::{WorkloadIdentityCloud, backend_workload_identity};
283 let src_wi = backend_workload_identity(source);
284 let dst_wi = backend_workload_identity(destination);
285 match (src_wi, dst_wi) {
286 (None, None) => Ok(()),
287 (Some((a, _)), Some((b, _))) => {
288 if a.service_account_name == b.service_account_name {
289 Ok(())
290 } else {
291 Err(ValidationError::InvalidFieldValue {
292 field: kind.service_account_field().to_string(),
293 reason: format!(
294 "{mover} runs as one ServiceAccount, but {src} uses {a:?} \
295 and {dst} uses {b:?}. Fix: use the same ServiceAccount \
296 (with IAM access to both stores)",
297 mover = kind.mover(),
298 src = kind.source_label(),
299 dst = kind.destination_label(),
300 a = a.service_account_name,
301 b = b.service_account_name,
302 ),
303 })
304 }
305 }
306 (Some((_, wi_cloud)), None) | (None, Some((_, wi_cloud))) => {
307 let static_side = if src_wi.is_some() {
308 destination
309 } else {
310 source
311 };
312 let conflicts = match wi_cloud {
313 WorkloadIdentityCloud::S3 => {
314 matches!(static_side, crate::backend::Backend::S3(_))
315 }
316 WorkloadIdentityCloud::Azure => {
317 matches!(static_side, crate::backend::Backend::Azure(_))
318 }
319 // GCS static keys travel as a --credentials-file path, never
320 // ambient env, so they cannot leak into the ADC chain.
321 WorkloadIdentityCloud::Gcs => false,
322 };
323 if conflicts {
324 Err(ValidationError::InvalidFieldValue {
325 field: kind.auth_field().to_string(),
326 reason: format!(
327 "a {pair} pair cannot mix workloadIdentity with a static \
328 credential Secret: {mover} would authenticate with the \
329 wrong one. Fix: use workloadIdentity on both sides or \
330 static Secrets on both",
331 pair = kind.pair_label(),
332 mover = kind.mover(),
333 ),
334 })
335 } else {
336 Ok(())
337 }
338 }
339 }
340}
341
342/// A `RepositoryReplication`'s **destination** credential Secret is reachable from
343/// the mover Job. The replicate Job runs in the CR's own namespace and loads the
344/// destination backend's keys via `envFrom`, which is namespace-local — a Secret
345/// in another namespace can never be read. `RepositoryReplication` deliberately has
346/// no `credentialProjection`, so an out-of-namespace destination `auth.secretRef`
347/// is a dead reference the Job would hang on (`CreateContainerConfigError`).
348/// Reject it at admission with an actionable message instead. An absent
349/// `namespace` means "same namespace as the CR" and is always legal; a workload-
350/// identity or filesystem destination carries no auth Secret and is unaffected.
351/// `cr_namespace` is the replication CR's own namespace.
352pub fn validate_replication_destination_secret_namespace(
353 destination: &crate::backend::Backend,
354 cr_namespace: &str,
355) -> ValidationResult {
356 let Some(secret_ref) = crate::creds::backend_auth_secret_ref(destination) else {
357 return Ok(());
358 };
359 match secret_ref.namespace.as_deref() {
360 Some(ns) if ns != cr_namespace => Err(ValidationError::InvalidFieldValue {
361 field: "destination backend auth.secretRef.namespace".to_string(),
362 reason: format!(
363 "Secret {name:?} is in namespace {ns:?}, but the replication Job runs in \
364 {cr_namespace:?} and can only read Secrets there. Fix: put the destination \
365 Secret in {cr_namespace:?}",
366 name = secret_ref.name,
367 ),
368 }),
369 _ => Ok(()),
370 }
371}