lightshuttle_export/emitters/
helm.rs1use 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
26pub 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
136fn 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
353fn 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
364fn 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
384fn 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
417const ARGV_SCALAR_COLUMN: usize = 10;
421
422fn 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
462fn 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#[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}