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 chart_name_for, chart_version_for, dns_name, enabled_for, image_pull_policy_for, namespace_for,
23 replicas_for,
24};
25
26const SECRET_MARKERS: &[&str] = &["PASSWORD", "PASSWD", "SECRET", "TOKEN", "KEY"];
29
30pub 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
110fn 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
310fn 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
339fn 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#[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}