use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::time::Duration;
use lightshuttle_manifest::ImagePullPolicy;
use lightshuttle_spec::{ContainerSpec, HealthcheckSpec, ImageSource, VolumeSource};
use serde::Serialize;
use crate::emit::Emitter;
use crate::error::Result;
use crate::model::{ExportModel, ExportProject, Target};
use crate::resolve::{
SECRET_MARKERS, chart_name_for, chart_version_for, dns_name, enabled_for,
image_pull_policy_for, namespace_for, replicas_for,
};
pub struct HelmEmitter;
impl Emitter for HelmEmitter {
fn target(&self) -> Target {
Target::Helm
}
fn emit(&self, model: &ExportModel) -> Result<crate::ExportArtifacts> {
let export = model.export.as_ref();
let mut artifacts = crate::ExportArtifacts::new();
artifacts.push("Chart.yaml", chart_yaml(&model.project, export)?);
artifacts.push("values.yaml", values_yaml(model)?);
for service in &model.services {
if !enabled_for(Target::Helm, &service.spec.resource, export) {
continue;
}
let name = dns_name(&service.spec.resource);
artifacts.push(
format!("templates/{name}.yaml"),
resource_template(&service.spec, &name),
);
}
Ok(artifacts)
}
}
fn chart_yaml(
project: &ExportProject,
export: Option<&lightshuttle_manifest::ExportConfig>,
) -> Result<String> {
let chart = Chart {
api_version: "v2",
name: dns_name(&chart_name_for(&project.name, export)),
version: chart_version_for(project.version.as_deref(), export),
description: format!("Helm chart for {} generated by LightShuttle", project.name),
};
to_yaml(&chart)
}
fn values_yaml(model: &ExportModel) -> Result<String> {
let export = model.export.as_ref();
let namespace = namespace_for(&model.project.name, export);
let mut services: BTreeMap<String, ServiceValues> = BTreeMap::new();
for service in &model.services {
if !enabled_for(Target::Helm, &service.spec.resource, export) {
continue;
}
let name = dns_name(&service.spec.resource);
let (env, secrets) = split_env(&service.spec.env);
let (repository, tag) = split_image(&service.spec.image);
services.insert(
name,
ServiceValues {
replicas: replicas_for(Target::Helm, &service.spec.resource, export),
image: ImageValues {
repository,
tag,
pull_policy: pull_policy_str(image_pull_policy_for(
&service.spec.resource,
export,
))
.to_owned(),
},
env,
secrets,
},
);
}
to_yaml(&Values {
namespace,
services,
})
}
fn resource_template(spec: &ContainerSpec, name: &str) -> String {
let mut out = String::new();
let _ = writeln!(out, "{{{{- $svc := index .Values.services {name:?} -}}}}");
out.push_str(&deployment_block(spec, name));
if !spec.ports.is_empty() {
out.push_str("---\n");
out.push_str(&service_block(spec, name));
}
if !split_env(&spec.env).0.is_empty() {
out.push_str("---\n");
out.push_str(&configmap_block(name));
}
if !split_env(&spec.env).1.is_empty() {
out.push_str("---\n");
out.push_str(&secret_block(name));
}
for volume in &spec.volumes {
if let VolumeSource::Named(vol) = &volume.source {
out.push_str("---\n");
out.push_str(&pvc_block(name, &dns_name(vol)));
}
}
out
}
fn deployment_block(spec: &ContainerSpec, name: &str) -> String {
let mut s = String::new();
let (has_config, has_secret) = {
let (config_env, secret_env) = split_env(&spec.env);
(!config_env.is_empty(), !secret_env.is_empty())
};
let _ = write!(
s,
"apiVersion: apps/v1\n\
kind: Deployment\n\
metadata:\n\
\x20 name: {name}\n\
\x20 namespace: {{{{ .Values.namespace }}}}\n\
\x20 labels:\n\
\x20\x20\x20 app: {name}\n\
spec:\n\
\x20 replicas: {{{{ $svc.replicas }}}}\n\
\x20 selector:\n\
\x20\x20\x20 matchLabels:\n\
\x20\x20\x20\x20\x20 app: {name}\n\
\x20 template:\n\
\x20\x20\x20 metadata:\n\
\x20\x20\x20\x20\x20 labels:\n\
\x20\x20\x20\x20\x20\x20\x20 app: {name}\n\
\x20\x20\x20 spec:\n\
\x20\x20\x20\x20\x20 containers:\n\
\x20\x20\x20\x20\x20 - name: {name}\n\
\x20\x20\x20\x20\x20\x20\x20 image: \"{{{{ $svc.image.repository }}}}:{{{{ $svc.image.tag }}}}\"\n\
\x20\x20\x20\x20\x20\x20\x20 imagePullPolicy: {{{{ $svc.image.pullPolicy }}}}\n"
);
if !spec.ports.is_empty() {
s.push_str(" ports:\n");
for port in &spec.ports {
let _ = writeln!(s, " - containerPort: {}", port.container_port);
}
}
if has_config || has_secret {
s.push_str(" envFrom:\n");
if has_config {
let _ = writeln!(
s,
" - configMapRef:\n name: {name}-config"
);
}
if has_secret {
let _ = writeln!(s, " - secretRef:\n name: {name}-secret");
}
}
let mounts: Vec<(String, &str)> = named_mounts(spec);
if !mounts.is_empty() {
s.push_str(" volumeMounts:\n");
for (vol, target) in &mounts {
let _ = writeln!(s, " - name: {vol}\n mountPath: {target}");
}
}
if let Some(dir) = &spec.working_dir {
let _ = writeln!(s, " workingDir: {dir}");
}
if let Some(hc) = &spec.healthcheck {
let probe = probe_block(hc);
let _ = write!(s, " readinessProbe:\n{probe}");
let _ = write!(s, " livenessProbe:\n{probe}");
}
if !mounts.is_empty() {
s.push_str(" volumes:\n");
for (vol, _) in &mounts {
let _ = writeln!(
s,
" - name: {vol}\n persistentVolumeClaim:\n claimName: {name}-{vol}"
);
}
}
s
}
fn service_block(spec: &ContainerSpec, name: &str) -> String {
let mut s = String::new();
let _ = write!(
s,
"apiVersion: v1\n\
kind: Service\n\
metadata:\n\
\x20 name: {name}\n\
\x20 namespace: {{{{ .Values.namespace }}}}\n\
\x20 labels:\n\
\x20\x20\x20 app: {name}\n\
spec:\n\
\x20 selector:\n\
\x20\x20\x20 app: {name}\n\
\x20 ports:\n"
);
for port in &spec.ports {
let _ = writeln!(
s,
" - port: {p}\n targetPort: {p}",
p = port.container_port
);
}
if spec.ports.is_empty() {
s.push_str(" []\n");
}
s
}
fn configmap_block(name: &str) -> String {
format!(
"apiVersion: v1\n\
kind: ConfigMap\n\
metadata:\n\
\x20 name: {name}-config\n\
\x20 namespace: {{{{ .Values.namespace }}}}\n\
\x20 labels:\n\
\x20\x20\x20 app: {name}\n\
data:\n\
{{{{- range $k, $v := $svc.env }}}}\n\
\x20 {{{{ $k }}}}: {{{{ $v | quote }}}}\n\
{{{{- end }}}}\n"
)
}
fn secret_block(name: &str) -> String {
format!(
"apiVersion: v1\n\
kind: Secret\n\
metadata:\n\
\x20 name: {name}-secret\n\
\x20 namespace: {{{{ .Values.namespace }}}}\n\
\x20 labels:\n\
\x20\x20\x20 app: {name}\n\
stringData:\n\
{{{{- range $k, $v := $svc.secrets }}}}\n\
\x20 {{{{ $k }}}}: {{{{ $v | quote }}}}\n\
{{{{- end }}}}\n"
)
}
fn pvc_block(name: &str, volume: &str) -> String {
format!(
"apiVersion: v1\n\
kind: PersistentVolumeClaim\n\
metadata:\n\
\x20 name: {name}-{volume}\n\
\x20 namespace: {{{{ .Values.namespace }}}}\n\
\x20 labels:\n\
\x20\x20\x20 app: {name}\n\
spec:\n\
\x20 accessModes:\n\
\x20 - ReadWriteOnce\n\
\x20 resources:\n\
\x20\x20\x20 requests:\n\
\x20\x20\x20\x20\x20 storage: 1Gi\n"
)
}
fn probe_block(hc: &HealthcheckSpec) -> String {
let command = match hc.test.first().map(String::as_str) {
Some("CMD") => hc.test[1..].to_vec(),
Some("CMD-SHELL") if hc.test.len() > 1 => {
vec!["sh".to_owned(), "-c".to_owned(), hc.test[1..].join(" ")]
}
_ => hc.test.clone(),
};
let mut s = String::from(" exec:\n command:\n");
for arg in &command {
let _ = writeln!(s, " - {arg}");
}
let _ = writeln!(s, " periodSeconds: {}", secs(hc.interval));
let _ = writeln!(s, " timeoutSeconds: {}", secs(hc.timeout));
let _ = writeln!(s, " failureThreshold: {}", hc.retries);
let _ = writeln!(
s,
" initialDelaySeconds: {}",
secs(hc.start_period)
);
s
}
fn named_mounts(spec: &ContainerSpec) -> Vec<(String, &str)> {
spec.volumes
.iter()
.filter_map(|v| match &v.source {
VolumeSource::Named(name) => Some((dns_name(name), v.target.as_str())),
_ => None,
})
.collect()
}
fn split_env(
env: &std::collections::HashMap<String, String>,
) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
let mut config = BTreeMap::new();
let mut secret = BTreeMap::new();
for (key, value) in env {
if SECRET_MARKERS
.iter()
.any(|m| key.to_ascii_uppercase().contains(m))
{
secret.insert(key.clone(), "***".to_owned());
} else {
config.insert(key.clone(), value.clone());
}
}
(config, secret)
}
fn split_image(image: &ImageSource) -> (String, String) {
let reference = match image {
ImageSource::Pull(img) => img.clone(),
ImageSource::Build { tag, .. } => tag.clone(),
};
match reference.rsplit_once(':') {
Some((repo, tag)) if !repo.is_empty() => (repo.to_owned(), tag.to_owned()),
_ => (reference, "latest".to_owned()),
}
}
fn pull_policy_str(policy: ImagePullPolicy) -> &'static str {
match policy {
ImagePullPolicy::Always => "Always",
ImagePullPolicy::IfNotPresent => "IfNotPresent",
ImagePullPolicy::Never => "Never",
}
}
#[allow(clippy::cast_possible_truncation)]
fn secs(d: Duration) -> u32 {
d.as_secs().min(u64::from(u32::MAX)) as u32
}
fn to_yaml<T: Serialize>(value: &T) -> Result<String> {
serde_norway::to_string(value).map_err(|e| crate::ExportError::Unsupported {
resource: "<helm>".to_owned(),
target: "helm",
reason: format!("failed to serialise chart data: {e}"),
})
}
#[derive(Serialize)]
struct Chart {
#[serde(rename = "apiVersion")]
api_version: &'static str,
name: String,
version: String,
description: String,
}
#[derive(Serialize)]
struct Values {
namespace: String,
services: BTreeMap<String, ServiceValues>,
}
#[derive(Serialize)]
struct ServiceValues {
replicas: u32,
image: ImageValues,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
env: BTreeMap<String, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
secrets: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct ImageValues {
repository: String,
tag: String,
#[serde(rename = "pullPolicy")]
pull_policy: String,
}