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;
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
206fn 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
229fn 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#[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}