tatara_process/encapsulates.rs
1//! `EncapsulatesSpec` — how a Process relates to pre-existing
2//! in-cluster state.
3//!
4//! The substrate move: every long-running workload on a pleme-io
5//! cluster — raw HelmReleases, Flux Kustomizations, bare Deployments
6//! — becomes a Process without disruption. Three modes:
7//!
8//! * **Manage** (default) — Process IS the control loop. New
9//! HR/Kustomization emitted by the reconciler use ownerRefs
10//! pointing at the Process; cascade-delete on Reaped.
11//!
12//! * **Adopt** — Take over an existing HR/Kustomization in place.
13//! Reconciler emits a new HR with `releaseName` matching the
14//! running release; helm-controller adopts the existing release
15//! under new management. **No pod restart**; no values diff
16//! unless operator changes them. The original raw HR can be
17//! deleted from git after the takeover confirms.
18//!
19//! * **Observe** — Read-only awareness. Process watches the existing
20//! state for postcondition pillars + emits routing/exports/
21//! attestation, but does NOT modify or own the underlying
22//! HR/Kustomization. Useful for adding DNS + observability to
23//! legacy stacks without taking over.
24//!
25//! These compose progressively: Observe an HR first to confirm
26//! shape, promote to Adopt for zero-downtime takeover, then to
27//! Manage once the Process drives values.
28//!
29//! Lisp authoring:
30//! ```lisp
31//! :encapsulates (:kind (:existing-helm-release
32//! :namespace "akeyless"
33//! :name "akeyless-saas"
34//! :release-name "akeyless-saas-consolidated")
35//! :mode Adopt)
36//! ```
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40use std::collections::BTreeMap;
41use tatara_lisp::DeriveTataraDomain;
42
43/// How a Process wraps pre-existing in-cluster state.
44///
45/// Optional on `ProcessSpec` — None means the Process is greenfield
46/// (Manage mode applied to nothing pre-existing). The render phase
47/// branches on `kind` to decide whether to emit fresh resources or
48/// reference/adopt running ones.
49#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "camelCase")]
51#[tatara(keyword = "defencapsulates")]
52pub struct EncapsulatesSpec {
53 /// What kind of pre-existing state.
54 pub kind: EncapsulationKind,
55
56 /// Reconciler's relationship to that state. Defaults to `Manage`
57 /// (the operational default when `encapsulates` is set without
58 /// an explicit mode).
59 #[serde(default)]
60 pub mode: EncapsulationMode,
61}
62
63/// Three concrete kinds the substrate knows how to wrap. Exactly-
64/// one-Option pattern matching `Intent` / `Lifetime` — additive on
65/// the wire, every variant typed.
66#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "camelCase")]
68pub struct EncapsulationKind {
69 /// An existing FluxCD HelmRelease. The reconciler emits a new HR
70 /// with the SAME `release_name` — helm-controller finds + adopts
71 /// the in-cluster release without recreating Pods.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub existing_helm_release: Option<ExistingHelmRelease>,
74
75 /// An existing FluxCD Kustomization. The reconciler stops emitting
76 /// its own and instead references the existing one.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub existing_kustomization: Option<ExistingKustomization>,
79
80 /// Pre-existing in-cluster workload (Deployment/StatefulSet/etc)
81 /// not Flux-managed. The reconciler adds ownerRefs + emits
82 /// routing only. The workload stays where it is.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub bare_workload: Option<BareWorkload>,
85}
86
87/// Resolved enum view used by the render phase.
88#[derive(Clone, Debug)]
89pub enum EncapsulationKindVariant<'a> {
90 ExistingHelmRelease(&'a ExistingHelmRelease),
91 ExistingKustomization(&'a ExistingKustomization),
92 BareWorkload(&'a BareWorkload),
93}
94
95impl EncapsulationKindVariant<'_> {
96 /// Reverse projection — every borrowed variant knows its
97 /// [`EncapsulationTarget`] discriminator. Pairs with
98 /// [`EncapsulationTarget::select`] so
99 /// `EncapsulationTarget::select(kind).map(|v| v.target())`
100 /// round-trips the closed set on the populated side; pinned by
101 /// `encapsulation_target_round_trips_through_variant_target`.
102 /// Future target-keyed consumers (metric labels like
103 /// `tatara_encapsulations_total{target="existingHelmRelease"}`,
104 /// status reason strings, audit-trail classifiers, LSP completion
105 /// lists) reach through this projection instead of pattern-matching
106 /// the payload-carrying view.
107 pub fn target(&self) -> EncapsulationTarget {
108 match self {
109 Self::ExistingHelmRelease(_) => EncapsulationTarget::ExistingHelmRelease,
110 Self::ExistingKustomization(_) => EncapsulationTarget::ExistingKustomization,
111 Self::BareWorkload(_) => EncapsulationTarget::BareWorkload,
112 }
113 }
114}
115
116/// Closed-set discriminator over `EncapsulationKind`'s three tagged-union
117/// slots. Single source of truth that drives `EncapsulationKind::variant`'s
118/// ambiguity + emptiness resolver, the `EncapsulationKindError::Empty`
119/// diagnostic message, and the reverse `EncapsulationKindVariant::target`
120/// projection. Adding a fourth encapsulation target (e.g., a future
121/// `ExistingNamespace`, `ExistingDaemonSet`, or `ExistingService`) lands
122/// at one `ALL` entry + one `as_str` arm + one `select` arm + one
123/// `EncapsulationKindVariant::target` arm — exhaustively checked by the
124/// compiler.
125///
126/// The (open authoring surface, closed typed discriminator) split mirrors
127/// every other multi-Option tagged union on this `ProcessSpec` axis:
128/// [`crate::intent::IntentKind`] discriminates [`crate::intent::Intent`];
129/// [`crate::lifetime::LifetimeKind`] discriminates
130/// [`crate::lifetime::Lifetime`];
131/// [`crate::export::ArtifactKind`] discriminates
132/// [`crate::export::ArtifactSource`];
133/// [`crate::export::ChannelKind`] discriminates
134/// [`crate::export::VectorChannel`]. The carrier here is named
135/// `EncapsulationKind` (not `Encapsulation`) because it predates the
136/// closed-set lift convention; `EncapsulationTarget` is the typed
137/// discriminator the rest of the typescape projects through, named for
138/// the semantic role each variant plays (a target of encapsulation).
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
140#[closed_set(via = "as_str", generate_unknown, display)]
141pub enum EncapsulationTarget {
142 ExistingHelmRelease,
143 ExistingKustomization,
144 BareWorkload,
145}
146
147impl EncapsulationTarget {
148 /// The closed set of encapsulation targets — single source of truth
149 /// that drives `EncapsulationKind::variant`'s sweep so a variant
150 /// added without an `ALL` entry never reaches the resolver. The
151 /// `[Self; 3]` array literal forces the arity at compile time.
152 pub const ALL: [Self; 3] = [
153 Self::ExistingHelmRelease,
154 Self::ExistingKustomization,
155 Self::BareWorkload,
156 ];
157
158 /// Canonical camelCase wire-format key — matches the serde
159 /// `rename_all = "camelCase"` field name on the corresponding
160 /// `Option<…>` slot of `EncapsulationKind`. The
161 /// `EncapsulationKindError::Empty` diagnostic composes the
162 /// human-readable list from this projection so a new variant lands
163 /// in the operator-facing diagnostic automatically via the `ALL`
164 /// sweep, not via hand-maintained error-string drift. Pinned by
165 /// `encapsulation_target_as_str_matches_field_name`.
166 pub const fn as_str(self) -> &'static str {
167 match self {
168 Self::ExistingHelmRelease => "existingHelmRelease",
169 Self::ExistingKustomization => "existingKustomization",
170 Self::BareWorkload => "bareWorkload",
171 }
172 }
173
174 /// Project an `EncapsulationKind` borrow into the optional typed
175 /// variant view for this target. Returns `None` iff the matching
176 /// slot is `None`. Composes the closed-set sweep
177 /// `EncapsulationKind::variant` loops over. Mirrors
178 /// [`crate::intent::IntentKind::select`],
179 /// [`crate::lifetime::LifetimeKind::select`],
180 /// [`crate::export::ArtifactKind::select`], and
181 /// [`crate::export::ChannelKind::select`].
182 pub fn select<'a>(self, kind: &'a EncapsulationKind) -> Option<EncapsulationKindVariant<'a>> {
183 match self {
184 Self::ExistingHelmRelease => kind
185 .existing_helm_release
186 .as_ref()
187 .map(EncapsulationKindVariant::ExistingHelmRelease),
188 Self::ExistingKustomization => kind
189 .existing_kustomization
190 .as_ref()
191 .map(EncapsulationKindVariant::ExistingKustomization),
192 Self::BareWorkload => kind
193 .bare_workload
194 .as_ref()
195 .map(EncapsulationKindVariant::BareWorkload),
196 }
197 }
198}
199
200// `impl FromStr for EncapsulationTarget` + `impl tatara_lisp::ClosedSet for
201// EncapsulationTarget` + `impl std::fmt::Display for EncapsulationTarget` are
202// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
203// declaration above. `label` delegates to the inherent
204// `EncapsulationTarget::as_str` via `#[closed_set(via = "as_str")]` so the
205// camelCase wire-format projection stays load-bearing (matches the serde
206// `rename_all = "camelCase"` field names on `EncapsulationKind` AND the
207// `ENCAPSULATION_TARGET_LIST` slash-joined operator diagnostic verbatim)
208// while generic `T: ClosedSet` consumers reach the STABLE workspace-wide
209// name (`label`). The `display` flag emits the `f.write_str(self.as_str())`
210// delegation block at the same proc-macro site rather than a hand-rolled
211// `fmt::Display` block per implementor.
212
213// `pub struct UnknownEncapsulationTarget(pub String)` is generated by
214// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
215// on the enum declaration above. The auto-derived label `"encapsulation target"`
216// matches the prior hand-rolled `#[error("unknown encapsulation target: {0}")]`
217// verbatim — pinned generically by clause (5) of
218// `tatara_closed_set::assert_closed_set_well_formed::<EncapsulationTarget>()` (called
219// from `encapsulation_target_is_well_formed_closed_set` in the test module).
220// Symmetric to [`UnknownEncapsulationMode`], [`crate::export::UnknownArtifactKind`],
221// [`crate::export::UnknownChannelKind`], and
222// [`crate::lifetime::UnknownTeardownPolicy`].
223
224#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
225pub enum EncapsulationKindError {
226 #[error("encapsulation kind has no variant set (one of {0} required)")]
227 Empty(&'static str),
228 #[error("encapsulation kind has multiple variants set; exactly one required")]
229 Ambiguous,
230}
231
232/// Slash-joined list of every `EncapsulationTarget::as_str()` — composed
233/// once at compile time so `EncapsulationKindError::Empty`'s diagnostic
234/// carries the closed-set summary without per-variant string drift.
235/// Mirrors [`crate::intent::INTENT_KIND_LIST`] /
236/// [`crate::export::ARTIFACT_KIND_LIST`] in shape; pinned by
237/// `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`.
238const ENCAPSULATION_TARGET_LIST: &str = "existingHelmRelease/existingKustomization/bareWorkload";
239
240impl EncapsulationKind {
241 /// Resolve to exactly one variant. Errors on zero or many.
242 ///
243 /// Sweeps over [`EncapsulationTarget::ALL`] so a fourth variant added
244 /// with an `ALL` entry is structurally honored at this site — no
245 /// parallel `is_some()` count, no per-variant if-let chain, no
246 /// `unreachable!()`. The Empty diagnostic carries the closed-set
247 /// list via `ENCAPSULATION_TARGET_LIST`.
248 pub fn variant(&self) -> Result<EncapsulationKindVariant<'_>, EncapsulationKindError> {
249 use crate::tagged_union::{resolve, ResolveError};
250 resolve(EncapsulationTarget::ALL.into_iter().map(|t| t.select(self))).map_err(|e| match e {
251 ResolveError::None => EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST),
252 ResolveError::Many => EncapsulationKindError::Ambiguous,
253 })
254 }
255}
256
257/// Pointer to an existing FluxCD HelmRelease the Process wraps.
258#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "camelCase")]
260pub struct ExistingHelmRelease {
261 /// Namespace of the HelmRelease CR.
262 pub namespace: String,
263 /// Name of the HelmRelease CR.
264 pub name: String,
265 /// The `spec.releaseName` Helm used for the actual chart install.
266 /// For Adopt mode, the reconciler's emitted HR matches this so
267 /// helm-controller adopts in-place. Required because the HR's
268 /// `metadata.name` and `spec.releaseName` aren't always equal.
269 pub release_name: String,
270}
271
272/// Pointer to an existing FluxCD Kustomization the Process wraps.
273#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
274#[serde(rename_all = "camelCase")]
275pub struct ExistingKustomization {
276 pub namespace: String,
277 pub name: String,
278}
279
280/// Pointer to a bare in-cluster workload (not Flux-managed). The
281/// reconciler identifies the underlying Pods by `selector` and adds
282/// ownerRefs / routing without emitting a new HR/Kustomization.
283#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
284#[serde(rename_all = "camelCase")]
285pub struct BareWorkload {
286 /// Namespace the workload lives in.
287 pub namespace: String,
288 /// Label selector. Must match a single Deployment/StatefulSet/
289 /// DaemonSet; multiple matches are a config error.
290 pub selector: BTreeMap<String, String>,
291}
292
293/// Three modes the reconciler dispatches on at render time.
294#[derive(
295 Clone,
296 Copy,
297 Debug,
298 Default,
299 Serialize,
300 Deserialize,
301 JsonSchema,
302 PartialEq,
303 Eq,
304 Hash,
305 tatara_closed_set::DeriveClosedSet,
306)]
307#[serde(rename_all = "PascalCase")]
308#[closed_set(via = "as_str", generate_unknown, display)]
309pub enum EncapsulationMode {
310 /// **Default** — Process IS the control loop for whatever is
311 /// inside. Emitted HR/Kustomization carry the Process's
312 /// ownerRefs; cascade-delete on Reaped.
313 #[default]
314 Manage,
315
316 /// **Adopt** — Take over the existing release/kustomization in
317 /// place. New HR emitted matches the existing `releaseName`;
318 /// pods don't restart. Used during migration from raw HR → Process.
319 Adopt,
320
321 /// **Observe** — Read-only. Emit routing/exports/attestation but
322 /// don't modify the underlying HR/Kustomization. Used to add
323 /// DNS + observability to legacy stacks without taking over.
324 Observe,
325}
326
327impl EncapsulationMode {
328 /// The closed set of encapsulation modes — single source of truth
329 /// that drives the `as_str` / Display / `FromStr` triad and the
330 /// typed `emits_workload` / `preserves_release_name` dispatch.
331 /// Adding a fourth variant lands at one `ALL` entry + one `as_str`
332 /// arm + one arm in each of the two boolean projections —
333 /// exhaustively checked by the compiler (the `[Self; 3]` array
334 /// literal forces the arity).
335 ///
336 /// Sibling closed-set lifts on the same `ProcessSpec` axis:
337 /// [`crate::export::ExportTrigger::ALL`],
338 /// [`crate::lifetime::TeardownPolicy::ALL`],
339 /// [`crate::intent::IntentKind::ALL`],
340 /// [`crate::lifetime::LifetimeKind::ALL`],
341 /// [`crate::boundary::ConditionKind::ALL`],
342 /// [`crate::phase::ProcessPhase::ALL`],
343 /// [`crate::signal::ProcessSignal::ALL`].
344 pub const ALL: [Self; 3] = [Self::Manage, Self::Adopt, Self::Observe];
345
346 /// Canonical PascalCase wire-format projection — matches the serde
347 /// `rename_all = "PascalCase"` output verbatim. Used by Display
348 /// (single source of truth), by `FromStr` to identify the variant
349 /// from its annotation / status-field representation, and by
350 /// operator-facing reason strings without reaching for `{:?}` Debug
351 /// formatting. Pinned by `mode_as_str_matches_serde`.
352 pub const fn as_str(self) -> &'static str {
353 match self {
354 Self::Manage => "Manage",
355 Self::Adopt => "Adopt",
356 Self::Observe => "Observe",
357 }
358 }
359
360 /// True iff the reconciler should emit (or re-emit) the
361 /// underlying HR/Kustomization at render time.
362 /// Observe ⇒ false; Manage/Adopt ⇒ true.
363 ///
364 /// Closed-set match (not `matches!`) so adding a fourth variant
365 /// triggers the compiler's exhaustiveness check at this site
366 /// rather than silently defaulting to `false`. ONE typed dispatch
367 /// over the closed set that replaces the
368 /// `mode == EncapsulationMode::Observe` hand-rolled equality at
369 /// the reconciler's render entry — the truth table for "should
370 /// this mode emit a workload?" is now owned by the typed surface,
371 /// not by a pattern fragment two crates have to keep coherent.
372 pub const fn emits_workload(self) -> bool {
373 match self {
374 Self::Manage | Self::Adopt => true,
375 Self::Observe => false,
376 }
377 }
378
379 /// True iff the reconciler should preserve the existing release
380 /// name (so helm-controller adopts in-place). Only Adopt.
381 ///
382 /// Closed-set match (not `matches!`) so adding a fourth variant
383 /// triggers the compiler's exhaustiveness check at this site.
384 /// ONE typed dispatch that replaces the
385 /// `mode == EncapsulationMode::Adopt` hand-rolled equality at the
386 /// reconciler's `render_aplicacao` adoption-annotation branch.
387 pub const fn preserves_release_name(self) -> bool {
388 match self {
389 Self::Adopt => true,
390 Self::Manage | Self::Observe => false,
391 }
392 }
393}
394
395// `impl FromStr for EncapsulationMode` + `impl tatara_lisp::ClosedSet for
396// EncapsulationMode` + `impl std::fmt::Display for EncapsulationMode` are
397// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
398// declaration above. `label` delegates to the inherent
399// `EncapsulationMode::as_str` via `#[closed_set(via = "as_str")]` so the
400// PascalCase wire-format projection stays load-bearing (matches the serde
401// `rename_all = "PascalCase"` external-tag form on the wire AND the
402// reconciler's `mode: {Manage,Adopt,Observe}` status-condition reason
403// strings verbatim) while generic `T: ClosedSet` consumers reach the
404// STABLE workspace-wide name (`label`). The `display` flag emits the
405// `f.write_str(self.as_str())` delegation block at the same proc-macro
406// site rather than a hand-rolled `fmt::Display` block per implementor.
407
408// `pub struct UnknownEncapsulationMode(pub String)` is generated by
409// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
410// on the enum declaration above. The auto-derived label `"encapsulation mode"`
411// matches the prior hand-rolled `#[error("unknown encapsulation mode: {0}")]`
412// verbatim — pinned generically by clause (5) of
413// `tatara_closed_set::assert_closed_set_well_formed::<EncapsulationMode>()` (called
414// from `mode_is_well_formed_closed_set` in the test module).
415// Symmetric to [`UnknownEncapsulationTarget`], [`crate::export::UnknownExportTrigger`],
416// [`crate::lifetime::UnknownTeardownPolicy`],
417// [`crate::boundary::UnknownConditionKind`], and
418// [`crate::phase::UnknownPhase`].
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn akeyless_adopt() -> EncapsulatesSpec {
425 EncapsulatesSpec {
426 kind: EncapsulationKind {
427 existing_helm_release: Some(ExistingHelmRelease {
428 namespace: "akeyless".into(),
429 name: "akeyless-saas".into(),
430 release_name: "akeyless-saas-consolidated".into(),
431 }),
432 ..EncapsulationKind::default()
433 },
434 mode: EncapsulationMode::Adopt,
435 }
436 }
437
438 #[test]
439 fn kind_empty_errors() {
440 let k = EncapsulationKind::default();
441 assert_eq!(
442 k.variant().unwrap_err(),
443 EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST)
444 );
445 }
446
447 #[test]
448 fn kind_existing_hr_resolves() {
449 let s = akeyless_adopt();
450 match s.kind.variant().unwrap() {
451 EncapsulationKindVariant::ExistingHelmRelease(h) => {
452 assert_eq!(h.namespace, "akeyless");
453 assert_eq!(h.release_name, "akeyless-saas-consolidated");
454 }
455 other => panic!("expected ExistingHelmRelease, got {other:?}"),
456 }
457 }
458
459 #[test]
460 fn kind_two_variants_ambiguous() {
461 let k = EncapsulationKind {
462 existing_helm_release: Some(ExistingHelmRelease {
463 namespace: "ns".into(),
464 name: "n".into(),
465 release_name: "r".into(),
466 }),
467 existing_kustomization: Some(ExistingKustomization {
468 namespace: "ns".into(),
469 name: "n".into(),
470 }),
471 ..EncapsulationKind::default()
472 };
473 assert_eq!(k.variant().unwrap_err(), EncapsulationKindError::Ambiguous);
474 }
475
476 #[test]
477 fn mode_dispatch() {
478 assert!(EncapsulationMode::Manage.emits_workload());
479 assert!(EncapsulationMode::Adopt.emits_workload());
480 assert!(!EncapsulationMode::Observe.emits_workload());
481
482 assert!(!EncapsulationMode::Manage.preserves_release_name());
483 assert!(EncapsulationMode::Adopt.preserves_release_name());
484 assert!(!EncapsulationMode::Observe.preserves_release_name());
485 }
486
487 #[test]
488 fn mode_default_is_manage() {
489 assert_eq!(EncapsulationMode::default(), EncapsulationMode::Manage);
490 }
491
492 // ── closed-set algebra for EncapsulationMode (ALL × as_str ×
493 // Display × FromStr × emits_workload × preserves_release_name) ─
494
495 /// Structural well-formedness of [`EncapsulationMode`] as a
496 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
497 /// testkit lift that pins all three structural invariants (`ALL`
498 /// is non-empty, every variant round-trips through
499 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
500 /// outside the closed set) at ONE call site. Replaces the hand-
501 /// derived `mode_all_is_unique_and_complete` +
502 /// `mode_roundtrip_via_as_str` + the empty-input arm of
503 /// `unknown_encapsulation_mode_errors`. `FromStr` delegates to
504 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
505 /// exercises the same code path the reconciler hits when parsing a
506 /// CRD `enum:`-validated `mode` value back to the typed mode.
507 #[test]
508 fn mode_is_well_formed_closed_set() {
509 tatara_closed_set::assert_closed_set_well_formed::<EncapsulationMode>();
510 }
511
512 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
513 /// output verbatim for every variant. A future variant rename
514 /// (or an `as_str` arm typo) lands here at one site, instead of
515 /// drifting between the typed surface and the YAML wire format
516 /// the reconciler / operator both read.
517 #[test]
518 fn mode_as_str_matches_serde() {
519 for mode in EncapsulationMode::ALL {
520 let serialized = serde_json::to_string(&mode).expect("serialize");
521 let unquoted = serialized
522 .trim_start_matches('"')
523 .trim_end_matches('"')
524 .to_string();
525 assert_eq!(
526 unquoted,
527 mode.as_str(),
528 "as_str drift for {mode:?}: as_str={} serde={unquoted}",
529 mode.as_str()
530 );
531 }
532 }
533
534 /// The Display impl IS `as_str` — pinning this lets future callers
535 /// reach for either projection without drift. If a reviewer
536 /// accidentally re-introduces an inline match in Display, this
537 /// test would fail the moment a variant rename touches one site
538 /// but not the other.
539 #[test]
540 fn mode_display_matches_as_str() {
541 for mode in EncapsulationMode::ALL {
542 assert_eq!(mode.to_string(), mode.as_str());
543 }
544 }
545
546 /// `FromStr` rejects strings that aren't in the canonical
547 /// projection — lowercased / typo / unrelated — and the error
548 /// echoes the input verbatim so the operator-facing diagnostic
549 /// carries the offending value, not a normalized form. The
550 /// empty-input arm is pinned by
551 /// [`mode_is_well_formed_closed_set`] via the
552 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
553 /// verbatim-echo contract on the [`UnknownEncapsulationMode`]
554 /// newtype, which the trait's `make_unknown` can't see.
555 #[test]
556 fn unknown_encapsulation_mode_errors() {
557 use std::str::FromStr;
558 for bad in ["manage", "ADOPT", "Observed", "Wrap"] {
559 let err = EncapsulationMode::from_str(bad).unwrap_err();
560 assert_eq!(err.0, bad, "error payload should echo input verbatim");
561 }
562 }
563
564 /// TRUTH-TABLE CONTRACT: `emits_workload` / `preserves_release_name`
565 /// agree with the documented (mode) -> (bool, bool) table for every
566 /// variant. A new variant in `EncapsulationMode` without extending
567 /// either projection's match is caught by the compiler (closed-set
568 /// match in each method); adding a variant without extending its
569 /// truth row is caught here.
570 #[test]
571 fn mode_projection_truth_table() {
572 let table: &[(EncapsulationMode, bool, bool)] = &[
573 // (mode, emits_workload, preserves_release_name)
574 (EncapsulationMode::Manage, true, false),
575 (EncapsulationMode::Adopt, true, true),
576 (EncapsulationMode::Observe, false, false),
577 ];
578 assert_eq!(table.len(), EncapsulationMode::ALL.len());
579 for (mode, emits, preserves) in table {
580 assert_eq!(
581 mode.emits_workload(),
582 *emits,
583 "emits_workload drift for {mode:?}"
584 );
585 assert_eq!(
586 mode.preserves_release_name(),
587 *preserves,
588 "preserves_release_name drift for {mode:?}"
589 );
590 }
591 }
592
593 /// DRIFT-PROOF CONTRACT: the hand-rolled
594 /// `mode == EncapsulationMode::Observe` and
595 /// `mode == EncapsulationMode::Adopt` checks the reconciler's
596 /// `render` function used pre-lift agree with the typed
597 /// projections for every variant in `ALL`. A regression that
598 /// re-introduces a raw `==` against a variant name fails here:
599 /// `!emits_workload()` IS "Observe mode" and
600 /// `preserves_release_name()` IS "Adopt mode", expressed as a
601 /// property of the typed surface rather than a pattern fragment
602 /// two crates have to keep coherent.
603 #[test]
604 fn mode_typed_projections_replace_raw_equality() {
605 for mode in EncapsulationMode::ALL {
606 assert_eq!(
607 !mode.emits_workload(),
608 mode == EncapsulationMode::Observe,
609 "!emits_workload() drift for {mode:?}"
610 );
611 assert_eq!(
612 mode.preserves_release_name(),
613 mode == EncapsulationMode::Adopt,
614 "preserves_release_name() drift for {mode:?}"
615 );
616 }
617 }
618
619 #[test]
620 fn serde_round_trip_via_yaml() {
621 let s = akeyless_adopt();
622 let yaml = serde_yaml::to_string(&s).unwrap();
623 assert!(yaml.contains("existingHelmRelease:"));
624 assert!(yaml.contains("releaseName: akeyless-saas-consolidated"));
625 assert!(yaml.contains("mode: Adopt"));
626 let back: EncapsulatesSpec = serde_yaml::from_str(&yaml).unwrap();
627 assert!(back.kind.existing_helm_release.is_some());
628 assert_eq!(back.mode, EncapsulationMode::Adopt);
629 }
630
631 #[test]
632 fn bare_workload_selector_round_trips() {
633 let mut sel = BTreeMap::new();
634 sel.insert("app".into(), "akeyless-gator".into());
635 sel.insert("tier".into(), "prod".into());
636 let s = EncapsulatesSpec {
637 kind: EncapsulationKind {
638 bare_workload: Some(BareWorkload {
639 namespace: "legacy".into(),
640 selector: sel,
641 }),
642 ..EncapsulationKind::default()
643 },
644 mode: EncapsulationMode::Observe,
645 };
646 let yaml = serde_yaml::to_string(&s).unwrap();
647 assert!(yaml.contains("bareWorkload:"));
648 assert!(yaml.contains("app: akeyless-gator"));
649 assert!(yaml.contains("mode: Observe"));
650 let back: EncapsulatesSpec = serde_yaml::from_str(&yaml).unwrap();
651 match back.kind.variant().unwrap() {
652 EncapsulationKindVariant::BareWorkload(b) => {
653 assert_eq!(b.selector.len(), 2);
654 assert_eq!(
655 b.selector.get("app").map(String::as_str),
656 Some("akeyless-gator")
657 );
658 }
659 other => panic!("expected BareWorkload, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn lisp_round_trip_existing_hr() {
665 let src = r#"
666 (defencapsulates akeyless-adopt
667 :kind (:existing-helm-release
668 (:namespace "akeyless"
669 :name "akeyless-saas"
670 :release-name "akeyless-saas-consolidated"))
671 :mode Adopt)
672 "#;
673 let defs: Vec<tatara_lisp::NamedDefinition<EncapsulatesSpec>> =
674 tatara_lisp::compile_named::<EncapsulatesSpec>(src).expect("compile");
675 let d = &defs[0];
676 assert_eq!(d.name, "akeyless-adopt");
677 assert_eq!(d.spec.mode, EncapsulationMode::Adopt);
678 let h = d.spec.kind.existing_helm_release.as_ref().unwrap();
679 assert_eq!(h.namespace, "akeyless");
680 assert_eq!(h.release_name, "akeyless-saas-consolidated");
681 }
682
683 #[test]
684 fn lisp_default_mode_is_manage() {
685 // `:mode` omitted ⇒ Manage (Default derive).
686 let src = r#"
687 (defencapsulates greenfield
688 :kind (:existing-kustomization
689 (:namespace "flux-system"
690 :name "openclaw")))
691 "#;
692 let defs: Vec<tatara_lisp::NamedDefinition<EncapsulatesSpec>> =
693 tatara_lisp::compile_named::<EncapsulatesSpec>(src).expect("compile");
694 let d = &defs[0];
695 assert_eq!(d.spec.mode, EncapsulationMode::Manage);
696 }
697
698 // ── closed-set algebra for EncapsulationTarget (ALL × as_str ×
699 // Display × FromStr × select × EncapsulationKindVariant::target) ─
700
701 /// Construct an `EncapsulationKind` with one slot populated — the
702 /// composable construction table the closed-set property tests
703 /// loop over. Mirrors `single_slot_source` in
704 /// [`crate::export`] in shape.
705 fn single_slot_kind(target: EncapsulationTarget) -> EncapsulationKind {
706 match target {
707 EncapsulationTarget::ExistingHelmRelease => EncapsulationKind {
708 existing_helm_release: Some(ExistingHelmRelease {
709 namespace: "ns".into(),
710 name: "hr".into(),
711 release_name: "rel".into(),
712 }),
713 ..EncapsulationKind::default()
714 },
715 EncapsulationTarget::ExistingKustomization => EncapsulationKind {
716 existing_kustomization: Some(ExistingKustomization {
717 namespace: "ns".into(),
718 name: "ks".into(),
719 }),
720 ..EncapsulationKind::default()
721 },
722 EncapsulationTarget::BareWorkload => {
723 let mut sel = BTreeMap::new();
724 sel.insert("app".into(), "x".into());
725 EncapsulationKind {
726 bare_workload: Some(BareWorkload {
727 namespace: "ns".into(),
728 selector: sel,
729 }),
730 ..EncapsulationKind::default()
731 }
732 }
733 }
734 }
735
736 /// Construct an `EncapsulationKind` with two slots populated — drives
737 /// the pairwise `Ambiguous` sweep. Composes the single-slot
738 /// constructor on top of itself to keep one source of truth for
739 /// per-variant inner payloads.
740 fn two_slot_kind(a: EncapsulationTarget, b: EncapsulationTarget) -> EncapsulationKind {
741 let ka = single_slot_kind(a);
742 let kb = single_slot_kind(b);
743 EncapsulationKind {
744 existing_helm_release: ka.existing_helm_release.or(kb.existing_helm_release),
745 existing_kustomization: ka.existing_kustomization.or(kb.existing_kustomization),
746 bare_workload: ka.bare_workload.or(kb.bare_workload),
747 }
748 }
749
750 /// Structural well-formedness of [`EncapsulationTarget`] as a
751 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
752 /// testkit lift that pins all three structural invariants (`ALL`
753 /// is non-empty, every variant round-trips through
754 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
755 /// outside the closed set) at ONE call site. Replaces the hand-
756 /// derived `encapsulation_target_all_is_unique_and_complete` +
757 /// `encapsulation_target_roundtrip_via_as_str` + the empty-input
758 /// arm of `unknown_encapsulation_target_errors`. `FromStr`
759 /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`, so
760 /// this helper exercises the same code path the
761 /// `EncapsulationKind::variant` resolver hits when keying on a
762 /// camelCase target name back to the typed target.
763 #[test]
764 fn encapsulation_target_is_well_formed_closed_set() {
765 tatara_closed_set::assert_closed_set_well_formed::<EncapsulationTarget>();
766 }
767
768 /// CANONICAL-KEY CONTRACT: every `EncapsulationTarget::as_str()`
769 /// matches the serde `rename_all = "camelCase"` field name on the
770 /// corresponding `Option<…>` slot of `EncapsulationKind`. A future
771 /// rename of either the struct field OR the `as_str` arm lands here
772 /// at one site, instead of drifting between the typed surface, the
773 /// YAML wire format, and the `EncapsulationKindError::Empty`
774 /// diagnostic. Drives the closed set via `ALL`.
775 #[test]
776 fn encapsulation_target_as_str_matches_field_name() {
777 for t in EncapsulationTarget::ALL {
778 let k = single_slot_kind(t);
779 let yaml = serde_yaml::to_string(&k).expect("serialize");
780 let key = t.as_str();
781 assert!(
782 yaml.contains(&format!("{key}:")),
783 "as_str(={key:?}) for {t:?} not present in serialized YAML:\n{yaml}"
784 );
785 }
786 }
787
788 /// CANONICAL-NAMES PIN: byte-exact camelCase wire-format pin —
789 /// renaming any of these strings IS a wire-format break that fails
790 /// this test FIRST so the rename stays a deliberate decision, not a
791 /// typo. Locks the (variant → operator-facing key) table.
792 #[test]
793 fn encapsulation_target_canonical_names_pinned() {
794 assert_eq!(
795 EncapsulationTarget::ExistingHelmRelease.as_str(),
796 "existingHelmRelease"
797 );
798 assert_eq!(
799 EncapsulationTarget::ExistingKustomization.as_str(),
800 "existingKustomization"
801 );
802 assert_eq!(EncapsulationTarget::BareWorkload.as_str(), "bareWorkload");
803 }
804
805 /// The Display impl IS `as_str` — pinning this lets future callers
806 /// reach for either projection without drift. If a reviewer
807 /// accidentally re-introduces an inline match in Display, this test
808 /// would fail the moment a variant rename touches one site but not
809 /// the other.
810 #[test]
811 fn encapsulation_target_display_matches_as_str() {
812 for t in EncapsulationTarget::ALL {
813 assert_eq!(t.to_string(), t.as_str());
814 }
815 }
816
817 /// `FromStr` rejects strings that aren't in the canonical projection
818 /// — PascalCased / typo / cross-axis-leaked inputs from sibling
819 /// closed-set enums on the same `ProcessSpec` axis (`Manage`,
820 /// `Adopt`, `Observe`, `OnAttested`, …) — and the error echoes the
821 /// input verbatim so the operator-facing diagnostic carries the
822 /// offending value, not a normalized form. `EncapsulationTarget`
823 /// is its own axis, NOT a transparent reflection of any sibling.
824 /// The empty-input arm is pinned by
825 /// [`encapsulation_target_is_well_formed_closed_set`] via the
826 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
827 /// verbatim-echo contract on the [`UnknownEncapsulationTarget`]
828 /// newtype, which the trait's `make_unknown` can't see.
829 #[test]
830 fn unknown_encapsulation_target_errors() {
831 use std::str::FromStr;
832 for bad in [
833 "ExistingHelmRelease",
834 "existing_helm_release",
835 "EXISTINGHELMRELEASE",
836 "helmRelease",
837 "kustomization",
838 "Manage",
839 "Adopt",
840 "Observe",
841 "OnAttested",
842 ] {
843 let err = EncapsulationTarget::from_str(bad).unwrap_err();
844 assert_eq!(err.0, bad, "error payload should echo input verbatim");
845 }
846 }
847
848 /// ROUND-TRIP CONTRACT: every target reaches its borrowed-variant
849 /// view via `select`, and that variant projects back to the same
850 /// target via `EncapsulationKindVariant::target`. A regression that
851 /// misroutes a `select` arm (e.g.
852 /// `Self::ExistingHelmRelease => kind.existing_kustomization
853 /// .as_ref()...`) fails loudly here. Also pins that the resolver
854 /// lands on the same target.
855 #[test]
856 fn encapsulation_target_round_trips_through_variant_target() {
857 for t in EncapsulationTarget::ALL {
858 let k = single_slot_kind(t);
859 let v = t.select(&k).expect("populated slot must select");
860 assert_eq!(v.target(), t, "round-trip failed for {t:?}");
861 assert_eq!(
862 k.variant().expect("exactly-one variant").target(),
863 t,
864 "variant() resolver disagreed on {t:?}"
865 );
866 }
867 }
868
869 /// SELECT-EMPTY CONTRACT: an unpopulated slot returns `None` from
870 /// `select`, for every target. Pairs with the resolver's `Empty`
871 /// path so a future target's slot defaulting wrong (e.g.
872 /// accidentally `Some(Default::default())` instead of `None`) is
873 /// caught here.
874 #[test]
875 fn encapsulation_target_select_returns_none_for_unset_slot() {
876 let empty = EncapsulationKind::default();
877 for t in EncapsulationTarget::ALL {
878 assert!(
879 t.select(&empty).is_none(),
880 "{t:?} reported populated on a default EncapsulationKind"
881 );
882 }
883 }
884
885 /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set target list embedded
886 /// in `EncapsulationKindError::Empty` echoes the canonical join of
887 /// every `EncapsulationTarget::as_str()` projection. A variant
888 /// added without updating `ENCAPSULATION_TARGET_LIST` (or a renamed
889 /// variant) shows up here as a mismatch. Mirrors
890 /// `artifact_error_empty_lists_every_kind_in_canonical_order` —
891 /// routes through [`tatara_lisp::ClosedSet::labels_joined`].
892 #[test]
893 fn encapsulation_kind_error_empty_lists_every_target_in_canonical_order() {
894 assert_eq!(
895 <EncapsulationTarget as tatara_closed_set::ClosedSet>::labels_joined("/"),
896 ENCAPSULATION_TARGET_LIST,
897 );
898 // And the diagnostic carries that exact list.
899 let err = EncapsulationKind::default().variant().unwrap_err();
900 assert_eq!(
901 err,
902 EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST)
903 );
904 }
905
906 /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
907 /// resolver yields `Ambiguous`, exhaustively across every pair in
908 /// `ALL × ALL` (excluding the diagonal). A future asymmetry where
909 /// one slot would silently shadow another (e.g. an `if-let` chain
910 /// re-introducing first-wins ordering) is caught here.
911 #[test]
912 fn encapsulation_kind_two_slots_is_ambiguous_across_every_pair() {
913 for a in EncapsulationTarget::ALL {
914 for b in EncapsulationTarget::ALL {
915 if a == b {
916 continue;
917 }
918 let k = two_slot_kind(a, b);
919 assert_eq!(
920 k.variant().unwrap_err(),
921 EncapsulationKindError::Ambiguous,
922 "({a:?}, {b:?}) should resolve Ambiguous"
923 );
924 }
925 }
926 }
927
928 // Per-implementor `unknown_X_message_matches_substrate_convention`
929 // tests removed — clause (5) of
930 // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
931 // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
932 // generically (called above on `EncapsulationTarget` /
933 // `EncapsulationMode` through their `*_is_well_formed_closed_set`
934 // sites). The `SET_LABEL` projection is pinned independently by
935 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
936 // together the two contracts guarantee the operator-facing
937 // diagnostic without needing per-enum literal pins.
938}