Skip to main content

lightshuttle_export/emitters/
compose.rs

1//! Docker Compose emitter: renders an [`ExportModel`] into a single
2//! `docker-compose.yml`.
3//!
4//! The emitted file uses the Compose v3 schema. Port bindings default to the
5//! loopback address so the stack keeps the same not-exposed-by-default posture
6//! as `lightshuttle up`. Named volumes are collected into the top-level
7//! `volumes:` block so Compose can manage their lifecycle.
8
9use 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
21/// Loopback address used when a port declares no explicit host bind, so
22/// the exported stack keeps the same not-exposed-by-default posture as
23/// `lightshuttle up`.
24const DEFAULT_HOST_BIND_ADDRESS: &str = "127.0.0.1";
25
26/// Emits a single `docker-compose.yml` from the export model.
27///
28/// Each enabled service in the [`crate::ExportModel`] becomes one entry in the
29/// Compose `services:` block. Ports default to `127.0.0.1` as the host bind
30/// address. Named volumes are collected into the top-level `volumes:` block.
31/// Dependencies with a healthcheck use the `service_healthy` condition;
32/// dependencies without one use `service_started`.
33///
34/// # Example
35///
36/// ```rust,no_run
37/// use lightshuttle_export::{lower, ComposeEmitter, Emitter};
38/// use lightshuttle_manifest::Manifest;
39///
40/// # fn main() -> lightshuttle_export::Result<()> {
41/// let manifest: Manifest = todo!("parse from YAML");
42/// let model = lower(&manifest)?;
43/// let artifacts = ComposeEmitter.emit(&model)?;
44/// // artifacts.files[0].path == "docker-compose.yml"
45/// # Ok(())
46/// # }
47/// ```
48pub 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/// Typed `docker-compose` document.
69#[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/// Named-volume definition. Rendered as `name: {}` while no options are
124/// set; the optional `driver` keeps it open for future overrides.
125#[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
247/// Render a duration as a Go-style compose duration string.
248fn 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}