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;
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
106fn 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
311fn 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
322fn 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
342fn 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#[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}