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