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