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(|| format!("{}-{}", env.spec.name, meta.kind.to_lowercase()));
123 let manifest = json!({
124 "apiVersion": meta.api_version,
125 "kind": meta.kind,
126 "metadata": self.metadata(env, &name),
127 "spec": &r.value,
128 });
129 // Filesystem layout: one directory per kind, lower-case.
130 // Stable + grep-friendly + Kustomize-friendly.
131 let dir = meta.kind.to_lowercase();
132 let path = format!("{dir}/{name}.yaml");
133 Ok(Manifest {
134 kind: "yaml".into(),
135 path,
136 content: yaml_string(&manifest)?,
137 })
138 }
139
140 fn render_bpf_configmap(&self, env: &Env, r: &Resource) -> Result<Manifest, RenderError> {
141 let name = string_field(&r.value, "name").unwrap_or("bpf");
142 let cm_name = format!("bpf-{}-{}", r.keyword.trim_start_matches("def"), name);
143 // The ConfigMap holds the typed BPF spec as JSON. A
144 // sibling DaemonSet (rendered by a different backend or
145 // hand-authored once) reads it + loads the matching
146 // .bpf.o object built by `substrate/lib/build/tatara/ebpf.nix`.
147 let payload = serde_json::to_string_pretty(&r.value)
148 .map_err(|e| RenderError::Yaml(format!("bpf json: {e}")))?;
149 let manifest = json!({
150 "apiVersion": "v1",
151 "kind": "ConfigMap",
152 "metadata": self.metadata(env, &cm_name),
153 "data": {
154 "spec.json": payload,
155 },
156 });
157 Ok(Manifest {
158 kind: "yaml".into(),
159 path: format!("bpf/{cm_name}.yaml"),
160 content: yaml_string(&manifest)?,
161 })
162 }
163}
164
165fn string_field<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
166 v.as_object()?.get(key)?.as_str()
167}
168
169/// Convert a `serde_json::Value` to a YAML string. Uses
170/// `serde_yaml_ng` so the output is deterministic + reads cleanly
171/// in `kubectl apply`.
172fn yaml_string(v: &Value) -> Result<String, RenderError> {
173 let yaml = serde_yaml_ng::to_string(v).map_err(|e| RenderError::Yaml(e.to_string()))?;
174 // Add a leading `---\n` so multiple manifests can be
175 // concatenated into one stream cleanly.
176 let mut out = String::new();
177 let _ = writeln!(out, "---");
178 out.push_str(&yaml);
179 Ok(out)
180}