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}