1use bollard::container::{Config, CreateContainerOptions};
4use bollard::models::{HostConfig, PortBinding};
5use fleetflow_atom::{Flow, Service};
6use std::collections::HashMap;
7
8pub fn service_to_container_config(
10 service_name: &str,
11 service: &Service,
12) -> (Config<String>, CreateContainerOptions<String>) {
13 let image = service
14 .image
15 .as_ref()
16 .cloned()
17 .unwrap_or_else(|| format!("{}:latest", service_name));
18
19 let env: Vec<String> = service
21 .environment
22 .iter()
23 .map(|(k, v)| format!("{}={}", k, v))
24 .collect();
25
26 let mut port_bindings = HashMap::new();
28 let mut exposed_ports = HashMap::new();
29
30 for port in &service.ports {
31 let container_port = format!(
32 "{}/{}",
33 port.container,
34 if port.protocol == fleetflow_atom::Protocol::Udp {
35 "udp"
36 } else {
37 "tcp"
38 }
39 );
40
41 exposed_ports.insert(container_port.clone(), HashMap::new());
43
44 let host_ip = port.host_ip.as_deref().unwrap_or("0.0.0.0");
46 port_bindings.insert(
47 container_port,
48 Some(vec![PortBinding {
49 host_ip: Some(host_ip.to_string()),
50 host_port: Some(port.host.to_string()),
51 }]),
52 );
53 }
54
55 let binds: Vec<String> = service
57 .volumes
58 .iter()
59 .map(|v| {
60 let mode = if v.read_only { "ro" } else { "rw" };
61 let host_path = if v.host.is_relative() {
63 std::env::current_dir()
64 .unwrap_or_else(|_| v.host.clone())
65 .join(&v.host)
66 } else {
67 v.host.clone()
68 };
69 format!("{}:{}:{}", host_path.display(), v.container.display(), mode)
70 })
71 .collect();
72
73 let host_config = Some(HostConfig {
75 port_bindings: Some(port_bindings),
76 binds: Some(binds),
77 ..Default::default()
78 });
79
80 let config = Config {
82 image: Some(image),
83 env: Some(env),
84 exposed_ports: Some(exposed_ports),
85 host_config,
86 cmd: service.command.as_ref().map(|c| {
87 c.split_whitespace().map(String::from).collect()
89 }),
90 ..Default::default()
91 };
92
93 let options = CreateContainerOptions {
94 name: format!("flow-{}", service_name),
95 platform: None,
96 };
97
98 (config, options)
99}
100
101pub fn get_stage_services(flow: &Flow, stage_name: &str) -> Result<Vec<String>, String> {
103 flow.stages
104 .get(stage_name)
105 .map(|stage| stage.services.clone())
106 .ok_or_else(|| format!("Stage '{}' not found", stage_name))
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use fleetflow_atom::{Port, Protocol, Service, Stage, Volume};
113 use std::collections::HashMap;
114 use std::path::PathBuf;
115
116 #[test]
117 fn test_service_to_container_config_basic() {
118 let service = Service {
119 image: Some("postgres:16".to_string()),
120 version: Some("16".to_string()),
121 ..Default::default()
122 };
123
124 let (config, options) = service_to_container_config("postgres", &service);
125
126 assert_eq!(config.image, Some("postgres:16".to_string()));
127 assert_eq!(options.name, "flow-postgres");
128 }
129
130 #[test]
131 fn test_service_to_container_config_default_image() {
132 let service = Service::default();
133
134 let (config, _) = service_to_container_config("redis", &service);
135
136 assert_eq!(config.image, Some("redis:latest".to_string()));
138 }
139
140 #[test]
141 fn test_service_to_container_config_with_environment() {
142 let mut environment = HashMap::new();
143 environment.insert(
144 "DATABASE_URL".to_string(),
145 "postgres://localhost".to_string(),
146 );
147 environment.insert("DEBUG".to_string(), "true".to_string());
148
149 let service = Service {
150 environment,
151 ..Default::default()
152 };
153
154 let (config, _) = service_to_container_config("api", &service);
155
156 let env = config.env.unwrap();
157 assert!(env.contains(&"DATABASE_URL=postgres://localhost".to_string()));
158 assert!(env.contains(&"DEBUG=true".to_string()));
159 }
160
161 #[test]
162 fn test_service_to_container_config_with_ports() {
163 let ports = vec![
164 Port {
165 host: 8080,
166 container: 3000,
167 protocol: Protocol::Tcp,
168 host_ip: None,
169 },
170 Port {
171 host: 5432,
172 container: 5432,
173 protocol: Protocol::Tcp,
174 host_ip: Some("127.0.0.1".to_string()),
175 },
176 ];
177
178 let service = Service {
179 ports,
180 ..Default::default()
181 };
182
183 let (config, _) = service_to_container_config("web", &service);
184
185 let exposed_ports = config.exposed_ports.unwrap();
186 assert!(exposed_ports.contains_key("3000/tcp"));
187 assert!(exposed_ports.contains_key("5432/tcp"));
188
189 let host_config = config.host_config.unwrap();
190 let port_bindings = host_config.port_bindings.unwrap();
191
192 let binding_3000 = port_bindings.get("3000/tcp").unwrap().as_ref().unwrap();
193 assert_eq!(binding_3000[0].host_port, Some("8080".to_string()));
194 assert_eq!(binding_3000[0].host_ip, Some("0.0.0.0".to_string()));
195
196 let binding_5432 = port_bindings.get("5432/tcp").unwrap().as_ref().unwrap();
197 assert_eq!(binding_5432[0].host_ip, Some("127.0.0.1".to_string()));
198 }
199
200 #[test]
201 fn test_service_to_container_config_with_udp_port() {
202 let ports = vec![Port {
203 host: 53,
204 container: 53,
205 protocol: Protocol::Udp,
206 host_ip: None,
207 }];
208
209 let service = Service {
210 ports,
211 ..Default::default()
212 };
213
214 let (config, _) = service_to_container_config("dns", &service);
215
216 let exposed_ports = config.exposed_ports.unwrap();
217 assert!(exposed_ports.contains_key("53/udp"));
218 }
219
220 #[test]
221 fn test_service_to_container_config_with_volumes() {
222 let volumes = vec![
223 Volume {
224 host: PathBuf::from("/data"),
225 container: PathBuf::from("/var/lib/data"),
226 read_only: false,
227 },
228 Volume {
229 host: PathBuf::from("/config"),
230 container: PathBuf::from("/etc/config"),
231 read_only: true,
232 },
233 ];
234
235 let service = Service {
236 volumes,
237 ..Default::default()
238 };
239
240 let (config, _) = service_to_container_config("db", &service);
241
242 let host_config = config.host_config.unwrap();
243 let binds = host_config.binds.unwrap();
244
245 assert_eq!(binds.len(), 2);
246 assert!(binds[0].contains("/data:/var/lib/data:rw"));
247 assert!(binds[1].contains("/config:/etc/config:ro"));
248 }
249
250 #[test]
251 fn test_service_to_container_config_with_command() {
252 let service = Service {
253 command: Some("start --user root --pass root".to_string()),
254 ..Default::default()
255 };
256
257 let (config, _) = service_to_container_config("db", &service);
258
259 let cmd = config.cmd.unwrap();
260 assert_eq!(cmd, vec!["start", "--user", "root", "--pass", "root"]);
261 }
262
263 #[test]
264 fn test_get_stage_services() {
265 let mut services = HashMap::new();
266 services.insert("api".to_string(), Service::default());
267 services.insert("db".to_string(), Service::default());
268
269 let mut stages = HashMap::new();
270 stages.insert(
271 "local".to_string(),
272 Stage {
273 services: vec!["api".to_string(), "db".to_string()],
274 variables: HashMap::new(),
275 },
276 );
277
278 let flow = Flow {
279 name: "test".to_string(),
280 services,
281 stages,
282 };
283
284 let result = get_stage_services(&flow, "local").unwrap();
285 assert_eq!(result.len(), 2);
286 assert!(result.contains(&"api".to_string()));
287 assert!(result.contains(&"db".to_string()));
288 }
289
290 #[test]
291 fn test_get_stage_services_not_found() {
292 let flow = Flow {
293 name: "test".to_string(),
294 services: HashMap::new(),
295 stages: HashMap::new(),
296 };
297
298 let result = get_stage_services(&flow, "prod");
299 assert!(result.is_err());
300 assert_eq!(result.unwrap_err(), "Stage 'prod' not found");
301 }
302
303 #[test]
304 fn test_container_name_format() {
305 let service = Service::default();
306 let (_, options) = service_to_container_config("my-service", &service);
307
308 assert_eq!(options.name, "flow-my-service");
309 }
310}