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