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