Skip to main content

lightshuttle_export/emitters/
kubernetes.rs

1//! Kubernetes emitter: renders an [`ExportModel`] into plain manifests,
2//! one multi-document file per resource plus a namespace file.
3
4use std::collections::BTreeMap;
5use std::time::Duration;
6
7use lightshuttle_manifest::ImagePullPolicy;
8use lightshuttle_spec::{
9    ContainerSpec, HealthcheckSpec, ImageSource, PortBinding, VolumeBinding, VolumeSource,
10};
11use serde::Serialize;
12
13use crate::emit::Emitter;
14use crate::error::Result;
15use crate::model::{ExportModel, ExportService, Target};
16use crate::resolve::{
17    SECRET_MARKERS, dns_name, enabled_for, image_pull_policy_for, namespace_for, replicas_for,
18};
19
20/// Emits plain Kubernetes manifests from the export model.
21pub struct KubernetesEmitter;
22
23impl Emitter for KubernetesEmitter {
24    fn target(&self) -> Target {
25        Target::Kubernetes
26    }
27
28    fn emit(&self, model: &ExportModel) -> Result<crate::ExportArtifacts> {
29        let namespace = namespace_for(&model.project.name, model.export.as_ref());
30        let mut artifacts = crate::ExportArtifacts::new();
31        artifacts.push("namespace.yaml", namespace_doc(&namespace)?);
32
33        for service in &model.services {
34            if !enabled_for(
35                Target::Kubernetes,
36                &service.spec.resource,
37                model.export.as_ref(),
38            ) {
39                continue;
40            }
41            let docs = resource_docs(service, model, &namespace)?;
42            artifacts.push(format!("{}.yaml", dns_name(&service.spec.resource)), docs);
43        }
44
45        Ok(artifacts)
46    }
47}
48
49fn namespace_doc(namespace: &str) -> Result<String> {
50    let ns = Namespace {
51        api_version: "v1",
52        kind: "Namespace",
53        metadata: NameOnly {
54            name: namespace.to_owned(),
55        },
56    };
57    to_yaml(&ns)
58}
59
60fn resource_docs(service: &ExportService, model: &ExportModel, namespace: &str) -> Result<String> {
61    let spec = &service.spec;
62    let name = dns_name(&spec.resource);
63    let labels = labels(&name);
64    let (config_env, secret_env) = split_env(&spec.env);
65
66    let mut docs: Vec<String> = Vec::new();
67
68    docs.push(to_yaml(&deployment(
69        spec, model, namespace, &name, &labels,
70    ))?);
71    if !spec.ports.is_empty() {
72        docs.push(to_yaml(&service_object(spec, namespace, &name, &labels))?);
73    }
74
75    if !config_env.is_empty() {
76        docs.push(to_yaml(&ConfigMap {
77            api_version: "v1",
78            kind: "ConfigMap",
79            metadata: meta(&format!("{name}-config"), namespace, &labels),
80            data: config_env,
81        })?);
82    }
83    if !secret_env.is_empty() {
84        docs.push(to_yaml(&Secret {
85            api_version: "v1",
86            kind: "Secret",
87            metadata: meta(&format!("{name}-secret"), namespace, &labels),
88            string_data: secret_env,
89        })?);
90    }
91    for volume in &spec.volumes {
92        if let VolumeSource::Named(vol) = &volume.source {
93            docs.push(to_yaml(&pvc(&name, &dns_name(vol), namespace, &labels))?);
94        }
95    }
96
97    Ok(docs.join("---\n"))
98}
99
100fn deployment(
101    spec: &ContainerSpec,
102    model: &ExportModel,
103    namespace: &str,
104    name: &str,
105    labels: &BTreeMap<String, String>,
106) -> Deployment {
107    let replicas = replicas_for(Target::Kubernetes, &spec.resource, model.export.as_ref());
108    let pull_policy = image_pull_policy_for(&spec.resource, model.export.as_ref());
109
110    let mut env_from: Vec<EnvFromSource> = Vec::new();
111    let (config_env, secret_env) = split_env(&spec.env);
112    if !config_env.is_empty() {
113        env_from.push(EnvFromSource::config(format!("{name}-config")));
114    }
115    if !secret_env.is_empty() {
116        env_from.push(EnvFromSource::secret(format!("{name}-secret")));
117    }
118
119    let mut mounts: Vec<VolumeMount> = Vec::new();
120    let mut volumes: Vec<PodVolume> = Vec::new();
121    for (idx, volume) in spec.volumes.iter().enumerate() {
122        let (vol_name, source) = pod_volume(name, idx, volume);
123        mounts.push(VolumeMount {
124            name: vol_name.clone(),
125            mount_path: volume.target.clone(),
126        });
127        volumes.push(PodVolume {
128            name: vol_name,
129            source,
130        });
131    }
132
133    let probe = spec.healthcheck.as_ref().map(probe);
134
135    Deployment {
136        api_version: "apps/v1",
137        kind: "Deployment",
138        metadata: meta(name, namespace, labels),
139        spec: DeploymentSpec {
140            replicas,
141            selector: Selector {
142                match_labels: labels.clone(),
143            },
144            template: PodTemplate {
145                metadata: TemplateMeta {
146                    labels: labels.clone(),
147                },
148                spec: PodSpec {
149                    containers: vec![Container {
150                        name: name.to_owned(),
151                        image: image_ref(&spec.image),
152                        image_pull_policy: pull_policy_str(pull_policy).to_owned(),
153                        ports: spec.ports.iter().map(container_port).collect(),
154                        env_from,
155                        volume_mounts: mounts,
156                        command: spec.command.clone(),
157                        working_dir: spec.working_dir.clone(),
158                        readiness_probe: probe.clone(),
159                        liveness_probe: probe,
160                    }],
161                    volumes,
162                },
163            },
164        },
165    }
166}
167
168fn service_object(
169    spec: &ContainerSpec,
170    namespace: &str,
171    name: &str,
172    labels: &BTreeMap<String, String>,
173) -> Service {
174    Service {
175        api_version: "v1",
176        kind: "Service",
177        metadata: meta(name, namespace, labels),
178        spec: ServiceSpec {
179            selector: labels.clone(),
180            ports: spec
181                .ports
182                .iter()
183                .map(|p| ServicePort {
184                    port: p.container_port,
185                    target_port: p.container_port,
186                })
187                .collect(),
188        },
189    }
190}
191
192fn pvc(name: &str, volume: &str, namespace: &str, labels: &BTreeMap<String, String>) -> Pvc {
193    Pvc {
194        api_version: "v1",
195        kind: "PersistentVolumeClaim",
196        metadata: meta(&format!("{name}-{volume}"), namespace, labels),
197        spec: PvcSpec {
198            access_modes: vec!["ReadWriteOnce".to_owned()],
199            resources: PvcResources {
200                requests: BTreeMap::from([("storage".to_owned(), "1Gi".to_owned())]),
201            },
202        },
203    }
204}
205
206/// Build the pod volume name and source for `volume`.
207fn pod_volume(resource: &str, idx: usize, volume: &VolumeBinding) -> (String, PodVolumeSource) {
208    match &volume.source {
209        VolumeSource::Named(vol) => {
210            let vol = dns_name(vol);
211            let claim = format!("{resource}-{vol}");
212            (vol, PodVolumeSource::Pvc(PvcRef::new(claim)))
213        }
214        VolumeSource::HostPath(path) => (
215            format!("{resource}-host-{idx}"),
216            PodVolumeSource::HostPath(HostPathSource {
217                host_path: HostPathInner { path: path.clone() },
218            }),
219        ),
220        VolumeSource::Anonymous => (
221            format!("{resource}-data-{idx}"),
222            PodVolumeSource::EmptyDir(EmptyDir {
223                empty_dir: EmptyDirInner {},
224            }),
225        ),
226    }
227}
228
229/// Split env into (config, secret) by case-insensitive key marker.
230///
231/// Secret values are replaced with a placeholder so the exported
232/// manifest never contains real credentials.
233fn split_env(
234    env: &std::collections::HashMap<String, String>,
235) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
236    let mut config = BTreeMap::new();
237    let mut secret = BTreeMap::new();
238    for (key, value) in env {
239        let upper = key.to_ascii_uppercase();
240        if SECRET_MARKERS.iter().any(|m| upper.contains(m)) {
241            secret.insert(key.clone(), "***".to_owned());
242        } else {
243            config.insert(key.clone(), value.clone());
244        }
245    }
246    (config, secret)
247}
248
249fn probe(hc: &HealthcheckSpec) -> Probe {
250    let command = match hc.test.first().map(String::as_str) {
251        Some("CMD") => hc.test[1..].to_vec(),
252        Some("CMD-SHELL") if hc.test.len() > 1 => {
253            vec!["sh".to_owned(), "-c".to_owned(), hc.test[1..].join(" ")]
254        }
255        _ => hc.test.clone(),
256    };
257    Probe {
258        exec: ExecAction { command },
259        period_seconds: secs(hc.interval),
260        timeout_seconds: secs(hc.timeout),
261        failure_threshold: hc.retries,
262        initial_delay_seconds: secs(hc.start_period),
263    }
264}
265
266fn container_port(port: &PortBinding) -> ContainerPort {
267    ContainerPort {
268        container_port: port.container_port,
269    }
270}
271
272fn image_ref(image: &ImageSource) -> String {
273    match image {
274        ImageSource::Pull(img) => img.clone(),
275        ImageSource::Build { tag, .. } => tag.clone(),
276    }
277}
278
279fn pull_policy_str(policy: ImagePullPolicy) -> &'static str {
280    match policy {
281        ImagePullPolicy::Always => "Always",
282        ImagePullPolicy::IfNotPresent => "IfNotPresent",
283        ImagePullPolicy::Never => "Never",
284    }
285}
286
287fn labels(name: &str) -> BTreeMap<String, String> {
288    BTreeMap::from([("app".to_owned(), name.to_owned())])
289}
290
291fn meta(name: &str, namespace: &str, labels: &BTreeMap<String, String>) -> Meta {
292    Meta {
293        name: name.to_owned(),
294        namespace: namespace.to_owned(),
295        labels: labels.clone(),
296    }
297}
298
299#[allow(clippy::cast_possible_truncation)]
300fn secs(d: Duration) -> u32 {
301    d.as_secs().min(u64::from(u32::MAX)) as u32
302}
303
304fn to_yaml<T: Serialize>(value: &T) -> Result<String> {
305    serde_norway::to_string(value).map_err(|e| crate::ExportError::Unsupported {
306        resource: "<kubernetes>".to_owned(),
307        target: "kubernetes",
308        reason: format!("failed to serialise manifest: {e}"),
309    })
310}
311
312// --- Typed Kubernetes objects -------------------------------------------
313
314#[derive(Serialize)]
315struct NameOnly {
316    name: String,
317}
318
319#[derive(Serialize)]
320struct Meta {
321    name: String,
322    namespace: String,
323    labels: BTreeMap<String, String>,
324}
325
326#[derive(Serialize)]
327struct Namespace {
328    #[serde(rename = "apiVersion")]
329    api_version: &'static str,
330    kind: &'static str,
331    metadata: NameOnly,
332}
333
334#[derive(Serialize)]
335struct Deployment {
336    #[serde(rename = "apiVersion")]
337    api_version: &'static str,
338    kind: &'static str,
339    metadata: Meta,
340    spec: DeploymentSpec,
341}
342
343#[derive(Serialize)]
344struct DeploymentSpec {
345    replicas: u32,
346    selector: Selector,
347    template: PodTemplate,
348}
349
350#[derive(Serialize)]
351struct Selector {
352    #[serde(rename = "matchLabels")]
353    match_labels: BTreeMap<String, String>,
354}
355
356#[derive(Serialize)]
357struct PodTemplate {
358    metadata: TemplateMeta,
359    spec: PodSpec,
360}
361
362#[derive(Serialize)]
363struct TemplateMeta {
364    labels: BTreeMap<String, String>,
365}
366
367#[derive(Serialize)]
368struct PodSpec {
369    containers: Vec<Container>,
370    #[serde(skip_serializing_if = "Vec::is_empty")]
371    volumes: Vec<PodVolume>,
372}
373
374#[derive(Serialize)]
375struct Container {
376    name: String,
377    image: String,
378    #[serde(rename = "imagePullPolicy")]
379    image_pull_policy: String,
380    #[serde(skip_serializing_if = "Vec::is_empty")]
381    ports: Vec<ContainerPort>,
382    #[serde(rename = "envFrom", skip_serializing_if = "Vec::is_empty")]
383    env_from: Vec<EnvFromSource>,
384    #[serde(rename = "volumeMounts", skip_serializing_if = "Vec::is_empty")]
385    volume_mounts: Vec<VolumeMount>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    command: Option<Vec<String>>,
388    #[serde(rename = "workingDir", skip_serializing_if = "Option::is_none")]
389    working_dir: Option<String>,
390    #[serde(rename = "readinessProbe", skip_serializing_if = "Option::is_none")]
391    readiness_probe: Option<Probe>,
392    #[serde(rename = "livenessProbe", skip_serializing_if = "Option::is_none")]
393    liveness_probe: Option<Probe>,
394}
395
396#[derive(Serialize)]
397struct ContainerPort {
398    #[serde(rename = "containerPort")]
399    container_port: u16,
400}
401
402#[derive(Serialize)]
403struct EnvFromSource {
404    #[serde(rename = "configMapRef", skip_serializing_if = "Option::is_none")]
405    config_map_ref: Option<RefName>,
406    #[serde(rename = "secretRef", skip_serializing_if = "Option::is_none")]
407    secret_ref: Option<RefName>,
408}
409
410impl EnvFromSource {
411    fn config(name: String) -> Self {
412        Self {
413            config_map_ref: Some(RefName { name }),
414            secret_ref: None,
415        }
416    }
417    fn secret(name: String) -> Self {
418        Self {
419            config_map_ref: None,
420            secret_ref: Some(RefName { name }),
421        }
422    }
423}
424
425#[derive(Serialize)]
426struct RefName {
427    name: String,
428}
429
430#[derive(Serialize)]
431struct VolumeMount {
432    name: String,
433    #[serde(rename = "mountPath")]
434    mount_path: String,
435}
436
437#[derive(Clone, Serialize)]
438struct Probe {
439    exec: ExecAction,
440    #[serde(rename = "periodSeconds")]
441    period_seconds: u32,
442    #[serde(rename = "timeoutSeconds")]
443    timeout_seconds: u32,
444    #[serde(rename = "failureThreshold")]
445    failure_threshold: u32,
446    #[serde(rename = "initialDelaySeconds")]
447    initial_delay_seconds: u32,
448}
449
450#[derive(Clone, Serialize)]
451struct ExecAction {
452    command: Vec<String>,
453}
454
455#[derive(Serialize)]
456struct PodVolume {
457    name: String,
458    #[serde(flatten)]
459    source: PodVolumeSource,
460}
461
462#[derive(Serialize)]
463#[serde(untagged)]
464enum PodVolumeSource {
465    Pvc(PvcRef),
466    HostPath(HostPathSource),
467    EmptyDir(EmptyDir),
468}
469
470#[derive(Serialize)]
471struct PvcRef {
472    #[serde(rename = "persistentVolumeClaim")]
473    persistent_volume_claim: ClaimName,
474}
475
476impl PvcRef {
477    fn new(claim_name: String) -> Self {
478        Self {
479            persistent_volume_claim: ClaimName { claim_name },
480        }
481    }
482}
483
484#[derive(Serialize)]
485struct ClaimName {
486    #[serde(rename = "claimName")]
487    claim_name: String,
488}
489
490#[derive(Serialize)]
491struct HostPathSource {
492    #[serde(rename = "hostPath")]
493    host_path: HostPathInner,
494}
495
496#[derive(Serialize)]
497struct HostPathInner {
498    path: String,
499}
500
501#[derive(Serialize)]
502struct EmptyDir {
503    #[serde(rename = "emptyDir")]
504    empty_dir: EmptyDirInner,
505}
506
507#[derive(Serialize)]
508struct EmptyDirInner {}
509
510#[derive(Serialize)]
511struct Service {
512    #[serde(rename = "apiVersion")]
513    api_version: &'static str,
514    kind: &'static str,
515    metadata: Meta,
516    spec: ServiceSpec,
517}
518
519#[derive(Serialize)]
520struct ServiceSpec {
521    selector: BTreeMap<String, String>,
522    ports: Vec<ServicePort>,
523}
524
525#[derive(Serialize)]
526struct ServicePort {
527    port: u16,
528    #[serde(rename = "targetPort")]
529    target_port: u16,
530}
531
532#[derive(Serialize)]
533struct ConfigMap {
534    #[serde(rename = "apiVersion")]
535    api_version: &'static str,
536    kind: &'static str,
537    metadata: Meta,
538    data: BTreeMap<String, String>,
539}
540
541#[derive(Serialize)]
542struct Secret {
543    #[serde(rename = "apiVersion")]
544    api_version: &'static str,
545    kind: &'static str,
546    metadata: Meta,
547    #[serde(rename = "stringData")]
548    string_data: BTreeMap<String, String>,
549}
550
551#[derive(Serialize)]
552struct Pvc {
553    #[serde(rename = "apiVersion")]
554    api_version: &'static str,
555    kind: &'static str,
556    metadata: Meta,
557    spec: PvcSpec,
558}
559
560#[derive(Serialize)]
561struct PvcSpec {
562    #[serde(rename = "accessModes")]
563    access_modes: Vec<String>,
564    resources: PvcResources,
565}
566
567#[derive(Serialize)]
568struct PvcResources {
569    requests: BTreeMap<String, String>,
570}
571
572#[cfg(test)]
573mod tests {
574    use std::time::Duration;
575
576    use super::probe;
577    use lightshuttle_spec::HealthcheckSpec;
578
579    fn hc(test: Vec<&str>) -> HealthcheckSpec {
580        HealthcheckSpec {
581            test: test.into_iter().map(ToOwned::to_owned).collect(),
582            interval: Duration::from_secs(5),
583            timeout: Duration::from_secs(3),
584            retries: 3,
585            start_period: Duration::from_secs(5),
586        }
587    }
588
589    #[test]
590    fn cmd_shell_empty_args_falls_back_to_raw_vector() {
591        let p = probe(&hc(vec!["CMD-SHELL"]));
592        assert_eq!(p.exec.command, vec!["CMD-SHELL"]);
593    }
594
595    #[test]
596    fn cmd_shell_with_args_wraps_in_sh_c() {
597        let p = probe(&hc(vec![
598            "CMD-SHELL",
599            "curl",
600            "-f",
601            "http://localhost/health",
602        ]));
603        assert_eq!(
604            p.exec.command,
605            vec!["sh", "-c", "curl -f http://localhost/health"]
606        );
607    }
608}