Skip to main content

lightshuttle_export/emitters/
compose.rs

1//! `docker-compose` emitter: renders an [`ExportModel`] into a single
2//! `docker-compose.yml`.
3
4use 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
16/// Loopback address used when a port declares no explicit host bind, so
17/// the exported stack keeps the same not-exposed-by-default posture as
18/// `lightshuttle up`.
19const DEFAULT_HOST_BIND_ADDRESS: &str = "127.0.0.1";
20
21/// Emits a `docker-compose.yml` from the export model.
22pub 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/// Typed `docker-compose` document.
43#[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/// Named-volume definition. Rendered as `name: {}` while no options are
96/// set; the optional `driver` keeps it open for future overrides.
97#[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
218/// Render a duration as a Go-style compose duration string.
219fn 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}