fleetflow_container/
converter.rs

1//! FlowConfig から Docker API パラメータへの変換
2
3use bollard::container::{Config, CreateContainerOptions};
4use bollard::models::{HostConfig, PortBinding};
5use fleetflow_atom::{Flow, Service};
6use std::collections::HashMap;
7
8/// FlowConfigのServiceをDockerのコンテナ設定に変換
9pub 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    // 環境変数の設定
20    let env: Vec<String> = service
21        .environment
22        .iter()
23        .map(|(k, v)| format!("{}={}", k, v))
24        .collect();
25
26    // ポートバインディングの設定
27    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        // ポート公開設定
42        exposed_ports.insert(container_port.clone(), HashMap::new());
43
44        // ホストポートバインディング
45        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    // ボリュームバインディング
56    let binds: Vec<String> = service
57        .volumes
58        .iter()
59        .map(|v| {
60            let mode = if v.read_only { "ro" } else { "rw" };
61            // 相対パスの場合は絶対パスに変換
62            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    // HostConfig設定
74    let host_config = Some(HostConfig {
75        port_bindings: Some(port_bindings),
76        binds: Some(binds),
77        ..Default::default()
78    });
79
80    // コンテナ設定
81    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            // コマンドをスペースで分割
88            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
101/// ステージに含まれるサービスのリストを取得
102pub 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        // imageが未指定の場合は"サービス名:latest"になる
137        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}