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.
27///
28/// The produced artifact set contains:
29///
30/// - `Chart.yaml`: chart name and version derived from the project metadata or
31///   `export.helm` overrides via [`crate::resolve::chart_name_for`] and
32///   [`crate::resolve::chart_version_for`].
33/// - `values.yaml`: namespace, per-service replica count, image coordinates,
34///   and environment variables split into `env` (plain) and `secrets`
35///   (placeholders only, never real values).
36/// - `templates/<name>.yaml`: one multi-document Go-template file per enabled
37///   resource, containing a `Deployment`, optionally a `Service`,
38///   a `ConfigMap`, a `Secret`, and `PersistentVolumeClaim` entries.
39///
40/// # Example
41///
42/// ```rust,no_run
43/// use lightshuttle_export::{lower, HelmEmitter, Emitter};
44/// use lightshuttle_manifest::Manifest;
45///
46/// # fn main() -> lightshuttle_export::Result<()> {
47/// let manifest: Manifest = todo!("parse from YAML");
48/// let model = lower(&manifest)?;
49/// let artifacts = HelmEmitter.emit(&model)?;
50/// for file in &artifacts.files {
51///     println!("{}", file.path.display());
52/// }
53/// // Prints: Chart.yaml, values.yaml, templates/<name>.yaml ...
54/// # Ok(())
55/// # }
56/// ```
57pub struct HelmEmitter;
58
59impl Emitter for HelmEmitter {
60    fn target(&self) -> Target {
61        Target::Helm
62    }
63
64    fn emit(&self, model: &ExportModel) -> Result<crate::ExportArtifacts> {
65        let export = model.export.as_ref();
66        let mut artifacts = crate::ExportArtifacts::new();
67
68        artifacts.push("Chart.yaml", chart_yaml(&model.project, export)?);
69        artifacts.push("values.yaml", values_yaml(model)?);
70
71        for service in &model.services {
72            if !enabled_for(Target::Helm, &service.spec.resource, export) {
73                continue;
74            }
75            let name = dns_name(&service.spec.resource);
76            artifacts.push(
77                format!("templates/{name}.yaml"),
78                resource_template(&service.spec, &name),
79            );
80        }
81
82        Ok(artifacts)
83    }
84}
85
86fn chart_yaml(
87    project: &ExportProject,
88    export: Option<&lightshuttle_manifest::ExportConfig>,
89) -> Result<String> {
90    let chart = Chart {
91        api_version: "v2",
92        name: dns_name(&chart_name_for(&project.name, export)),
93        version: chart_version_for(project.version.as_deref(), export),
94        description: format!("Helm chart for {} generated by LightShuttle", project.name),
95    };
96    to_yaml(&chart)
97}
98
99fn values_yaml(model: &ExportModel) -> Result<String> {
100    let export = model.export.as_ref();
101    let namespace = namespace_for(&model.project.name, export);
102
103    let mut services: BTreeMap<String, ServiceValues> = BTreeMap::new();
104    for service in &model.services {
105        if !enabled_for(Target::Helm, &service.spec.resource, export) {
106            continue;
107        }
108        let name = dns_name(&service.spec.resource);
109        let (env, secrets) = split_env(&service.spec.env);
110        let (repository, tag) = split_image(&service.spec.image);
111        services.insert(
112            name,
113            ServiceValues {
114                replicas: replicas_for(Target::Helm, &service.spec.resource, export),
115                image: ImageValues {
116                    repository,
117                    tag,
118                    pull_policy: pull_policy_str(image_pull_policy_for(
119                        &service.spec.resource,
120                        export,
121                    ))
122                    .to_owned(),
123                },
124                env,
125                secrets,
126            },
127        );
128    }
129
130    to_yaml(&Values {
131        namespace,
132        services,
133    })
134}
135
136/// Build the multi-document template for one resource.
137fn resource_template(spec: &ContainerSpec, name: &str) -> String {
138    let mut out = String::new();
139    let _ = writeln!(out, "{{{{- $svc := index .Values.services {name:?} -}}}}");
140    out.push_str(&deployment_block(spec, name));
141    if !spec.ports.is_empty() {
142        out.push_str("---\n");
143        out.push_str(&service_block(spec, name));
144    }
145    if !split_env(&spec.env).0.is_empty() {
146        out.push_str("---\n");
147        out.push_str(&configmap_block(name));
148    }
149    if !split_env(&spec.env).1.is_empty() {
150        out.push_str("---\n");
151        out.push_str(&secret_block(name));
152    }
153    for volume in &spec.volumes {
154        if let VolumeSource::Named(vol) = &volume.source {
155            out.push_str("---\n");
156            out.push_str(&pvc_block(name, &dns_name(vol)));
157        }
158    }
159    out
160}
161
162fn deployment_block(spec: &ContainerSpec, name: &str) -> String {
163    let mut s = String::new();
164    let (has_config, has_secret) = {
165        let (config_env, secret_env) = split_env(&spec.env);
166        (!config_env.is_empty(), !secret_env.is_empty())
167    };
168
169    let _ = write!(
170        s,
171        "apiVersion: apps/v1\n\
172         kind: Deployment\n\
173         metadata:\n\
174         \x20 name: {name}\n\
175         \x20 namespace: {{{{ .Values.namespace }}}}\n\
176         \x20 labels:\n\
177         \x20\x20\x20 app: {name}\n\
178         spec:\n\
179         \x20 replicas: {{{{ $svc.replicas }}}}\n\
180         \x20 selector:\n\
181         \x20\x20\x20 matchLabels:\n\
182         \x20\x20\x20\x20\x20 app: {name}\n\
183         \x20 template:\n\
184         \x20\x20\x20 metadata:\n\
185         \x20\x20\x20\x20\x20 labels:\n\
186         \x20\x20\x20\x20\x20\x20\x20 app: {name}\n\
187         \x20\x20\x20 spec:\n\
188         \x20\x20\x20\x20\x20 containers:\n\
189         \x20\x20\x20\x20\x20 - name: {name}\n\
190         \x20\x20\x20\x20\x20\x20\x20 image: \"{{{{ $svc.image.repository }}}}:{{{{ $svc.image.tag }}}}\"\n\
191         \x20\x20\x20\x20\x20\x20\x20 imagePullPolicy: {{{{ $svc.image.pullPolicy }}}}\n"
192    );
193
194    if !spec.ports.is_empty() {
195        s.push_str("        ports:\n");
196        for port in &spec.ports {
197            let _ = writeln!(s, "        - containerPort: {}", port.container_port);
198        }
199    }
200    if has_config || has_secret {
201        s.push_str("        envFrom:\n");
202        if has_config {
203            let _ = writeln!(
204                s,
205                "        - configMapRef:\n            name: {name}-config"
206            );
207        }
208        if has_secret {
209            let _ = writeln!(s, "        - secretRef:\n            name: {name}-secret");
210        }
211    }
212    let mounts: Vec<(String, &str)> = named_mounts(spec);
213    if !mounts.is_empty() {
214        s.push_str("        volumeMounts:\n");
215        for (vol, target) in &mounts {
216            let _ = writeln!(s, "        - name: {vol}\n          mountPath: {target}");
217        }
218    }
219    if let Some(entrypoint) = &spec.entrypoint {
220        s.push_str("        command:\n");
221        for arg in entrypoint {
222            let _ = writeln!(s, "        - {}", yaml_scalar(arg));
223        }
224    }
225    if let Some(args) = &spec.command {
226        s.push_str("        args:\n");
227        for arg in args {
228            let _ = writeln!(s, "        - {}", yaml_scalar(arg));
229        }
230    }
231    if let Some(dir) = &spec.working_dir {
232        let _ = writeln!(s, "        workingDir: {dir}");
233    }
234    if let Some(hc) = &spec.healthcheck {
235        let probe = probe_block(hc);
236        let _ = write!(s, "        readinessProbe:\n{probe}");
237        let _ = write!(s, "        livenessProbe:\n{probe}");
238    }
239    if !mounts.is_empty() {
240        s.push_str("      volumes:\n");
241        for (vol, _) in &mounts {
242            let _ = writeln!(
243                s,
244                "      - name: {vol}\n        persistentVolumeClaim:\n          claimName: {name}-{vol}"
245            );
246        }
247    }
248    s
249}
250
251fn service_block(spec: &ContainerSpec, name: &str) -> String {
252    let mut s = String::new();
253    let _ = write!(
254        s,
255        "apiVersion: v1\n\
256         kind: Service\n\
257         metadata:\n\
258         \x20 name: {name}\n\
259         \x20 namespace: {{{{ .Values.namespace }}}}\n\
260         \x20 labels:\n\
261         \x20\x20\x20 app: {name}\n\
262         spec:\n\
263         \x20 selector:\n\
264         \x20\x20\x20 app: {name}\n\
265         \x20 ports:\n"
266    );
267    for port in &spec.ports {
268        let _ = writeln!(
269            s,
270            "  - port: {p}\n    targetPort: {p}",
271            p = port.container_port
272        );
273    }
274    if spec.ports.is_empty() {
275        s.push_str("  []\n");
276    }
277    s
278}
279
280fn configmap_block(name: &str) -> String {
281    format!(
282        "apiVersion: v1\n\
283         kind: ConfigMap\n\
284         metadata:\n\
285         \x20 name: {name}-config\n\
286         \x20 namespace: {{{{ .Values.namespace }}}}\n\
287         \x20 labels:\n\
288         \x20\x20\x20 app: {name}\n\
289         data:\n\
290         {{{{- range $k, $v := $svc.env }}}}\n\
291         \x20 {{{{ $k }}}}: {{{{ $v | quote }}}}\n\
292         {{{{- end }}}}\n"
293    )
294}
295
296fn secret_block(name: &str) -> String {
297    format!(
298        "apiVersion: v1\n\
299         kind: Secret\n\
300         metadata:\n\
301         \x20 name: {name}-secret\n\
302         \x20 namespace: {{{{ .Values.namespace }}}}\n\
303         \x20 labels:\n\
304         \x20\x20\x20 app: {name}\n\
305         stringData:\n\
306         {{{{- range $k, $v := $svc.secrets }}}}\n\
307         \x20 {{{{ $k }}}}: {{{{ $v | quote }}}}\n\
308         {{{{- end }}}}\n"
309    )
310}
311
312fn pvc_block(name: &str, volume: &str) -> String {
313    format!(
314        "apiVersion: v1\n\
315         kind: PersistentVolumeClaim\n\
316         metadata:\n\
317         \x20 name: {name}-{volume}\n\
318         \x20 namespace: {{{{ .Values.namespace }}}}\n\
319         \x20 labels:\n\
320         \x20\x20\x20 app: {name}\n\
321         spec:\n\
322         \x20 accessModes:\n\
323         \x20 - ReadWriteOnce\n\
324         \x20 resources:\n\
325         \x20\x20\x20 requests:\n\
326         \x20\x20\x20\x20\x20 storage: 1Gi\n"
327    )
328}
329
330fn probe_block(hc: &HealthcheckSpec) -> String {
331    let command = match hc.test.first().map(String::as_str) {
332        Some("CMD") => hc.test[1..].to_vec(),
333        Some("CMD-SHELL") if hc.test.len() > 1 => {
334            vec!["sh".to_owned(), "-c".to_owned(), hc.test[1..].join(" ")]
335        }
336        _ => hc.test.clone(),
337    };
338    let mut s = String::from("          exec:\n            command:\n");
339    for arg in &command {
340        let _ = writeln!(s, "            - {arg}");
341    }
342    let _ = writeln!(s, "          periodSeconds: {}", secs(hc.interval));
343    let _ = writeln!(s, "          timeoutSeconds: {}", secs(hc.timeout));
344    let _ = writeln!(s, "          failureThreshold: {}", hc.retries);
345    let _ = writeln!(
346        s,
347        "          initialDelaySeconds: {}",
348        secs(hc.start_period)
349    );
350    s
351}
352
353/// Named volume mounts as `(volume_name, mount_path)`.
354fn named_mounts(spec: &ContainerSpec) -> Vec<(String, &str)> {
355    spec.volumes
356        .iter()
357        .filter_map(|v| match &v.source {
358            VolumeSource::Named(name) => Some((dns_name(name), v.target.as_str())),
359            _ => None,
360        })
361        .collect()
362}
363
364/// Secret values are replaced with a placeholder so the exported
365/// chart never contains real credentials.
366fn split_env(
367    env: &std::collections::HashMap<String, String>,
368) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
369    let mut config = BTreeMap::new();
370    let mut secret = BTreeMap::new();
371    for (key, value) in env {
372        if SECRET_MARKERS
373            .iter()
374            .any(|m| key.to_ascii_uppercase().contains(m))
375        {
376            secret.insert(key.clone(), "***".to_owned());
377        } else {
378            config.insert(key.clone(), value.clone());
379        }
380    }
381    (config, secret)
382}
383
384/// Split an image reference into `(repository, tag)` on the last colon.
385fn split_image(image: &ImageSource) -> (String, String) {
386    let reference = match image {
387        ImageSource::Pull(img) => img.clone(),
388        ImageSource::Build { tag, .. } => tag.clone(),
389    };
390    match reference.rsplit_once(':') {
391        Some((repo, tag)) if !repo.is_empty() => (repo.to_owned(), tag.to_owned()),
392        _ => (reference, "latest".to_owned()),
393    }
394}
395
396fn pull_policy_str(policy: ImagePullPolicy) -> &'static str {
397    match policy {
398        ImagePullPolicy::Always => "Always",
399        ImagePullPolicy::IfNotPresent => "IfNotPresent",
400        ImagePullPolicy::Never => "Never",
401    }
402}
403
404#[allow(clippy::cast_possible_truncation)]
405fn secs(d: Duration) -> u32 {
406    d.as_secs().min(u64::from(u32::MAX)) as u32
407}
408
409fn to_yaml<T: Serialize>(value: &T) -> Result<String> {
410    serde_norway::to_string(value).map_err(|e| crate::ExportError::Unsupported {
411        resource: "<helm>".to_owned(),
412        target: "helm",
413        reason: format!("failed to serialise chart data: {e}"),
414    })
415}
416
417/// Column, counted from the start of the line, at which a
418/// `command:`/`args:` list item's scalar begins: `        - ` is eight
419/// spaces, a dash and a space.
420const ARGV_SCALAR_COLUMN: usize = 10;
421
422/// Render a single string as a YAML scalar for splicing into the
423/// hand-written `command:`/`args:` blocks below, closing the Go
424/// `text/template` injection hazard those blocks are exposed to.
425///
426/// Two mechanisms are layered here, in this order:
427///
428/// 1. The value is quoted exactly as `serde_norway` would quote it when
429///    serialising the same value as part of a typed struct (as the
430///    Kubernetes emitter does): an argument containing `: ` is
431///    single-quoted so it is not read as a YAML mapping, and a
432///    multi-line argument becomes a literal block scalar.
433/// 2. Helm renders every file under a chart's `templates/` directory
434///    through Go `text/template` BEFORE any YAML parser sees it, so
435///    YAML quoting alone does nothing against `{{`: a value quoted as
436///    `'{{ .Values.x }}'` is handed to Go's templater as the literal
437///    characters `{{ .Values.x }}`, which Go evaluates as a template
438///    action at `helm install` time, quotes or not. Every `{{` in the
439///    already-serialised scalar is therefore additionally escaped to
440///    the Helm literal `{{ "{{" }}`, which Go renders back to a literal
441///    `{{` before the YAML parser ever runs. This is the point where
442///    the Helm emitter's raw template text deliberately diverges from
443///    what the Kubernetes emitter emits for the same value: the two
444///    agree again only after Helm renders.
445///
446/// A multi-line value also needs its block scalar body re-indented:
447/// `serde_norway` indents a block scalar's body two columns from the
448/// document root, but the result here is spliced after a `        - `
449/// list marker, so an unindented body would dedent out of the list
450/// item and the chart would fail to parse. The body is shifted to
451/// [`ARGV_SCALAR_COLUMN`], the column where the list item's scalar
452/// starts.
453fn yaml_scalar(value: &str) -> String {
454    let serialised = serde_norway::to_string(value).map_or_else(
455        |_| value.to_owned(),
456        |s| s.strip_suffix('\n').unwrap_or(&s).to_owned(),
457    );
458    let escaped = serialised.replace("{{", r#"{{ "{{" }}"#);
459    reindent_block_scalar(&escaped, ARGV_SCALAR_COLUMN)
460}
461
462/// Shift the body of a literal or folded block scalar (`serde_norway`'s
463/// `|`, `|-`, `|2-`, ... forms) from its default two-column indentation
464/// to `column`. A plain or quoted single-line scalar has no body to
465/// shift and is returned unchanged.
466fn reindent_block_scalar(scalar: &str, column: usize) -> String {
467    let Some(newline_at) = scalar.find('\n') else {
468        return scalar.to_owned();
469    };
470    let (header, body) = scalar.split_at(newline_at);
471    let pad = " ".repeat(column.saturating_sub(2));
472    let mut out = header.to_owned();
473    for line in body[1..].split('\n') {
474        out.push('\n');
475        if !line.is_empty() {
476            out.push_str(&pad);
477        }
478        out.push_str(line);
479    }
480    out
481}
482
483// --- Typed chart data ---------------------------------------------------
484
485#[derive(Serialize)]
486struct Chart {
487    #[serde(rename = "apiVersion")]
488    api_version: &'static str,
489    name: String,
490    version: String,
491    description: String,
492}
493
494#[derive(Serialize)]
495struct Values {
496    namespace: String,
497    services: BTreeMap<String, ServiceValues>,
498}
499
500#[derive(Serialize)]
501struct ServiceValues {
502    replicas: u32,
503    image: ImageValues,
504    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
505    env: BTreeMap<String, String>,
506    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
507    secrets: BTreeMap<String, String>,
508}
509
510#[derive(Serialize)]
511struct ImageValues {
512    repository: String,
513    tag: String,
514    #[serde(rename = "pullPolicy")]
515    pull_policy: String,
516}