1use crate::cluster_repository::IdentityTemplate;
32use crate::common::{Identity, ResolvedIdentity};
33use crate::error::{ValidationError, ValidationResult};
34use tera::{Context, Tera};
35
36#[derive(Debug, Clone)]
40pub struct IdentityInputs<'a> {
41 pub object_name: &'a str,
43 pub namespace: &'a str,
46 pub overrides: Option<&'a Identity>,
48 pub template: Option<&'a IdentityTemplate>,
50 pub pvc_name: Option<&'a str>,
54 pub source_path_override: Option<&'a str>,
57}
58
59fn strip_leading_dots(tmpl: &str) -> String {
63 let mut out = String::with_capacity(tmpl.len());
64 let bytes = tmpl.as_bytes();
65 let mut i = 0;
66 while i < bytes.len() {
67 if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
68 out.push_str("{{");
69 i += 2;
70 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
72 out.push(bytes[i] as char);
73 i += 1;
74 }
75 if i < bytes.len() && bytes[i] == b'.' {
77 i += 1;
78 }
79 continue;
80 }
81 out.push(bytes[i] as char);
82 i += 1;
83 }
84 out
85}
86
87fn render(tmpl: &str, ctx: &Context) -> ValidationResult<String> {
88 let prepared = strip_leading_dots(tmpl);
89 Tera::one_off(&prepared, ctx, false).map_err(|e| ValidationError::IdentityTemplateRender {
90 reason: e
92 .source()
93 .map(|s| s.to_string())
94 .unwrap_or_else(|| e.to_string()),
95 })
96}
97
98use std::error::Error as _;
100
101pub fn resolve_identity(inputs: &IdentityInputs<'_>) -> ValidationResult<ResolvedIdentity> {
128 let mut ctx = Context::new();
129 ctx.insert("Namespace", inputs.namespace);
130 ctx.insert("ConfigName", inputs.object_name);
131
132 let override_username = inputs.overrides.and_then(|o| o.username.as_deref());
133 let override_hostname = inputs.overrides.and_then(|o| o.hostname.as_deref());
134
135 let username = match override_username {
136 Some(u) => u.to_string(),
137 None => match inputs.template.and_then(|t| t.username_template.as_deref()) {
138 Some(tmpl) => render(tmpl, &ctx)?,
139 None => inputs.object_name.to_string(),
140 },
141 };
142
143 let hostname = match override_hostname {
144 Some(h) => h.to_string(),
145 None => match inputs.template.and_then(|t| t.hostname_template.as_deref()) {
146 Some(tmpl) => render(tmpl, &ctx)?,
147 None => inputs.namespace.to_string(),
148 },
149 };
150
151 let source_path = match inputs.source_path_override {
152 Some(p) => Some(p.to_string()),
153 None => inputs.pvc_name.map(|n| format!("/pvc/{n}")),
154 };
155
156 Ok(ResolvedIdentity {
157 username,
158 hostname,
159 source_path,
160 })
161}
162
163pub fn identity_string(id: &ResolvedIdentity) -> String {
184 match &id.source_path {
185 Some(p) => format!("{}@{}:{}", id.username, id.hostname, p),
186 None => format!("{}@{}", id.username, id.hostname),
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn inputs<'a>(
195 name: &'a str,
196 ns: &'a str,
197 overrides: Option<&'a Identity>,
198 template: Option<&'a IdentityTemplate>,
199 pvc: Option<&'a str>,
200 ) -> IdentityInputs<'a> {
201 IdentityInputs {
202 object_name: name,
203 namespace: ns,
204 overrides,
205 template,
206 pvc_name: pvc,
207 source_path_override: None,
208 }
209 }
210
211 #[test]
212 fn defaults_use_name_namespace_and_pvc_path() {
213 let r = resolve_identity(&inputs(
214 "postgres-data",
215 "billing",
216 None,
217 None,
218 Some("postgres-data"),
219 ))
220 .unwrap();
221 assert_eq!(r.username, "postgres-data");
222 assert_eq!(r.hostname, "billing");
223 assert_eq!(r.source_path.as_deref(), Some("/pvc/postgres-data"));
224 }
225
226 #[test]
227 fn adr_cluster_repository_template_example() {
228 let tmpl = IdentityTemplate {
234 hostname_template: Some("{{ .Namespace }}".to_string()),
235 username_template: Some("{{ .Namespace }}-{{ .ConfigName }}".to_string()),
236 };
237 let r = resolve_identity(&inputs(
238 "postgres-data",
239 "billing",
240 None,
241 Some(&tmpl),
242 Some("data"),
243 ))
244 .unwrap();
245 assert_eq!(r.username, "billing-postgres-data");
246 assert_eq!(r.hostname, "billing");
247 }
248
249 #[test]
250 fn tera_native_syntax_also_works() {
251 let tmpl = IdentityTemplate {
253 hostname_template: Some("{{ Namespace }}".to_string()),
254 username_template: Some("{{ Namespace }}-{{ ConfigName }}".to_string()),
255 };
256 let r = resolve_identity(&inputs(
257 "postgres-data",
258 "billing",
259 None,
260 Some(&tmpl),
261 Some("data"),
262 ))
263 .unwrap();
264 assert_eq!(r.username, "billing-postgres-data");
265 assert_eq!(r.hostname, "billing");
266 }
267
268 #[test]
269 fn override_beats_template() {
270 let tmpl = IdentityTemplate {
271 hostname_template: Some("{{ .Namespace }}".to_string()),
272 username_template: Some("{{ .Namespace }}-{{ .ConfigName }}".to_string()),
273 };
274 let ovr = Identity {
275 username: Some("custom-user".to_string()),
276 hostname: Some("custom-host".to_string()),
277 };
278 let r = resolve_identity(&inputs("cfg", "ns", Some(&ovr), Some(&tmpl), Some("p"))).unwrap();
279 assert_eq!(r.username, "custom-user");
280 assert_eq!(r.hostname, "custom-host");
281 }
282
283 #[test]
284 fn partial_override_falls_through_to_template_for_the_other_field() {
285 let tmpl = IdentityTemplate {
286 hostname_template: Some("{{ .Namespace }}".to_string()),
287 username_template: Some("{{ .Namespace }}-{{ .ConfigName }}".to_string()),
288 };
289 let ovr = Identity {
291 username: None,
292 hostname: Some("pinned-host".to_string()),
293 };
294 let r = resolve_identity(&inputs(
295 "postgres-data",
296 "billing",
297 Some(&ovr),
298 Some(&tmpl),
299 Some("d"),
300 ))
301 .unwrap();
302 assert_eq!(r.hostname, "pinned-host");
303 assert_eq!(r.username, "billing-postgres-data");
304 }
305
306 #[test]
307 fn source_path_override_beats_default() {
308 let mut i = inputs("cfg", "ns", None, None, Some("vol"));
309 i.source_path_override = Some("/data");
310 let r = resolve_identity(&i).unwrap();
311 assert_eq!(r.source_path.as_deref(), Some("/data"));
312 }
313
314 #[test]
315 fn no_pvc_yields_no_source_path() {
316 let r = resolve_identity(&inputs("cfg", "ns", None, None, None)).unwrap();
317 assert_eq!(r.source_path, None);
318 }
319
320 #[test]
321 fn identity_string_with_and_without_path() {
322 let with = ResolvedIdentity {
323 username: "postgres-data".into(),
324 hostname: "billing".into(),
325 source_path: Some("/data".into()),
326 };
327 assert_eq!(identity_string(&with), "postgres-data@billing:/data");
328 let without = ResolvedIdentity {
329 source_path: None,
330 ..with
331 };
332 assert_eq!(identity_string(&without), "postgres-data@billing");
333 }
334
335 #[test]
336 fn malformed_template_is_rejected() {
337 let tmpl = IdentityTemplate {
338 hostname_template: Some("{{ .Namespace ".to_string()), username_template: None,
340 };
341 let err = resolve_identity(&inputs("c", "n", None, Some(&tmpl), Some("p"))).unwrap_err();
342 assert!(matches!(
343 err,
344 ValidationError::IdentityTemplateRender { .. }
345 ));
346 }
347}