Skip to main content

tatara_process/
lib.rs

1//! Process CRD — the K8s-as-Unix-processes wire format.
2//!
3//! A `Process` is one element of the tatara convergence lattice.
4//! Clusters, HelmReleases, migrations, tests — all are Processes.
5//! The reconciliation loop *is* Unix: fork → exec → wait → exit → reap.
6
7pub mod allocation;
8pub mod attestation;
9pub mod boundary;
10pub mod classification;
11pub mod compliance;
12pub mod crd;
13pub mod encapsulates;
14pub mod env;
15pub mod ephemeral;
16pub mod export;
17pub mod hostname;
18pub mod identity;
19pub mod intent;
20pub mod lifetime;
21pub mod lifetime_clock;
22pub mod matrix;
23pub mod phase;
24pub mod pool;
25pub mod receipt;
26pub mod routing;
27pub mod signal;
28pub mod spec;
29pub mod status;
30pub mod table;
31pub mod tagged_union;
32
33pub mod prelude {
34    pub use crate::allocation::{
35        AllocationCondition, AllocationPhase, AllocationSpec, AllocationStatus,
36        EphemeralAllocation, Requestor,
37    };
38    pub use crate::attestation::ProcessAttestation;
39    pub use crate::boundary::{Boundary, Condition, ConditionKind, UnknownConditionKind};
40    pub use crate::classification::{
41        Arity, CalmClassification, Classification, ConvergencePointType, DataClassification,
42        Horizon, HorizonKind, OptimizationDirection, SubstrateType, UnknownCalmClassification,
43        UnknownConvergencePointType, UnknownDataClassification, UnknownHorizonKind,
44        UnknownOptimizationDirection, UnknownSubstrateType,
45    };
46    pub use crate::compliance::{
47        ComplianceBinding, ComplianceSpec, UnknownVerificationPhase, VerificationPhase,
48    };
49    pub use crate::crd::{Process, ProcessSpec, ProcessStatus};
50    pub use crate::encapsulates::{
51        BareWorkload, EncapsulatesSpec, EncapsulationKind, EncapsulationKindError,
52        EncapsulationKindVariant, EncapsulationMode, EncapsulationTarget, ExistingHelmRelease,
53        ExistingKustomization, UnknownEncapsulationMode, UnknownEncapsulationTarget,
54    };
55    pub use crate::ephemeral::{compile_ephemeral_source, EphemeralSpec};
56    pub use crate::export::{
57        ArtifactError, ArtifactKind, ArtifactSource, ArtifactVariant, ChannelError, ChannelKind,
58        ChannelVariant, ExportSpec, ExportTrigger, HttpEventChannel, NatsSubjectChannel,
59        ProcessSnapshotSource, ReceiptsSource, ReportFormat, ReportPayloadShape, RunMarkerSource,
60        StdoutChannel, TestReportSource, UnknownArtifactKind, UnknownChannelKind,
61        UnknownExportTrigger, UnknownReportFormat, VectorChannel, DEFAULT_NATS_URL,
62        DEFAULT_VECTOR_INGEST,
63    };
64    pub use crate::hostname::{
65        ephemeral_id_from_spec, fmt_fqdn, fmt_fqdn_stable, resolve_ephemeral_id, HostnameError,
66        EPHEMERAL_ID_HASH_LEN,
67    };
68    pub use crate::identity::{content_hash, derive_identity, format_process_address, Identity};
69    pub use crate::intent::{
70        AplicacaoIntent, ContainerIntent, FluxIntent, GuestIntent, HelmLifecyclePolicy,
71        HelmRemediationPolicy, Intent, IntentError, IntentKind, IntentVariant, LispIntent,
72        NixIntent, UnknownWorkloadKind, WorkloadKind, FLUX_HELM_DEFAULT_INTERVAL,
73        HELM_LIFECYCLE_DEFAULT_RETRIES, HELM_LIFECYCLE_DEFAULT_TIMEOUT,
74    };
75    pub use crate::lifetime::{
76        EphemeralLifetime, Lifetime, LifetimeError, LifetimeKind, LifetimeVariant,
77        PermanentLifetime, TeardownPolicy, UnknownTeardownPolicy,
78    };
79    pub use crate::lifetime_clock::{
80        evaluate as lifetime_clock_evaluate, AutoTerminate, AutoTerminateKind, TerminateReason,
81        TerminateReasonKind, UnknownAutoTerminateKind, UnknownTerminateReasonKind,
82    };
83    pub use crate::matrix::{
84        compile_env_matrix_source, EnvMatrixSpec, MatrixAxis, MatrixBudget, NamedEphemeral,
85        SelectStrategy, SelectStrategyKind, UnknownSelectStrategyKind,
86    };
87    pub use crate::phase::{ProcessPhase, UnknownPhase};
88    pub use crate::pool::{
89        AllocationRef, EphemeralPool, MatchKey, MemberState, PoolCondition, PoolMember, PoolPhase,
90        PoolSelector, PoolSpec, PoolStatus, ReplacementPolicy, ReturnPolicy, UnknownMemberState,
91        UnknownPoolPhase, UnknownReplacementPolicy,
92    };
93    pub use crate::receipt::{
94        default_receipt_config_map_name, ReceiptEnvelope, ReceiptError, ReceiptKind,
95        RECEIPT_CM_SUFFIX, RECEIPT_VERSION,
96    };
97    pub use crate::routing::{RoutingBackend, RoutingForm, RoutingHostname, RoutingSpec};
98    pub use crate::signal::{ProcessSignal, SighupStrategy, UnknownSighupStrategy};
99    pub use crate::spec::{
100        DependsOn, IdentitySpec, MustReachPhase, SignalPolicy, UnknownMustReachPhase,
101    };
102    pub use crate::status::{
103        BoundaryStatus, CheckedCondition, ComplianceStatus, FluxResourceRef, ProcessCondition,
104        RenderedResourceCoords,
105    };
106    pub use crate::table::{
107        ClaimRecord, ProcessEntry, ProcessTable, ProcessTableSpec, ProcessTableStatus,
108    };
109}
110
111/// CRD API group for every tatara CRD.
112pub const GROUP: &str = "tatara.pleme.io";
113/// CRD version for this module.
114pub const VERSION: &str = "v1alpha1";
115/// Kind spelling of the tatara Process CRD as it appears in a K8s
116/// [`OwnerReference.kind`][ownref] field. Peer to [`GROUP`] +
117/// [`VERSION`] — centralizes the ONE literal every SSA-time
118/// re-injection helper pre-lift restated by hand across
119/// `tatara-reconciler` (`render.rs`, `edges.rs`, `ssapply.rs`).
120///
121/// [ownref]: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/
122pub const PROCESS_KIND: &str = "Process";
123
124/// Canonical `<GROUP>/<VERSION>` as an owned `String` — the ONE
125/// K8s `apiVersion` shape every tatara CRD stamps. Composed from
126/// [`GROUP`] + [`VERSION`] so a bump of either constant lands here
127/// exactly once; pre-lift, two `tatara-reconciler` sites hand-wrote
128/// `format!("{}/{}", tatara_process::GROUP, tatara_process::VERSION)`
129/// while a third inlined the literal `"tatara.pleme.io/v1alpha1"`,
130/// opening a silent drift path if `VERSION` ever advances past
131/// `v1alpha1`.
132pub fn api_version() -> String {
133    format!("{GROUP}/{VERSION}")
134}
135
136/// Build a Kubernetes [`OwnerReference`][ownref] JSON blob pointing
137/// at a Process (`kind = `[`PROCESS_KIND`], `apiVersion = `
138/// [`api_version`]) with `controller: true` +
139/// `blockOwnerDeletion: true` — the exact 6-slot shape every SSA
140/// re-injection site pre-lift restated three times across
141/// `tatara-reconciler` (`render.rs::owner_refs` for export-Job
142/// owners, `edges.rs::build_owner_refs` for Ingress + DNSEndpoint
143/// owners, `ssapply.rs::build_owner_reference` for the injected
144/// owner-ref stamped on every applied `DynamicObject`). Callers
145/// with a live `Process` value read `metadata.{name,uid}` and pass
146/// them through as `&str`.
147///
148/// The 6-slot shape is fixed (`controller` + `blockOwnerDeletion`
149/// both `true`); a Process-owned resource that wants a non-
150/// controller reference doesn't belong on this owner and can build
151/// its own `json!` inline — this primitive is the composer for the
152/// canonical "Process controls this resource, cascade-delete on
153/// GC" shape, not a general OwnerReference builder.
154///
155/// [ownref]: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/
156pub fn owner_reference_json(name: &str, uid: &str) -> serde_json::Value {
157    serde_json::json!({
158        "apiVersion": api_version(),
159        "kind": PROCESS_KIND,
160        "name": name,
161        "uid": uid,
162        "controller": true,
163        "blockOwnerDeletion": true,
164    })
165}
166
167/// Annotation keys the reconciler reads/writes on owned FluxCD resources.
168pub mod annotations {
169    pub const MANAGED_BY: &str = "tatara.pleme.io/managed-by";
170    pub const PROCESS: &str = "tatara.pleme.io/process";
171    pub const PID: &str = "tatara.pleme.io/pid";
172    pub const CONTENT_HASH: &str = "tatara.pleme.io/content-hash";
173    pub const ATTESTATION_ROOT: &str = "tatara.pleme.io/attestation-root";
174    pub const GENERATION: &str = "tatara.pleme.io/generation";
175    pub const SIGNAL: &str = "tatara.pleme.io/signal";
176    /// Stamped by the reconciler when transitioning into `Releasing`
177    /// — records which terminal-reached gate the Process came from
178    /// (`Attested` or `Failed`) so `handle_releasing` can pick the
179    /// matching `ExportTrigger` set + the correct post-Releasing
180    /// destination (`Exiting` from Attested, `Zombie` from Failed).
181    pub const RELEASED_FROM: &str = "tatara.pleme.io/released-from";
182    /// Labels the export-worker Jobs the reconciler emits during
183    /// `Releasing`. Selector: `tatara.pleme.io/role=export`.
184    pub const ROLE: &str = "tatara.pleme.io/role";
185    /// Index of an export inside `lifetime.ephemeral.exports`.
186    /// Stamped on the corresponding tatara-export-worker Job + its
187    /// receipt ConfigMap so the reconciler can correlate them
188    /// without re-parsing the spec JSON.
189    pub const EXPORT_INDEX: &str = "tatara.pleme.io/export-index";
190    /// Label / annotation key stamping which
191    /// `RoutingSpec.hostnames` entry a routing edge (Ingress /
192    /// DNSEndpoint) belongs to. Value is the entry's `app` slot;
193    /// a `label`-selector on this key slices every emitted edge
194    /// for a given `app` regardless of hostname form. Peer to
195    /// [`ROUTING_FORM`] on the routing-axis pair.
196    pub const APP: &str = "tatara.pleme.io/app";
197    /// Label / annotation key stamping the routing form
198    /// (`"stable"` | `"instance"`) on every emitted routing edge.
199    /// Value is a [`crate::routing::RoutingForm`] wire-form string;
200    /// consumers filtering the two forms compare to
201    /// [`RoutingForm::as_str`][crate::routing::RoutingForm::as_str],
202    /// never to a bare literal.
203    pub const ROUTING_FORM: &str = "tatara.pleme.io/routing-form";
204}
205
206/// Standard finalizer for the Process reconciler.
207pub const PROCESS_FINALIZER: &str = "tatara.pleme.io/process-finalizer";
208
209/// Shared schemars helpers — emit OpenAPI schemas Kubernetes accepts.
210/// Free-form `serde_json::Value` fields default to an *empty* schema
211/// in schemars, which the K8s API server rejects with "type: Required
212/// value: must not be empty for specified object fields". The typed
213/// workaround is to emit `{type: object, x-kubernetes-preserve-unknown-
214/// fields: true}` — same shape kube-rs's own helpers produce.
215pub mod schema_helpers {
216    use schemars::{gen::SchemaGenerator, schema::Schema};
217    /// Schema for a free-form JSON object field. Apply via
218    /// `#[schemars(schema_with = "tatara_process::schema_helpers::preserve_unknown_object")]`
219    /// on any `serde_json::Value` / `BTreeMap<String, serde_json::Value>`
220    /// field exposed through a CRD.
221    pub fn preserve_unknown_object(_g: &mut SchemaGenerator) -> Schema {
222        serde_json::from_value(serde_json::json!({
223            "type": "object",
224            "x-kubernetes-preserve-unknown-fields": true
225        }))
226        .expect("static JSON literal parses as Schema")
227    }
228}
229
230#[cfg(test)]
231mod owner_reference_tests {
232    //! Pin the `owner_reference_json` composer at fail-before-pass-
233    //! after granularity. Every shape a pre-lift caller hand-authored
234    //! is re-asserted here so a regression that inlined any of the
235    //! six slots at a call site (breaking the primitive's role as
236    //! the ONE source of truth) fails HERE at the composer's shipped-
237    //! shape pin rather than as silent drift between the pre-lift
238    //! `render.rs` / `edges.rs` / `ssapply.rs` sites (which pre-lift
239    //! already carried TWO different `apiVersion` spellings — a
240    //! composed `format!("{}/{}", GROUP, VERSION)` at two sites and
241    //! the frozen literal `"tatara.pleme.io/v1alpha1"` at the third).
242    use super::{api_version, owner_reference_json, GROUP, PROCESS_KIND, VERSION};
243    use serde_json::json;
244
245    #[test]
246    fn api_version_composes_group_and_version() {
247        // Any bump of GROUP or VERSION lands at ONE composer.
248        assert_eq!(api_version(), format!("{GROUP}/{VERSION}"));
249    }
250
251    #[test]
252    fn api_version_byte_matches_wire_form_pre_lift() {
253        // Byte-identity pin: the frozen wire-form literal
254        // `"tatara.pleme.io/v1alpha1"` that `ssapply.rs::
255        // build_owner_reference` hand-wrote pre-lift must equal the
256        // composed shape now sourced through the ONE owner. A
257        // future VERSION bump that missed this test would land as
258        // an operator-visible reference-mismatch after apply.
259        assert_eq!(api_version(), "tatara.pleme.io/v1alpha1");
260    }
261
262    #[test]
263    fn process_kind_is_process_literal() {
264        // Symbol-vs-string pin: any consumer that hand-wrote `"Process"`
265        // pre-lift routes through this const post-lift.
266        assert_eq!(PROCESS_KIND, "Process");
267    }
268
269    #[test]
270    fn owner_reference_json_has_all_six_slots_present() {
271        let v = owner_reference_json("my-process", "abc-uid");
272        let obj = v.as_object().expect("owner reference is a JSON object");
273        for k in [
274            "apiVersion",
275            "kind",
276            "name",
277            "uid",
278            "controller",
279            "blockOwnerDeletion",
280        ] {
281            assert!(obj.contains_key(k), "missing owner-reference slot: {k}");
282        }
283        assert_eq!(obj.len(), 6, "owner reference must have exactly 6 slots");
284    }
285
286    #[test]
287    fn owner_reference_json_apiversion_routes_through_api_version_owner() {
288        let v = owner_reference_json("x", "y");
289        assert_eq!(v["apiVersion"], api_version());
290    }
291
292    #[test]
293    fn owner_reference_json_kind_routes_through_process_kind_const() {
294        let v = owner_reference_json("x", "y");
295        assert_eq!(v["kind"], PROCESS_KIND);
296    }
297
298    #[test]
299    fn owner_reference_json_stamps_supplied_name_and_uid() {
300        let v = owner_reference_json("some-name", "some-uid");
301        assert_eq!(v["name"], "some-name");
302        assert_eq!(v["uid"], "some-uid");
303    }
304
305    #[test]
306    fn owner_reference_json_controller_and_block_owner_deletion_are_true() {
307        // These are structural — a Process-owned resource always
308        // has a controlling reference that cascade-deletes with
309        // the owner. A regression that flipped either boolean
310        // would silently detach every emitted resource.
311        let v = owner_reference_json("x", "y");
312        assert_eq!(v["controller"], true);
313        assert_eq!(v["blockOwnerDeletion"], true);
314    }
315
316    #[test]
317    fn owner_reference_json_matches_hand_authored_shape_pre_lift() {
318        // Byte-shape pin against the exact `json!({…})` incantation
319        // every pre-lift call site restated. A regression that
320        // reordered a slot, dropped one, or added a seventh here
321        // surfaces at THIS pin rather than as a subtle SSA-apply
322        // failure downstream when the K8s API server rejects the
323        // OwnerReference on schema mismatch.
324        let via_owner = owner_reference_json("p", "u");
325        let hand_authored = json!({
326            "apiVersion": "tatara.pleme.io/v1alpha1",
327            "kind": "Process",
328            "name": "p",
329            "uid": "u",
330            "controller": true,
331            "blockOwnerDeletion": true,
332        });
333        assert_eq!(via_owner, hand_authored);
334    }
335
336    #[test]
337    fn owner_reference_json_preserves_empty_name_and_uid_bytewise() {
338        // The primitive does not guard against empty inputs — its
339        // callers pre-lift did the empty-check upstream
340        // (`render.rs` guards `!uid.is_empty()`,
341        // `edges.rs::build_owner_refs` returns `vec![]` on empty
342        // uid, `ssapply.rs::build_owner_reference` unwraps a
343        // required `metadata.uid` via anyhow). The primitive owns
344        // shape composition, not admission control; a downstream
345        // rename that wants strict input validation lands as a
346        // peer, not a change to the composer's contract.
347        let v = owner_reference_json("", "");
348        assert_eq!(v["name"], "");
349        assert_eq!(v["uid"], "");
350    }
351}
352
353// ── Lisp → ProcessSpec compile bridge ──────────────────────────────────
354//
355// `(defpoint NAME :k v …)` compiles to a `NamedDefinition<ProcessSpec>`.
356// The derive on ProcessSpec handles every field via the serde Deserialize
357// fallthrough — no hand-rolled keyword parsing needed.
358
359/// A named ProcessSpec as produced by `compile_source`.
360pub type Definition = tatara_lisp::NamedDefinition<crate::crd::ProcessSpec>;
361
362/// Compile a Lisp source string into a list of named ProcessSpecs.
363/// Each top-level `(defpoint NAME …)` form becomes one `Definition`.
364pub fn compile_source(src: &str) -> tatara_lisp::Result<Vec<Definition>> {
365    tatara_lisp::compile_named::<crate::crd::ProcessSpec>(src)
366}
367
368/// Register every domain owned by this crate with the global Lisp
369/// dispatcher. Call once per binary, typically near the top of `main`.
370/// After this call, `tatara_lisp::domain::lookup("defpoint")` and
371/// `lookup("defephemeral")` both resolve to the right typed compiler.
372///
373/// Idempotent — registering the same type twice is a no-op.
374pub fn register_all() {
375    tatara_lisp::domain::register::<crate::crd::ProcessSpec>();
376    tatara_lisp::domain::register::<crate::ephemeral::EphemeralSpec>();
377}
378
379#[cfg(test)]
380mod compile_tests {
381    use super::compile_source;
382    use crate::classification::{ConvergencePointType, SubstrateType};
383    use crate::compliance::VerificationPhase;
384    use crate::spec::MustReachPhase;
385
386    /// The full derive-powered pipeline — no hand-rolled parsing anywhere.
387    /// Every field travels: Lisp → Sexp → serde_json → typed ProcessSpec.
388    #[test]
389    fn full_processspec_round_trip_via_derive() {
390        let src = r#"
391            (defpoint observability-stack
392              :identity       (:parent "seph.1")
393              :classification (:point-type Gate
394                               :substrate Observability
395                               :horizon (:kind Bounded)
396                               :calm Monotone
397                               :data-classification Internal)
398              :intent         (:nix (:flake-ref "github:pleme-io/k8s"
399                                     :attribute "observability"
400                                     :attic-cache "main"))
401              :boundary       (:postconditions
402                                 ((:kind KustomizationHealthy
403                                   :params (:name "observability-stack"
404                                            :namespace "flux-system"))
405                                  (:kind PromQL
406                                   :params (:query "up == 1")))
407                               :timeout "15m")
408              :compliance     (:baseline "fedramp-moderate"
409                               :bindings ((:framework "nist-800-53"
410                                           :control-id "SC-7"
411                                           :phase AtBoundary)))
412              :depends-on     ((:name "secret-injection" :must-reach Attested))
413              :signals        (:sigterm-grace-seconds 480
414                               :sighup-strategy Reconverge))
415        "#;
416        let defs = compile_source(src).expect("compile");
417        assert_eq!(defs.len(), 1);
418        let d = &defs[0];
419        assert_eq!(d.name, "observability-stack");
420
421        // identity
422        assert_eq!(d.spec.identity.parent.as_deref(), Some("seph.1"));
423
424        // classification (enums deserialized via symbol → string)
425        assert_eq!(d.spec.classification.point_type, ConvergencePointType::Gate);
426        assert_eq!(
427            d.spec.classification.substrate,
428            SubstrateType::Observability
429        );
430
431        // intent (tagged-union with one of four options)
432        let nix = d.spec.intent.nix.as_ref().expect("nix intent");
433        assert_eq!(nix.flake_ref, "github:pleme-io/k8s");
434        assert_eq!(nix.attribute, "observability");
435        assert_eq!(nix.attic_cache.as_deref(), Some("main"));
436
437        // boundary (Vec<nested struct with params object>)
438        assert_eq!(d.spec.boundary.postconditions.len(), 2);
439        assert_eq!(d.spec.boundary.timeout.as_deref(), Some("15m"));
440
441        // compliance (Vec<binding with enum phase>)
442        assert_eq!(
443            d.spec.compliance.baseline.as_deref(),
444            Some("fedramp-moderate")
445        );
446        assert_eq!(d.spec.compliance.bindings.len(), 1);
447        assert_eq!(
448            d.spec.compliance.bindings[0].phase,
449            VerificationPhase::AtBoundary
450        );
451
452        // depends_on (Vec<struct with enum>)
453        assert_eq!(d.spec.depends_on.len(), 1);
454        assert_eq!(d.spec.depends_on[0].must_reach, MustReachPhase::Attested);
455
456        // signals (numeric + enum defaults)
457        assert_eq!(d.spec.signals.sigterm_grace_seconds, 480);
458    }
459
460    #[test]
461    fn missing_required_field_errors() {
462        // `:classification` has no #[serde(default)] — omit it and compile must fail.
463        let src = r#"(defpoint x :intent (:nix (:flake-ref "f" :attribute "a")))"#;
464        assert!(compile_source(src).is_err());
465    }
466
467    #[test]
468    fn serde_default_fields_are_optional() {
469        // Omit every #[serde(default)] field — compile must succeed because
470        // the derive honors serde defaults.
471        let src = r#"
472            (defpoint x
473              :classification (:point-type Transform :substrate Compute)
474              :intent (:flux (:git-repository "g" :path ".")))
475        "#;
476        let defs = compile_source(src).expect("compile");
477        assert_eq!(defs.len(), 1);
478        let d = &defs[0];
479        assert!(d.spec.depends_on.is_empty());
480        assert!(d.spec.boundary.postconditions.is_empty());
481        assert!(d.spec.compliance.bindings.is_empty());
482        assert!(!d.spec.suspended);
483        // Lifetime defaults to Permanent (no variant set, resolver still works).
484        assert!(d.spec.lifetime.is_default());
485        assert!(!d.spec.lifetime.is_ephemeral());
486    }
487
488    /// Registering all process-owned domains is idempotent and resolves
489    /// both `defpoint` (ProcessSpec) and `defephemeral` (EphemeralSpec).
490    #[test]
491    fn register_all_resolves_defpoint_and_defephemeral() {
492        use tatara_lisp::domain::lookup;
493        super::register_all();
494        super::register_all(); // idempotent
495        assert!(lookup("defpoint").is_some(), "defpoint must resolve");
496        assert!(
497            lookup("defephemeral").is_some(),
498            "defephemeral must resolve"
499        );
500    }
501
502    /// End-to-end: a `(defpoint …)` form may carry the full ephemeral
503    /// shape directly — `:intent (:aplicacao …)` + `:lifetime (:ephemeral …)`.
504    /// This is what the `(defephemeral …)` sugar lowers to via `From`.
505    #[test]
506    fn defpoint_with_aplicacao_intent_and_ephemeral_lifetime() {
507        use crate::intent::IntentVariant;
508        use crate::lifetime::{LifetimeVariant, TeardownPolicy};
509        let src = r#"
510            (defpoint closed-loop-attest
511              :classification (:point-type Gate :substrate Compute)
512              :intent (:aplicacao
513                        (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
514                         :version "0.5.5"
515                         :profile "all-in-one"
516                         :values-overlay (:cluster (:name "ephemeral-test-01"))
517                         :target-namespace "demo-test"))
518              :boundary (:postconditions
519                          ((:kind HelmReleaseReleased
520                            :params (:name "demo-app-consolidated"
521                                     :namespace "demo-test"))
522                           (:kind ClosedLoopAuth
523                            :params (:issuer (:service "demo-app-issuer" :port 8080)
524                                     :consumer (:service "demo-app-gateway" :port 8000)
525                                     :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
526              :lifetime (:ephemeral (:ttl "1h"
527                                     :teardown-policy OnAttested
528                                     :max-concurrent 1)))
529        "#;
530        let defs = compile_source(src).expect("compile");
531        assert_eq!(defs.len(), 1);
532        let d = &defs[0];
533
534        // Aplicacao intent landed.
535        match d.spec.intent.variant().unwrap() {
536            IntentVariant::Aplicacao(a) => {
537                assert_eq!(a.profile, "all-in-one");
538                assert_eq!(a.version, "0.5.5");
539                assert_eq!(a.target_namespace.as_deref(), Some("demo-test"));
540                assert_eq!(a.values_overlay["cluster"]["name"], "ephemeral-test-01");
541            }
542            other => panic!("expected Aplicacao, got {other:?}"),
543        }
544
545        // Ephemeral lifetime landed with the right teardown policy.
546        match d.spec.lifetime.variant().unwrap() {
547            LifetimeVariant::Ephemeral(e) => {
548                assert_eq!(e.ttl, "1h");
549                assert_eq!(e.teardown_policy, TeardownPolicy::OnAttested);
550                assert_eq!(e.max_concurrent, 1);
551            }
552            other => panic!("expected ephemeral, got {other:?}"),
553        }
554
555        // Two typed postconditions including ClosedLoopAuth.
556        assert_eq!(d.spec.boundary.postconditions.len(), 2);
557        assert_eq!(
558            d.spec.boundary.postconditions[1].kind,
559            crate::boundary::ConditionKind::ClosedLoopAuth
560        );
561    }
562}