Skip to main content

lava_operator/
lib.rs

1//! lava-operator — typed Kubernetes controller for the LavaArchitecture
2//! CRD. Pangea-operator analog for the lava + tatara-lisp stack.
3//!
4//! ## Shape
5//!
6//! Operator authors a LavaArchitecture manifest:
7//!
8//! ```yaml
9//! apiVersion: lava.pleme.io/v1alpha1
10//! kind: LavaArchitecture
11//! metadata: { name: prod-vpc, namespace: infra }
12//! spec:
13//!   source:
14//!     # Either inline tlisp source ...
15//!     inline: |
16//!       (deflava-architecture demo-vpc
17//!         :inputs ((:cidr "10.42.0.0/16"))
18//!         :resources ((aws-vpc "main" :cidr-block "{cidr}")))
19//!     # ... or a registry-hosted reference:
20//!     # name: aws-vpc-network  (looks up bundled architecture)
21//!   bindings:
22//!     name: prod
23//!     cidr: 10.42.0.0/16
24//!   gate: aws-vpc-network              # optional typed Interface gate
25//!   engine: embedded                   # embedded | tofu | terraform
26//! status:
27//!   phase: Applied
28//!   conditions:
29//!     - type: Synthesized | Planned | Applied | Failed
30//!       status: True
31//! ```
32//!
33//! The controller's Reconcile loop:
34//!   1. Resolve source (inline or bundled name)
35//!   2. magma-lava::synthesize → typed Architecture + terraform.json
36//!   3. (Optional) typed-interface gate
37//!   4. engine: embedded (in-process) OR shell out to tofu/terraform
38//!   5. Status update with typed Conditions
39//!
40//! ## Status
41//!
42//! This crate ships the **typed CRD schema** + **reconcile state machine**
43//! (typed Phase + Condition + RenderRecord values). The kube-rs
44//! controller binary is the next-milestone (M1) wrapper — keeps this
45//! crate dependency-light + testable without a live cluster.
46
47#![allow(clippy::module_name_repetitions)]
48
49pub mod anomaly_bridge;
50pub mod attest;
51pub mod drift;
52pub mod finalizer;
53pub mod viggy_loop;
54
55#[cfg(feature = "controller")]
56pub mod controller;
57
58#[cfg(all(feature = "controller", feature = "magma-bridge"))]
59pub mod magma_bridge;
60
61use indexmap::IndexMap;
62use serde::{Deserialize, Serialize};
63use thiserror::Error;
64
65/// Top-level CRD spec + status.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct LavaArchitecture {
68    pub api_version: String,
69    pub kind: String,
70    pub metadata: ObjectMeta,
71    pub spec: LavaArchitectureSpec,
72    #[serde(default)]
73    pub status: LavaArchitectureStatus,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct ObjectMeta {
78    pub name: String,
79    #[serde(default)]
80    pub namespace: Option<String>,
81    #[serde(default)]
82    pub labels: IndexMap<String, String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct LavaArchitectureSpec {
87    pub source: Source,
88    #[serde(default)]
89    pub bindings: IndexMap<String, String>,
90    #[serde(default)]
91    pub gate: Option<String>,
92    #[serde(default = "default_engine")]
93    pub engine: String,
94}
95
96fn default_engine() -> String {
97    "embedded".to_string()
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub enum Source {
103    /// .tlisp source text embedded directly.
104    Inline { inline: String },
105    /// Bundled architecture name (looked up via lava-architectures).
106    Name { name: String },
107    /// Remote git ref + path.
108    Git {
109        url: String,
110        rev: String,
111        path: String,
112    },
113}
114
115#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
116pub struct LavaArchitectureStatus {
117    #[serde(default)]
118    pub phase: Option<Phase>,
119    #[serde(default)]
120    pub conditions: Vec<Condition>,
121    #[serde(default)]
122    pub last_synthesized_hash: Option<String>,
123    #[serde(default)]
124    pub last_applied_at: Option<String>,
125}
126
127/// Typed phase the controller drives the resource through.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "PascalCase")]
130pub enum Phase {
131    /// Initial state; nothing rendered yet.
132    Pending,
133    /// magma-lava produced typed Architecture + terraform.json.
134    Synthesized,
135    /// engine plan succeeded; no apply yet.
136    Planned,
137    /// engine apply succeeded; live resources match desired state.
138    Applied,
139    /// Drift detector found non-NoOp changes against live state;
140    /// the next tick will reconverge per the remediation policy.
141    Drifted,
142    /// Reconciling to close detected drift.
143    Reconverging,
144    /// Finalizer is running — destroy in progress before CR removal.
145    Finalizing,
146    /// Last reconcile failed (see conditions for details).
147    Failed,
148}
149
150impl Phase {
151    /// Stable string token. Used in OutcomeChain payload + CRD status.
152    #[must_use]
153    pub const fn as_str(self) -> &'static str {
154        match self {
155            Self::Pending => "Pending",
156            Self::Synthesized => "Synthesized",
157            Self::Planned => "Planned",
158            Self::Applied => "Applied",
159            Self::Drifted => "Drifted",
160            Self::Reconverging => "Reconverging",
161            Self::Finalizing => "Finalizing",
162            Self::Failed => "Failed",
163        }
164    }
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct Condition {
169    #[serde(rename = "type")]
170    pub kind: String,
171    pub status: String, // True | False | Unknown
172    #[serde(default)]
173    pub reason: Option<String>,
174    #[serde(default)]
175    pub message: Option<String>,
176    #[serde(default)]
177    pub last_transition_time: Option<String>,
178}
179
180impl Condition {
181    #[must_use]
182    pub fn ok(kind: impl Into<String>, reason: impl Into<String>) -> Self {
183        Self {
184            kind: kind.into(),
185            status: "True".into(),
186            reason: Some(reason.into()),
187            message: None,
188            last_transition_time: None,
189        }
190    }
191    #[must_use]
192    pub fn fail(kind: impl Into<String>, message: impl Into<String>) -> Self {
193        Self {
194            kind: kind.into(),
195            status: "False".into(),
196            reason: Some("Error".into()),
197            message: Some(message.into()),
198            last_transition_time: None,
199        }
200    }
201}
202
203/// Result of one reconcile pass. The controller serializes this back
204/// into the resource's `.status` block.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct ReconcileOutcome {
207    pub phase: Phase,
208    pub conditions: Vec<Condition>,
209    pub terraform_json: Option<serde_json::Value>,
210}
211
212/// Pure state-machine: given the current resource + a synthesize
213/// callback, advance the phase. The kube-rs wrapper supplies the
214/// callback (which routes through magma-lava); this function stays
215/// kube-free + unit-testable.
216pub fn reconcile<F>(
217    spec: &LavaArchitectureSpec,
218    synthesize_fn: F,
219) -> Result<ReconcileOutcome, ReconcileError>
220where
221    F: FnOnce(&Source, &IndexMap<String, String>, Option<&str>)
222        -> Result<serde_json::Value, String>,
223{
224    let synthesized = synthesize_fn(&spec.source, &spec.bindings, spec.gate.as_deref());
225    match synthesized {
226        Ok(tf_json) => Ok(ReconcileOutcome {
227            phase: Phase::Synthesized,
228            conditions: vec![Condition::ok("Synthesized", "RenderOk")],
229            terraform_json: Some(tf_json),
230        }),
231        Err(e) => Ok(ReconcileOutcome {
232            phase: Phase::Failed,
233            conditions: vec![Condition::fail("Synthesized", e)],
234            terraform_json: None,
235        }),
236    }
237}
238
239#[derive(Debug, Error)]
240pub enum ReconcileError {
241    #[error("synthesize: {0}")]
242    Synthesize(String),
243    #[error("apply: {0}")]
244    Apply(String),
245}
246
247/// Render the LavaArchitecture CRD YAML for `kubectl apply`.
248/// Backwards-compat single-CRD form — new consumers should use
249/// [`crd_yaml_all`] which emits every CRD the operator owns.
250pub fn crd_yaml() -> String {
251    crd_yaml_for("LavaArchitecture", "lavaarchitectures", "lavaarchitecture")
252}
253
254/// Emit every CRD the operator owns, joined with `---` separators
255/// so the output is a single `kubectl apply -f -` stream. Each CRD
256/// carries a full `openAPIV3Schema` derived from the kube-rs
257/// `#[derive(CustomResource, JsonSchema)]` shapes — apiextensions/v1
258/// requires this; the hand-rolled `crd_yaml_for` helper below is
259/// kept only for the backwards-compat single-CRD form.
260#[cfg(feature = "controller")]
261pub fn crd_yaml_all() -> String {
262    use kube::CustomResourceExt;
263    let parts: Vec<String> = [
264        serde_yaml::to_string(&crate::controller::LavaArchitecture::crd()).unwrap_or_default(),
265        serde_yaml::to_string(&crate::controller::RemediationPolicy::crd()).unwrap_or_default(),
266        serde_yaml::to_string(&crate::controller::LavaArchitectureDependency::crd())
267            .unwrap_or_default(),
268    ]
269    .into_iter()
270    .collect();
271    parts.join("---\n")
272}
273
274#[cfg(not(feature = "controller"))]
275pub fn crd_yaml_all() -> String {
276    // Without the controller feature, fall back to the schema-less
277    // hand-rolled form — kept so library consumers can still preview
278    // CRD names without pulling kube-rs.
279    let parts = [
280        crd_yaml_for("LavaArchitecture", "lavaarchitectures", "lavaarchitecture"),
281        crd_yaml_for("RemediationPolicy", "remediationpolicies", "remediationpolicy"),
282        crd_yaml_for(
283            "LavaArchitectureDependency",
284            "lavaarchitecturedependencies",
285            "lavaarchitecturedependency",
286        ),
287    ];
288    parts.join("---\n")
289}
290
291fn crd_yaml_for(kind: &str, plural: &str, singular: &str) -> String {
292    let list_kind = format!("{kind}List");
293    let names = serde_yaml::Mapping::from_iter([
294        ("kind".into(), kind.into()),
295        ("listKind".into(), list_kind.into()),
296        ("plural".into(), plural.into()),
297        ("singular".into(), singular.into()),
298    ]);
299    let version = serde_yaml::Mapping::from_iter([
300        ("name".into(), "v1alpha1".into()),
301        ("served".into(), serde_yaml::Value::Bool(true)),
302        ("storage".into(), serde_yaml::Value::Bool(true)),
303    ]);
304    let crd_spec = serde_yaml::Mapping::from_iter([
305        ("group".into(), "lava.pleme.io".into()),
306        ("scope".into(), "Namespaced".into()),
307        ("names".into(), serde_yaml::Value::Mapping(names)),
308        (
309            "versions".into(),
310            serde_yaml::Value::Sequence(vec![serde_yaml::Value::Mapping(version)]),
311        ),
312    ]);
313    let metadata_name = format!("{plural}.lava.pleme.io");
314    let crd = serde_yaml::Mapping::from_iter([
315        ("apiVersion".into(), "apiextensions.k8s.io/v1".into()),
316        ("kind".into(), "CustomResourceDefinition".into()),
317        (
318            "metadata".into(),
319            serde_yaml::Value::Mapping(serde_yaml::Mapping::from_iter([(
320                "name".into(),
321                metadata_name.into(),
322            )])),
323        ),
324        ("spec".into(), serde_yaml::Value::Mapping(crd_spec)),
325    ]);
326    serde_yaml::to_string(&serde_yaml::Value::Mapping(crd)).unwrap_or_default()
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn sample_spec() -> LavaArchitectureSpec {
334        LavaArchitectureSpec {
335            source: Source::Inline {
336                inline: "(deflava-architecture demo :inputs () :resources ())".into(),
337            },
338            bindings: IndexMap::new(),
339            gate: None,
340            engine: "embedded".into(),
341        }
342    }
343
344    #[test]
345    fn reconcile_drives_to_synthesized_when_synthesize_callback_succeeds() {
346        let outcome = reconcile(&sample_spec(), |_src, _b, _g| {
347            Ok(serde_json::json!({"resource": {}}))
348        })
349        .unwrap();
350        assert_eq!(outcome.phase, Phase::Synthesized);
351        assert_eq!(outcome.conditions[0].status, "True");
352        assert!(outcome.terraform_json.is_some());
353    }
354
355    #[test]
356    fn reconcile_drives_to_failed_when_synthesize_callback_errors() {
357        let outcome = reconcile(&sample_spec(), |_src, _b, _g| {
358            Err("render failed: bad input".to_string())
359        })
360        .unwrap();
361        assert_eq!(outcome.phase, Phase::Failed);
362        assert_eq!(outcome.conditions[0].status, "False");
363        assert!(outcome.terraform_json.is_none());
364    }
365
366    #[test]
367    fn condition_ok_and_fail_constructors_set_status_correctly() {
368        let ok = Condition::ok("Synthesized", "RenderOk");
369        assert_eq!(ok.status, "True");
370        assert_eq!(ok.reason.as_deref(), Some("RenderOk"));
371        let fail = Condition::fail("Applied", "tofu apply exited non-zero");
372        assert_eq!(fail.status, "False");
373        assert_eq!(fail.message.as_deref(), Some("tofu apply exited non-zero"));
374    }
375
376    #[test]
377    fn lava_architecture_round_trips_through_serde_yaml() {
378        let la = LavaArchitecture {
379            api_version: "lava.pleme.io/v1alpha1".into(),
380            kind: "LavaArchitecture".into(),
381            metadata: ObjectMeta {
382                name: "prod-vpc".into(),
383                namespace: Some("infra".into()),
384                labels: IndexMap::new(),
385            },
386            spec: sample_spec(),
387            status: LavaArchitectureStatus::default(),
388        };
389        let yaml = serde_yaml::to_string(&la).unwrap();
390        let parsed: LavaArchitecture = serde_yaml::from_str(&yaml).unwrap();
391        assert_eq!(la, parsed);
392    }
393
394    #[test]
395    fn source_variants_round_trip_through_serde() {
396        for src in [
397            Source::Inline { inline: "(x)".into() },
398            Source::Name { name: "aws-vpc".into() },
399            Source::Git {
400                url: "https://github.com/x/y".into(),
401                rev: "main".into(),
402                path: "infra/vpc.tlisp".into(),
403            },
404        ] {
405            let json = serde_json::to_string(&src).unwrap();
406            let parsed: Source = serde_json::from_str(&json).unwrap();
407            assert_eq!(src, parsed);
408        }
409    }
410
411    #[test]
412    fn crd_yaml_emits_valid_two_doc_apiextensions_yaml() {
413        let yaml = crd_yaml();
414        assert!(yaml.contains("apiVersion: apiextensions.k8s.io/v1"));
415        assert!(yaml.contains("kind: CustomResourceDefinition"));
416        assert!(yaml.contains("name: lavaarchitectures.lava.pleme.io"));
417        assert!(yaml.contains("group: lava.pleme.io"));
418        assert!(yaml.contains("v1alpha1"));
419    }
420
421    #[test]
422    fn crd_yaml_all_emits_every_owned_crd() {
423        let yaml = crd_yaml_all();
424        assert!(yaml.contains("lavaarchitectures.lava.pleme.io"));
425        assert!(yaml.contains("remediationpolicies.lava.pleme.io"));
426        assert!(yaml.contains("lavaarchitecturedependencies.lava.pleme.io"));
427        // Three docs separated by `---`.
428        assert_eq!(yaml.matches("kind: CustomResourceDefinition").count(), 3);
429    }
430
431    #[test]
432    fn phase_variants_serialize_as_pascal_case_strings() {
433        let p = Phase::Synthesized;
434        let json = serde_json::to_string(&p).unwrap();
435        assert_eq!(json, "\"Synthesized\"");
436    }
437}