Skip to main content

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 alphanumerics, '-' or '.', \
27                 starting and ending with an alphanumeric, 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/// Validate backend *content* the structural schema can't express: the
64/// inline-NFS volume on a `Filesystem` backend, the `secretRef` XOR
65/// `workloadIdentity` rule on the cloud-IAM backends, and Azure's
66/// workload-identity prerequisites. Exhaustive `match` so a new `Backend`
67/// variant must be considered here before it compiles.
68pub fn validate_backend(backend: &crate::backend::Backend) -> ValidationResult {
69    use crate::backend::{Backend, RepoVolume};
70    match backend {
71        Backend::Filesystem(fs) => match &fs.volume {
72            Some(RepoVolume::Nfs(nfs)) => validate_nfs_volume(nfs, "filesystem repo"),
73            Some(RepoVolume::Pvc(_)) | None => Ok(()),
74        },
75        Backend::S3(s) => match &s.auth {
76            Some(auth) => validate_backend_auth(auth, "s3 backend"),
77            None => Ok(()),
78        },
79        Backend::Azure(a) => match &a.auth {
80            Some(auth) => {
81                validate_backend_auth(auth, "azure backend")?;
82                // kopia's `--storage-account` is a required flag, and with
83                // workload identity there is no Secret to deliver it via the
84                // AZURE_STORAGE_ACCOUNT env var (the azure-workload-identity
85                // webhook injects only tenant/client/token-file). It must be in
86                // the spec, or every mover run fails at kopia flag parsing.
87                if auth.workload_identity.is_some() && a.storage_account.is_none() {
88                    return Err(ValidationError::InvalidFieldValue {
89                        field: "azure backend storageAccount".to_string(),
90                        reason: "required with auth.workloadIdentity: the \
91                                 azure-workload-identity webhook injects the tenant, \
92                                 client id, and federated token, but not the storage \
93                                 account — set spec.backend.azure.storageAccount"
94                            .to_string(),
95                    });
96                }
97                Ok(())
98            }
99            None => Ok(()),
100        },
101        Backend::Gcs(g) => match &g.auth {
102            Some(auth) => validate_backend_auth(auth, "gcs backend"),
103            None => Ok(()),
104        },
105        Backend::Rclone(r) => {
106            // kopia's `--rclone-startup-timeout` takes a Go duration; reject a
107            // malformed value at admission instead of failing every connect.
108            if let Some(t) = &r.startup_timeout
109                && crate::duration::parse_go_duration(t).is_none()
110            {
111                return Err(ValidationError::InvalidFieldValue {
112                    field: "rclone backend startupTimeout".to_string(),
113                    reason: format!("must be a Go duration like \"30s\" or \"2m\" (got {t:?})"),
114                });
115            }
116            Ok(())
117        }
118        Backend::Gdrive(g) => {
119            if g.folder_id.trim().is_empty() {
120                return Err(ValidationError::MissingRequiredField {
121                    field: "gdrive backend folderId".to_string(),
122                });
123            }
124            Ok(())
125        }
126        Backend::B2(_) | Backend::Sftp(_) | Backend::WebDav(_) => Ok(()),
127    }
128}
129
130/// A `RepositoryReplication`'s source/destination auth pair is safe to run in
131/// **one** mover pod. The replicate pod's environment carries the static side's
132/// credential Secret (`envFrom`); for a same-kind S3 or Azure pair where exactly
133/// one side uses workload identity, the workload-identity side's credential
134/// chain reads those same env vars (minio-go's `EnvAWS`; kopia's env-bound azure
135/// flags) and would silently authenticate as the *other* side — wrong identity,
136/// plausibly wrong permissions, no error. Rejected at admission instead. GCS
137/// mixed pairs are safe (the static side's key travels as a `--credentials-file`
138/// path, not ambient env). Both-workload-identity pairs must name the same
139/// ServiceAccount — a pod runs as exactly one.
140pub fn validate_replication_auth(
141    source: &crate::backend::Backend,
142    destination: &crate::backend::Backend,
143) -> ValidationResult {
144    use crate::creds::{WorkloadIdentityCloud, backend_workload_identity};
145    let src_wi = backend_workload_identity(source);
146    let dst_wi = backend_workload_identity(destination);
147    match (src_wi, dst_wi) {
148        (None, None) => Ok(()),
149        (Some((a, _)), Some((b, _))) => {
150            if a.service_account_name == b.service_account_name {
151                Ok(())
152            } else {
153                Err(ValidationError::InvalidFieldValue {
154                    field: "destination auth.workloadIdentity.serviceAccountName".to_string(),
155                    reason: format!(
156                        "the replication mover is one pod and runs as exactly one \
157                         ServiceAccount, but the source repository federates as \
158                         {:?} and the destination as {:?} — point both at the same \
159                         ServiceAccount (with IAM access to both stores)",
160                        a.service_account_name, b.service_account_name
161                    ),
162                })
163            }
164        }
165        (Some((_, wi_cloud)), None) | (None, Some((_, wi_cloud))) => {
166            let static_side = if src_wi.is_some() {
167                destination
168            } else {
169                source
170            };
171            let conflicts = match wi_cloud {
172                WorkloadIdentityCloud::S3 => {
173                    matches!(static_side, crate::backend::Backend::S3(_))
174                }
175                WorkloadIdentityCloud::Azure => {
176                    matches!(static_side, crate::backend::Backend::Azure(_))
177                }
178                // GCS static keys travel as a --credentials-file path, never
179                // ambient env, so they cannot leak into the ADC chain.
180                WorkloadIdentityCloud::Gcs => false,
181            };
182            if conflicts {
183                Err(ValidationError::InvalidFieldValue {
184                    field: "destination backend auth".to_string(),
185                    reason: "a same-kind source/destination pair cannot mix \
186                             workloadIdentity with a static credential Secret: the \
187                             replication mover's environment carries the static \
188                             side's keys, and the workload-identity side's ambient \
189                             credential chain would silently pick them up and \
190                             authenticate as the wrong identity — use \
191                             workloadIdentity on both sides (one ServiceAccount \
192                             with IAM access to both stores) or static Secrets on \
193                             both"
194                        .to_string(),
195                })
196            } else {
197                Ok(())
198            }
199        }
200    }
201}
202
203/// A `RepositoryReplication`'s **destination** credential Secret is reachable from
204/// the mover Job. The replicate Job runs in the CR's own namespace and loads the
205/// destination backend's keys via `envFrom`, which is namespace-local — a Secret
206/// in another namespace can never be read. `RepositoryReplication` deliberately has
207/// no `credentialProjection`, so an out-of-namespace destination `auth.secretRef`
208/// is a dead reference the Job would hang on (`CreateContainerConfigError`).
209/// Reject it at admission with an actionable message instead. An absent
210/// `namespace` means "same namespace as the CR" and is always legal; a workload-
211/// identity or filesystem destination carries no auth Secret and is unaffected.
212/// `cr_namespace` is the replication CR's own namespace.
213pub fn validate_replication_destination_secret_namespace(
214    destination: &crate::backend::Backend,
215    cr_namespace: &str,
216) -> ValidationResult {
217    let Some(secret_ref) = crate::creds::backend_auth_secret_ref(destination) else {
218        return Ok(());
219    };
220    match secret_ref.namespace.as_deref() {
221        Some(ns) if ns != cr_namespace => Err(ValidationError::InvalidFieldValue {
222            field: "destination backend auth.secretRef.namespace".to_string(),
223            reason: format!(
224                "the replication mover Job runs in namespace {cr_namespace:?} and loads the \
225                 destination credentials via envFrom, which is namespace-local, but the Secret \
226                 {name:?} is pinned to namespace {ns:?} — the Job could never read it. \
227                 RepositoryReplication does not project credentials across namespaces; put the \
228                 destination Secret in {cr_namespace:?} (omit `namespace`, or set it to \
229                 {cr_namespace:?})",
230                name = secret_ref.name,
231            ),
232        }),
233        _ => Ok(()),
234    }
235}