Skip to main content

kopiur_api/
identity.rs

1//! Kopia identity resolution (ADR §4.2).
2//!
3//! Kopia records every snapshot under `username@hostname:sourcePath`. Kopiur makes
4//! that identity an explicit, overridable part of the API rather than an accident
5//! of `metadata.name`/`metadata.namespace` (ADR §2.2 principle 9). This module is
6//! the single place the defaulting + templating rules live; the webhook calls it at
7//! admission and pins the result into `status.resolved.identity`, which is **never
8//! re-rendered** afterwards (ADR §4.2).
9//!
10//! ## Defaults (ADR §4.2)
11//! - `username` ← `BackupConfig.metadata.name`
12//! - `hostname` ← namespace
13//! - `sourcePath` ← `/pvc/<pvcName>`
14//!
15//! ## ClusterRepository identity templates
16//!
17//! A [`crate::cluster_repository::IdentityTemplate`] supplies
18//! `hostnameTemplate`/`usernameTemplate`, rendered with `tera` (Jinja2-compatible).
19//! A consumer's explicit [`Identity`] override **always wins** over the template.
20//!
21//! ### Template syntax decision
22//!
23//! The ADR example is written Go-template-style with a leading dot
24//! (`hostnameTemplate: "{{ .Namespace }}"`), but `tera` uses `{{ Namespace }}`.
25//! Rather than force users to learn that `kopiur` templates are tera, we
26//! **preprocess** the leading dot away: `{{ .Foo }}` → `{{ Foo }}` (see
27//! `strip_leading_dots`). Both spellings therefore work, and the exact ADR
28//! example renders correctly (proven in tests). Context variables exposed:
29//! `Namespace` and `ConfigName`.
30
31use crate::cluster_repository::IdentityTemplate;
32use crate::common::{Identity, ResolvedIdentity};
33use crate::error::{ValidationError, ValidationResult};
34use tera::{Context, Tera};
35
36/// Inputs to identity resolution. Grouped into a struct so call sites are readable
37/// and future inputs (e.g. extra template vars) slot in without churning the
38/// signature.
39#[derive(Debug, Clone)]
40pub struct IdentityInputs<'a> {
41    /// The consumer object's `metadata.name` (default `username`).
42    pub object_name: &'a str,
43    /// The consumer object's namespace (default `hostname`, and the `Namespace`
44    /// template var).
45    pub namespace: &'a str,
46    /// Explicit overrides from `BackupConfig.spec.identity`, if any.
47    pub overrides: Option<&'a Identity>,
48    /// `ClusterRepository.spec.identityDefaults`, if the consumer targets one.
49    pub template: Option<&'a IdentityTemplate>,
50    /// The PVC name backing `sourcePath`'s `/pvc/<name>` default. `None` for
51    /// surfaces without a single PVC (e.g. a maintenance identity); then
52    /// `sourcePath` is left `None`.
53    pub pvc_name: Option<&'a str>,
54    /// An explicit `sourcePathOverride` (ADR §3.3), which beats the `/pvc/<name>`
55    /// default.
56    pub source_path_override: Option<&'a str>,
57}
58
59/// Rewrite Go-template leading-dot variables (`{{ .Foo }}`, `{{.Foo}}`) into tera
60/// syntax (`{{ Foo }}`). Only touches `.` immediately following `{{` and optional
61/// whitespace, so it never disturbs method calls or literals elsewhere.
62fn 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            // Skip whitespace after `{{`, preserving it in the output.
71            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
72                out.push(bytes[i] as char);
73                i += 1;
74            }
75            // Drop a single leading dot if present.
76            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        // tera errors nest the real cause one level down; surface it.
91        reason: e
92            .source()
93            .map(|s| s.to_string())
94            .unwrap_or_else(|| e.to_string()),
95    })
96}
97
98// Local trait import for `.source()` on the tera error.
99use std::error::Error as _;
100
101/// Resolve a [`ResolvedIdentity`] from defaults, an optional `ClusterRepository`
102/// identity template, and explicit consumer overrides (ADR §4.2).
103///
104/// Precedence per component: **explicit override > template > default**. Returns a
105/// [`ValidationError::IdentityTemplateRender`] if a supplied template fails to
106/// render (so the webhook rejects it at admission rather than pinning garbage).
107///
108/// ```
109/// use kopiur_api::{IdentityInputs, resolve_identity, identity_string};
110///
111/// // Bare defaults: username <- object name, hostname <- namespace,
112/// // sourcePath <- /pvc/<pvcName> (ADR §4.2).
113/// let inputs = IdentityInputs {
114///     object_name: "postgres-data",
115///     namespace: "billing",
116///     overrides: None,
117///     template: None,
118///     pvc_name: Some("postgres-data"),
119///     source_path_override: None,
120/// };
121/// let id = resolve_identity(&inputs).unwrap();
122/// assert_eq!(id.username, "postgres-data");
123/// assert_eq!(id.hostname, "billing");
124/// assert_eq!(id.source_path.as_deref(), Some("/pvc/postgres-data"));
125/// assert_eq!(identity_string(&id), "postgres-data@billing:/pvc/postgres-data");
126/// ```
127pub 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
163/// Format a kopia identity string. With a source path: `username@hostname:path`;
164/// without one: `username@hostname` (kopia's identity-only form, used for catalog
165/// queries that aren't pinned to a path).
166///
167/// ```
168/// use kopiur_api::{IdentityInputs, resolve_identity, identity_string};
169///
170/// // No PVC => no sourcePath => kopia's identity-only `username@hostname` form.
171/// let inputs = IdentityInputs {
172///     object_name: "cfg",
173///     namespace: "ns",
174///     overrides: None,
175///     template: None,
176///     pvc_name: None,
177///     source_path_override: None,
178/// };
179/// let id = resolve_identity(&inputs).unwrap();
180/// assert_eq!(id.source_path, None);
181/// assert_eq!(identity_string(&id), "cfg@ns");
182/// ```
183pub 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        // ADR §3.2 example, verbatim Go-template dot syntax:
229        //   hostnameTemplate: "{{ .Namespace }}"
230        //   usernameTemplate: "{{ .Namespace }}-{{ .ConfigName }}"
231        // For namespace `billing`, config `postgres-data`, must render to
232        //   username = billing-postgres-data, hostname = billing.
233        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        // Same render without the leading dot.
252        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        // Only hostname overridden; username still comes from the template.
290        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()), // unterminated
339            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}