1#![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#[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 Inline { inline: String },
105 Name { name: String },
107 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "PascalCase")]
130pub enum Phase {
131 Pending,
133 Synthesized,
135 Planned,
137 Applied,
139 Drifted,
142 Reconverging,
144 Finalizing,
146 Failed,
148}
149
150impl Phase {
151 #[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, #[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#[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
212pub 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
247pub fn crd_yaml() -> String {
251 crd_yaml_for("LavaArchitecture", "lavaarchitectures", "lavaarchitecture")
252}
253
254#[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 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 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}