lightshuttle_export/emitters/
compose.rs1use std::collections::BTreeMap;
5use std::time::Duration;
6
7use indexmap::IndexMap;
8use lightshuttle_spec::{ContainerSpec, ImageSource, PortBinding, VolumeBinding, VolumeSource};
9use serde::Serialize;
10
11use crate::emit::Emitter;
12use crate::error::{ExportError, Result};
13use crate::model::{ExportModel, ExportService, Target};
14use crate::resolve::enabled_for;
15
16const DEFAULT_HOST_BIND_ADDRESS: &str = "127.0.0.1";
20
21pub struct ComposeEmitter;
23
24impl Emitter for ComposeEmitter {
25 fn target(&self) -> Target {
26 Target::Compose
27 }
28
29 fn emit(&self, model: &ExportModel) -> Result<crate::ExportArtifacts> {
30 let file = build_compose(model);
31 let yaml = serde_norway::to_string(&file).map_err(|e| ExportError::Unsupported {
32 resource: "<compose>".to_owned(),
33 target: "compose",
34 reason: format!("failed to serialise compose file: {e}"),
35 })?;
36 let mut artifacts = crate::ExportArtifacts::new();
37 artifacts.push("docker-compose.yml", yaml);
38 Ok(artifacts)
39 }
40}
41
42#[derive(Debug, Serialize)]
44struct ComposeFile {
45 services: IndexMap<String, ComposeService>,
46 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
47 volumes: BTreeMap<String, ComposeVolumeDef>,
48}
49
50#[derive(Debug, Serialize, Default)]
51struct ComposeService {
52 #[serde(skip_serializing_if = "Option::is_none")]
53 image: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 build: Option<ComposeBuild>,
56 #[serde(skip_serializing_if = "Vec::is_empty")]
57 ports: Vec<String>,
58 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
59 environment: BTreeMap<String, String>,
60 #[serde(skip_serializing_if = "Vec::is_empty")]
61 volumes: Vec<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 command: Option<Vec<String>>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 healthcheck: Option<ComposeHealthcheck>,
66 #[serde(skip_serializing_if = "IndexMap::is_empty")]
67 depends_on: IndexMap<String, ComposeDependency>,
68}
69
70#[derive(Debug, Serialize)]
71struct ComposeBuild {
72 context: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 dockerfile: Option<String>,
75 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
76 args: BTreeMap<String, String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 target: Option<String>,
79}
80
81#[derive(Debug, Serialize)]
82struct ComposeDependency {
83 condition: &'static str,
84}
85
86#[derive(Debug, Serialize)]
87struct ComposeHealthcheck {
88 test: Vec<String>,
89 interval: String,
90 timeout: String,
91 retries: u32,
92 start_period: String,
93}
94
95#[derive(Debug, Serialize, Default)]
98struct ComposeVolumeDef {
99 #[serde(skip_serializing_if = "Option::is_none")]
100 driver: Option<String>,
101}
102
103fn build_compose(model: &ExportModel) -> ComposeFile {
104 let mut services = IndexMap::new();
105 let mut volumes: BTreeMap<String, ComposeVolumeDef> = BTreeMap::new();
106
107 for service in &model.services {
108 if !enabled_for(
109 Target::Compose,
110 &service.spec.resource,
111 model.export.as_ref(),
112 ) {
113 continue;
114 }
115 collect_named_volumes(&service.spec.volumes, &mut volumes);
116 services.insert(
117 service.spec.resource.clone(),
118 compose_service(service, model),
119 );
120 }
121
122 ComposeFile { services, volumes }
123}
124
125fn compose_service(service: &ExportService, model: &ExportModel) -> ComposeService {
126 let spec = &service.spec;
127 let (image, build) = image_or_build(spec);
128
129 ComposeService {
130 image,
131 build,
132 ports: spec.ports.iter().map(port_string).collect(),
133 environment: spec
134 .env
135 .iter()
136 .map(|(k, v)| (k.clone(), v.clone()))
137 .collect(),
138 volumes: spec.volumes.iter().map(volume_string).collect(),
139 command: spec.command.clone(),
140 healthcheck: spec.healthcheck.as_ref().map(|hc| ComposeHealthcheck {
141 test: hc.test.clone(),
142 interval: duration_str(hc.interval),
143 timeout: duration_str(hc.timeout),
144 retries: hc.retries,
145 start_period: duration_str(hc.start_period),
146 }),
147 depends_on: service
148 .depends_on
149 .iter()
150 .map(|dep| {
151 let has_healthcheck = model
152 .services
153 .iter()
154 .any(|s| s.spec.resource == *dep && s.spec.healthcheck.is_some());
155 (
156 dep.clone(),
157 ComposeDependency {
158 condition: if has_healthcheck {
159 "service_healthy"
160 } else {
161 "service_started"
162 },
163 },
164 )
165 })
166 .collect(),
167 }
168}
169
170fn image_or_build(spec: &ContainerSpec) -> (Option<String>, Option<ComposeBuild>) {
171 match &spec.image {
172 ImageSource::Pull(image) => (Some(image.clone()), None),
173 ImageSource::Build {
174 context,
175 dockerfile,
176 build_args,
177 target,
178 tag,
179 } => {
180 let build = ComposeBuild {
181 context: context.clone(),
182 dockerfile: Some(dockerfile.clone()),
183 args: build_args
184 .iter()
185 .map(|(k, v)| (k.clone(), v.clone()))
186 .collect(),
187 target: target.clone(),
188 };
189 (Some(tag.clone()), Some(build))
190 }
191 }
192}
193
194fn port_string(port: &PortBinding) -> String {
195 let host = port
196 .host_address
197 .as_deref()
198 .unwrap_or(DEFAULT_HOST_BIND_ADDRESS);
199 format!("{host}:{}:{}", port.host_port, port.container_port)
200}
201
202fn volume_string(volume: &VolumeBinding) -> String {
203 match &volume.source {
204 VolumeSource::HostPath(path) => format!("{path}:{}", volume.target),
205 VolumeSource::Named(name) => format!("{name}:{}", volume.target),
206 VolumeSource::Anonymous => volume.target.clone(),
207 }
208}
209
210fn collect_named_volumes(volumes: &[VolumeBinding], out: &mut BTreeMap<String, ComposeVolumeDef>) {
211 for volume in volumes {
212 if let VolumeSource::Named(name) = &volume.source {
213 out.entry(name.clone()).or_default();
214 }
215 }
216}
217
218fn duration_str(d: Duration) -> String {
220 let secs = d.as_secs();
221 let millis = d.subsec_millis();
222 match (secs, millis) {
223 (s, 0) => format!("{s}s"),
224 (0, ms) => format!("{ms}ms"),
225 (s, ms) => format!("{s}s{ms}ms"),
226 }
227}