1use 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
20pub 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
237fn 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
260fn 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#[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}