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::{dns_name, enabled_for, image_pull_policy_for, namespace_for, replicas_for};
17
18const SECRET_MARKERS: &[&str] = &["PASSWORD", "PASSWD", "SECRET", "TOKEN", "KEY"];
21
22pub 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
207fn 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
230fn 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#[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}