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
7//! at admission (so a bad expression/component is rejected on `kubectl apply`) and
8//! the controller calls it again on every reconcile, resolving from the LIVE
9//! `SnapshotPolicy.spec.identity` and the LIVE referenced repository's
10//! `identityDefaults` — **not** a value pinned once and frozen. `status.resolved.identity`
11//! mirrors the most recent resolution for observability; it is not the source of
12//! truth a later run reads back. What actually keeps an already-snapshotted policy
13//! stable is the fork guard (`ValidationError::IdentityWouldFork`/
14//! `RepositoryIdentityWouldFork`): an edit that would change the resolved identity
15//! on a policy (or repository) with existing history is rejected at admission
16//! unless acknowledged with the `allow-identity-change` annotation.
17//!
18//! ## Defaults (ADR §4.2)
19//! - `username` ← `SnapshotPolicy.metadata.name`
20//! - `hostname` ← namespace
21//! - `sourcePath` ← `/pvc/<pvcName>`
22//!
23//! ## Repository/ClusterRepository identity expressions (CEL)
24//!
25//! A [`crate::common::IdentityDefaults`] (`Repository.spec.identityDefaults` or
26//! `ClusterRepository.spec.identityDefaults`) supplies `hostnameExpr`/
27//! `usernameExpr`, **CEL** expressions ([`cel`]) validated (compiled + trial-evaluated
28//! via [`validate_identity_expr`]) at admission, then actually rendered against the
29//! LIVE consumer + LIVE repository on every reconcile (ADR-0004 §5) — so editing
30//! `identityDefaults` re-renders every consumer that resolves through it on its
31//! next backup (guarded by [`ValidationError::RepositoryIdentityWouldFork`], see
32//! the module intro above). A consumer's explicit [`Identity`] override **always
33//! wins** over the expression.
34//!
35//! ### CEL environment
36//!
37//! Each expression returns a **string** and is evaluated against:
38//! `namespace` (the consumer's namespace), `policyName` (the `SnapshotPolicy`'s
39//! name), `labels` and `annotations` (its metadata maps), and `cluster`
40//! ([`IdentityDefaults::cluster`], or `""` when unset — see [`identity_context`]).
41//! Examples: `hostnameExpr: "namespace"`,
42//! `usernameExpr: "namespace + '-' + policyName"`,
43//! `"'team' in labels ? labels['team'] : namespace"`,
44//! `"namespace + '.' + cluster"`. CEL is sandboxed (no I/O, no arbitrary code); a
45//! syntax error or out-of-scope variable is rejected at `kubectl apply` via
46//! [`validate_identity_expr`], and a non-string result is a typed error.
47//! Expressions are length-capped ([`MAX_EXPR_LEN`]) as the cost-budget surrogate.
48//!
49//! ### Multi-cluster hostname default
50//!
51//! When a repository's [`IdentityDefaults::cluster`] is set, the default
52//! (no override, no `hostnameExpr`) kopia identity hostname becomes
53//! `<namespace>.<cluster>` instead of bare `<namespace>`, so N clusters sharing one
54//! repository never collide on a same-named namespace. [`classify_hostname`]
55//! recovers the namespace/cluster split on the read path (retention, discovered
56//! `Snapshot` placement).
57
58use std::collections::BTreeMap;
59
60use cel::{Context, Program, Value};
61
62use crate::common::{Identity, IdentityDefaults, ResolvedIdentity};
63use crate::error::{ValidationError, ValidationResult};
64
65/// Maximum CEL expression length accepted at admission (the cost-budget surrogate;
66/// `cel` 0.13 has no built-in cost API). 1 KiB is far beyond any real identity
67/// expression and bounds parse/eval work on adversarial input.
68pub const MAX_EXPR_LEN: usize = 1024;
69
70/// Inputs to identity resolution. Grouped into a struct so call sites are readable
71/// and future inputs slot in without churning the signature.
72#[derive(Debug, Clone)]
73pub struct IdentityInputs<'a> {
74    /// The consumer object's `metadata.name` (default `username`; the `policyName`
75    /// CEL variable).
76    pub object_name: &'a str,
77    /// The consumer object's namespace (default `hostname`; the `namespace` CEL
78    /// variable).
79    pub namespace: &'a str,
80    /// Explicit overrides from `SnapshotPolicy.spec.identity`, if any.
81    pub overrides: Option<&'a Identity>,
82    /// The referenced repository's `spec.identityDefaults` — `Repository` or
83    /// `ClusterRepository`, whichever the consumer targets. Carries the CEL
84    /// `*Expr` pair (`hostnameExpr`/`usernameExpr`) AND `cluster`, the
85    /// multi-cluster hostname-default suffix (see [`IdentityDefaults::cluster`]
86    /// and the module's "Multi-cluster hostname default" section).
87    pub defaults: Option<&'a IdentityDefaults>,
88    /// The consumer's `metadata.labels`, exposed to CEL as `labels`.
89    pub labels: Option<&'a BTreeMap<String, String>>,
90    /// The consumer's `metadata.annotations`, exposed to CEL as `annotations`.
91    pub annotations: Option<&'a BTreeMap<String, String>>,
92    /// The PVC name backing `sourcePath`'s `/pvc/<name>` default. `None` for
93    /// surfaces without a single PVC (a non-PVC source like NFS, or a maintenance
94    /// identity). When set it takes precedence over [`Self::default_source_path`].
95    pub pvc_name: Option<&'a str>,
96    /// The `sourcePath` default for a non-PVC source (e.g. an NFS export's path),
97    /// used when there is no `pvc_name` and no override. `None` leaves `sourcePath`
98    /// unset (kopia's identity-only `username@hostname` form).
99    pub default_source_path: Option<&'a str>,
100    /// An explicit `sourcePathOverride` (ADR §3.3), which beats every default.
101    pub source_path_override: Option<&'a str>,
102}
103
104/// Compile a CEL identity expression, enforcing the [`MAX_EXPR_LEN`] budget first.
105/// Maps a parse failure to [`ValidationError::IdentityExprCompile`].
106fn compile_expr(expr: &str) -> ValidationResult<Program> {
107    if expr.len() > MAX_EXPR_LEN {
108        return Err(ValidationError::IdentityExprCompile {
109            expr: expr.to_string(),
110            reason: format!(
111                "expression is {} bytes; the maximum is {MAX_EXPR_LEN}",
112                expr.len()
113            ),
114        });
115    }
116    Program::compile(expr).map_err(|e| ValidationError::IdentityExprCompile {
117        expr: expr.to_string(),
118        reason: e.to_string(),
119    })
120}
121
122/// Build the CEL evaluation context for identity expressions: `namespace`,
123/// `policyName`, `labels`, `annotations`, `cluster`. `cluster` is **always**
124/// declared (so an expression referencing it never hits an undeclared-variable
125/// error), taking `inputs.defaults.and_then(|d| d.cluster.as_deref())` or `""`
126/// when the consumer's repository has no `identityDefaults.cluster` set (bare
127/// single-cluster deployments never see a `cluster` variable value).
128fn identity_context<'a>(inputs: &IdentityInputs<'_>) -> Context<'a> {
129    let mut ctx = Context::default();
130    // `add_variable` only errors if a value cannot serialize; these are
131    // `&str`/`BTreeMap<String,String>`, which always serialize.
132    let empty = BTreeMap::<String, String>::new();
133    let _ = ctx.add_variable("namespace", inputs.namespace);
134    let _ = ctx.add_variable("policyName", inputs.object_name);
135    let _ = ctx.add_variable("labels", inputs.labels.unwrap_or(&empty));
136    let _ = ctx.add_variable("annotations", inputs.annotations.unwrap_or(&empty));
137    let cluster = inputs
138        .defaults
139        .and_then(|d| d.cluster.as_deref())
140        .unwrap_or("");
141    let _ = ctx.add_variable("cluster", cluster);
142    ctx
143}
144
145/// Evaluate a compiled identity [`Program`] against `inputs`, requiring a string
146/// result. Maps evaluation failure to [`ValidationError::IdentityExprEval`] and a
147/// non-string result to [`ValidationError::IdentityExprType`].
148fn eval_expr(
149    expr: &str,
150    program: &Program,
151    inputs: &IdentityInputs<'_>,
152) -> ValidationResult<String> {
153    let ctx = identity_context(inputs);
154    match program.execute(&ctx) {
155        Ok(Value::String(s)) => Ok(s.to_string()),
156        Ok(other) => Err(ValidationError::IdentityExprType {
157            expr: expr.to_string(),
158            got: other.type_of().to_string(),
159        }),
160        Err(e) => Err(ValidationError::IdentityExprEval {
161            expr: expr.to_string(),
162            reason: e.to_string(),
163        }),
164    }
165}
166
167/// Compile + evaluate an identity expression in one step.
168fn render_expr(expr: &str, inputs: &IdentityInputs<'_>) -> ValidationResult<String> {
169    let program = compile_expr(expr)?;
170    eval_expr(expr, &program, inputs)
171}
172
173/// Validate a `Repository`/`ClusterRepository` `identityDefaults` CEL expression
174/// at admission (ADR-0004 §5): it must compile, and — because CEL reports an
175/// out-of-scope variable only at *evaluation* time — it must also evaluate
176/// against a representative context without referencing an undeclared variable.
177/// A non-string result is rejected. Missing *map keys* (e.g. `labels['env']`
178/// when the trial data lacks `env`) are tolerated: they are data-dependent, not
179/// a structural error.
180pub fn validate_identity_expr(expr: &str) -> ValidationResult {
181    let program = compile_expr(expr)?;
182    // Representative trial context: non-empty maps so `'k' in labels`-style guards
183    // behave, plus placeholder scalars. `cluster` gets a non-empty placeholder too
184    // (rather than the "unset" empty string [`identity_context`] would otherwise
185    // supply) so an expression referencing it — e.g. `namespace + '.' + cluster` —
186    // trial-evaluates the same way regardless of whether *this particular*
187    // repository happens to set `identityDefaults.cluster`.
188    let labels = BTreeMap::from([("app".to_string(), "trial".to_string())]);
189    let annotations = BTreeMap::from([("note".to_string(), "trial".to_string())]);
190    let trial_defaults = IdentityDefaults {
191        cluster: Some("trial-cluster".to_string()),
192        hostname_expr: None,
193        username_expr: None,
194    };
195    let inputs = IdentityInputs {
196        object_name: "policy",
197        namespace: "namespace",
198        overrides: None,
199        defaults: Some(&trial_defaults),
200        labels: Some(&labels),
201        annotations: Some(&annotations),
202        pvc_name: None,
203        default_source_path: None,
204        source_path_override: None,
205    };
206    let ctx = identity_context(&inputs);
207    match program.execute(&ctx) {
208        Ok(Value::String(_)) => Ok(()),
209        Ok(other) => Err(ValidationError::IdentityExprType {
210            expr: expr.to_string(),
211            got: other.type_of().to_string(),
212        }),
213        // An undeclared-variable reference (a typo / out-of-scope var) is a hard
214        // rejection. Other runtime errors (e.g. NoSuchKey on a label the trial data
215        // lacks) are data-dependent and tolerated — the real object may supply them.
216        Err(cel::ExecutionError::UndeclaredReference(name)) => {
217            Err(ValidationError::IdentityExprEval {
218                expr: expr.to_string(),
219                reason: format!("undeclared reference to '{name}'"),
220            })
221        }
222        Err(_) => Ok(()),
223    }
224}
225
226/// Resolve a [`ResolvedIdentity`] from defaults, an optional `Repository`/
227/// `ClusterRepository` identity expression set, and explicit consumer overrides
228/// (ADR §4.2 / ADR-0004 §5).
229///
230/// Precedence per component: **explicit override, then expression, then default**.
231/// For `hostname` specifically, the default step itself has two tiers: with
232/// [`IdentityDefaults::cluster`] set, the default is `<namespace>.<cluster>`
233/// (M1, multi-cluster shared repositories); otherwise it's the bare `namespace`
234/// (ADR §4.2). So the full hostname chain, highest precedence first, is:
235/// explicit override, `hostnameExpr`, `<namespace>.<cluster>` (cluster set),
236/// `namespace`. `username` is unaffected — `cluster` is not part of its default.
237/// Returns a
238/// [`ValidationError::IdentityExprCompile`]/`IdentityExprEval`/`IdentityExprType` if a
239/// supplied expression fails (so the webhook rejects it at admission rather than
240/// pinning garbage).
241///
242/// ```
243/// use kopiur_api::{IdentityInputs, resolve_identity, identity_string};
244///
245/// // Bare defaults: username <- object name, hostname <- namespace,
246/// // sourcePath <- /pvc/<pvcName> (ADR §4.2).
247/// let inputs = IdentityInputs {
248///     object_name: "postgres-data",
249///     namespace: "billing",
250///     overrides: None,
251///     defaults: None,
252///     labels: None,
253///     annotations: None,
254///     pvc_name: Some("postgres-data"),
255///     default_source_path: None,
256///     source_path_override: None,
257/// };
258/// let id = resolve_identity(&inputs).unwrap();
259/// assert_eq!(id.username, "postgres-data");
260/// assert_eq!(id.hostname, "billing");
261/// assert_eq!(id.source_path.as_deref(), Some("/pvc/postgres-data"));
262/// assert_eq!(identity_string(&id), "postgres-data@billing:/pvc/postgres-data");
263/// ```
264pub fn resolve_identity(inputs: &IdentityInputs<'_>) -> ValidationResult<ResolvedIdentity> {
265    let override_username = inputs.overrides.and_then(|o| o.username.as_deref());
266    let override_hostname = inputs.overrides.and_then(|o| o.hostname.as_deref());
267
268    let username = match override_username {
269        Some(u) => u.to_string(),
270        None => match inputs.defaults.and_then(|t| t.username_expr.as_deref()) {
271            Some(expr) => render_expr(expr, inputs)?,
272            None => inputs.object_name.to_string(),
273        },
274    };
275
276    let hostname = match override_hostname {
277        Some(h) => h.to_string(),
278        None => match inputs.defaults.and_then(|t| t.hostname_expr.as_deref()) {
279            Some(expr) => render_expr(expr, inputs)?,
280            // No explicit expression: fall back to `<namespace>.<cluster>` when this
281            // repository has a cluster identity configured (M1 — distinguishes
282            // same-named namespaces across clusters sharing one repository), else the
283            // plain namespace (ADR §4.2).
284            None => match inputs.defaults.and_then(|d| d.cluster.as_deref()) {
285                Some(cluster) => format!("{}.{cluster}", inputs.namespace),
286                None => inputs.namespace.to_string(),
287            },
288        },
289    };
290
291    let source_path = match inputs.source_path_override {
292        Some(p) => Some(p.to_string()),
293        None => inputs
294            .pvc_name
295            .map(|n| format!("/pvc/{n}"))
296            .or_else(|| inputs.default_source_path.map(String::from)),
297    };
298
299    // Shape-check the fully-resolved identity, regardless of where each component came
300    // from (explicit override, CEL expression result, or the name/namespace/PVC
301    // default). kopia parses `username@hostname:path` on the first `@`/`:` with no
302    // escaping, so a component carrying a delimiter/whitespace/control char would
303    // silently misparse or become un-findable. Rejecting here means the controller
304    // never PINS a bad identity into status, and the webhook surfaces it at admission.
305    crate::validate::validate_identity_component("resolved username", &username)?;
306    crate::validate::validate_identity_component("resolved hostname", &hostname)?;
307    if let Some(p) = &source_path {
308        crate::validate::validate_source_path("resolved sourcePath", p)?;
309    }
310
311    Ok(ResolvedIdentity {
312        username,
313        hostname,
314        source_path,
315    })
316}
317
318/// Format a kopia identity string. With a source path: `username@hostname:path`;
319/// without one: `username@hostname` (kopia's identity-only form, used for catalog
320/// queries that aren't pinned to a path).
321///
322/// ```
323/// use kopiur_api::{IdentityInputs, resolve_identity, identity_string};
324///
325/// // No PVC => no sourcePath => kopia's identity-only `username@hostname` form.
326/// let inputs = IdentityInputs {
327///     object_name: "cfg",
328///     namespace: "ns",
329///     overrides: None,
330///     defaults: None,
331///     labels: None,
332///     annotations: None,
333///     pvc_name: None,
334///     default_source_path: None,
335///     source_path_override: None,
336/// };
337/// let id = resolve_identity(&inputs).unwrap();
338/// assert_eq!(id.source_path, None);
339/// assert_eq!(identity_string(&id), "cfg@ns");
340/// ```
341pub fn identity_string(id: &ResolvedIdentity) -> String {
342    match &id.source_path {
343        Some(p) => format!("{}@{}:{}", id.username, id.hostname, p),
344        None => format!("{}@{}", id.username, id.hostname),
345    }
346}
347
348/// Classification of a snapshot identity hostname relative to THIS cluster.
349/// Kubernetes namespace names cannot contain `.`, so the FIRST `.` unambiguously
350/// ends the namespace part of a `<namespace>.<cluster>` hostname. Total: with no
351/// cluster identity every hostname is `Bare`; an empty namespace part (".east")
352/// or empty suffix ("ns.") also classifies `Bare` (never a panic, never an
353/// invalid empty namespace). Suffix comparison is case-sensitive.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum HostClass<'a> {
356    /// No dot (or cluster mode off): the legacy `hostname == namespace` form.
357    Bare {
358        /// The hostname verbatim (there is no separate suffix to strip).
359        namespace: &'a str,
360    },
361    /// `<namespace>.<suffix>` where suffix == this cluster: written by us.
362    OwnCluster {
363        /// The part of the hostname before the first `.`.
364        namespace: &'a str,
365    },
366    /// `<namespace>.<suffix>` with a different suffix: another cluster's.
367    ForeignCluster {
368        /// Everything after the first `.` (may itself contain further dots).
369        suffix: &'a str,
370    },
371}
372
373/// Classify a kopia identity `hostname` against `my_cluster`
374/// ([`IdentityDefaults::cluster`], resolved from the consuming repository), so a
375/// reader (retention pruning, discovered-`Snapshot` placement) can tell whether a
376/// hostname it sees was written by this cluster, another cluster sharing the same
377/// repository, or predates cluster identity entirely.
378///
379/// Total and pure — never panics, never fabricates an empty namespace:
380/// - `my_cluster` is `None` or `""` (no cluster identity configured): always
381///   [`HostClass::Bare`].
382/// - No `.` in `hostname`: [`HostClass::Bare`].
383/// - The part before the first `.` is empty (e.g. `".east"`), or the part after
384///   is empty (e.g. `"ns."`): [`HostClass::Bare`] with the **whole** hostname as
385///   `namespace` — these aren't well-formed `<namespace>.<cluster>` hostnames, so
386///   classification declines to guess which part is which.
387/// - The suffix (everything after the first `.`) exactly equals `my_cluster`:
388///   [`HostClass::OwnCluster`].
389/// - Otherwise: [`HostClass::ForeignCluster`] — note `"ns.east.x"` under cluster
390///   `"east"` is `ForeignCluster { suffix: "east.x" }`, not `OwnCluster`; only an
391///   **exact** suffix match is ours.
392///
393/// ```
394/// use kopiur_api::identity::{HostClass, classify_hostname};
395///
396/// assert_eq!(
397///     classify_hostname("billing.east", Some("east")),
398///     HostClass::OwnCluster { namespace: "billing" }
399/// );
400/// assert_eq!(
401///     classify_hostname("billing.west", Some("east")),
402///     HostClass::ForeignCluster { suffix: "west" }
403/// );
404/// // No cluster identity configured => every hostname reads as legacy/bare.
405/// assert_eq!(
406///     classify_hostname("billing.east", None),
407///     HostClass::Bare { namespace: "billing.east" }
408/// );
409/// ```
410pub fn classify_hostname<'a>(hostname: &'a str, my_cluster: Option<&str>) -> HostClass<'a> {
411    let my_cluster = match my_cluster {
412        Some(c) if !c.is_empty() => c,
413        _ => {
414            return HostClass::Bare {
415                namespace: hostname,
416            };
417        }
418    };
419    match hostname.split_once('.') {
420        None => HostClass::Bare {
421            namespace: hostname,
422        },
423        Some((namespace, suffix)) if namespace.is_empty() || suffix.is_empty() => HostClass::Bare {
424            namespace: hostname,
425        },
426        Some((namespace, suffix)) if suffix == my_cluster => HostClass::OwnCluster { namespace },
427        Some((_, suffix)) => HostClass::ForeignCluster { suffix },
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn inputs<'a>(
436        name: &'a str,
437        ns: &'a str,
438        overrides: Option<&'a Identity>,
439        defaults: Option<&'a IdentityDefaults>,
440        pvc: Option<&'a str>,
441    ) -> IdentityInputs<'a> {
442        IdentityInputs {
443            object_name: name,
444            namespace: ns,
445            overrides,
446            defaults,
447            labels: None,
448            annotations: None,
449            pvc_name: pvc,
450            default_source_path: None,
451            source_path_override: None,
452        }
453    }
454
455    fn defaults(host: Option<&str>, user: Option<&str>) -> IdentityDefaults {
456        IdentityDefaults {
457            cluster: None,
458            hostname_expr: host.map(String::from),
459            username_expr: user.map(String::from),
460        }
461    }
462
463    /// Like [`defaults`] but also sets `identityDefaults.cluster` (M1 precedence tests).
464    fn defaults_with_cluster(
465        host: Option<&str>,
466        user: Option<&str>,
467        cluster: Option<&str>,
468    ) -> IdentityDefaults {
469        IdentityDefaults {
470            cluster: cluster.map(String::from),
471            hostname_expr: host.map(String::from),
472            username_expr: user.map(String::from),
473        }
474    }
475
476    #[test]
477    fn nfs_source_uses_default_source_path() {
478        // No PVC, but an NFS export supplies the sourcePath default.
479        let mut i = inputs("media", "default", None, None, None);
480        i.default_source_path = Some("/mnt/eros/Media");
481        let r = resolve_identity(&i).unwrap();
482        assert_eq!(r.source_path.as_deref(), Some("/mnt/eros/Media"));
483        assert_eq!(identity_string(&r), "media@default:/mnt/eros/Media");
484    }
485
486    #[test]
487    fn override_beats_default_source_path() {
488        let mut i = inputs("media", "default", None, None, None);
489        i.default_source_path = Some("/mnt/eros/Media");
490        i.source_path_override = Some("/data");
491        let r = resolve_identity(&i).unwrap();
492        assert_eq!(r.source_path.as_deref(), Some("/data"));
493    }
494
495    #[test]
496    fn defaults_use_name_namespace_and_pvc_path() {
497        let r = resolve_identity(&inputs(
498            "postgres-data",
499            "billing",
500            None,
501            None,
502            Some("postgres-data"),
503        ))
504        .unwrap();
505        assert_eq!(r.username, "postgres-data");
506        assert_eq!(r.hostname, "billing");
507        assert_eq!(r.source_path.as_deref(), Some("/pvc/postgres-data"));
508    }
509
510    // --- M1: identityDefaults.cluster hostname precedence -------------------
511    // explicit override > hostnameExpr > `<namespace>.<cluster>` (cluster set) > namespace.
512
513    #[test]
514    fn cluster_default_is_used_when_no_override_or_expr() {
515        let d = defaults_with_cluster(None, None, Some("east"));
516        let r = resolve_identity(&inputs(
517            "postgres-data",
518            "billing",
519            None,
520            Some(&d),
521            Some("data"),
522        ))
523        .unwrap();
524        assert_eq!(r.hostname, "billing.east");
525        // username is unaffected — cluster is not part of its default.
526        assert_eq!(r.username, "postgres-data");
527    }
528
529    #[test]
530    fn no_cluster_no_expr_no_override_falls_back_to_bare_namespace() {
531        // The `defaults_use_name_namespace_and_pvc_path` test above already covers
532        // `defaults: None`; this covers `defaults: Some(..)` with `cluster: None`.
533        let d = defaults_with_cluster(None, None, None);
534        let r = resolve_identity(&inputs("cfg", "billing", None, Some(&d), Some("data"))).unwrap();
535        assert_eq!(r.hostname, "billing");
536    }
537
538    #[test]
539    fn explicit_hostname_override_beats_cluster_default() {
540        let d = defaults_with_cluster(None, None, Some("east"));
541        let ovr = Identity {
542            username: None,
543            hostname: Some("pinned-host".to_string()),
544        };
545        let r = resolve_identity(&inputs(
546            "postgres-data",
547            "billing",
548            Some(&ovr),
549            Some(&d),
550            Some("data"),
551        ))
552        .unwrap();
553        assert_eq!(r.hostname, "pinned-host");
554    }
555
556    #[test]
557    fn hostname_expr_beats_cluster_default() {
558        // Both a hostnameExpr AND a cluster default are set; the expr wins (the
559        // cluster tier only applies when there is no expression at all).
560        let d = defaults_with_cluster(Some("namespace"), None, Some("east"));
561        let r = resolve_identity(&inputs(
562            "postgres-data",
563            "billing",
564            None,
565            Some(&d),
566            Some("data"),
567        ))
568        .unwrap();
569        assert_eq!(r.hostname, "billing"); // NOT "billing.east"
570    }
571
572    #[test]
573    fn hostname_expr_can_reference_cluster_variable() {
574        // hostnameExpr explicitly renders using `cluster` — the brief's example.
575        let d = defaults_with_cluster(Some("namespace + '.' + cluster"), None, Some("east"));
576        let r = resolve_identity(&inputs(
577            "postgres-data",
578            "billing",
579            None,
580            Some(&d),
581            Some("data"),
582        ))
583        .unwrap();
584        assert_eq!(r.hostname, "billing.east");
585    }
586
587    #[test]
588    fn cluster_variable_is_empty_string_when_defaults_lack_cluster() {
589        // Same expression, but `identityDefaults.cluster` isn't set: `cluster`
590        // evaluates to "" (identity_context's documented "unset" value).
591        let d = defaults_with_cluster(Some("namespace + '.' + cluster"), None, None);
592        let r = resolve_identity(&inputs(
593            "postgres-data",
594            "billing",
595            None,
596            Some(&d),
597            Some("data"),
598        ))
599        .unwrap();
600        assert_eq!(r.hostname, "billing.");
601    }
602
603    #[test]
604    fn validate_identity_expr_admits_cluster_reference_even_without_a_real_cluster_default() {
605        // The *admission-time trial* always declares `cluster` (a placeholder),
606        // independent of whether the real ClusterRepository sets
607        // identityDefaults.cluster — so this must admit even though no `defaults`
608        // is threaded through validate_identity_expr's caller in this test.
609        assert!(validate_identity_expr("namespace + '.' + cluster").is_ok());
610        assert!(validate_identity_expr("cluster").is_ok());
611    }
612
613    #[test]
614    fn adr_cluster_repository_cel_example() {
615        // ADR-0004 §5 example:
616        //   hostnameExpr: "namespace"
617        //   usernameExpr: "namespace + '-' + policyName"
618        // For namespace `billing`, policy `postgres-data`, must evaluate to
619        //   username = billing-postgres-data, hostname = billing.
620        let d = defaults(Some("namespace"), Some("namespace + '-' + policyName"));
621        let r = resolve_identity(&inputs(
622            "postgres-data",
623            "billing",
624            None,
625            Some(&d),
626            Some("data"),
627        ))
628        .unwrap();
629        assert_eq!(r.username, "billing-postgres-data");
630        assert_eq!(r.hostname, "billing");
631    }
632
633    #[test]
634    fn cel_conditional_on_labels_resolves() {
635        // ADR-0004 §5 conditional: 'team' in labels ? labels['team'] : namespace.
636        let d = defaults(
637            Some("'team' in labels ? labels['team'] : namespace"),
638            Some("namespace + '-' + policyName"),
639        );
640        let labels = BTreeMap::from([("team".to_string(), "payments".to_string())]);
641        let mut i = inputs("atuin", "default", None, Some(&d), Some("data"));
642        i.labels = Some(&labels);
643        let r = resolve_identity(&i).unwrap();
644        assert_eq!(r.hostname, "payments"); // label present → label value
645        assert_eq!(r.username, "default-atuin");
646
647        // No `team` label → falls back to namespace.
648        let mut i2 = inputs("atuin", "default", None, Some(&d), Some("data"));
649        i2.labels = None;
650        assert_eq!(resolve_identity(&i2).unwrap().hostname, "default");
651    }
652
653    #[test]
654    fn override_beats_expr() {
655        let d = defaults(Some("namespace"), Some("namespace + '-' + policyName"));
656        let ovr = Identity {
657            username: Some("custom-user".to_string()),
658            hostname: Some("custom-host".to_string()),
659        };
660        let r = resolve_identity(&inputs("cfg", "ns", Some(&ovr), Some(&d), Some("p"))).unwrap();
661        assert_eq!(r.username, "custom-user");
662        assert_eq!(r.hostname, "custom-host");
663    }
664
665    #[test]
666    fn partial_override_falls_through_to_expr_for_the_other_field() {
667        let d = defaults(Some("namespace"), Some("namespace + '-' + policyName"));
668        // Only hostname overridden; username still comes from the expression.
669        let ovr = Identity {
670            username: None,
671            hostname: Some("pinned-host".to_string()),
672        };
673        let r = resolve_identity(&inputs(
674            "postgres-data",
675            "billing",
676            Some(&ovr),
677            Some(&d),
678            Some("d"),
679        ))
680        .unwrap();
681        assert_eq!(r.hostname, "pinned-host");
682        assert_eq!(r.username, "billing-postgres-data");
683    }
684
685    #[test]
686    fn source_path_override_beats_default() {
687        let mut i = inputs("cfg", "ns", None, None, Some("vol"));
688        i.source_path_override = Some("/data");
689        let r = resolve_identity(&i).unwrap();
690        assert_eq!(r.source_path.as_deref(), Some("/data"));
691    }
692
693    #[test]
694    fn no_pvc_yields_no_source_path() {
695        let r = resolve_identity(&inputs("cfg", "ns", None, None, None)).unwrap();
696        assert_eq!(r.source_path, None);
697    }
698
699    #[test]
700    fn identity_string_with_and_without_path() {
701        let with = ResolvedIdentity {
702            username: "postgres-data".into(),
703            hostname: "billing".into(),
704            source_path: Some("/data".into()),
705        };
706        assert_eq!(identity_string(&with), "postgres-data@billing:/data");
707        let without = ResolvedIdentity {
708            source_path: None,
709            ..with
710        };
711        assert_eq!(identity_string(&without), "postgres-data@billing");
712    }
713
714    #[test]
715    fn resolve_rejects_override_with_kopia_delimiter() {
716        // An explicit override carrying kopia's '@' delimiter would misparse — rejected
717        // at resolution so the controller never pins it.
718        let ovr = Identity {
719            username: Some("bad@user".to_string()),
720            hostname: None,
721        };
722        let err = resolve_identity(&inputs("c", "n", Some(&ovr), None, Some("p"))).unwrap_err();
723        assert!(matches!(
724            err,
725            ValidationError::IdentityComponentInvalid { .. }
726        ));
727    }
728
729    #[test]
730    fn resolve_rejects_cel_result_with_delimiter() {
731        // A CEL expression that resolves to a string with ':' is caught at resolution
732        // (the static override validator can't see CEL results).
733        let d = defaults(None, Some("'a:b'"));
734        let err = resolve_identity(&inputs("c", "n", None, Some(&d), Some("p"))).unwrap_err();
735        assert!(matches!(
736            err,
737            ValidationError::IdentityComponentInvalid { .. }
738        ));
739    }
740
741    #[test]
742    fn malformed_expr_is_rejected_at_resolve() {
743        let d = defaults(Some("namespace +"), None); // syntax error
744        let err = resolve_identity(&inputs("c", "n", None, Some(&d), Some("p"))).unwrap_err();
745        assert!(matches!(err, ValidationError::IdentityExprCompile { .. }));
746    }
747
748    // --- validate_identity_expr (admission-time check, ADR-0004 §5) ---
749
750    #[test]
751    fn validate_accepts_valid_string_exprs() {
752        assert!(validate_identity_expr("namespace").is_ok());
753        assert!(validate_identity_expr("namespace + '-' + policyName").is_ok());
754        assert!(validate_identity_expr("'team' in labels ? labels['team'] : namespace").is_ok());
755        // Data-dependent map index is tolerated (the real object may supply the key).
756        assert!(
757            validate_identity_expr("namespace + (labels['env'] == 'prod' ? '-prod' : '')").is_ok()
758        );
759    }
760
761    #[test]
762    fn validate_rejects_syntax_error() {
763        let err = validate_identity_expr("namespace +").unwrap_err();
764        assert!(matches!(err, ValidationError::IdentityExprCompile { .. }));
765    }
766
767    #[test]
768    fn validate_rejects_out_of_scope_variable() {
769        // `namspace` is a typo — an undeclared reference, caught at trial-eval.
770        let err = validate_identity_expr("namspace").unwrap_err();
771        assert!(matches!(err, ValidationError::IdentityExprEval { .. }));
772    }
773
774    #[test]
775    fn validate_rejects_non_string_result() {
776        // A bool/int result is not a valid hostname/username.
777        let err = validate_identity_expr("1 + 1").unwrap_err();
778        assert!(matches!(err, ValidationError::IdentityExprType { .. }));
779    }
780
781    #[test]
782    fn validate_rejects_over_length_expr() {
783        let long = format!("'{}'", "a".repeat(MAX_EXPR_LEN));
784        let err = validate_identity_expr(&long).unwrap_err();
785        assert!(matches!(err, ValidationError::IdentityExprCompile { .. }));
786    }
787
788    // --- classify_hostname (M1) ---
789
790    #[test]
791    fn classify_hostname_table() {
792        let cases: &[(&str, Option<&str>, HostClass<'_>)] = &[
793            // No cluster identity configured at all: always Bare, dot or no dot.
794            (
795                "billing",
796                None,
797                HostClass::Bare {
798                    namespace: "billing",
799                },
800            ),
801            (
802                "billing.east",
803                None,
804                HostClass::Bare {
805                    namespace: "billing.east",
806                },
807            ),
808            // Empty cluster string behaves like None (defensive; shouldn't occur post
809            // admission-validation, but classify_hostname is total).
810            (
811                "billing",
812                Some(""),
813                HostClass::Bare {
814                    namespace: "billing",
815                },
816            ),
817            // No dot in the hostname at all.
818            (
819                "billing",
820                Some("east"),
821                HostClass::Bare {
822                    namespace: "billing",
823                },
824            ),
825            // Exact suffix match => ours.
826            (
827                "ns.east",
828                Some("east"),
829                HostClass::OwnCluster { namespace: "ns" },
830            ),
831            // Different suffix => another cluster's.
832            (
833                "ns.west",
834                Some("east"),
835                HostClass::ForeignCluster { suffix: "west" },
836            ),
837            // Only an EXACT suffix match is "own" — "east.x" != "east".
838            (
839                "ns.east.x",
840                Some("east"),
841                HostClass::ForeignCluster { suffix: "east.x" },
842            ),
843            // Empty namespace part or empty suffix: Bare, whole hostname verbatim.
844            (
845                ".east",
846                Some("east"),
847                HostClass::Bare { namespace: ".east" },
848            ),
849            ("ns.", Some("east"), HostClass::Bare { namespace: "ns." }),
850            // Case-sensitive suffix comparison.
851            (
852                "ns.East",
853                Some("east"),
854                HostClass::ForeignCluster { suffix: "East" },
855            ),
856            // Hostname exactly equal to the cluster name, no dot => Bare, never
857            // OwnCluster (there is no namespace/cluster split to make).
858            ("east", Some("east"), HostClass::Bare { namespace: "east" }),
859        ];
860        for (hostname, cluster, expected) in cases {
861            assert_eq!(
862                classify_hostname(hostname, *cluster),
863                *expected,
864                "classify_hostname({hostname:?}, {cluster:?})"
865            );
866        }
867    }
868
869    #[test]
870    fn classify_hostname_round_trips_the_rendered_default() {
871        // For any valid namespace + cluster, classifying the hostname
872        // resolve_identity's own default would render must come back OwnCluster
873        // with the original namespace — the write path and the read path agree.
874        for (ns, cluster) in [
875            ("billing", "east"),
876            ("a", "b"),
877            ("kube-system", "prod-1"),
878            ("default", "us-west-2"),
879        ] {
880            let rendered = format!("{ns}.{cluster}");
881            assert_eq!(
882                classify_hostname(&rendered, Some(cluster)),
883                HostClass::OwnCluster { namespace: ns },
884                "rendered={rendered:?} cluster={cluster:?}"
885            );
886        }
887    }
888}