Skip to main content

orca_core/import/
compose.rs

1//! Docker-compose.yml parser that converts compose services into orca ServiceConfig.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use serde::Deserialize;
7
8use crate::config::{ServiceConfig, ServicesConfig};
9use crate::types::Replicas;
10
11/// Top-level docker-compose.yml structure (subset we care about).
12#[derive(Debug, Deserialize)]
13pub struct ComposeFile {
14    #[serde(default)]
15    pub services: HashMap<String, ComposeService>,
16}
17
18/// A single service in docker-compose.yml.
19#[derive(Debug, Deserialize)]
20pub struct ComposeService {
21    pub image: Option<String>,
22    #[serde(default)]
23    pub ports: Vec<String>,
24    #[serde(default)]
25    pub environment: ComposeEnv,
26    #[serde(default)]
27    pub volumes: Vec<String>,
28    #[serde(default)]
29    pub depends_on: ComposeDependsOn,
30}
31
32/// Environment can be a list of "KEY=VALUE" strings or a map.
33#[derive(Debug, Deserialize, Default)]
34#[serde(untagged)]
35pub enum ComposeEnv {
36    #[default]
37    None,
38    List(Vec<String>),
39    Map(HashMap<String, serde_yaml::Value>),
40}
41
42/// depends_on can be a list of strings or a map with condition keys.
43#[derive(Debug, Deserialize, Default)]
44#[serde(untagged)]
45pub enum ComposeDependsOn {
46    #[default]
47    None,
48    List(Vec<String>),
49    Map(HashMap<String, serde_yaml::Value>),
50}
51
52/// Parse a docker-compose.yml file and return orca ServicesConfig.
53pub fn parse_compose_file(path: &Path) -> anyhow::Result<ServicesConfig> {
54    let content = std::fs::read_to_string(path)?;
55    parse_compose_str(&content, path)
56}
57
58/// Parse docker-compose.yml content string into orca ServicesConfig.
59pub fn parse_compose_str(content: &str, path: &Path) -> anyhow::Result<ServicesConfig> {
60    let compose: ComposeFile = serde_yaml::from_str(content)?;
61    let network_name = derive_network_name(path);
62    let services = compose
63        .services
64        .into_iter()
65        .map(|(name, svc)| convert_service(&name, &svc, &network_name))
66        .collect();
67    Ok(ServicesConfig { service: services })
68}
69
70/// Derive a network name from the compose file path or directory name.
71fn derive_network_name(path: &Path) -> String {
72    path.parent()
73        .and_then(|p| p.file_name())
74        .and_then(|n| n.to_str())
75        .unwrap_or("default")
76        .to_string()
77}
78
79/// Convert a single compose service to an orca ServiceConfig.
80fn convert_service(name: &str, svc: &ComposeService, network: &str) -> ServiceConfig {
81    let (port, mounts_from_ports) = parse_ports(&svc.ports);
82    let env = parse_env(&svc.environment);
83    let aliases = vec![name.to_string()];
84
85    ServiceConfig {
86        name: name.to_string(),
87        project: None,
88        runtime: Default::default(),
89        image: svc.image.clone(),
90        module: None,
91        replicas: Replicas::Fixed(1),
92        port,
93        host_port: mounts_from_ports,
94        domain: None,
95        routes: vec![],
96        health: None,
97        readiness: None,
98        liveness: None,
99        env,
100        resources: None,
101        volume: None,
102        deploy: None,
103        placement: None,
104        network: Some(network.to_string()),
105        aliases,
106        mounts: svc.volumes.clone(),
107        triggers: vec![],
108        assets: None,
109        build: None,
110        tls_cert: None,
111        tls_key: None,
112        internal: false,
113        depends_on: vec![],
114        cmd: vec![],
115        extra_ports: vec![],
116        strip_prefix: None,
117        pull_policy: Default::default(),
118    }
119}
120
121/// Parse compose port mappings. Returns (container_port, host_port).
122fn parse_ports(ports: &[String]) -> (Option<u16>, Option<u16>) {
123    for port_str in ports {
124        // Handle "host:container" or just "container"
125        let stripped = port_str.split('/').next().unwrap_or(port_str);
126        if let Some((host, container)) = stripped.split_once(':') {
127            let container_port = container.parse::<u16>().ok();
128            let host_port = host.parse::<u16>().ok();
129            if container_port.is_some() {
130                return (container_port, host_port);
131            }
132        } else if let Ok(p) = stripped.parse::<u16>() {
133            return (Some(p), None);
134        }
135    }
136    (None, None)
137}
138
139/// Parse compose environment variables into a HashMap.
140fn parse_env(env: &ComposeEnv) -> HashMap<String, String> {
141    match env {
142        ComposeEnv::None => HashMap::new(),
143        ComposeEnv::List(list) => list
144            .iter()
145            .filter_map(|entry| {
146                let (k, v) = entry.split_once('=')?;
147                Some((k.to_string(), v.to_string()))
148            })
149            .collect(),
150        ComposeEnv::Map(map) => map
151            .iter()
152            .map(|(k, v)| {
153                let val = match v {
154                    serde_yaml::Value::String(s) => s.clone(),
155                    serde_yaml::Value::Null => String::new(),
156                    other => format!("{other:?}"),
157                };
158                (k.clone(), val)
159            })
160            .collect(),
161    }
162}
163
164/// Serialize a ServicesConfig to TOML string for writing services.toml.
165pub fn services_to_toml(config: &ServicesConfig) -> anyhow::Result<String> {
166    Ok(toml::to_string_pretty(config)?)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn parse_simple_compose() {
175        let yaml = r#"
176services:
177  web:
178    image: nginx:latest
179    ports:
180      - "8080:80"
181    environment:
182      - FOO=bar
183  db:
184    image: postgres:16
185    ports:
186      - "5432:5432"
187    environment:
188      POSTGRES_PASSWORD: secret
189    volumes:
190      - pgdata:/var/lib/postgresql/data
191"#;
192        let path = Path::new("/tmp/myproject/docker-compose.yml");
193        let config = parse_compose_str(yaml, path).unwrap();
194        assert_eq!(config.service.len(), 2);
195
196        let web = config.service.iter().find(|s| s.name == "web").unwrap();
197        assert_eq!(web.image.as_deref(), Some("nginx:latest"));
198        assert_eq!(web.port, Some(80));
199        assert_eq!(web.host_port, Some(8080));
200        assert_eq!(web.env.get("FOO").map(String::as_str), Some("bar"));
201        assert_eq!(web.network.as_deref(), Some("myproject"));
202        assert!(web.aliases.contains(&"web".to_string()));
203
204        let db = config.service.iter().find(|s| s.name == "db").unwrap();
205        assert_eq!(db.image.as_deref(), Some("postgres:16"));
206        assert_eq!(db.port, Some(5432));
207        assert_eq!(
208            db.env.get("POSTGRES_PASSWORD").map(String::as_str),
209            Some("secret")
210        );
211    }
212
213    #[test]
214    fn parse_compose_no_ports() {
215        let yaml = r#"
216services:
217  worker:
218    image: myapp/worker:latest
219    environment:
220      - QUEUE=default
221"#;
222        let path = Path::new("/projects/app/docker-compose.yml");
223        let config = parse_compose_str(yaml, path).unwrap();
224        assert_eq!(config.service.len(), 1);
225        let worker = &config.service[0];
226        assert_eq!(worker.port, None);
227        assert_eq!(worker.host_port, None);
228    }
229
230    #[test]
231    fn services_to_toml_roundtrip() {
232        let config = ServicesConfig {
233            service: vec![ServiceConfig {
234                name: "test".to_string(),
235                project: None,
236                runtime: Default::default(),
237                image: Some("nginx:latest".to_string()),
238                module: None,
239                replicas: Replicas::Fixed(1),
240                port: Some(80),
241                host_port: None,
242                domain: None,
243                routes: vec![],
244                health: None,
245                readiness: None,
246                liveness: None,
247                env: HashMap::new(),
248                resources: None,
249                volume: None,
250                deploy: None,
251                placement: None,
252                network: Some("default".to_string()),
253                aliases: vec!["test".to_string()],
254                mounts: vec![],
255                triggers: vec![],
256                assets: None,
257                build: None,
258                tls_cert: None,
259                tls_key: None,
260                internal: false,
261                depends_on: vec![],
262                cmd: vec![],
263                extra_ports: vec![],
264                strip_prefix: None,
265                pull_policy: Default::default(),
266            }],
267        };
268        let toml_str = services_to_toml(&config).unwrap();
269        assert!(toml_str.contains("nginx:latest"));
270    }
271}