Skip to main content

tatara_process/
status.rs

1//! `ProcessStatus` sub-structures — conditions, checked boundaries, Flux refs.
2
3use chrono::{DateTime, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::boundary::Condition;
9use crate::crd::Process;
10
11/// Standard K8s Condition (shape of `metav1.Condition`).
12#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "camelCase")]
14pub struct ProcessCondition {
15    #[serde(rename = "type")]
16    pub type_: String,
17    pub status: String,
18    pub last_transition_time: DateTime<Utc>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub reason: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub message: Option<String>,
23}
24
25impl ProcessCondition {
26    pub fn ready(reason: impl Into<String>, message: Option<String>) -> Self {
27        Self {
28            type_: "Ready".into(),
29            status: "True".into(),
30            last_transition_time: Utc::now(),
31            reason: Some(reason.into()),
32            message,
33        }
34    }
35
36    pub fn not_ready(reason: impl Into<String>, message: impl Into<String>) -> Self {
37        Self {
38            type_: "Ready".into(),
39            status: "False".into(),
40            last_transition_time: Utc::now(),
41            reason: Some(reason.into()),
42            message: Some(message.into()),
43        }
44    }
45
46    pub fn attested(root: &str) -> Self {
47        Self {
48            type_: "Attested".into(),
49            status: "True".into(),
50            last_transition_time: Utc::now(),
51            reason: Some("AttestationWritten".into()),
52            message: Some(format!("composed_root={root}")),
53        }
54    }
55}
56
57/// Reference to a FluxCD resource emitted as part of this Process.
58#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "camelCase")]
60pub struct FluxResourceRef {
61    pub api_version: String,
62    pub kind: String,
63    pub name: String,
64    pub namespace: String,
65    #[serde(default)]
66    pub ready: bool,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub message: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub last_check: Option<DateTime<Utc>>,
71}
72
73/// Identifying coordinates of a rendered K8s resource — the
74/// `(apiVersion, kind, metadata.name, metadata.namespace)` 4-tuple
75/// every consumer that walks a rendered `serde_json::Value` resource
76/// unwraps by hand pre-lift.
77///
78/// The three K8s API-path segments (`apiVersion`, `kind`,
79/// `metadata.name`) are REQUIRED — a rendered resource missing any
80/// of them cannot be applied via kube-rs's dynamic API surface, so
81/// the extraction fails fast at the boundary rather than as a
82/// downstream `Api::patch` panic. `metadata.namespace` is
83/// intentionally kept as `Option<String>` because different consumers
84/// resolve the fallback differently: `apply_owned` uses the
85/// caller-supplied `namespace: &str` argument (the reconciler already
86/// resolved the target namespace upstream), while `flux_ref_from_json`
87/// records the K8s canonical `"default"` fallback into the persisted
88/// `FluxResourceRef.namespace` slot. The peer method
89/// [`Self::namespace_or_default`] applies the K8s canonical fallback
90/// (`Process::DEFAULT_NAMESPACE = "default"`) for consumers wanting
91/// the same shape [`FluxResourceRef.namespace`] carries.
92///
93/// Pre-lift the 3+1 slot extraction was hand-authored at TWO sites
94/// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold across
95/// `tatara-reconciler`:
96/// * `tatara-reconciler::phase_machine::flux_ref_from_json` — the
97///   post-SSA `FluxResourceRef` builder that persists into
98///   `ProcessStatus.flux_resources`; namespace half fallback-
99///   defaulted to `"default"`.
100/// * `tatara-reconciler::ssapply::apply_owned` — the SSA entry
101///   point that extracts (apiVersion, kind, name) for the
102///   [`kube::Api::patch`] call; namespace half discarded (the
103///   `namespace: &str` argument comes from the caller upstream).
104///
105/// Both callsites restated the same three
106/// `.get(K).and_then(|v| v.as_str()).ok_or_else(|| anyhow!(...))?
107/// .to_string()` incantations with subtly different error wording
108/// (`"resource missing X"` vs `"rendered resource missing X"`); post-
109/// lift both route through this ONE substrate owner with the
110/// canonical `"rendered resource missing X"` wording. A future
111/// addition (case-fold on the group, a rename of the namespace
112/// fallback, a stricter kind gate, a Unicode-safe collation step,
113/// support for `metadata.generateName` as a name fallback) lands at
114/// the primitive's body on the substrate, not at 2 independent
115/// hand-writes across 2 reconciler files.
116///
117/// Namespace fallback const is shared with
118/// [`Process::DEFAULT_NAMESPACE`] — a rename of the K8s canonical
119/// default namespace lands at that ONE workspace-wide const, not at
120/// per-primitive local literals that would drift silently.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct RenderedResourceCoords {
123    /// `apiVersion` — the group+version pair kube-rs uses to resolve
124    /// the `ApiResource` for the SSA call.
125    pub api_version: String,
126    /// `kind` — the resource kind (Kustomization, HelmRelease, …).
127    pub kind: String,
128    /// `metadata.name` — the API-path leaf segment.
129    pub name: String,
130    /// `metadata.namespace` — raw from the resource, `None` when the
131    /// slot is absent (a cluster-scoped resource, or a namespaced
132    /// resource whose namespace was left for the API server to
133    /// substitute). Consumers apply their own fallback:
134    /// [`Self::namespace_or_default`] applies the K8s canonical
135    /// `"default"` (matching what [`FluxResourceRef.namespace`]
136    /// records); other consumers substitute a caller-supplied string
137    /// (see `tatara-reconciler::ssapply::apply_owned`).
138    pub namespace: Option<String>,
139}
140
141impl RenderedResourceCoords {
142    /// Extract the 4-tuple from a rendered K8s resource JSON `Value`.
143    ///
144    /// Fails with a canonical `"rendered resource missing X"` message
145    /// when any of the three required slots (`apiVersion`, `kind`,
146    /// `metadata.name`) is absent or non-string; `metadata.namespace`
147    /// is optional and captured as `None` when absent.
148    ///
149    /// The error wording is pinned by
150    /// [`tests::rendered_resource_coords_error_wording_is_canonical`]
151    /// so a regression that reshaped the message surfaces at the test
152    /// surface rather than as silent drift between the two pre-lift
153    /// call sites (which used subtly different wording — `"resource
154    /// missing X"` in `apply_owned` vs `"rendered resource missing
155    /// X"` in `flux_ref_from_json`).
156    pub fn from_json(res: &Value) -> anyhow::Result<Self> {
157        let api_version = res
158            .get("apiVersion")
159            .and_then(|v| v.as_str())
160            .ok_or_else(|| anyhow::anyhow!("rendered resource missing apiVersion"))?
161            .to_string();
162        let kind = res
163            .get("kind")
164            .and_then(|v| v.as_str())
165            .ok_or_else(|| anyhow::anyhow!("rendered resource missing kind"))?
166            .to_string();
167        let metadata = res.get("metadata");
168        let name = metadata
169            .and_then(|m| m.get("name"))
170            .and_then(|v| v.as_str())
171            .ok_or_else(|| anyhow::anyhow!("rendered resource missing metadata.name"))?
172            .to_string();
173        let namespace = metadata
174            .and_then(|m| m.get("namespace"))
175            .and_then(|v| v.as_str())
176            .map(str::to_string);
177        Ok(Self {
178            api_version,
179            kind,
180            name,
181            namespace,
182        })
183    }
184
185    /// `metadata.namespace` slice with the K8s canonical `"default"`
186    /// fallback applied — matching what [`Process::DEFAULT_NAMESPACE`]
187    /// spells for the `Process`-borne coordinate primitive family
188    /// and what [`FluxResourceRef.namespace`] records into
189    /// `ProcessStatus.flux_resources`.
190    pub fn namespace_or_default(&self) -> &str {
191        self.namespace
192            .as_deref()
193            .unwrap_or(Process::DEFAULT_NAMESPACE)
194    }
195}
196
197/// A boundary condition paired with its current satisfaction state.
198#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
199#[serde(rename_all = "camelCase")]
200pub struct CheckedCondition {
201    #[serde(flatten)]
202    pub condition: Condition,
203    pub satisfied: bool,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub last_check: Option<DateTime<Utc>>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub message: Option<String>,
208}
209
210/// Summary of boundary verification.
211#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "camelCase")]
213pub struct BoundaryStatus {
214    #[serde(default)]
215    pub preconditions: Vec<CheckedCondition>,
216    #[serde(default)]
217    pub postconditions: Vec<CheckedCondition>,
218    /// Absolute deadline for VERIFY (derived from `spec.boundary.timeout`).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub deadline: Option<DateTime<Utc>>,
221}
222
223/// Summary of compliance checks at the latest attestation.
224#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226pub struct ComplianceStatus {
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub baseline: Option<String>,
229    pub satisfied: u32,
230    pub violated: u32,
231    pub total: u32,
232    #[serde(default)]
233    pub violations: Vec<String>,
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use serde_json::json;
240
241    // ─── RenderedResourceCoords substrate pins ──────────────────────
242
243    #[test]
244    fn rendered_resource_coords_from_json_extracts_all_four_slots_when_present() {
245        let res = json!({
246            "apiVersion": "kustomize.toolkit.fluxcd.io/v1",
247            "kind": "Kustomization",
248            "metadata": {
249                "name": "observability-stack",
250                "namespace": "flux-system",
251            },
252        });
253        let c = RenderedResourceCoords::from_json(&res).expect("extract");
254        assert_eq!(c.api_version, "kustomize.toolkit.fluxcd.io/v1");
255        assert_eq!(c.kind, "Kustomization");
256        assert_eq!(c.name, "observability-stack");
257        assert_eq!(c.namespace.as_deref(), Some("flux-system"));
258    }
259
260    #[test]
261    fn rendered_resource_coords_from_json_captures_absent_namespace_as_none() {
262        // Cluster-scoped resource — `metadata.namespace` intentionally absent.
263        let res = json!({
264            "apiVersion": "v1",
265            "kind": "Namespace",
266            "metadata": {"name": "demo-test"},
267        });
268        let c = RenderedResourceCoords::from_json(&res).expect("extract");
269        assert_eq!(c.namespace, None);
270        assert_eq!(c.name, "demo-test");
271    }
272
273    #[test]
274    fn rendered_resource_coords_from_json_errors_on_missing_api_version() {
275        let res = json!({"kind": "K", "metadata": {"name": "n"}});
276        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
277        assert_eq!(e.to_string(), "rendered resource missing apiVersion");
278    }
279
280    #[test]
281    fn rendered_resource_coords_from_json_errors_on_missing_kind() {
282        let res = json!({"apiVersion": "v1", "metadata": {"name": "n"}});
283        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
284        assert_eq!(e.to_string(), "rendered resource missing kind");
285    }
286
287    #[test]
288    fn rendered_resource_coords_from_json_errors_on_missing_metadata_name() {
289        let res = json!({"apiVersion": "v1", "kind": "K", "metadata": {}});
290        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
291        assert_eq!(e.to_string(), "rendered resource missing metadata.name");
292    }
293
294    #[test]
295    fn rendered_resource_coords_from_json_errors_on_missing_metadata_object() {
296        // `metadata` absent entirely — same failure as `metadata.name` missing,
297        // because the API-path leaf segment cannot be resolved.
298        let res = json!({"apiVersion": "v1", "kind": "K"});
299        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
300        assert_eq!(e.to_string(), "rendered resource missing metadata.name");
301    }
302
303    #[test]
304    fn rendered_resource_coords_from_json_errors_on_non_string_slot() {
305        // A numeric `apiVersion` slot falls through the `.as_str()` gate and
306        // triggers the same missing-slot failure as absence — the API-path
307        // segment is not a string.
308        let res = json!({
309            "apiVersion": 42,
310            "kind": "K",
311            "metadata": {"name": "n"},
312        });
313        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
314        assert_eq!(e.to_string(), "rendered resource missing apiVersion");
315    }
316
317    #[test]
318    fn rendered_resource_coords_error_wording_is_canonical() {
319        // Pins the exact spelling every downstream consumer sees.
320        // Pre-lift wording differed across the two call sites (`"resource
321        // missing X"` in `apply_owned` vs `"rendered resource missing X"` in
322        // `flux_ref_from_json`); post-lift the canonical wording is
323        // `"rendered resource missing X"` at every site.
324        let cases = [
325            (
326                "apiVersion",
327                json!({"kind": "K", "metadata": {"name": "n"}}),
328            ),
329            (
330                "kind",
331                json!({"apiVersion": "v1", "metadata": {"name": "n"}}),
332            ),
333            (
334                "metadata.name",
335                json!({"apiVersion": "v1", "kind": "K", "metadata": {}}),
336            ),
337        ];
338        for (slot, res) in cases {
339            let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
340            assert_eq!(
341                e.to_string(),
342                format!("rendered resource missing {slot}"),
343                "slot {slot} error must be canonical"
344            );
345        }
346    }
347
348    #[test]
349    fn rendered_resource_coords_namespace_or_default_returns_slice_when_some() {
350        let c = RenderedResourceCoords {
351            api_version: "v1".into(),
352            kind: "K".into(),
353            name: "n".into(),
354            namespace: Some("prod".into()),
355        };
356        assert_eq!(c.namespace_or_default(), "prod");
357    }
358
359    #[test]
360    fn rendered_resource_coords_namespace_or_default_falls_back_when_none() {
361        let c = RenderedResourceCoords {
362            api_version: "v1".into(),
363            kind: "K".into(),
364            name: "n".into(),
365            namespace: None,
366        };
367        assert_eq!(c.namespace_or_default(), Process::DEFAULT_NAMESPACE);
368        assert_eq!(c.namespace_or_default(), "default");
369    }
370
371    #[test]
372    fn rendered_resource_coords_namespace_fallback_shares_process_default_const() {
373        // Byte-identity between the namespace fallback and the workspace-
374        // wide `Process::DEFAULT_NAMESPACE` const. A regression that spelled
375        // the fallback as any other string ("kube-system", "", "default-ns")
376        // would silently drift between the coord-primitive family here and
377        // the `Process`-borne family in `crd.rs` — surfaces here rather than
378        // as operator-observed namespace routing skew between the two
379        // primitive families.
380        let c = RenderedResourceCoords {
381            api_version: "v1".into(),
382            kind: "K".into(),
383            name: "n".into(),
384            namespace: None,
385        };
386        assert_eq!(c.namespace_or_default(), Process::DEFAULT_NAMESPACE);
387    }
388}