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-demo-app`).
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 the substrate testkit
57 /// [`crate::tagged_union::assert_variant_round_trip`] shared
58 /// across every `<T: TaggedUnion>` implementor. The inherent
59 /// method stays load-bearing (the `.kind()` calling convention
60 /// pre-dates the trait lift; no consumer needs `use
61 /// crate::tagged_union::VariantKind` to reach the reverse
62 /// projection) while the trait impl below delegates to this body
63 /// as the ground-truth arm-to-Kind mapping.
64 pub fn kind(&self) -> IntentKind {
65 match self {
66 Self::Nix(_) => IntentKind::Nix,
67 Self::Flux(_) => IntentKind::Flux,
68 Self::Lisp(_) => IntentKind::Lisp,
69 Self::Container(_) => IntentKind::Container,
70 Self::Aplicacao(_) => IntentKind::Aplicacao,
71 Self::Guest(_) => IntentKind::Guest,
72 }
73 }
74
75 /// Canonical attestation-pillar bytes for the populated variant —
76 /// `serde_json::to_vec` on the inner reference, with an empty
77 /// fallback that matches the pre-lift Observe-mode shape in
78 /// `tatara-reconciler::render`. ONE site owns the per-variant
79 /// serialization so adding a 7th variant requires only the
80 /// arm here, not the parallel match the pre-lift Observe arm
81 /// carried.
82 pub fn canonical_bytes(&self) -> Vec<u8> {
83 match self {
84 Self::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
85 Self::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
86 Self::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
87 Self::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
88 Self::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
89 Self::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
90 }
91 }
92}
93
94impl crate::tagged_union::VariantKind<IntentKind> for IntentVariant<'_> {
95 fn variant_kind(&self) -> IntentKind {
96 self.kind()
97 }
98}
99
100/// Closed-set discriminator over `Intent`'s six tagged-union slots.
101/// Single source of truth that drives `Intent::variant`'s ambiguity
102/// + emptiness resolver, the `IntentError::Empty` message, and the
103/// reverse `IntentVariant::kind` projection. Adding a 7th intent
104/// variant lands at one `ALL` entry + one `as_str` arm + one
105/// `select` arm + one `IntentVariant::kind` arm — exhaustively
106/// checked by the compiler.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
108#[closed_set(via = "as_str", generate_unknown, display)]
109pub enum IntentKind {
110 Nix,
111 Flux,
112 Lisp,
113 Container,
114 Aplicacao,
115 Guest,
116}
117
118impl IntentKind {
119 /// The closed set of intent kinds — single source of truth that
120 /// drives `Intent::variant`'s sweep so a variant added without
121 /// an `ALL` entry never reaches the resolver.
122 pub const ALL: [Self; 6] = [
123 Self::Nix,
124 Self::Flux,
125 Self::Lisp,
126 Self::Container,
127 Self::Aplicacao,
128 Self::Guest,
129 ];
130
131 /// Canonical lower-case wire-format key — matches the serde
132 /// `rename_all = "camelCase"` field name on `Intent`. The
133 /// `IntentError::Empty` message composes the human-readable
134 /// list from this projection so a new variant lands in the
135 /// operator-facing diagnostic automatically via the `ALL`
136 /// sweep, not via hand-maintained error-string drift.
137 pub const fn as_str(self) -> &'static str {
138 match self {
139 Self::Nix => "nix",
140 Self::Flux => "flux",
141 Self::Lisp => "lisp",
142 Self::Container => "container",
143 Self::Aplicacao => "aplicacao",
144 Self::Guest => "guest",
145 }
146 }
147
148 /// Project an `Intent` borrow into the optional typed variant
149 /// view for this kind. Returns `None` iff the matching slot is
150 /// `None`. Composes the closed-set sweep `Intent::variant`
151 /// loops over.
152 pub fn select<'a>(self, intent: &'a Intent) -> Option<IntentVariant<'a>> {
153 match self {
154 Self::Nix => intent.nix.as_ref().map(IntentVariant::Nix),
155 Self::Flux => intent.flux.as_ref().map(IntentVariant::Flux),
156 Self::Lisp => intent.lisp.as_ref().map(IntentVariant::Lisp),
157 Self::Container => intent.container.as_ref().map(IntentVariant::Container),
158 Self::Aplicacao => intent.aplicacao.as_ref().map(IntentVariant::Aplicacao),
159 Self::Guest => intent.guest.as_ref().map(IntentVariant::Guest),
160 }
161 }
162}
163
164crate::declare_tagged_union_error! {
165 pub IntentError,
166 empty = "intent has no variant set (one of {0} required)",
167 ambiguous = "intent has multiple variants set; exactly one required",
168}
169
170/// Slash-joined list of every `IntentKind::as_str()` — composed once
171/// at compile time so `IntentError::Empty`'s diagnostic carries the
172/// closed-set summary without per-variant string drift. Pinned against
173/// the canonical [`tatara_lisp::ClosedSet::labels_joined`] projection
174/// by `intent_error_empty_lists_every_kind_in_canonical_order`, so a
175/// regression that drifts this `&'static str` constant from the
176/// `IntentKind::ALL × as_str` composition fails-loudly at the test
177/// site without per-variant inline materialization.
178pub(crate) const INTENT_KIND_LIST: &str = "nix/flux/lisp/container/aplicacao/guest";
179
180// `impl FromStr for IntentKind` +
181// `impl tatara_lisp::ClosedSet for IntentKind` +
182// `impl fmt::Display for IntentKind` +
183// `pub struct UnknownIntentKind(pub String)` are all generated by
184// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
185// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
186// enum declaration above. `label` delegates to the inherent
187// `IntentKind::as_str` — the camelCase wire-vocabulary projection
188// stays load-bearing (matches the serde `rename_all = "camelCase"`
189// field names on `Intent` AND the `IntentVariant::canonical_bytes`
190// per-variant arm), while generic `T: ClosedSet` consumers reach the
191// STABLE workspace-wide name (`label`). The auto-derived carrier
192// label "intent kind" matches the substrate-wide
193// `#[error("unknown intent kind: {0}")]` shape every sibling
194// closed-set carrier across `tatara-process` renders verbatim.
195// Symmetric to [`crate::intent::WorkloadKind`] (the workload-axis
196// sibling on the same `ProcessSpec` slice) and every other
197// `#[derive(DeriveClosedSet)]` implementor across the crate.
198
199crate::declare_tagged_union_impls! {
200 parent = Intent,
201 kind = IntentKind,
202 variant = IntentVariant,
203 error = IntentError,
204 kind_list = INTENT_KIND_LIST,
205}
206
207/// Nix-sourced intent — tatara-engine's nix_eval driver produces resources.
208#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct NixIntent {
211 /// Flake reference, e.g., `github:pleme-io/k8s?dir=shared/infrastructure`.
212 pub flake_ref: String,
213 /// Attribute path within the flake (e.g., `observability`).
214 pub attribute: String,
215 /// Target system. Defaults to the controller host's system.
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub system: Option<String>,
218 /// Attic cache to push the resulting store path into.
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub attic_cache: Option<String>,
221 /// Additional `nix build` arguments (e.g., `["--impure"]`).
222 #[serde(default)]
223 pub extra_args: Vec<String>,
224 /// Delegate the actual build to a sibling NixBuild CRD
225 /// (bridges to tatara-operator NATS bare-metal builder path).
226 #[serde(default)]
227 pub delegate_to_nix_build: bool,
228}
229
230/// FluxCD passthrough intent — reuse an existing GitRepository.
231#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "camelCase")]
233pub struct FluxIntent {
234 /// Name of an existing `GitRepository` (typically in `flux-system`).
235 pub git_repository: String,
236 /// Path inside the repository that the Kustomization will apply.
237 pub path: String,
238 /// Optional namespace of the GitRepository CR (defaults to `flux-system`).
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub git_repository_namespace: Option<String>,
241 /// Optional target namespace for the emitted Kustomization.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub target_namespace: Option<String>,
244 /// SOPS decryption — defaults to true to match pleme-io conventions.
245 #[serde(default = "default_true")]
246 pub decrypt_sops: bool,
247 /// If set, additionally emit a HelmRelease for this chart.
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub helm_chart: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub helm_values: Option<BTreeMap<String, serde_json::Value>>,
252}
253
254fn default_true() -> bool {
255 true
256}
257
258/// Lisp-sourced intent — tatara-lisp reader + macroexpander produces resources.
259#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
260#[serde(rename_all = "camelCase")]
261pub struct LispIntent {
262 /// Raw S-expression source, OR `include:<path>` / `configmap:<name>/<key>` pointer.
263 pub source: String,
264 /// Reader dialect / version tag.
265 #[serde(default = "default_reader")]
266 pub reader: String,
267 /// Macro form version.
268 #[serde(default = "default_version")]
269 pub version: String,
270 /// Symbols injected into the reader env (e.g., `cluster`, `region`).
271 #[serde(default)]
272 pub bindings: BTreeMap<String, serde_json::Value>,
273}
274
275fn default_reader() -> String {
276 "tatara-lisp".to_string()
277}
278fn default_version() -> String {
279 "v1".to_string()
280}
281
282/// Aplicacao intent — emit a FluxCD `HelmRelease` for a pleme-io
283/// typed Aplicacao chart. The chart owns its own sub-chart DAG;
284/// the reconciler only watches `HelmRelease.status.conditions[type=Ready]`.
285///
286/// This is the canonical handoff from caixa `(defaplicacao …)` declarations
287/// (which the typescape renders to this Intent) into in-cluster
288/// reconciliation. Closed-loop ephemeral test environments use this
289/// variant with `:lifetime :ephemeral` on the surrounding ProcessSpec.
290///
291/// Example (Lisp):
292/// ```lisp
293/// :intent (:aplicacao
294/// (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
295/// :version "0.5.5"
296/// :profile "all-in-one"
297/// :values-overlay (:cluster (:name "ephemeral-test-01")
298/// :persistence false
299/// :compliance (:overlays []))))
300/// ```
301#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
302#[serde(rename_all = "camelCase")]
303pub struct AplicacaoIntent {
304 /// Helm chart reference. OCI (`oci://…`) or repo-relative (`pleme-io/lareira-demo-app`).
305 pub chart_ref: String,
306 /// Chart version (Helm semver constraint; `">=0.5.5"` allowed).
307 pub version: String,
308 /// Architecture profile from the chart's `values/*.yaml` family
309 /// (e.g. `all-in-one`, `saas-internal`).
310 /// Leave empty to use chart defaults.
311 #[serde(default, skip_serializing_if = "String::is_empty")]
312 pub profile: String,
313 /// Typed values overlay merged on top of the profile.
314 /// Free-form JSON to keep tatara-process decoupled from chart schemas.
315 #[serde(default)]
316 #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
317 pub values_overlay: serde_json::Value,
318 /// HelmRelease name override. Defaults to the Process's PID-derived name.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub release_name: Option<String>,
321 /// Target namespace for the chart. Defaults to the Process's namespace.
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub target_namespace: Option<String>,
324 /// Install timeout (`humantime` duration). Empty = chart-controller default.
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub install_timeout: Option<String>,
327}
328
329/// Workspace-wide default for the `timeout` slot on a Flux
330/// `HelmRelease.spec.{install,upgrade}` block, applied when the
331/// operator did not populate [`AplicacaoIntent::install_timeout`].
332/// Load-bearing on the reconciler's Helm-driven RENDER surface —
333/// [`AplicacaoIntent::helm_lifecycle_policy`] substitutes this exact
334/// string, and the reconciler's `render_aplicacao` byte-installs
335/// the resulting policy into both install AND upgrade slots.
336pub const HELM_LIFECYCLE_DEFAULT_TIMEOUT: &str = "25m";
337
338/// Workspace-wide default for the `remediation.retries` slot on a
339/// Flux `HelmRelease.spec.{install,upgrade}` block. Constant across
340/// both slots today; a future two-slot split (e.g. distinct retry
341/// budgets for a first install vs a rolling upgrade) lands as two
342/// consts here + a two-slot [`HelmLifecyclePolicy`] shape, not at
343/// the render callsite.
344pub const HELM_LIFECYCLE_DEFAULT_RETRIES: u8 = 3;
345
346/// Workspace-wide default for the reconcile-loop cadence on both Flux
347/// resources a Helm-driven `AplicacaoIntent` publishes today: the
348/// `OCIRepository.spec.interval` on the source side (how often the
349/// source-controller re-pulls the chart from OCI) and the
350/// `HelmRelease.spec.interval` on the release side (how often the
351/// helm-controller re-reconciles the release against the chart).
352/// The fleet convention ties both cadences to the same `5m` string
353/// today, so the substrate exposes ONE named const rather than two
354/// literals sprayed across `render_aplicacao`.
355///
356/// Peer to [`HELM_LIFECYCLE_DEFAULT_TIMEOUT`] on the same
357/// AplicacaoIntent-facing "workspace-wide Flux default" axis. A
358/// future per-slot divergence (`SOURCE_INTERVAL` vs `RELEASE_INTERVAL`
359/// as two consts, or a two-slot method returning a
360/// `FluxReconcileIntervals { source, release }` shape) lands here,
361/// NOT at the two render callsites.
362///
363/// Load-bearing wire-format string: the byte-exact `5m` shape is
364/// what the Flux source- and helm-controllers parse via `humantime`;
365/// a regression that renamed it to any other duration would silently
366/// throttle or hammer every Helm-driven Process's reconciliation
367/// loop. Pinned at
368/// [`tests::flux_helm_default_interval_is_pinned_to_5m`].
369pub const FLUX_HELM_DEFAULT_INTERVAL: &str = "5m";
370
371/// Typed shape of one Flux `HelmRelease.spec.{install,upgrade}` slot
372/// — the substrate's projection of the "how long may Helm take, and
373/// how many retries after a failed run" contract every Helm-driven
374/// Process publishes on both slots. Pre-lift the reconciler's
375/// `render_aplicacao` hand-authored the shape via TWO adjacent
376/// identical `json!({"timeout": …, "remediation": {"retries": …}})`
377/// blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
378/// — one for install, one for upgrade, each restating the same
379/// three-slot literal with the same `Option::unwrap_or_else` fallback
380/// on the timeout. Post-lift the shape lives at ONE named typed
381/// struct here whose serde projection matches Flux HelmRelease v2's
382/// `install` / `upgrade` block schema byte-identically, and the
383/// reconciler composes both slots off ONE
384/// [`AplicacaoIntent::helm_lifecycle_policy`] call.
385///
386/// A future addition — a `wait: bool` slot, a `crds:
387/// CreateReplace` slot, a `disableOpenAPIValidation: bool` slot,
388/// a two-slot split that lets install carry a longer timeout than
389/// upgrade — lands at ONE struct here and every downstream
390/// consumer (the render surface, snapshot tests, an operator-
391/// facing dashboard column, a future validating webhook) inherits
392/// the upgrade mechanically.
393#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
394pub struct HelmLifecyclePolicy {
395 /// Chart-controller timeout (`humantime` duration). Set from
396 /// [`AplicacaoIntent::install_timeout`] when present; otherwise
397 /// [`HELM_LIFECYCLE_DEFAULT_TIMEOUT`].
398 pub timeout: String,
399 /// Retry budget for the slot.
400 pub remediation: HelmRemediationPolicy,
401}
402
403/// Typed shape of one `HelmLifecyclePolicy::remediation` slot.
404/// A named struct rather than an inline `{retries: u8}` map so
405/// downstream consumers can talk about "one Helm remediation
406/// policy" as a nameable handle rather than an unnamed nested
407/// object.
408#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
409pub struct HelmRemediationPolicy {
410 /// Number of times Flux's helm-controller retries a failed
411 /// install / upgrade before surfacing the failure to the parent
412 /// Process's boundary evaluator.
413 pub retries: u8,
414}
415
416impl HelmLifecyclePolicy {
417 /// The workspace-wide default policy — used when the operator
418 /// omitted [`AplicacaoIntent::install_timeout`]. Named projection
419 /// of the two `HELM_LIFECYCLE_DEFAULT_*` consts so a future
420 /// consumer wanting "the substrate's fresh-out-of-the-box Helm
421 /// lifecycle policy" pulls the pair through ONE call rather than
422 /// composing the struct by hand at every callsite.
423 pub fn workspace_default() -> Self {
424 Self {
425 timeout: HELM_LIFECYCLE_DEFAULT_TIMEOUT.to_string(),
426 remediation: HelmRemediationPolicy {
427 retries: HELM_LIFECYCLE_DEFAULT_RETRIES,
428 },
429 }
430 }
431}
432
433impl AplicacaoIntent {
434 /// Chart-pointer-only composer — the canonical minimal
435 /// `AplicacaoIntent` every fixture and default-shape callsite
436 /// restated pre-lift by binding only `(chart_ref, version)` and
437 /// leaving every remaining slot at its K8s-schema-default value
438 /// (`profile = ""`, `values_overlay = Value::Null`,
439 /// `release_name = target_namespace = install_timeout = None`).
440 ///
441 /// Pre-lift the 7-slot struct-literal was hand-authored at 14
442 /// workspace-wide sites past the ★★ PRIME-DIRECTIVE ≥ 2
443 /// duplication threshold, each restating the SAME 5-slot default
444 /// tail after a caller-varying `(chart_ref, version)` pair:
445 ///
446 /// * `tatara-process` — 10 sites across `lib.rs` (3× `empty_template`
447 /// in the ephemeral-spec / matrix / observer test blocks),
448 /// `lifetime_clock.rs` (2× the ephemeral / permanent Process
449 /// composer helpers), `tagged_union.rs` (1× the
450 /// `IntentKind::Aplicacao` sample-intent arm), `pool.rs` (1×
451 /// `empty_template`), and `intent.rs` (3× the `helm_intent`
452 /// helper + the `aplicacao_plus_flux_is_ambiguous` fixture + the
453 /// `IntentKind::Aplicacao` sample-intent-for arm).
454 /// * `tatara-pool-reconciler` — 4 sites across `router.rs`,
455 /// `pool_decide.rs`, `desired.rs`, `allocation_decide.rs`
456 /// (all `empty_template` fixtures seeding the pool + allocation
457 /// convergence-decision test batteries).
458 /// * `tatara-reconciler` — 1 site in `render.rs`
459 /// (`helmrepository_chartref_for_non_oci` production-shape
460 /// fixture stamping the non-OCI chart-ref render path).
461 ///
462 /// All 14 sites walked the SAME 5-slot default tail — differing
463 /// only in the caller-varying `chart_ref` / `version` values
464 /// (`"oci://x"` / `"1"` on the majority test-fixture slice,
465 /// `"oci://ghcr.io/x"` / `"0.1.0"` on the `IntentKind` sample,
466 /// `"pleme-io/lareira-demo-app"` / `"0.5.5"` on the non-OCI
467 /// render pin). Post-lift each callsite reads
468 /// `AplicacaoIntent::chart_only(chart, version)` and the 5-slot
469 /// default tail lives at ONE substrate owner.
470 ///
471 /// The `impl Into<String>` argument form matches the pre-lift
472 /// call shape — every site that spelled `"oci://x".into()` +
473 /// `"1".into()` in the struct-literal continues to compile
474 /// unchanged, and callers with a live `String` (e.g. reading
475 /// from a caller-supplied fixture parameter) pass it through
476 /// without a `.to_string()` re-wrap.
477 ///
478 /// Return-form axis: `AplicacaoIntent` — the owned typed value
479 /// every consumer's downstream `Intent { aplicacao: Some(...), ..
480 /// }` / `EphemeralSpec { aplicacao: ..., .. }` binding stamps
481 /// verbatim, matching the pre-lift 7-slot struct-literal's return
482 /// shape exactly. The five default-tail slots reify the K8s
483 /// schema's own defaults (`profile` empty ⇒ chart profile default;
484 /// `values_overlay = Null` ⇒ pass-through; three `None` slots ⇒
485 /// server / chart-computed) so no consumer inherits a semantic
486 /// change from the lift.
487 ///
488 /// A future normalization of the "minimal AplicacaoIntent" default
489 /// — an added struct field with its own K8s-schema default, a
490 /// tightening of a `None` slot to a substrate-owned default value,
491 /// a per-workspace-default `install_timeout` override — lands at
492 /// THIS ONE substrate primitive and every downstream fixture /
493 /// default-shape consumer inherits the upgrade mechanically — no
494 /// per-site edit at any of the 14 listed callers or at future
495 /// consumers (a new pool-shard-flavor fixture, a new intent-axis
496 /// convergence probe, a per-tenant AplicacaoIntent minimal seed).
497 ///
498 /// Peer to [`Self::helm_lifecycle_policy`] +
499 /// [`Self::flux_reconcile_interval`] on the (COMPOSE, DERIVE)
500 /// axis: `chart_only` is the WRITE-side composer (stamp a minimal
501 /// `AplicacaoIntent`); the lifecycle + interval methods are the
502 /// READ-side derivers (project a substrate-default Flux policy /
503 /// cadence off an `AplicacaoIntent`). The three primitives
504 /// partition the `AplicacaoIntent` compose × derive surface at
505 /// the substrate.
506 ///
507 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
508 /// the 5-slot default tail recurred at 14 hand-authored sites
509 /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning
510 /// three workspace crates, and is lifted onto the ONE workspace-
511 /// wide substrate owner here). THEORY.md §II.1 invariant 5
512 /// (composition preserves proofs — the pin block below binds the
513 /// primitive at fail-before-pass-after granularity so a
514 /// regression that drifted any of the five default-tail slots
515 /// surfaces at THESE pins rather than as silent fixture skew
516 /// across the 14 downstream consumers).
517 #[must_use]
518 pub fn chart_only(chart_ref: impl Into<String>, version: impl Into<String>) -> Self {
519 Self {
520 chart_ref: chart_ref.into(),
521 version: version.into(),
522 profile: String::new(),
523 values_overlay: serde_json::Value::Null,
524 release_name: None,
525 target_namespace: None,
526 install_timeout: None,
527 }
528 }
529
530 /// Derive the Flux `HelmRelease.spec.{install,upgrade}` policy
531 /// this intent publishes on BOTH slots. Pre-lift the reconciler's
532 /// `render_aplicacao` restated the shape by hand via two adjacent
533 /// identical `json!` blocks (install and upgrade); post-lift both
534 /// slots ride through this ONE composer. A future two-slot split
535 /// (distinct install vs upgrade policies) lands as a two-method
536 /// pair here, not at the render callsite.
537 pub fn helm_lifecycle_policy(&self) -> HelmLifecyclePolicy {
538 HelmLifecyclePolicy {
539 timeout: self
540 .install_timeout
541 .clone()
542 .unwrap_or_else(|| HELM_LIFECYCLE_DEFAULT_TIMEOUT.to_string()),
543 remediation: HelmRemediationPolicy {
544 retries: HELM_LIFECYCLE_DEFAULT_RETRIES,
545 },
546 }
547 }
548
549 /// Derive the Flux reconcile-loop cadence this intent publishes on
550 /// BOTH `OCIRepository.spec.interval` (source-controller poll) and
551 /// `HelmRelease.spec.interval` (helm-controller re-reconcile).
552 /// Pre-lift the reconciler's `render_aplicacao` restated the value
553 /// via two adjacent hand-authored `"5m"` string literals past the
554 /// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold; post-lift both
555 /// slots ride through this ONE composer. A future divergence
556 /// (distinct per-slot cadences, a per-intent override field, a
557 /// two-slot method returning a `FluxReconcileIntervals` shape)
558 /// lands at ONE method here, not at the render callsites.
559 ///
560 /// Sibling composer to [`Self::helm_lifecycle_policy`]: both
561 /// return the substrate-default shape a Helm-driven Process
562 /// publishes on the Flux resources `render_aplicacao` emits,
563 /// keyed off the same `AplicacaoIntent`.
564 pub fn flux_reconcile_interval(&self) -> String {
565 FLUX_HELM_DEFAULT_INTERVAL.to_string()
566 }
567}
568
569/// Container intent — direct Deployment/StatefulSet/etc, no Helm.
570#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
571#[serde(rename_all = "camelCase")]
572pub struct ContainerIntent {
573 pub image: String,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub replicas: Option<i32>,
576 #[serde(default)]
577 pub command: Vec<String>,
578 #[serde(default)]
579 pub args: Vec<String>,
580 #[serde(default)]
581 pub env: BTreeMap<String, String>,
582 #[serde(default)]
583 pub workload_kind: WorkloadKind,
584}
585
586/// K8s workload kind the `container` intent renders into. PascalCase
587/// values match the K8s `kind:` field on the emitted manifest verbatim,
588/// so `as_str` doubles as the canonical `kind:` projection at render time.
589#[derive(
590 Clone,
591 Copy,
592 Debug,
593 PartialEq,
594 Eq,
595 Hash,
596 Serialize,
597 Deserialize,
598 JsonSchema,
599 Default,
600 tatara_closed_set::DeriveClosedSet,
601)]
602#[serde(rename_all = "PascalCase")]
603#[closed_set(via = "as_str", generate_unknown, display)]
604pub enum WorkloadKind {
605 #[default]
606 Deployment,
607 StatefulSet,
608 DaemonSet,
609 Job,
610 CronJob,
611}
612
613impl WorkloadKind {
614 /// The closed set of workload kinds — single source of truth that
615 /// drives the `as_str` / Display / `FromStr` triad and the typed
616 /// `api_version` / `is_batch` projections. Adding a sixth variant
617 /// lands at one `ALL` entry + one `as_str` arm + one arm in each
618 /// projection — exhaustively checked by the compiler (the `[Self; 5]`
619 /// array literal forces the arity).
620 ///
621 /// Sibling closed-set lifts on the same `ProcessSpec` axis:
622 /// [`crate::encapsulates::EncapsulationMode::ALL`],
623 /// [`crate::export::ExportTrigger::ALL`],
624 /// [`crate::export::ReportFormat::ALL`],
625 /// [`crate::lifetime::TeardownPolicy::ALL`],
626 /// [`crate::intent::IntentKind::ALL`],
627 /// [`crate::lifetime::LifetimeKind::ALL`],
628 /// [`crate::boundary::ConditionKind::ALL`],
629 /// [`crate::phase::ProcessPhase::ALL`],
630 /// [`crate::signal::ProcessSignal::ALL`].
631 pub const ALL: [Self; 5] = [
632 Self::Deployment,
633 Self::StatefulSet,
634 Self::DaemonSet,
635 Self::Job,
636 Self::CronJob,
637 ];
638
639 /// Canonical PascalCase wire-format projection — matches the serde
640 /// `rename_all = "PascalCase"` output verbatim AND the K8s manifest
641 /// `kind:` field the `container` intent's future renderer will emit.
642 /// Used by Display (single source of truth), by `FromStr` to identify
643 /// the variant from its annotation / status-field representation, and
644 /// by operator-facing reason strings without reaching for `{:?}` Debug
645 /// formatting. Pinned by `workload_kind_as_str_matches_serde`.
646 pub const fn as_str(self) -> &'static str {
647 match self {
648 Self::Deployment => "Deployment",
649 Self::StatefulSet => "StatefulSet",
650 Self::DaemonSet => "DaemonSet",
651 Self::Job => "Job",
652 Self::CronJob => "CronJob",
653 }
654 }
655
656 /// Canonical K8s `apiVersion:` projection — `apps/v1` for the
657 /// long-running workload trio, `batch/v1` for the batch pair.
658 /// Single source of truth for the apiVersion the `container` intent
659 /// renderer will stamp on the emitted manifest; pinned by
660 /// `workload_kind_projection_truth_table` so a future variant lands
661 /// at one arm here, not at every render site that previously
662 /// hand-rolled `match kind { Job | CronJob => "batch/v1", _ => … }`.
663 ///
664 /// Closed-set match (not `matches!`) so adding a sixth variant
665 /// triggers the compiler's exhaustiveness check at this site
666 /// rather than silently defaulting to either group.
667 pub const fn api_version(self) -> &'static str {
668 match self {
669 Self::Deployment | Self::StatefulSet | Self::DaemonSet => "apps/v1",
670 Self::Job | Self::CronJob => "batch/v1",
671 }
672 }
673
674 /// True iff the workload kind is a batch (terminating) workload —
675 /// `Job` or `CronJob`. Drives the future container renderer's
676 /// decision between persistent / one-shot retry semantics and lets
677 /// the lifetime clock distinguish "naturally terminates" from "runs
678 /// until SIGTERM" without re-deriving the partition from
679 /// `api_version() == "batch/v1"`.
680 ///
681 /// Closed-set match (not `matches!`) so adding a sixth variant
682 /// triggers the compiler's exhaustiveness check at this site.
683 pub const fn is_batch(self) -> bool {
684 match self {
685 Self::Job | Self::CronJob => true,
686 Self::Deployment | Self::StatefulSet | Self::DaemonSet => false,
687 }
688 }
689}
690
691// `impl FromStr for WorkloadKind` +
692// `impl tatara_lisp::ClosedSet for WorkloadKind` +
693// `impl fmt::Display for WorkloadKind` +
694// `pub struct UnknownWorkloadKind(pub String)` are all generated by
695// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
696// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
697// enum declaration above. `label` delegates to the inherent
698// `WorkloadKind::as_str` — the PascalCase wire-vocabulary projection
699// stays load-bearing (matches the serde `rename_all = "PascalCase"`
700// output AND the K8s manifest `kind:` field verbatim), while generic
701// `T: ClosedSet` consumers reach the STABLE workspace-wide name
702// (`label`). The auto-derived carrier label "workload kind" matches
703// the prior hand-rolled `#[error("unknown workload kind: {0}")]`
704// annotation byte-for-byte. Symmetric to every other
705// `#[derive(DeriveClosedSet)]` implementor across the crate.
706
707/// Guest intent — the Process is a Linux VM or WASM component supervised
708/// by `tatara-hospedeiro`. See `tatara/docs/declarative-guests.md`.
709///
710/// The actual `GuestSpec` is stored as a serde JSON value to keep
711/// `tatara-process` decoupled from `tatara-vm`. Hospedeiro re-parses
712/// the value as the concrete `tatara_vm::GuestSpec` at boot time; a
713/// round-trip test on the tatara-vm side guarantees the shape.
714#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
715#[serde(rename_all = "camelCase")]
716pub struct GuestIntent {
717 /// The (defguest …) spec as JSON. Shape matches `tatara_vm::GuestSpec`.
718 #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
719 pub spec: serde_json::Value,
720
721 /// Where to write per-guest state on the host (logs, socket, PID file).
722 /// Defaults to `~/.local/state/tatara/guests/<name>/`.
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub state_dir: Option<String>,
725
726 /// Whether hospedeiro is allowed to pull guest artifacts from a remote
727 /// transport (Attic, ssh-ng) if not already present locally. The
728 /// default is taken from the GuestSpec's `buildOn` field; setting
729 /// this explicitly overrides at the intent layer.
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub allow_remote_build: Option<bool>,
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
739 fn empty_intent_errors() {
740 let i = Intent::default();
741 match i.variant().unwrap_err() {
742 IntentError::Empty(list) => assert_eq!(list, INTENT_KIND_LIST),
743 other => panic!("expected Empty, got {other:?}"),
744 }
745 }
746
747 #[test]
748 fn exactly_one_ok() {
749 let i = Intent {
750 nix: Some(NixIntent {
751 flake_ref: "github:a/b".into(),
752 attribute: "x".into(),
753 system: None,
754 attic_cache: None,
755 extra_args: vec![],
756 delegate_to_nix_build: false,
757 }),
758 ..Intent::default()
759 };
760 assert!(matches!(i.variant().unwrap(), IntentVariant::Nix(_)));
761 }
762
763 /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
764 /// resolver yields `Ambiguous`, exhaustively across every pair in
765 /// `ALL × ALL` (excluding the diagonal). Routes through the
766 /// substrate primitive
767 /// [`crate::tagged_union::assert_two_slots_ambiguous`] shared with
768 /// the sibling
769 /// `encapsulation_kind_two_slots_is_ambiguous_across_every_pair`
770 /// / `artifact_source_two_slots_is_ambiguous_across_every_pair`
771 /// / `vector_channel_two_slots_is_ambiguous_across_every_pair`
772 /// sites. Subsumes the pre-lift hand-authored two-pair probes
773 /// (`nix + flux`, `nix + guest`) with exhaustive `6 × 5 = 30`
774 /// coverage — every off-diagonal pair on `IntentKind` is pinned.
775 #[test]
776 fn intent_two_slots_is_ambiguous_across_every_pair() {
777 crate::tagged_union::assert_two_slots_ambiguous::<Intent, _>(two_slot_intent);
778 }
779
780 #[test]
781 fn guest_intent_selects_its_variant() {
782 let i = Intent {
783 guest: Some(GuestIntent {
784 spec: serde_json::json!({
785 "name": "fast-fn",
786 "kind": { "kind": "wasm", "runtime": "wasmtime",
787 "wasiPreview": "p2",
788 "component": { "kind": "flake",
789 "value": {"url":"github:x/y","attr":"wasi"} },
790 "features": { "simd": true } },
791 "cmdline": []
792 }),
793 state_dir: None,
794 allow_remote_build: Some(true),
795 }),
796 ..Intent::default()
797 };
798 match i.variant().unwrap() {
799 IntentVariant::Guest(g) => {
800 assert_eq!(g.spec["name"], "fast-fn");
801 assert_eq!(g.allow_remote_build, Some(true));
802 }
803 other => panic!("expected Guest, got {other:?}"),
804 }
805 }
806
807 #[test]
808 fn aplicacao_intent_selects_its_variant() {
809 let i = Intent {
810 aplicacao: Some(AplicacaoIntent {
811 chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
812 version: "0.5.5".into(),
813 profile: "all-in-one".into(),
814 values_overlay: serde_json::json!({ "cluster": { "name": "test-01" } }),
815 release_name: None,
816 target_namespace: None,
817 install_timeout: Some("25m".into()),
818 }),
819 ..Intent::default()
820 };
821 match i.variant().unwrap() {
822 IntentVariant::Aplicacao(a) => {
823 assert_eq!(a.profile, "all-in-one");
824 assert_eq!(a.version, "0.5.5");
825 assert_eq!(a.install_timeout.as_deref(), Some("25m"));
826 }
827 other => panic!("expected Aplicacao, got {other:?}"),
828 }
829 }
830
831 /// Structural well-formedness of [`IntentKind`] as a
832 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
833 /// testkit lift that pins all structural invariants (`ALL` is
834 /// non-empty, every variant round-trips through `label ↔
835 /// parse_label`, labels are pairwise distinct, `""` is outside
836 /// the closed set, the `UnknownIntentKind` carrier's Display
837 /// renders the substrate-wide `"unknown intent kind: <input>"`
838 /// shape, `labels()` equals the natural `ALL × label` projection,
839 /// `parse_label_with_hint` composes `parse_label` +
840 /// `suggest_closest` verbatim) at ONE call site. Replaces the
841 /// hand-derived `intent_kind_all_is_unique_and_complete` —
842 /// clause (1)+(3) of the testkit subsume the uniqueness +
843 /// non-emptiness sweep that test pinned independently.
844 #[test]
845 fn intent_kind_is_well_formed_closed_set() {
846 tatara_closed_set::assert_closed_set_well_formed::<IntentKind>();
847 }
848
849 /// The Display impl IS `as_str` — pinning this lets future callers
850 /// reach for either projection without drift. Symmetric to the
851 /// sibling `workload_kind_display_matches_as_str` invariant; if a
852 /// reviewer accidentally re-introduces an inline match in Display,
853 /// this test would fail the moment a variant rename touches one
854 /// site but not the other.
855 ///
856 /// Routes through the substrate primitive
857 /// [`crate::tagged_union::assert_display_matches_label`], which
858 /// composes `<T as ClosedSet>::label` against `T::to_string`
859 /// byte-identically for every `<T: ClosedSet + Display>`
860 /// implementor — the Display-alignment testkit shared with every
861 /// sibling `X_display_matches_as_str` site across the crate.
862 /// Pre-lift the 27 bodies each restated the same
863 /// `for k in K::ALL { assert_eq!(k.to_string(), k.as_str()) }`
864 /// two-line probe at the test surface; post-lift the projection
865 /// lives at ONE substrate primitive and every site binds through
866 /// a single call.
867 #[test]
868 fn intent_kind_display_matches_as_str() {
869 crate::tagged_union::assert_display_matches_label::<IntentKind>();
870 }
871
872 /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
873 /// camelCase serde field name on `Intent`. A future rename of
874 /// any field lands here at one site — and the `Empty` diagnostic
875 /// composed from `INTENT_KIND_LIST` stays coherent with the
876 /// wire format.
877 ///
878 /// Routes through the substrate primitive
879 /// [`crate::tagged_union::assert_single_slot_key_matches_label`],
880 /// which pins the exactly-one-key + name-equality projection
881 /// byte-identically for every `<T: TaggedUnion + Serialize>`
882 /// implementor — the wire-alignment testkit shared with the sibling
883 /// `encapsulation_target_as_str_matches_field_name` /
884 /// `artifact_kind_as_str_matches_field_name` /
885 /// `channel_kind_as_str_matches_field_name` sites. Pre-lift the
886 /// four bodies each restated the same serialize-and-inspect sweep
887 /// at the test surface (three through a weaker YAML-substring
888 /// check; this site alone through the strong JSON-object exactly-
889 /// one form); post-lift the projection lives at ONE substrate
890 /// primitive and every site binds through a single call — the
891 /// three YAML sites simultaneously upgrade to the strong exactly-
892 /// one form.
893 #[test]
894 fn intent_kind_as_str_matches_intent_field_name() {
895 crate::tagged_union::assert_single_slot_key_matches_label::<Intent, _>(single_slot_intent);
896 }
897
898 /// ROUND-TRIP CONTRACT: `IntentKind::select(intent).map(|v|
899 /// v.kind()) == Some(kind)`. The reverse `IntentVariant::kind`
900 /// projection composes the closed set in both directions — a
901 /// regression that misroutes a select arm (e.g. `Self::Nix =>
902 /// intent.flux.as_ref()...`) fails loudly here.
903 ///
904 /// Routes through the substrate primitive
905 /// [`crate::tagged_union::assert_variant_round_trip`], which
906 /// composes [`crate::tagged_union::VariantSelector::select`]
907 /// (forward) with [`crate::tagged_union::VariantKind::variant_kind`]
908 /// (reverse) byte-identically for every `<T: TaggedUnion>`
909 /// implementor — the round-trip testkit shared with the sibling
910 /// `artifact_kind_round_trips_through_variant_kind` /
911 /// `channel_kind_round_trips_through_variant_kind` /
912 /// `encapsulation_target_round_trips_through_variant_target`
913 /// sites. Pre-lift the four bodies each restated the same
914 /// two-arm round-trip probe at the test surface; post-lift the
915 /// projection lives at ONE substrate primitive and every site
916 /// binds through a single call.
917 #[test]
918 fn intent_kind_round_trips_through_variant_kind() {
919 crate::tagged_union::assert_variant_round_trip::<Intent, _>(single_slot_intent);
920 }
921
922 /// PRESENCE-PROBE WIRE CONTRACT: the `intent-<kind>` require-tag
923 /// dispatcher in `tatara-check` (`bin/tatara-check.rs`) parses
924 /// each suffix via `IntentKind::from_str` and dispatches through
925 /// the substrate primitive `Intent::has` (a one-line inherent
926 /// forwarder over [`crate::tagged_union::TaggedUnion::has`]).
927 /// Pre-lift the dispatcher restated five hand-authored
928 /// `spec.intent.<field>.is_some()` arms whose per-field addressing
929 /// drifted from `IntentKind::ALL` (the sixth variant `Guest` had
930 /// no `intent-guest` arm at all); post-lift adding a seventh
931 /// variant to `IntentKind` lands the corresponding `intent-<kind>`
932 /// tag automatically — the sweep here pins that every
933 /// `IntentKind` roundtrips through the `intent-{as_str}` wire
934 /// key, and that `Intent::has(k)` fires exactly on the populated
935 /// slot addressed by `k`.
936 #[test]
937 fn intent_has_dispatches_through_wire_key_across_every_kind() {
938 for populated in IntentKind::ALL {
939 let intent = single_slot_intent(populated);
940 for probed in IntentKind::ALL {
941 let wire_key = format!("intent-{}", probed.as_str());
942 let parsed: IntentKind = wire_key
943 .strip_prefix("intent-")
944 .expect("wire key composes as intent-<as_str>")
945 .parse()
946 .expect("as_str→from_str round trip pinned by DeriveClosedSet");
947 assert_eq!(parsed, probed);
948 let expected = probed == populated;
949 assert_eq!(
950 intent.has(probed),
951 expected,
952 "Intent::has drift — populated={populated:?} probed={probed:?}",
953 );
954 }
955 }
956 }
957
958 /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set kind list embedded
959 /// in `IntentError::Empty` echoes the canonical join of every
960 /// `IntentKind::as_str()` projection. A variant added without
961 /// updating `INTENT_KIND_LIST` (or a renamed variant) shows up
962 /// here as a mismatch.
963 ///
964 /// Routes through the substrate primitive
965 /// [`crate::tagged_union::assert_kind_list_matches_closed_set`],
966 /// which composes `<T::Kind as ClosedSet>::labels_joined("/")`
967 /// against `<T as TaggedUnion>::KIND_LIST` byte-identically for
968 /// every implementor — the diagnostic-stability testkit shared
969 /// with the sibling `artifact_error_empty_lists_every_kind_in_canonical_order`
970 /// / `channel_error_empty_lists_every_kind_in_canonical_order`
971 /// / `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`
972 /// sites. Pre-lift the four bodies each restated the same
973 /// two-argument `assert_eq!(<XxxKind as ClosedSet>::labels_joined("/"),
974 /// XXX_KIND_LIST)` comparison at the test surface; post-lift
975 /// the projection lives at ONE substrate primitive and every
976 /// site binds through a single call.
977 #[test]
978 fn intent_error_empty_lists_every_kind_in_canonical_order() {
979 crate::tagged_union::assert_kind_list_matches_closed_set::<Intent>();
980 }
981
982 /// CANONICAL-BYTES CONTRACT: every populated variant yields the
983 /// SAME bytes as `serde_json::to_vec` on the inner reference.
984 /// Pins the lift of the parallel observe-mode match in
985 /// `tatara-reconciler::render` to this single method.
986 #[test]
987 fn intent_variant_canonical_bytes_matches_inner_serialize() {
988 for kind in IntentKind::ALL {
989 let i = single_slot_intent(kind);
990 let v = i.variant().expect("exactly-one variant");
991 let via_method = v.canonical_bytes();
992 let expected: Vec<u8> = match &v {
993 IntentVariant::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
994 IntentVariant::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
995 IntentVariant::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
996 IntentVariant::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
997 IntentVariant::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
998 IntentVariant::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
999 };
1000 assert_eq!(
1001 via_method, expected,
1002 "canonical_bytes mismatch for {kind:?}"
1003 );
1004 assert!(!via_method.is_empty(), "{kind:?} produced empty bytes");
1005 }
1006 }
1007
1008 /// Construct an `Intent` with two slots populated — drives the
1009 /// pairwise `Ambiguous` sweep through the substrate primitive
1010 /// [`crate::tagged_union::assert_two_slots_ambiguous`]. Composes
1011 /// the single-slot constructor on top of itself per-field so ONE
1012 /// source of truth for per-variant inner payloads is preserved.
1013 /// Mirrors `two_slot_source` / `two_slot_channel` / `two_slot_kind`
1014 /// in shape across `ProcessSpec`'s tagged-union axis.
1015 fn two_slot_intent(a: IntentKind, b: IntentKind) -> Intent {
1016 let ia = single_slot_intent(a);
1017 let ib = single_slot_intent(b);
1018 Intent {
1019 nix: ia.nix.or(ib.nix),
1020 flux: ia.flux.or(ib.flux),
1021 lisp: ia.lisp.or(ib.lisp),
1022 container: ia.container.or(ib.container),
1023 aplicacao: ia.aplicacao.or(ib.aplicacao),
1024 guest: ia.guest.or(ib.guest),
1025 }
1026 }
1027
1028 /// Construct an `Intent` with exactly the given kind's slot
1029 /// populated by a minimal valid inner spec. Shared across the
1030 /// closed-set property tests so they each cover every variant
1031 /// without restating the construction table.
1032 fn single_slot_intent(kind: IntentKind) -> Intent {
1033 match kind {
1034 IntentKind::Nix => Intent {
1035 nix: Some(NixIntent {
1036 flake_ref: "github:a/b".into(),
1037 attribute: "x".into(),
1038 system: None,
1039 attic_cache: None,
1040 extra_args: vec![],
1041 delegate_to_nix_build: false,
1042 }),
1043 ..Intent::default()
1044 },
1045 IntentKind::Flux => Intent {
1046 flux: Some(FluxIntent {
1047 git_repository: "g".into(),
1048 path: "p".into(),
1049 git_repository_namespace: None,
1050 target_namespace: None,
1051 decrypt_sops: true,
1052 helm_chart: None,
1053 helm_values: None,
1054 }),
1055 ..Intent::default()
1056 },
1057 IntentKind::Lisp => Intent {
1058 lisp: Some(LispIntent {
1059 source: "()".into(),
1060 reader: "tatara-lisp".into(),
1061 version: "v1".into(),
1062 bindings: BTreeMap::new(),
1063 }),
1064 ..Intent::default()
1065 },
1066 IntentKind::Container => Intent {
1067 container: Some(ContainerIntent {
1068 image: "ghcr.io/x:1".into(),
1069 replicas: Some(1),
1070 command: vec![],
1071 args: vec![],
1072 env: BTreeMap::new(),
1073 workload_kind: WorkloadKind::default(),
1074 }),
1075 ..Intent::default()
1076 },
1077 IntentKind::Aplicacao => Intent {
1078 aplicacao: Some(AplicacaoIntent::chart_only("oci://ghcr.io/x", "0.1.0")),
1079 ..Intent::default()
1080 },
1081 IntentKind::Guest => Intent {
1082 guest: Some(GuestIntent {
1083 spec: serde_json::json!({"name": "guest-1"}),
1084 state_dir: None,
1085 allow_remote_build: None,
1086 }),
1087 ..Intent::default()
1088 },
1089 }
1090 }
1091
1092 // ── closed-set algebra for WorkloadKind (ALL × as_str × Display ×
1093 // FromStr × api_version × is_batch) ─────────────────────────────
1094
1095 /// Structural well-formedness of [`WorkloadKind`] as a
1096 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1097 /// testkit lift that pins all three structural invariants (`ALL`
1098 /// is non-empty, every variant round-trips through `label ↔
1099 /// parse_label`, labels are pairwise distinct, `""` is outside the
1100 /// closed set) at ONE call site. Replaces the hand-derived
1101 /// `workload_kind_all_is_unique_and_complete` +
1102 /// `workload_kind_roundtrip_via_as_str` + the empty-input arm of
1103 /// `unknown_workload_kind_errors`. `FromStr` delegates to
1104 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1105 /// exercises the same code path the reconciler hits when parsing a
1106 /// K8s `kind:`-shaped value back to the typed workload kind.
1107 #[test]
1108 fn workload_kind_is_well_formed_closed_set() {
1109 tatara_closed_set::assert_closed_set_well_formed::<WorkloadKind>();
1110 }
1111
1112 /// CANONICAL-KEY CONTRACT: every variant's `as_str()` matches serde's
1113 /// PascalCase output verbatim. A future variant rename (or an
1114 /// `as_str` arm typo) lands at one site, instead of drifting
1115 /// between the typed surface, the K8s `kind:` manifest field, and
1116 /// the YAML wire format the reconciler / operator both read.
1117 #[test]
1118 fn workload_kind_as_str_matches_serde() {
1119 crate::tagged_union::assert_label_matches_serde_serialization::<WorkloadKind>();
1120 }
1121
1122 /// The Display impl IS `as_str` — pinning this lets future callers
1123 /// reach for either projection without drift. If a reviewer
1124 /// accidentally re-introduces an inline match in Display, this
1125 /// test would fail the moment a variant rename touches one site
1126 /// but not the other.
1127 #[test]
1128 fn workload_kind_display_matches_as_str() {
1129 crate::tagged_union::assert_display_matches_label::<WorkloadKind>();
1130 }
1131
1132 /// `FromStr` rejects strings that aren't in the canonical
1133 /// projection — lowercased / typo / unrelated — and the error
1134 /// echoes the input verbatim so the operator-facing diagnostic
1135 /// carries the offending value, not a normalized form. The
1136 /// empty-input arm is pinned by
1137 /// [`workload_kind_is_well_formed_closed_set`] via the
1138 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1139 /// verbatim-echo contract on the [`UnknownWorkloadKind`]
1140 /// newtype, which the trait's `make_unknown` can't see.
1141 #[test]
1142 fn unknown_workload_kind_errors() {
1143 use std::str::FromStr;
1144 for bad in ["deployment", "JOB", "ReplicaSet", "Pod"] {
1145 let err = WorkloadKind::from_str(bad).unwrap_err();
1146 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1147 }
1148 }
1149
1150 #[test]
1151 fn workload_kind_default_is_deployment() {
1152 assert_eq!(WorkloadKind::default(), WorkloadKind::Deployment);
1153 }
1154
1155 /// TRUTH-TABLE CONTRACT: `api_version` / `is_batch` agree with the
1156 /// documented (kind) -> (apiVersion, is_batch) table for every
1157 /// variant. A new variant in `WorkloadKind` without extending
1158 /// either projection's match is caught by the compiler (closed-set
1159 /// match in each method); adding a variant without extending its
1160 /// truth row is caught here. Also pins the invariant
1161 /// `is_batch <=> api_version == "batch/v1"`, so a future renderer
1162 /// can route on either projection without re-deriving the partition.
1163 #[test]
1164 fn workload_kind_projection_truth_table() {
1165 let table: &[(WorkloadKind, &str, bool)] = &[
1166 // (kind, api_version, is_batch)
1167 (WorkloadKind::Deployment, "apps/v1", false),
1168 (WorkloadKind::StatefulSet, "apps/v1", false),
1169 (WorkloadKind::DaemonSet, "apps/v1", false),
1170 (WorkloadKind::Job, "batch/v1", true),
1171 (WorkloadKind::CronJob, "batch/v1", true),
1172 ];
1173 assert_eq!(table.len(), WorkloadKind::ALL.len());
1174 for (kind, api, batch) in table {
1175 assert_eq!(kind.api_version(), *api, "api_version drift for {kind:?}");
1176 assert_eq!(kind.is_batch(), *batch, "is_batch drift for {kind:?}");
1177 assert_eq!(
1178 kind.is_batch(),
1179 kind.api_version() == "batch/v1",
1180 "is_batch / api_version partition disagrees for {kind:?}"
1181 );
1182 }
1183 }
1184
1185 #[test]
1186 fn aplicacao_plus_flux_is_ambiguous() {
1187 let i = Intent {
1188 aplicacao: Some(AplicacaoIntent::chart_only("x", "1")),
1189 flux: Some(FluxIntent {
1190 git_repository: "g".into(),
1191 path: "p".into(),
1192 git_repository_namespace: None,
1193 target_namespace: None,
1194 decrypt_sops: true,
1195 helm_chart: None,
1196 helm_values: None,
1197 }),
1198 ..Intent::default()
1199 };
1200 assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
1201 }
1202
1203 // ── Helm lifecycle policy — install / upgrade slot substrate ────
1204
1205 fn helm_intent(install_timeout: Option<&str>) -> AplicacaoIntent {
1206 AplicacaoIntent {
1207 install_timeout: install_timeout.map(str::to_string),
1208 ..AplicacaoIntent::chart_only("oci://ghcr.io/pleme-io/charts/lareira-demo-app", "0.5.5")
1209 }
1210 }
1211
1212 /// The workspace-wide default timeout const is pinned to `25m`.
1213 /// A regression that renamed it to any other duration would
1214 /// silently misroute every Helm-driven Process's default retry
1215 /// budget, so pin the byte-exact spelling here rather than at
1216 /// every consumer's own callsite.
1217 #[test]
1218 fn helm_lifecycle_default_timeout_is_pinned_to_25m() {
1219 assert_eq!(HELM_LIFECYCLE_DEFAULT_TIMEOUT, "25m");
1220 }
1221
1222 /// The workspace-wide default retries const is pinned to `3`.
1223 /// Peer to the `_timeout` pin; same rationale.
1224 #[test]
1225 fn helm_lifecycle_default_retries_is_pinned_to_three() {
1226 assert_eq!(HELM_LIFECYCLE_DEFAULT_RETRIES, 3);
1227 }
1228
1229 /// Fallback branch of the primitive: an intent that omitted
1230 /// `install_timeout` picks up the workspace-wide default
1231 /// (`25m` + retries `3`). Pin binds the "no override" shape
1232 /// every render / snapshot / dashboard consumer sees today.
1233 #[test]
1234 fn helm_lifecycle_policy_defaults_when_install_timeout_is_none() {
1235 let policy = helm_intent(None).helm_lifecycle_policy();
1236 assert_eq!(policy.timeout, HELM_LIFECYCLE_DEFAULT_TIMEOUT);
1237 assert_eq!(policy.remediation.retries, HELM_LIFECYCLE_DEFAULT_RETRIES);
1238 }
1239
1240 /// Override branch of the primitive: when the operator populated
1241 /// `install_timeout`, the primitive substitutes that string
1242 /// verbatim (no normalization, no trimming) — the reconciler
1243 /// hands the exact `humantime` shape to Flux, and any parse
1244 /// error surfaces from the chart-controller, not from here.
1245 #[test]
1246 fn helm_lifecycle_policy_uses_install_timeout_when_present() {
1247 for shape in ["10m", "1h30m", "5s", "25m", "0s"] {
1248 let policy = helm_intent(Some(shape)).helm_lifecycle_policy();
1249 assert_eq!(
1250 policy.timeout, shape,
1251 "override shape {shape} not substituted verbatim"
1252 );
1253 // Retries stay at the workspace default regardless of timeout.
1254 assert_eq!(policy.remediation.retries, HELM_LIFECYCLE_DEFAULT_RETRIES);
1255 }
1256 }
1257
1258 /// Coherence axis: the retries slot is invariant across every
1259 /// timeout shape the operator might publish — a regression that
1260 /// coupled the two slots (e.g. "when timeout is short, retry
1261 /// more") surfaces here rather than at every consumer.
1262 #[test]
1263 fn helm_lifecycle_policy_retries_are_invariant_across_timeout_shapes() {
1264 let seen: std::collections::BTreeSet<u8> = [None, Some("1m"), Some("25m"), Some("2h")]
1265 .into_iter()
1266 .map(|t| helm_intent(t).helm_lifecycle_policy().remediation.retries)
1267 .collect();
1268 assert_eq!(
1269 seen.len(),
1270 1,
1271 "retries should be constant across timeout shapes"
1272 );
1273 assert_eq!(
1274 seen.into_iter().next(),
1275 Some(HELM_LIFECYCLE_DEFAULT_RETRIES)
1276 );
1277 }
1278
1279 /// Wire-shape pin: the serde projection matches Flux
1280 /// `HelmRelease.spec.{install,upgrade}` v2 byte-identically —
1281 /// `{"timeout": <string>, "remediation": {"retries": <int>}}`
1282 /// with no extra keys, no field renames, no camelCase surprises.
1283 /// A regression that added a slot to `HelmLifecyclePolicy` or
1284 /// renamed one would fail here rather than as a Flux CR
1285 /// rejection at every deployment.
1286 #[test]
1287 fn helm_lifecycle_policy_serializes_to_flux_hr_v2_install_upgrade_shape() {
1288 let policy = helm_intent(Some("10m")).helm_lifecycle_policy();
1289 let json = serde_json::to_value(&policy).unwrap();
1290 assert_eq!(
1291 json,
1292 serde_json::json!({
1293 "timeout": "10m",
1294 "remediation": { "retries": 3 },
1295 }),
1296 );
1297 }
1298
1299 /// Coherence axis: `HelmLifecyclePolicy::workspace_default()`
1300 /// composes byte-identically to the intent-derived policy of an
1301 /// intent with `install_timeout: None` — the two paths to the
1302 /// substrate default (via the `Aplicacao` intent's own resolver
1303 /// vs the standalone workspace-default constructor) yield the
1304 /// same shape. Binds the "workspace_default IS the fallback"
1305 /// invariant so a future divergence (e.g. workspace_default
1306 /// changes but the intent resolver's inline fallback does not)
1307 /// surfaces here rather than as a silent drift at every render
1308 /// callsite.
1309 #[test]
1310 fn helm_lifecycle_policy_workspace_default_matches_intent_fallback_branch() {
1311 let default_policy = HelmLifecyclePolicy::workspace_default();
1312 let intent_policy = helm_intent(None).helm_lifecycle_policy();
1313 assert_eq!(default_policy, intent_policy);
1314 }
1315
1316 // ── Flux reconcile interval — OCIRepository + HelmRelease shared ─
1317
1318 /// The workspace-wide default Flux reconcile-interval const is
1319 /// pinned to `5m`. A regression that renamed it would silently
1320 /// throttle or hammer every Helm-driven Process's OCIRepository
1321 /// pull cadence AND its HelmRelease reconcile cadence, so pin
1322 /// the byte-exact spelling here rather than at the two render
1323 /// callsites the primitive owns.
1324 #[test]
1325 fn flux_helm_default_interval_is_pinned_to_5m() {
1326 assert_eq!(FLUX_HELM_DEFAULT_INTERVAL, "5m");
1327 }
1328
1329 /// The intent-side composer returns the workspace-wide default
1330 /// verbatim today. A regression that hand-authored some other
1331 /// string here (or that stopped routing through the const)
1332 /// would surface at this pin.
1333 #[test]
1334 fn flux_reconcile_interval_returns_workspace_default() {
1335 assert_eq!(
1336 helm_intent(None).flux_reconcile_interval(),
1337 FLUX_HELM_DEFAULT_INTERVAL,
1338 );
1339 }
1340
1341 /// Coherence axis: the reconcile interval is invariant across
1342 /// every `install_timeout` shape the operator publishes today.
1343 /// Pre-lift the two slots were siblings hand-authored with the
1344 /// same `"5m"` value regardless of any other AplicacaoIntent
1345 /// shape; post-lift the same invariance holds through the
1346 /// composer. A future coupling (e.g. "when timeout is short,
1347 /// reconcile more often") lands at the composer's shape, not
1348 /// silently at any render callsite.
1349 #[test]
1350 fn flux_reconcile_interval_is_invariant_across_install_timeout_shapes() {
1351 let seen: std::collections::BTreeSet<String> =
1352 [None, Some("10m"), Some("1h30m"), Some("25m"), Some("2h")]
1353 .into_iter()
1354 .map(|t| helm_intent(t).flux_reconcile_interval())
1355 .collect();
1356 assert_eq!(
1357 seen.len(),
1358 1,
1359 "reconcile interval should be constant across install_timeout shapes"
1360 );
1361 assert_eq!(
1362 seen.into_iter().next().as_deref(),
1363 Some(FLUX_HELM_DEFAULT_INTERVAL),
1364 );
1365 }
1366
1367 // ─── AplicacaoIntent::chart_only substrate pins ─────────────────
1368 //
1369 // Bind the chart-pointer-only composer at fail-before-pass-after
1370 // granularity so a regression that drifted any of the five
1371 // default-tail slots (profile → non-empty, values_overlay → non-
1372 // `Null`, any of the three `Option<String>` slots → `Some`),
1373 // reshaped the two-argument surface, or swapped the positional
1374 // slot order surfaces HERE rather than as silent fixture skew at
1375 // the 14 downstream consumers.
1376
1377 #[test]
1378 fn chart_only_binds_two_caller_slots_and_defaults_the_other_five() {
1379 // Primary shape asserted end-to-end: the returned value
1380 // carries the caller-supplied `(chart_ref, version)` and the
1381 // K8s-schema-default `("", Null, None, None, None)` tail. A
1382 // regression that swapped the two positional slots would
1383 // land `"1"` in `chart_ref` and `"oci://x"` in `version`;
1384 // the byte-equality pin below catches that.
1385 let a = AplicacaoIntent::chart_only("oci://x", "1");
1386 assert_eq!(a.chart_ref, "oci://x");
1387 assert_eq!(a.version, "1");
1388 assert_eq!(a.profile, "");
1389 assert_eq!(a.values_overlay, serde_json::Value::Null);
1390 assert!(a.release_name.is_none());
1391 assert!(a.target_namespace.is_none());
1392 assert!(a.install_timeout.is_none());
1393 }
1394
1395 #[test]
1396 fn chart_only_matches_hand_authored_pre_lift_struct_literal_shape() {
1397 // Byte-identical parity with the pre-lift 7-slot struct-
1398 // literal every one of the 14 hand-authored sites restated.
1399 // A regression that drifted the composer would surface HERE
1400 // rather than as silent fixture skew at every downstream
1401 // `empty_template` / `sample_intent_for` / `helm_intent`
1402 // consumer. Swept across the three representative
1403 // `(chart_ref, version)` shape families the pre-lift sites
1404 // used (fixture stub `oci://x`/`1`; sample `oci://ghcr.io/x`
1405 // / `0.1.0`; production non-OCI `pleme-io/lareira-demo-app`
1406 // / `0.5.5`).
1407 for (chart_ref, version) in [
1408 ("oci://x", "1"),
1409 ("oci://ghcr.io/x", "0.1.0"),
1410 ("pleme-io/lareira-demo-app", "0.5.5"),
1411 ("x", "1"),
1412 ] {
1413 let composed = AplicacaoIntent::chart_only(chart_ref, version);
1414 let hand_authored = AplicacaoIntent {
1415 chart_ref: chart_ref.into(),
1416 version: version.into(),
1417 profile: String::new(),
1418 values_overlay: serde_json::Value::Null,
1419 release_name: None,
1420 target_namespace: None,
1421 install_timeout: None,
1422 };
1423 assert_eq!(
1424 serde_json::to_value(&composed).unwrap(),
1425 serde_json::to_value(&hand_authored).unwrap(),
1426 "composed and hand-authored must agree for ({chart_ref}, {version})"
1427 );
1428 }
1429 }
1430
1431 #[test]
1432 fn chart_only_accepts_string_and_str_uniformly() {
1433 // The `impl Into<String>` argument form matches both the
1434 // pre-lift `"literal".into()` shape AND callers with a live
1435 // `String` (e.g. a fixture parameter). A regression that
1436 // narrowed the argument type to `&str` or `String` would
1437 // break one of the two shapes; this pin binds both.
1438 let owned_chart = String::from("oci://y");
1439 let owned_version = String::from("2");
1440 let via_string = AplicacaoIntent::chart_only(owned_chart.clone(), owned_version.clone());
1441 let via_str = AplicacaoIntent::chart_only("oci://y", "2");
1442 assert_eq!(
1443 serde_json::to_value(&via_string).unwrap(),
1444 serde_json::to_value(&via_str).unwrap(),
1445 );
1446 }
1447
1448 #[test]
1449 fn chart_only_composes_downstream_through_helm_lifecycle_policy_default_branch() {
1450 // Cross-primitive coherence pin: an `AplicacaoIntent` built
1451 // through `chart_only` has `install_timeout = None` and
1452 // therefore rides the workspace-default branch of
1453 // `helm_lifecycle_policy`. A regression that flipped
1454 // `install_timeout` to `Some(_)` at the composer would
1455 // silently un-default every downstream Helm policy; this
1456 // pin binds the default-branch composition end-to-end.
1457 let a = AplicacaoIntent::chart_only("oci://x", "1");
1458 let policy = a.helm_lifecycle_policy();
1459 assert_eq!(policy.timeout, HELM_LIFECYCLE_DEFAULT_TIMEOUT);
1460 assert_eq!(policy.remediation.retries, HELM_LIFECYCLE_DEFAULT_RETRIES);
1461 }
1462}