kopiur_api/validate/
backend.rs1use super::*;
2use crate::error::{ValidationError, ValidationResult};
3
4pub 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
34pub 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
63pub 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 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 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
130pub 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 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
203pub 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}