Skip to main content

tatara_process/
intent.rs

1//! Intent — where the rendered artifacts come from.
2//!
3//! Exactly one field on `Intent` must be set. The reconciler's RENDER phase
4//! selects a driver based on which variant is present:
5//!   - `nix`:        tatara-engine `nix_eval` → resources
6//!   - `flux`:       pass through an existing `GitRepository`
7//!   - `lisp`:       tatara-lisp reader + macroexpander → resources
8//!   - `container`:  emit Deployment/StatefulSet/etc directly (no Helm)
9//!   - `aplicacao`:  emit a FluxCD `HelmRelease` for a pleme-io typed
10//!                   Aplicacao chart (e.g. `lareira-akeyless-deployment`).
11//!                   This is the canonical handoff from caixa-shaped
12//!                   declarations to in-cluster reconciliation.
13//!   - `guest`:      tatara-hospedeiro supervises a Linux VM or WASM
14//!                   component. See `tatara/docs/declarative-guests.md`.
15//!                   The GuestSpec itself is type-erased here (JSON value)
16//!                   so tatara-process stays decoupled from tatara-vm;
17//!                   hospedeiro re-parses the value as GuestSpec on boot.
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22
23/// Intent — exactly one variant should be populated.
24#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "camelCase")]
26pub struct Intent {
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub nix: Option<NixIntent>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub flux: Option<FluxIntent>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub lisp: Option<LispIntent>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub container: Option<ContainerIntent>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub aplicacao: Option<AplicacaoIntent>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub guest: Option<GuestIntent>,
39}
40
41/// Enum view over the populated variant — convenience for the reconciler.
42#[derive(Clone, Debug)]
43pub enum IntentVariant<'a> {
44    Nix(&'a NixIntent),
45    Flux(&'a FluxIntent),
46    Lisp(&'a LispIntent),
47    Container(&'a ContainerIntent),
48    Aplicacao(&'a AplicacaoIntent),
49    Guest(&'a GuestIntent),
50}
51
52impl IntentVariant<'_> {
53    /// Reverse projection — every borrowed variant knows its
54    /// `IntentKind` discriminator. Pairs with `IntentKind::select`
55    /// so `IntentKind::select(intent).map(|v| v.kind())` round-trips
56    /// the closed set; pinned by `intent_kind_round_trips_through_variant_kind`.
57    pub fn kind(&self) -> IntentKind {
58        match self {
59            Self::Nix(_) => IntentKind::Nix,
60            Self::Flux(_) => IntentKind::Flux,
61            Self::Lisp(_) => IntentKind::Lisp,
62            Self::Container(_) => IntentKind::Container,
63            Self::Aplicacao(_) => IntentKind::Aplicacao,
64            Self::Guest(_) => IntentKind::Guest,
65        }
66    }
67
68    /// Canonical attestation-pillar bytes for the populated variant —
69    /// `serde_json::to_vec` on the inner reference, with an empty
70    /// fallback that matches the pre-lift Observe-mode shape in
71    /// `tatara-reconciler::render`. ONE site owns the per-variant
72    /// serialization so adding a 7th variant requires only the
73    /// arm here, not the parallel match the pre-lift Observe arm
74    /// carried.
75    pub fn canonical_bytes(&self) -> Vec<u8> {
76        match self {
77            Self::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
78            Self::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
79            Self::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
80            Self::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
81            Self::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
82            Self::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
83        }
84    }
85}
86
87/// Closed-set discriminator over `Intent`'s six tagged-union slots.
88/// Single source of truth that drives `Intent::variant`'s ambiguity
89/// + emptiness resolver, the `IntentError::Empty` message, and the
90/// reverse `IntentVariant::kind` projection. Adding a 7th intent
91/// variant lands at one `ALL` entry + one `as_str` arm + one
92/// `select` arm + one `IntentVariant::kind` arm — exhaustively
93/// checked by the compiler.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
95#[closed_set(via = "as_str", generate_unknown, display)]
96pub enum IntentKind {
97    Nix,
98    Flux,
99    Lisp,
100    Container,
101    Aplicacao,
102    Guest,
103}
104
105impl IntentKind {
106    /// The closed set of intent kinds — single source of truth that
107    /// drives `Intent::variant`'s sweep so a variant added without
108    /// an `ALL` entry never reaches the resolver.
109    pub const ALL: [Self; 6] = [
110        Self::Nix,
111        Self::Flux,
112        Self::Lisp,
113        Self::Container,
114        Self::Aplicacao,
115        Self::Guest,
116    ];
117
118    /// Canonical lower-case wire-format key — matches the serde
119    /// `rename_all = "camelCase"` field name on `Intent`. The
120    /// `IntentError::Empty` message composes the human-readable
121    /// list from this projection so a new variant lands in the
122    /// operator-facing diagnostic automatically via the `ALL`
123    /// sweep, not via hand-maintained error-string drift.
124    pub const fn as_str(self) -> &'static str {
125        match self {
126            Self::Nix => "nix",
127            Self::Flux => "flux",
128            Self::Lisp => "lisp",
129            Self::Container => "container",
130            Self::Aplicacao => "aplicacao",
131            Self::Guest => "guest",
132        }
133    }
134
135    /// Project an `Intent` borrow into the optional typed variant
136    /// view for this kind. Returns `None` iff the matching slot is
137    /// `None`. Composes the closed-set sweep `Intent::variant`
138    /// loops over.
139    pub fn select<'a>(self, intent: &'a Intent) -> Option<IntentVariant<'a>> {
140        match self {
141            Self::Nix => intent.nix.as_ref().map(IntentVariant::Nix),
142            Self::Flux => intent.flux.as_ref().map(IntentVariant::Flux),
143            Self::Lisp => intent.lisp.as_ref().map(IntentVariant::Lisp),
144            Self::Container => intent.container.as_ref().map(IntentVariant::Container),
145            Self::Aplicacao => intent.aplicacao.as_ref().map(IntentVariant::Aplicacao),
146            Self::Guest => intent.guest.as_ref().map(IntentVariant::Guest),
147        }
148    }
149}
150
151#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
152pub enum IntentError {
153    #[error("intent has no variant set (one of {0} required)")]
154    Empty(&'static str),
155    #[error("intent has multiple variants set; exactly one required")]
156    Ambiguous,
157}
158
159/// Slash-joined list of every `IntentKind::as_str()` — composed once
160/// at compile time so `IntentError::Empty`'s diagnostic carries the
161/// closed-set summary without per-variant string drift. Pinned against
162/// the canonical [`tatara_lisp::ClosedSet::labels_joined`] projection
163/// by `intent_error_empty_lists_every_kind_in_canonical_order`, so a
164/// regression that drifts this `&'static str` constant from the
165/// `IntentKind::ALL × as_str` composition fails-loudly at the test
166/// site without per-variant inline materialization.
167const INTENT_KIND_LIST: &str = "nix/flux/lisp/container/aplicacao/guest";
168
169// `impl FromStr for IntentKind` +
170// `impl tatara_lisp::ClosedSet for IntentKind` +
171// `impl fmt::Display for IntentKind` +
172// `pub struct UnknownIntentKind(pub String)` are all generated by
173// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
174// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
175// enum declaration above. `label` delegates to the inherent
176// `IntentKind::as_str` — the camelCase wire-vocabulary projection
177// stays load-bearing (matches the serde `rename_all = "camelCase"`
178// field names on `Intent` AND the `IntentVariant::canonical_bytes`
179// per-variant arm), while generic `T: ClosedSet` consumers reach the
180// STABLE workspace-wide name (`label`). The auto-derived carrier
181// label "intent kind" matches the substrate-wide
182// `#[error("unknown intent kind: {0}")]` shape every sibling
183// closed-set carrier across `tatara-process` renders verbatim.
184// Symmetric to [`crate::intent::WorkloadKind`] (the workload-axis
185// sibling on the same `ProcessSpec` slice) and every other
186// `#[derive(DeriveClosedSet)]` implementor across the crate.
187
188impl Intent {
189    /// Resolve to exactly one variant. Errors on zero or many.
190    /// Sweeps over `IntentKind::ALL` so a 7th variant added with an
191    /// `ALL` entry is structurally honored at this site — no
192    /// parallel `is_some()` count array, no if-let-else chain, no
193    /// `unreachable!()`. The Empty diagnostic carries the closed-set
194    /// list via `INTENT_KIND_LIST`.
195    pub fn variant(&self) -> Result<IntentVariant<'_>, IntentError> {
196        use crate::tagged_union::{resolve, ResolveError};
197        resolve(IntentKind::ALL.into_iter().map(|k| k.select(self))).map_err(|e| match e {
198            ResolveError::None => IntentError::Empty(INTENT_KIND_LIST),
199            ResolveError::Many => IntentError::Ambiguous,
200        })
201    }
202}
203
204/// Nix-sourced intent — tatara-engine's nix_eval driver produces resources.
205#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
206#[serde(rename_all = "camelCase")]
207pub struct NixIntent {
208    /// Flake reference, e.g., `github:pleme-io/k8s?dir=shared/infrastructure`.
209    pub flake_ref: String,
210    /// Attribute path within the flake (e.g., `observability`).
211    pub attribute: String,
212    /// Target system. Defaults to the controller host's system.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub system: Option<String>,
215    /// Attic cache to push the resulting store path into.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub attic_cache: Option<String>,
218    /// Additional `nix build` arguments (e.g., `["--impure"]`).
219    #[serde(default)]
220    pub extra_args: Vec<String>,
221    /// Delegate the actual build to a sibling NixBuild CRD
222    /// (bridges to tatara-operator NATS bare-metal builder path).
223    #[serde(default)]
224    pub delegate_to_nix_build: bool,
225}
226
227/// FluxCD passthrough intent — reuse an existing GitRepository.
228#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
229#[serde(rename_all = "camelCase")]
230pub struct FluxIntent {
231    /// Name of an existing `GitRepository` (typically in `flux-system`).
232    pub git_repository: String,
233    /// Path inside the repository that the Kustomization will apply.
234    pub path: String,
235    /// Optional namespace of the GitRepository CR (defaults to `flux-system`).
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub git_repository_namespace: Option<String>,
238    /// Optional target namespace for the emitted Kustomization.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub target_namespace: Option<String>,
241    /// SOPS decryption — defaults to true to match pleme-io conventions.
242    #[serde(default = "default_true")]
243    pub decrypt_sops: bool,
244    /// If set, additionally emit a HelmRelease for this chart.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub helm_chart: Option<String>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub helm_values: Option<BTreeMap<String, serde_json::Value>>,
249}
250
251fn default_true() -> bool {
252    true
253}
254
255/// Lisp-sourced intent — tatara-lisp reader + macroexpander produces resources.
256#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
257#[serde(rename_all = "camelCase")]
258pub struct LispIntent {
259    /// Raw S-expression source, OR `include:<path>` / `configmap:<name>/<key>` pointer.
260    pub source: String,
261    /// Reader dialect / version tag.
262    #[serde(default = "default_reader")]
263    pub reader: String,
264    /// Macro form version.
265    #[serde(default = "default_version")]
266    pub version: String,
267    /// Symbols injected into the reader env (e.g., `cluster`, `region`).
268    #[serde(default)]
269    pub bindings: BTreeMap<String, serde_json::Value>,
270}
271
272fn default_reader() -> String {
273    "tatara-lisp".to_string()
274}
275fn default_version() -> String {
276    "v1".to_string()
277}
278
279/// Aplicacao intent — emit a FluxCD `HelmRelease` for a pleme-io
280/// typed Aplicacao chart. The chart owns its own sub-chart DAG;
281/// the reconciler only watches `HelmRelease.status.conditions[type=Ready]`.
282///
283/// This is the canonical handoff from caixa `(defaplicacao …)` declarations
284/// (which the typescape renders to this Intent) into in-cluster
285/// reconciliation. Closed-loop ephemeral test environments use this
286/// variant with `:lifetime :ephemeral` on the surrounding ProcessSpec.
287///
288/// Example (Lisp):
289/// ```lisp
290/// :intent (:aplicacao
291///           (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment"
292///            :version "0.5.5"
293///            :profile "gateway-with-internal-saas"
294///            :values-overlay (:cluster (:name "ephemeral-test-01")
295///                             :persistence false
296///                             :compliance (:overlays []))))
297/// ```
298#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
299#[serde(rename_all = "camelCase")]
300pub struct AplicacaoIntent {
301    /// Helm chart reference. OCI (`oci://…`) or repo-relative (`pleme-io/lareira-akeyless-deployment`).
302    pub chart_ref: String,
303    /// Chart version (Helm semver constraint; `">=0.5.5"` allowed).
304    pub version: String,
305    /// Architecture profile from the chart's `values/*.yaml` family
306    /// (e.g. `gateway-with-internal-saas`, `saas-internal`).
307    /// Leave empty to use chart defaults.
308    #[serde(default, skip_serializing_if = "String::is_empty")]
309    pub profile: String,
310    /// Typed values overlay merged on top of the profile.
311    /// Free-form JSON to keep tatara-process decoupled from chart schemas.
312    #[serde(default)]
313    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
314    pub values_overlay: serde_json::Value,
315    /// HelmRelease name override. Defaults to the Process's PID-derived name.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub release_name: Option<String>,
318    /// Target namespace for the chart. Defaults to the Process's namespace.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub target_namespace: Option<String>,
321    /// Install timeout (`humantime` duration). Empty = chart-controller default.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub install_timeout: Option<String>,
324}
325
326/// Container intent — direct Deployment/StatefulSet/etc, no Helm.
327#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
328#[serde(rename_all = "camelCase")]
329pub struct ContainerIntent {
330    pub image: String,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub replicas: Option<i32>,
333    #[serde(default)]
334    pub command: Vec<String>,
335    #[serde(default)]
336    pub args: Vec<String>,
337    #[serde(default)]
338    pub env: BTreeMap<String, String>,
339    #[serde(default)]
340    pub workload_kind: WorkloadKind,
341}
342
343/// K8s workload kind the `container` intent renders into. PascalCase
344/// values match the K8s `kind:` field on the emitted manifest verbatim,
345/// so `as_str` doubles as the canonical `kind:` projection at render time.
346#[derive(
347    Clone,
348    Copy,
349    Debug,
350    PartialEq,
351    Eq,
352    Hash,
353    Serialize,
354    Deserialize,
355    JsonSchema,
356    Default,
357    tatara_closed_set::DeriveClosedSet,
358)]
359#[serde(rename_all = "PascalCase")]
360#[closed_set(via = "as_str", generate_unknown, display)]
361pub enum WorkloadKind {
362    #[default]
363    Deployment,
364    StatefulSet,
365    DaemonSet,
366    Job,
367    CronJob,
368}
369
370impl WorkloadKind {
371    /// The closed set of workload kinds — single source of truth that
372    /// drives the `as_str` / Display / `FromStr` triad and the typed
373    /// `api_version` / `is_batch` projections. Adding a sixth variant
374    /// lands at one `ALL` entry + one `as_str` arm + one arm in each
375    /// projection — exhaustively checked by the compiler (the `[Self; 5]`
376    /// array literal forces the arity).
377    ///
378    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
379    /// [`crate::encapsulates::EncapsulationMode::ALL`],
380    /// [`crate::export::ExportTrigger::ALL`],
381    /// [`crate::export::ReportFormat::ALL`],
382    /// [`crate::lifetime::TeardownPolicy::ALL`],
383    /// [`crate::intent::IntentKind::ALL`],
384    /// [`crate::lifetime::LifetimeKind::ALL`],
385    /// [`crate::boundary::ConditionKind::ALL`],
386    /// [`crate::phase::ProcessPhase::ALL`],
387    /// [`crate::signal::ProcessSignal::ALL`].
388    pub const ALL: [Self; 5] = [
389        Self::Deployment,
390        Self::StatefulSet,
391        Self::DaemonSet,
392        Self::Job,
393        Self::CronJob,
394    ];
395
396    /// Canonical PascalCase wire-format projection — matches the serde
397    /// `rename_all = "PascalCase"` output verbatim AND the K8s manifest
398    /// `kind:` field the `container` intent's future renderer will emit.
399    /// Used by Display (single source of truth), by `FromStr` to identify
400    /// the variant from its annotation / status-field representation, and
401    /// by operator-facing reason strings without reaching for `{:?}` Debug
402    /// formatting. Pinned by `workload_kind_as_str_matches_serde`.
403    pub const fn as_str(self) -> &'static str {
404        match self {
405            Self::Deployment => "Deployment",
406            Self::StatefulSet => "StatefulSet",
407            Self::DaemonSet => "DaemonSet",
408            Self::Job => "Job",
409            Self::CronJob => "CronJob",
410        }
411    }
412
413    /// Canonical K8s `apiVersion:` projection — `apps/v1` for the
414    /// long-running workload trio, `batch/v1` for the batch pair.
415    /// Single source of truth for the apiVersion the `container` intent
416    /// renderer will stamp on the emitted manifest; pinned by
417    /// `workload_kind_projection_truth_table` so a future variant lands
418    /// at one arm here, not at every render site that previously
419    /// hand-rolled `match kind { Job | CronJob => "batch/v1", _ => … }`.
420    ///
421    /// Closed-set match (not `matches!`) so adding a sixth variant
422    /// triggers the compiler's exhaustiveness check at this site
423    /// rather than silently defaulting to either group.
424    pub const fn api_version(self) -> &'static str {
425        match self {
426            Self::Deployment | Self::StatefulSet | Self::DaemonSet => "apps/v1",
427            Self::Job | Self::CronJob => "batch/v1",
428        }
429    }
430
431    /// True iff the workload kind is a batch (terminating) workload —
432    /// `Job` or `CronJob`. Drives the future container renderer's
433    /// decision between persistent / one-shot retry semantics and lets
434    /// the lifetime clock distinguish "naturally terminates" from "runs
435    /// until SIGTERM" without re-deriving the partition from
436    /// `api_version() == "batch/v1"`.
437    ///
438    /// Closed-set match (not `matches!`) so adding a sixth variant
439    /// triggers the compiler's exhaustiveness check at this site.
440    pub const fn is_batch(self) -> bool {
441        match self {
442            Self::Job | Self::CronJob => true,
443            Self::Deployment | Self::StatefulSet | Self::DaemonSet => false,
444        }
445    }
446}
447
448// `impl FromStr for WorkloadKind` +
449// `impl tatara_lisp::ClosedSet for WorkloadKind` +
450// `impl fmt::Display for WorkloadKind` +
451// `pub struct UnknownWorkloadKind(pub String)` are all generated by
452// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
453// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
454// enum declaration above. `label` delegates to the inherent
455// `WorkloadKind::as_str` — the PascalCase wire-vocabulary projection
456// stays load-bearing (matches the serde `rename_all = "PascalCase"`
457// output AND the K8s manifest `kind:` field verbatim), while generic
458// `T: ClosedSet` consumers reach the STABLE workspace-wide name
459// (`label`). The auto-derived carrier label "workload kind" matches
460// the prior hand-rolled `#[error("unknown workload kind: {0}")]`
461// annotation byte-for-byte. Symmetric to every other
462// `#[derive(DeriveClosedSet)]` implementor across the crate.
463
464/// Guest intent — the Process is a Linux VM or WASM component supervised
465/// by `tatara-hospedeiro`. See `tatara/docs/declarative-guests.md`.
466///
467/// The actual `GuestSpec` is stored as a serde JSON value to keep
468/// `tatara-process` decoupled from `tatara-vm`. Hospedeiro re-parses
469/// the value as the concrete `tatara_vm::GuestSpec` at boot time; a
470/// round-trip test on the tatara-vm side guarantees the shape.
471#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
472#[serde(rename_all = "camelCase")]
473pub struct GuestIntent {
474    /// The (defguest …) spec as JSON. Shape matches `tatara_vm::GuestSpec`.
475    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
476    pub spec: serde_json::Value,
477
478    /// Where to write per-guest state on the host (logs, socket, PID file).
479    /// Defaults to `~/.local/state/tatara/guests/<name>/`.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub state_dir: Option<String>,
482
483    /// Whether hospedeiro is allowed to pull guest artifacts from a remote
484    /// transport (Attic, ssh-ng) if not already present locally. The
485    /// default is taken from the GuestSpec's `buildOn` field; setting
486    /// this explicitly overrides at the intent layer.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub allow_remote_build: Option<bool>,
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn empty_intent_errors() {
497        let i = Intent::default();
498        match i.variant().unwrap_err() {
499            IntentError::Empty(list) => assert_eq!(list, INTENT_KIND_LIST),
500            other => panic!("expected Empty, got {other:?}"),
501        }
502    }
503
504    #[test]
505    fn exactly_one_ok() {
506        let i = Intent {
507            nix: Some(NixIntent {
508                flake_ref: "github:a/b".into(),
509                attribute: "x".into(),
510                system: None,
511                attic_cache: None,
512                extra_args: vec![],
513                delegate_to_nix_build: false,
514            }),
515            ..Intent::default()
516        };
517        assert!(matches!(i.variant().unwrap(), IntentVariant::Nix(_)));
518    }
519
520    #[test]
521    fn two_variants_ambiguous() {
522        let i = Intent {
523            nix: Some(NixIntent {
524                flake_ref: "a".into(),
525                attribute: "b".into(),
526                system: None,
527                attic_cache: None,
528                extra_args: vec![],
529                delegate_to_nix_build: false,
530            }),
531            flux: Some(FluxIntent {
532                git_repository: "g".into(),
533                path: "p".into(),
534                git_repository_namespace: None,
535                target_namespace: None,
536                decrypt_sops: true,
537                helm_chart: None,
538                helm_values: None,
539            }),
540            ..Intent::default()
541        };
542        assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
543    }
544
545    #[test]
546    fn guest_intent_selects_its_variant() {
547        let i = Intent {
548            guest: Some(GuestIntent {
549                spec: serde_json::json!({
550                    "name": "fast-fn",
551                    "kind": { "kind": "wasm", "runtime": "wasmtime",
552                              "wasiPreview": "p2",
553                              "component": { "kind": "flake",
554                                             "value": {"url":"github:x/y","attr":"wasi"} },
555                              "features": { "simd": true } },
556                    "cmdline": []
557                }),
558                state_dir: None,
559                allow_remote_build: Some(true),
560            }),
561            ..Intent::default()
562        };
563        match i.variant().unwrap() {
564            IntentVariant::Guest(g) => {
565                assert_eq!(g.spec["name"], "fast-fn");
566                assert_eq!(g.allow_remote_build, Some(true));
567            }
568            other => panic!("expected Guest, got {other:?}"),
569        }
570    }
571
572    #[test]
573    fn guest_plus_nix_is_ambiguous() {
574        let i = Intent {
575            nix: Some(NixIntent {
576                flake_ref: "github:a/b".into(),
577                attribute: "x".into(),
578                system: None,
579                attic_cache: None,
580                extra_args: vec![],
581                delegate_to_nix_build: false,
582            }),
583            guest: Some(GuestIntent {
584                spec: serde_json::json!({"name": "x"}),
585                state_dir: None,
586                allow_remote_build: None,
587            }),
588            ..Intent::default()
589        };
590        assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
591    }
592
593    #[test]
594    fn aplicacao_intent_selects_its_variant() {
595        let i = Intent {
596            aplicacao: Some(AplicacaoIntent {
597                chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment".into(),
598                version: "0.5.5".into(),
599                profile: "gateway-with-internal-saas".into(),
600                values_overlay: serde_json::json!({ "cluster": { "name": "test-01" } }),
601                release_name: None,
602                target_namespace: None,
603                install_timeout: Some("25m".into()),
604            }),
605            ..Intent::default()
606        };
607        match i.variant().unwrap() {
608            IntentVariant::Aplicacao(a) => {
609                assert_eq!(a.profile, "gateway-with-internal-saas");
610                assert_eq!(a.version, "0.5.5");
611                assert_eq!(a.install_timeout.as_deref(), Some("25m"));
612            }
613            other => panic!("expected Aplicacao, got {other:?}"),
614        }
615    }
616
617    /// Structural well-formedness of [`IntentKind`] as a
618    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
619    /// testkit lift that pins all structural invariants (`ALL` is
620    /// non-empty, every variant round-trips through `label ↔
621    /// parse_label`, labels are pairwise distinct, `""` is outside
622    /// the closed set, the `UnknownIntentKind` carrier's Display
623    /// renders the substrate-wide `"unknown intent kind: <input>"`
624    /// shape, `labels()` equals the natural `ALL × label` projection,
625    /// `parse_label_with_hint` composes `parse_label` +
626    /// `suggest_closest` verbatim) at ONE call site. Replaces the
627    /// hand-derived `intent_kind_all_is_unique_and_complete` —
628    /// clause (1)+(3) of the testkit subsume the uniqueness +
629    /// non-emptiness sweep that test pinned independently.
630    #[test]
631    fn intent_kind_is_well_formed_closed_set() {
632        tatara_closed_set::assert_closed_set_well_formed::<IntentKind>();
633    }
634
635    /// The Display impl IS `as_str` — pinning this lets future callers
636    /// reach for either projection without drift. Symmetric to the
637    /// sibling `workload_kind_display_matches_as_str` invariant; if a
638    /// reviewer accidentally re-introduces an inline match in Display,
639    /// this test would fail the moment a variant rename touches one
640    /// site but not the other.
641    #[test]
642    fn intent_kind_display_matches_as_str() {
643        for kind in IntentKind::ALL {
644            assert_eq!(kind.to_string(), kind.as_str());
645        }
646    }
647
648    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
649    /// camelCase serde field name on `Intent`. A future rename of
650    /// any field lands here at one site — and the `Empty` diagnostic
651    /// composed from `INTENT_KIND_LIST` stays coherent with the
652    /// wire format.
653    #[test]
654    fn intent_kind_as_str_matches_intent_field_name() {
655        for kind in IntentKind::ALL {
656            // Pre-serialize an `Intent` carrying just this kind's
657            // slot populated; the only key in the resulting JSON
658            // object must equal `kind.as_str()`.
659            let i = match kind {
660                IntentKind::Nix => Intent {
661                    nix: Some(NixIntent {
662                        flake_ref: "f".into(),
663                        attribute: "a".into(),
664                        system: None,
665                        attic_cache: None,
666                        extra_args: vec![],
667                        delegate_to_nix_build: false,
668                    }),
669                    ..Intent::default()
670                },
671                IntentKind::Flux => Intent {
672                    flux: Some(FluxIntent {
673                        git_repository: "g".into(),
674                        path: "p".into(),
675                        git_repository_namespace: None,
676                        target_namespace: None,
677                        decrypt_sops: true,
678                        helm_chart: None,
679                        helm_values: None,
680                    }),
681                    ..Intent::default()
682                },
683                IntentKind::Lisp => Intent {
684                    lisp: Some(LispIntent {
685                        source: "()".into(),
686                        reader: "tatara-lisp".into(),
687                        version: "v1".into(),
688                        bindings: BTreeMap::new(),
689                    }),
690                    ..Intent::default()
691                },
692                IntentKind::Container => Intent {
693                    container: Some(ContainerIntent {
694                        image: "x".into(),
695                        replicas: None,
696                        command: vec![],
697                        args: vec![],
698                        env: BTreeMap::new(),
699                        workload_kind: WorkloadKind::default(),
700                    }),
701                    ..Intent::default()
702                },
703                IntentKind::Aplicacao => Intent {
704                    aplicacao: Some(AplicacaoIntent {
705                        chart_ref: "x".into(),
706                        version: "1".into(),
707                        profile: String::new(),
708                        values_overlay: serde_json::Value::Null,
709                        release_name: None,
710                        target_namespace: None,
711                        install_timeout: None,
712                    }),
713                    ..Intent::default()
714                },
715                IntentKind::Guest => Intent {
716                    guest: Some(GuestIntent {
717                        spec: serde_json::json!({"name": "x"}),
718                        state_dir: None,
719                        allow_remote_build: None,
720                    }),
721                    ..Intent::default()
722                },
723            };
724            let v = serde_json::to_value(&i).expect("Intent serializes");
725            let obj = v.as_object().expect("Intent serializes to object");
726            let keys: Vec<&String> = obj.keys().collect();
727            assert_eq!(
728                keys.len(),
729                1,
730                "exactly one slot populated for kind {kind:?}, got {keys:?}"
731            );
732            assert_eq!(
733                keys[0],
734                kind.as_str(),
735                "as_str() must match serde field name for {kind:?}"
736            );
737        }
738    }
739
740    /// ROUND-TRIP CONTRACT: `IntentKind::select(intent).map(|v|
741    /// v.kind()) == Some(kind)`. The reverse `IntentVariant::kind`
742    /// projection composes the closed set in both directions — a
743    /// regression that misroutes a select arm (e.g. `Self::Nix =>
744    /// intent.flux.as_ref()...`) fails loudly here.
745    #[test]
746    fn intent_kind_round_trips_through_variant_kind() {
747        for kind in IntentKind::ALL {
748            let i = single_slot_intent(kind);
749            let v = kind.select(&i).expect("populated slot must select");
750            assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
751            // And the resolver lands on the same variant.
752            assert_eq!(
753                i.variant().expect("exactly-one variant").kind(),
754                kind,
755                "variant() resolver disagreed on {kind:?}"
756            );
757        }
758    }
759
760    /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set kind list embedded
761    /// in `IntentError::Empty` echoes the canonical join of every
762    /// `IntentKind::as_str()` projection. A variant added without
763    /// updating `INTENT_KIND_LIST` (or a renamed variant) shows up
764    /// here as a mismatch.
765    ///
766    /// Routes through [`tatara_lisp::ClosedSet::labels_joined`] —
767    /// the canonical generative origin the `INTENT_KIND_LIST`
768    /// `&'static str` constant is pinned against. Symmetric to the
769    /// sibling `artifact_error_empty_lists_every_kind_in_canonical_order`
770    /// / `channel_error_empty_lists_every_kind_in_canonical_order`
771    /// / `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`
772    /// invariants — every closed-set enum's `INTENT_KIND_LIST`-shaped
773    /// production constant now pins against ONE trait method that
774    /// composes `Self::ALL` + `Self::label` + the slash separator,
775    /// rather than re-deriving the `ALL.iter().map(as_str).collect()
776    /// .join("/")` triple inline at the test site.
777    #[test]
778    fn intent_error_empty_lists_every_kind_in_canonical_order() {
779        assert_eq!(
780            <IntentKind as tatara_closed_set::ClosedSet>::labels_joined("/"),
781            INTENT_KIND_LIST,
782        );
783    }
784
785    /// CANONICAL-BYTES CONTRACT: every populated variant yields the
786    /// SAME bytes as `serde_json::to_vec` on the inner reference.
787    /// Pins the lift of the parallel observe-mode match in
788    /// `tatara-reconciler::render` to this single method.
789    #[test]
790    fn intent_variant_canonical_bytes_matches_inner_serialize() {
791        for kind in IntentKind::ALL {
792            let i = single_slot_intent(kind);
793            let v = i.variant().expect("exactly-one variant");
794            let via_method = v.canonical_bytes();
795            let expected: Vec<u8> = match &v {
796                IntentVariant::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
797                IntentVariant::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
798                IntentVariant::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
799                IntentVariant::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
800                IntentVariant::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
801                IntentVariant::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
802            };
803            assert_eq!(
804                via_method, expected,
805                "canonical_bytes mismatch for {kind:?}"
806            );
807            assert!(!via_method.is_empty(), "{kind:?} produced empty bytes");
808        }
809    }
810
811    /// Construct an `Intent` with exactly the given kind's slot
812    /// populated by a minimal valid inner spec. Shared across the
813    /// closed-set property tests so they each cover every variant
814    /// without restating the construction table.
815    fn single_slot_intent(kind: IntentKind) -> Intent {
816        match kind {
817            IntentKind::Nix => Intent {
818                nix: Some(NixIntent {
819                    flake_ref: "github:a/b".into(),
820                    attribute: "x".into(),
821                    system: None,
822                    attic_cache: None,
823                    extra_args: vec![],
824                    delegate_to_nix_build: false,
825                }),
826                ..Intent::default()
827            },
828            IntentKind::Flux => Intent {
829                flux: Some(FluxIntent {
830                    git_repository: "g".into(),
831                    path: "p".into(),
832                    git_repository_namespace: None,
833                    target_namespace: None,
834                    decrypt_sops: true,
835                    helm_chart: None,
836                    helm_values: None,
837                }),
838                ..Intent::default()
839            },
840            IntentKind::Lisp => Intent {
841                lisp: Some(LispIntent {
842                    source: "()".into(),
843                    reader: "tatara-lisp".into(),
844                    version: "v1".into(),
845                    bindings: BTreeMap::new(),
846                }),
847                ..Intent::default()
848            },
849            IntentKind::Container => Intent {
850                container: Some(ContainerIntent {
851                    image: "ghcr.io/x:1".into(),
852                    replicas: Some(1),
853                    command: vec![],
854                    args: vec![],
855                    env: BTreeMap::new(),
856                    workload_kind: WorkloadKind::default(),
857                }),
858                ..Intent::default()
859            },
860            IntentKind::Aplicacao => Intent {
861                aplicacao: Some(AplicacaoIntent {
862                    chart_ref: "oci://ghcr.io/x".into(),
863                    version: "0.1.0".into(),
864                    profile: String::new(),
865                    values_overlay: serde_json::Value::Null,
866                    release_name: None,
867                    target_namespace: None,
868                    install_timeout: None,
869                }),
870                ..Intent::default()
871            },
872            IntentKind::Guest => Intent {
873                guest: Some(GuestIntent {
874                    spec: serde_json::json!({"name": "guest-1"}),
875                    state_dir: None,
876                    allow_remote_build: None,
877                }),
878                ..Intent::default()
879            },
880        }
881    }
882
883    // ── closed-set algebra for WorkloadKind (ALL × as_str × Display ×
884    //    FromStr × api_version × is_batch) ─────────────────────────────
885
886    /// Structural well-formedness of [`WorkloadKind`] as a
887    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
888    /// testkit lift that pins all three structural invariants (`ALL`
889    /// is non-empty, every variant round-trips through `label ↔
890    /// parse_label`, labels are pairwise distinct, `""` is outside the
891    /// closed set) at ONE call site. Replaces the hand-derived
892    /// `workload_kind_all_is_unique_and_complete` +
893    /// `workload_kind_roundtrip_via_as_str` + the empty-input arm of
894    /// `unknown_workload_kind_errors`. `FromStr` delegates to
895    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
896    /// exercises the same code path the reconciler hits when parsing a
897    /// K8s `kind:`-shaped value back to the typed workload kind.
898    #[test]
899    fn workload_kind_is_well_formed_closed_set() {
900        tatara_closed_set::assert_closed_set_well_formed::<WorkloadKind>();
901    }
902
903    /// CANONICAL-KEY CONTRACT: every variant's `as_str()` matches serde's
904    /// PascalCase output verbatim. A future variant rename (or an
905    /// `as_str` arm typo) lands at one site, instead of drifting
906    /// between the typed surface, the K8s `kind:` manifest field, and
907    /// the YAML wire format the reconciler / operator both read.
908    #[test]
909    fn workload_kind_as_str_matches_serde() {
910        for kind in WorkloadKind::ALL {
911            let serialized = serde_json::to_string(&kind).expect("serialize");
912            let unquoted = serialized
913                .trim_start_matches('"')
914                .trim_end_matches('"')
915                .to_string();
916            assert_eq!(
917                unquoted,
918                kind.as_str(),
919                "as_str drift for {kind:?}: as_str={} serde={unquoted}",
920                kind.as_str()
921            );
922        }
923    }
924
925    /// The Display impl IS `as_str` — pinning this lets future callers
926    /// reach for either projection without drift. If a reviewer
927    /// accidentally re-introduces an inline match in Display, this
928    /// test would fail the moment a variant rename touches one site
929    /// but not the other.
930    #[test]
931    fn workload_kind_display_matches_as_str() {
932        for kind in WorkloadKind::ALL {
933            assert_eq!(kind.to_string(), kind.as_str());
934        }
935    }
936
937    /// `FromStr` rejects strings that aren't in the canonical
938    /// projection — lowercased / typo / unrelated — and the error
939    /// echoes the input verbatim so the operator-facing diagnostic
940    /// carries the offending value, not a normalized form. The
941    /// empty-input arm is pinned by
942    /// [`workload_kind_is_well_formed_closed_set`] via the
943    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
944    /// verbatim-echo contract on the [`UnknownWorkloadKind`]
945    /// newtype, which the trait's `make_unknown` can't see.
946    #[test]
947    fn unknown_workload_kind_errors() {
948        use std::str::FromStr;
949        for bad in ["deployment", "JOB", "ReplicaSet", "Pod"] {
950            let err = WorkloadKind::from_str(bad).unwrap_err();
951            assert_eq!(err.0, bad, "error payload should echo input verbatim");
952        }
953    }
954
955    #[test]
956    fn workload_kind_default_is_deployment() {
957        assert_eq!(WorkloadKind::default(), WorkloadKind::Deployment);
958    }
959
960    /// TRUTH-TABLE CONTRACT: `api_version` / `is_batch` agree with the
961    /// documented (kind) -> (apiVersion, is_batch) table for every
962    /// variant. A new variant in `WorkloadKind` without extending
963    /// either projection's match is caught by the compiler (closed-set
964    /// match in each method); adding a variant without extending its
965    /// truth row is caught here. Also pins the invariant
966    /// `is_batch <=> api_version == "batch/v1"`, so a future renderer
967    /// can route on either projection without re-deriving the partition.
968    #[test]
969    fn workload_kind_projection_truth_table() {
970        let table: &[(WorkloadKind, &str, bool)] = &[
971            // (kind, api_version, is_batch)
972            (WorkloadKind::Deployment, "apps/v1", false),
973            (WorkloadKind::StatefulSet, "apps/v1", false),
974            (WorkloadKind::DaemonSet, "apps/v1", false),
975            (WorkloadKind::Job, "batch/v1", true),
976            (WorkloadKind::CronJob, "batch/v1", true),
977        ];
978        assert_eq!(table.len(), WorkloadKind::ALL.len());
979        for (kind, api, batch) in table {
980            assert_eq!(kind.api_version(), *api, "api_version drift for {kind:?}");
981            assert_eq!(kind.is_batch(), *batch, "is_batch drift for {kind:?}");
982            assert_eq!(
983                kind.is_batch(),
984                kind.api_version() == "batch/v1",
985                "is_batch / api_version partition disagrees for {kind:?}"
986            );
987        }
988    }
989
990    #[test]
991    fn aplicacao_plus_flux_is_ambiguous() {
992        let i = Intent {
993            aplicacao: Some(AplicacaoIntent {
994                chart_ref: "x".into(),
995                version: "1".into(),
996                profile: String::new(),
997                values_overlay: serde_json::Value::Null,
998                release_name: None,
999                target_namespace: None,
1000                install_timeout: None,
1001            }),
1002            flux: Some(FluxIntent {
1003                git_repository: "g".into(),
1004                path: "p".into(),
1005                git_repository_namespace: None,
1006                target_namespace: None,
1007                decrypt_sops: true,
1008                helm_chart: None,
1009                helm_values: None,
1010            }),
1011            ..Intent::default()
1012        };
1013        assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
1014    }
1015}