Skip to main content

lightshuttle_export/emitters/
helm.rs

1//! Helm emitter: renders an [`ExportModel`] into a chart with a
2//! `Chart.yaml`, a `values.yaml` and one template per resource.
3//!
4//! Two engines live here. `Chart.yaml` and `values.yaml` are real data,
5//! so they are typed structs serialised with `serde_norway`. The
6//! `templates/*.yaml` files are Go templates: their `{{ ... }}`
7//! directives are literals, written as text, while the structural parts
8//! (ports, probes, volumes) are derived from the resolved spec.
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12use std::time::Duration;
13
14use lightshuttle_manifest::ImagePullPolicy;
15use lightshuttle_spec::{ContainerSpec, HealthcheckSpec, ImageSource, VolumeSource};
16use serde::Serialize;
17
18use crate::emit::Emitter;
19use crate::error::Result;
20use crate::model::{ExportModel, ExportProject, Target};
21use crate::resolve::{
22    chart_name_for, chart_version_for, dns_name, enabled_for, image_pull_policy_for, namespace_for,
23    replicas_for,
24};
25
26/// Environment key fragments that route a variable into `secrets`
27/// rather than `env`. Matched case-insensitively.
28const SECRET_MARKERS: &[&str] = &["PASSWORD", "PASSWD", "SECRET", "TOKEN", "KEY"];
29
30/// Emits a Helm chart from the export model.
31pub struct HelmEmitter;
32
33impl Emitter for HelmEmitter {
34    fn target(&self) -> Target {
35        Target::Helm
36    }
37
38    fn emit(&self, model: &ExportModel) -> Result<crate::ExportArtifacts> {
39        let export = model.export.as_ref();
40        let mut artifacts = crate::ExportArtifacts::new();
41
42        artifacts.push("Chart.yaml", chart_yaml(&model.project, export)?);
43        artifacts.push("values.yaml", values_yaml(model)?);
44
45        for service in &model.services {
46            if !enabled_for(Target::Helm, &service.spec.resource, export) {
47                continue;
48            }
49            let name = dns_name(&service.spec.resource);
50            artifacts.push(
51                format!("templates/{name}.yaml"),
52                resource_template(&service.spec, &name),
53            );
54        }
55
56        Ok(artifacts)
57    }
58}
59
60fn chart_yaml(
61    project: &ExportProject,
62    export: Option<&lightshuttle_manifest::ExportConfig>,
63) -> Result<String> {
64    let chart = Chart {
65        api_version: "v2",
66        name: dns_name(&chart_name_for(&project.name, export)),
67        version: chart_version_for(project.version.as_deref(), export),
68        description: format!("Helm chart for {} generated by LightShuttle", project.name),
69    };
70    to_yaml(&chart)
71}
72
73fn values_yaml(model: &ExportModel) -> Result<String> {
74    let export = model.export.as_ref();
75    let namespace = namespace_for(&model.project.name, export);
76
77    let mut services: BTreeMap<String, ServiceValues> = BTreeMap::new();
78    for service in &model.services {
79        if !enabled_for(Target::Helm, &service.spec.resource, export) {
80            continue;
81        }
82        let name = dns_name(&service.spec.resource);
83        let (env, secrets) = split_env(&service.spec.env);
84        let (repository, tag) = split_image(&service.spec.image);
85        services.insert(
86            name,
87            ServiceValues {
88                replicas: replicas_for(Target::Helm, &service.spec.resource, export),
89                image: ImageValues {
90                    repository,
91                    tag,
92                    pull_policy: pull_policy_str(image_pull_policy_for(
93                        &service.spec.resource,
94                        export,
95                    ))
96                    .to_owned(),
97                },
98                env,
99                secrets,
100            },
101        );
102    }
103
104    to_yaml(&Values {
105        namespace,
106        services,
107    })
108}
109
110/// Build the multi-document template for one resource.
111fn resource_template(spec: &ContainerSpec, name: &str) -> String {
112    let mut out = String::new();
113    let _ = writeln!(out, "{{{{- $svc := index .Values.services {name:?} -}}}}");
114    out.push_str(&deployment_block(spec, name));
115    if !spec.ports.is_empty() {
116        out.push_str("---\n");
117        out.push_str(&service_block(spec, name));
118    }
119    if !split_env(&spec.env).0.is_empty() {
120        out.push_str("---\n");
121        out.push_str(&configmap_block(name));
122    }
123    if !split_env(&spec.env).1.is_empty() {
124        out.push_str("---\n");
125        out.push_str(&secret_block(name));
126    }
127    for volume in &spec.volumes {
128        if let VolumeSource::Named(vol) = &volume.source {
129            out.push_str("---\n");
130            out.push_str(&pvc_block(name, &dns_name(vol)));
131        }
132    }
133    out
134}
135
136fn deployment_block(spec: &ContainerSpec, name: &str) -> String {
137    let mut s = String::new();
138    let (has_config, has_secret) = {
139        let (config_env, secret_env) = split_env(&spec.env);
140        (!config_env.is_empty(), !secret_env.is_empty())
141    };
142
143    let _ = write!(
144        s,
145        "apiVersion: apps/v1\n\
146         kind: Deployment\n\
147         metadata:\n\
148         \x20 name: {name}\n\
149         \x20 namespace: {{{{ .Values.namespace }}}}\n\
150         \x20 labels:\n\
151         \x20\x20\x20 app: {name}\n\
152         spec:\n\
153         \x20 replicas: {{{{ $svc.replicas }}}}\n\
154         \x20 selector:\n\
155         \x20\x20\x20 matchLabels:\n\
156         \x20\x20\x20\x20\x20 app: {name}\n\
157         \x20 template:\n\
158         \x20\x20\x20 metadata:\n\
159         \x20\x20\x20\x20\x20 labels:\n\
160         \x20\x20\x20\x20\x20\x20\x20 app: {name}\n\
161         \x20\x20\x20 spec:\n\
162         \x20\x20\x20\x20\x20 containers:\n\
163         \x20\x20\x20\x20\x20 - name: {name}\n\
164         \x20\x20\x20\x20\x20\x20\x20 image: \"{{{{ $svc.image.repository }}}}:{{{{ $svc.image.tag }}}}\"\n\
165         \x20\x20\x20\x20\x20\x20\x20 imagePullPolicy: {{{{ $svc.image.pullPolicy }}}}\n"
166    );
167
168    if !spec.ports.is_empty() {
169        s.push_str("        ports:\n");
170        for port in &spec.ports {
171            let _ = writeln!(s, "        - containerPort: {}", port.container_port);
172        }
173    }
174    if has_config || has_secret {
175        s.push_str("        envFrom:\n");
176        if has_config {
177            let _ = writeln!(
178                s,
179                "        - configMapRef:\n            name: {name}-config"
180            );
181        }
182        if has_secret {
183            let _ = writeln!(s, "        - secretRef:\n            name: {name}-secret");
184        }
185    }
186    let mounts: Vec<(String, &str)> = named_mounts(spec);
187    if !mounts.is_empty() {
188        s.push_str("        volumeMounts:\n");
189        for (vol, target) in &mounts {
190            let _ = writeln!(s, "        - name: {vol}\n          mountPath: {target}");
191        }
192    }
193    if let Some(hc) = &spec.healthcheck {
194        let probe = probe_block(hc);
195        let _ = write!(s, "        readinessProbe:\n{probe}");
196        let _ = write!(s, "        livenessProbe:\n{probe}");
197    }
198    if !mounts.is_empty() {
199        s.push_str("      volumes:\n");
200        for (vol, _) in &mounts {
201            let _ = writeln!(
202                s,
203                "      - name: {vol}\n        persistentVolumeClaim:\n          claimName: {name}-{vol}"
204            );
205        }
206    }
207    s
208}
209
210fn service_block(spec: &ContainerSpec, name: &str) -> String {
211    let mut s = String::new();
212    let _ = write!(
213        s,
214        "apiVersion: v1\n\
215         kind: Service\n\
216         metadata:\n\
217         \x20 name: {name}\n\
218         \x20 namespace: {{{{ .Values.namespace }}}}\n\
219         \x20 labels:\n\
220         \x20\x20\x20 app: {name}\n\
221         spec:\n\
222         \x20 selector:\n\
223         \x20\x20\x20 app: {name}\n\
224         \x20 ports:\n"
225    );
226    for port in &spec.ports {
227        let _ = writeln!(
228            s,
229            "  - port: {p}\n    targetPort: {p}",
230            p = port.container_port
231        );
232    }
233    if spec.ports.is_empty() {
234        s.push_str("  []\n");
235    }
236    s
237}
238
239fn configmap_block(name: &str) -> String {
240    format!(
241        "apiVersion: v1\n\
242         kind: ConfigMap\n\
243         metadata:\n\
244         \x20 name: {name}-config\n\
245         \x20 namespace: {{{{ .Values.namespace }}}}\n\
246         \x20 labels:\n\
247         \x20\x20\x20 app: {name}\n\
248         data:\n\
249         {{{{- range $k, $v := $svc.env }}}}\n\
250         \x20 {{{{ $k }}}}: {{{{ $v | quote }}}}\n\
251         {{{{- end }}}}\n"
252    )
253}
254
255fn secret_block(name: &str) -> String {
256    format!(
257        "apiVersion: v1\n\
258         kind: Secret\n\
259         metadata:\n\
260         \x20 name: {name}-secret\n\
261         \x20 namespace: {{{{ .Values.namespace }}}}\n\
262         \x20 labels:\n\
263         \x20\x20\x20 app: {name}\n\
264         stringData:\n\
265         {{{{- range $k, $v := $svc.secrets }}}}\n\
266         \x20 {{{{ $k }}}}: {{{{ $v | quote }}}}\n\
267         {{{{- end }}}}\n"
268    )
269}
270
271fn pvc_block(name: &str, volume: &str) -> String {
272    format!(
273        "apiVersion: v1\n\
274         kind: PersistentVolumeClaim\n\
275         metadata:\n\
276         \x20 name: {name}-{volume}\n\
277         \x20 namespace: {{{{ .Values.namespace }}}}\n\
278         \x20 labels:\n\
279         \x20\x20\x20 app: {name}\n\
280         spec:\n\
281         \x20 accessModes:\n\
282         \x20 - ReadWriteOnce\n\
283         \x20 resources:\n\
284         \x20\x20\x20 requests:\n\
285         \x20\x20\x20\x20\x20 storage: 1Gi\n"
286    )
287}
288
289fn probe_block(hc: &HealthcheckSpec) -> String {
290    let command = match hc.test.first().map(String::as_str) {
291        Some("CMD") => hc.test[1..].to_vec(),
292        Some("CMD-SHELL") => vec!["sh".to_owned(), "-c".to_owned(), hc.test[1..].join(" ")],
293        _ => hc.test.clone(),
294    };
295    let mut s = String::from("          exec:\n            command:\n");
296    for arg in &command {
297        let _ = writeln!(s, "            - {arg}");
298    }
299    let _ = writeln!(s, "          periodSeconds: {}", secs(hc.interval));
300    let _ = writeln!(s, "          timeoutSeconds: {}", secs(hc.timeout));
301    let _ = writeln!(s, "          failureThreshold: {}", hc.retries);
302    let _ = writeln!(
303        s,
304        "          initialDelaySeconds: {}",
305        secs(hc.start_period)
306    );
307    s
308}
309
310/// Named volume mounts as `(volume_name, mount_path)`.
311fn named_mounts(spec: &ContainerSpec) -> Vec<(String, &str)> {
312    spec.volumes
313        .iter()
314        .filter_map(|v| match &v.source {
315            VolumeSource::Named(name) => Some((dns_name(name), v.target.as_str())),
316            _ => None,
317        })
318        .collect()
319}
320
321fn split_env(
322    env: &std::collections::HashMap<String, String>,
323) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
324    let mut config = BTreeMap::new();
325    let mut secret = BTreeMap::new();
326    for (key, value) in env {
327        if SECRET_MARKERS
328            .iter()
329            .any(|m| key.to_ascii_uppercase().contains(m))
330        {
331            secret.insert(key.clone(), value.clone());
332        } else {
333            config.insert(key.clone(), value.clone());
334        }
335    }
336    (config, secret)
337}
338
339/// Split an image reference into `(repository, tag)` on the last colon.
340fn split_image(image: &ImageSource) -> (String, String) {
341    let reference = match image {
342        ImageSource::Pull(img) => img.clone(),
343        ImageSource::Build { tag, .. } => tag.clone(),
344    };
345    match reference.rsplit_once(':') {
346        Some((repo, tag)) if !repo.is_empty() => (repo.to_owned(), tag.to_owned()),
347        _ => (reference, "latest".to_owned()),
348    }
349}
350
351fn pull_policy_str(policy: ImagePullPolicy) -> &'static str {
352    match policy {
353        ImagePullPolicy::Always => "Always",
354        ImagePullPolicy::IfNotPresent => "IfNotPresent",
355        ImagePullPolicy::Never => "Never",
356    }
357}
358
359#[allow(clippy::cast_possible_truncation)]
360fn secs(d: Duration) -> u32 {
361    d.as_secs().min(u64::from(u32::MAX)) as u32
362}
363
364fn to_yaml<T: Serialize>(value: &T) -> Result<String> {
365    serde_norway::to_string(value).map_err(|e| crate::ExportError::Unsupported {
366        resource: "<helm>".to_owned(),
367        target: "helm",
368        reason: format!("failed to serialise chart data: {e}"),
369    })
370}
371
372// --- Typed chart data ---------------------------------------------------
373
374#[derive(Serialize)]
375struct Chart {
376    #[serde(rename = "apiVersion")]
377    api_version: &'static str,
378    name: String,
379    version: String,
380    description: String,
381}
382
383#[derive(Serialize)]
384struct Values {
385    namespace: String,
386    services: BTreeMap<String, ServiceValues>,
387}
388
389#[derive(Serialize)]
390struct ServiceValues {
391    replicas: u32,
392    image: ImageValues,
393    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
394    env: BTreeMap<String, String>,
395    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
396    secrets: BTreeMap<String, String>,
397}
398
399#[derive(Serialize)]
400struct ImageValues {
401    repository: String,
402    tag: String,
403    #[serde(rename = "pullPolicy")]
404    pull_policy: String,
405}