Skip to main content

tatara_render/
kubernetes_yaml.rs

1//! Kubernetes YAML backend — the simplest renderer in the
2//! `Backend` family.
3//!
4//! For each resource in the env, produce one YAML manifest in
5//! the upstream CR shape. The target reader is `kubectl apply`
6//! (or FluxCD's Kustomize, since the manifests tree is
7//! Kustomize-friendly by construction — one manifest per file,
8//! deterministic file naming).
9//!
10//! Per-domain knowledge is encoded as small functions: each
11//! `render_<kind>` takes the typed JSON value produced by the
12//! domain's `TataraDomain::compile_from_args` and emits a YAML
13//! document with the right `apiVersion` / `kind` / `metadata` /
14//! `spec` shape.
15//!
16//! Unhandled domains (`defbpf-*`, future-domain CRDs we haven't
17//! taught the backend) return `RenderError::Unsupported` so the
18//! caller can fail-soft (skip them, route to a different
19//! backend) or fail-hard.
20
21use crate::backend::{Backend, Manifest, RenderError};
22use serde_json::{json, Value};
23use std::fmt::Write;
24use tatara_env::compile::{Env, Resource};
25
26/// Configuration for the renderer. Defaults match the typical
27/// FluxCD / Kustomize layout most pleme-io clusters use today.
28#[derive(Debug, Clone)]
29pub struct KubernetesYaml {
30    /// Default namespace for resources whose typed value
31    /// doesn't override. The vast majority of resources won't
32    /// override; centralizing here keeps env files terse.
33    pub namespace: String,
34    /// Labels added to every resource's metadata. Lifted from
35    /// `env.spec.labels` plus any caller-supplied additions.
36    pub extra_labels: Vec<(String, String)>,
37}
38
39impl Default for KubernetesYaml {
40    fn default() -> Self {
41        Self {
42            namespace: "default".into(),
43            extra_labels: Vec::new(),
44        }
45    }
46}
47
48impl Backend for KubernetesYaml {
49    fn render(&self, env: &Env) -> Result<Vec<Manifest>, RenderError> {
50        let mut out = Vec::new();
51        for r in &env.resources {
52            // Per-keyword overrides for kinds that need special
53            // shaping (BPF resources don't map to a single CR;
54            // they emit ConfigMaps + the substrate-built object
55            // is loaded by a sibling DaemonSet).
56            let m = match r.keyword.as_str() {
57                "defbpf-program" | "defbpf-map" | "defbpf-policy" => {
58                    self.render_bpf_configmap(env, r)?
59                }
60                _ => {
61                    // Generic path — every domain that registers a
62                    // `RenderableDomain` impl gets this for free.
63                    // Adding a new CRD to the catalog now produces
64                    // working YAML the moment the generated crate's
65                    // `register()` is called.
66                    if let Some(meta) = tatara_lisp::domain::lookup_render(&r.keyword) {
67                        self.render_via_registry(env, r, &meta)?
68                    } else {
69                        return Err(RenderError::Unsupported(r.keyword.clone()));
70                    }
71                }
72            };
73            out.push(m);
74        }
75        Ok(out)
76    }
77}
78
79impl KubernetesYaml {
80    /// Compose the `metadata` block every K8s manifest needs.
81    /// Pulls labels from the env spec + the renderer's own
82    /// `extra_labels`. Keeps a `pleme.io/` prefix on env-derived
83    /// labels so they don't collide with user-set conventions.
84    fn metadata(&self, env: &Env, name: &str) -> Value {
85        let mut labels = serde_json::Map::new();
86        labels.insert("pleme.io/env".into(), json!(env.spec.name));
87        for (k, v) in &env.spec.labels {
88            labels.insert(format!("pleme.io/{k}"), json!(v));
89        }
90        for (k, v) in &self.extra_labels {
91            labels.insert(k.clone(), json!(v));
92        }
93        json!({
94            "name": name,
95            "namespace": self.namespace,
96            "labels": labels,
97        })
98    }
99
100    /// Generic registry-driven render path. Works for any domain
101    /// that registered itself via
102    /// `tatara_lisp::domain::register_render::<T>()`. The caller
103    /// has already looked up the metadata; we just compose the
104    /// envelope.
105    ///
106    /// This is the **compounding seam**: every new CRD-shaped
107    /// domain crate auto-renders the moment its `register()` is
108    /// called. No edits to this file. No special-cased match arms.
109    fn render_via_registry(
110        &self,
111        env: &Env,
112        r: &Resource,
113        meta: &tatara_lisp::RenderHandler,
114    ) -> Result<Manifest, RenderError> {
115        // Pick the resource's name. Order:
116        //   1. the registered NAME_FIELD on the typed value
117        //   2. fallback to `name` if NAME_FIELD missing
118        //   3. fallback to env-derived `<env-name>-<kind>`
119        let name = string_field(&r.value, meta.name_field)
120            .or_else(|| string_field(&r.value, "name"))
121            .map(str::to_string)
122            .unwrap_or_else(|| {
123                format!("{}-{}", env.spec.name, meta.kind.to_lowercase())
124            });
125        let manifest = json!({
126            "apiVersion": meta.api_version,
127            "kind": meta.kind,
128            "metadata": self.metadata(env, &name),
129            "spec": &r.value,
130        });
131        // Filesystem layout: one directory per kind, lower-case.
132        // Stable + grep-friendly + Kustomize-friendly.
133        let dir = meta.kind.to_lowercase();
134        let path = format!("{dir}/{name}.yaml");
135        Ok(Manifest {
136            kind: "yaml".into(),
137            path,
138            content: yaml_string(&manifest)?,
139        })
140    }
141
142    fn render_bpf_configmap(&self, env: &Env, r: &Resource) -> Result<Manifest, RenderError> {
143        let name = string_field(&r.value, "name").unwrap_or("bpf");
144        let cm_name = format!("bpf-{}-{}", r.keyword.trim_start_matches("def"), name);
145        // The ConfigMap holds the typed BPF spec as JSON. A
146        // sibling DaemonSet (rendered by a different backend or
147        // hand-authored once) reads it + loads the matching
148        // .bpf.o object built by `substrate/lib/build/tatara/ebpf.nix`.
149        let payload = serde_json::to_string_pretty(&r.value)
150            .map_err(|e| RenderError::Yaml(format!("bpf json: {e}")))?;
151        let manifest = json!({
152            "apiVersion": "v1",
153            "kind": "ConfigMap",
154            "metadata": self.metadata(env, &cm_name),
155            "data": {
156                "spec.json": payload,
157            },
158        });
159        Ok(Manifest {
160            kind: "yaml".into(),
161            path: format!("bpf/{cm_name}.yaml"),
162            content: yaml_string(&manifest)?,
163        })
164    }
165}
166
167fn string_field<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
168    v.as_object()?.get(key)?.as_str()
169}
170
171/// Convert a `serde_json::Value` to a YAML string. Uses
172/// `serde_yaml_ng` so the output is deterministic + reads cleanly
173/// in `kubectl apply`.
174fn yaml_string(v: &Value) -> Result<String, RenderError> {
175    let yaml = serde_yaml_ng::to_string(v).map_err(|e| RenderError::Yaml(e.to_string()))?;
176    // Add a leading `---\n` so multiple manifests can be
177    // concatenated into one stream cleanly.
178    let mut out = String::new();
179    let _ = writeln!(out, "---");
180    out.push_str(&yaml);
181    Ok(out)
182}