mockforge_core/
docker_compose.rs

1/// Docker Compose generation for MockForge
2///
3/// Automatically generates docker-compose.yml files for local integration testing
4/// with networked mock services
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Docker Compose configuration structure
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DockerComposeConfig {
11    /// Docker Compose file format version
12    pub version: String,
13    /// Service definitions keyed by service name
14    pub services: HashMap<String, ServiceConfig>,
15    /// Network definitions for service communication
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub networks: Option<HashMap<String, NetworkConfig>>,
18    /// Volume definitions for persistent storage
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub volumes: Option<HashMap<String, VolumeConfig>>,
21}
22
23/// Docker service configuration
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ServiceConfig {
26    /// Docker image to use for this service
27    pub image: String,
28    /// Build configuration if building from source
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub build: Option<BuildConfig>,
31    /// Port mappings in format "host:container"
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub ports: Option<Vec<String>>,
34    /// Environment variables for the service
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub environment: Option<HashMap<String, String>>,
37    /// Volume mounts for persistent data
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub volumes: Option<Vec<String>>,
40    /// Networks this service is attached to
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub networks: Option<Vec<String>>,
43    /// Service dependencies (start order)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub depends_on: Option<Vec<String>>,
46    /// Health check configuration
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub healthcheck: Option<HealthCheckConfig>,
49    /// Override default command
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub command: Option<String>,
52}
53
54/// Build configuration for building Docker images
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct BuildConfig {
57    /// Build context directory
58    pub context: String,
59    /// Path to Dockerfile (relative to context)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub dockerfile: Option<String>,
62    /// Build arguments (ARG values)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub args: Option<HashMap<String, String>>,
65}
66
67/// Docker network configuration
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct NetworkConfig {
70    /// Network driver (bridge, host, overlay, etc.)
71    pub driver: String,
72    /// Driver-specific options
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub driver_opts: Option<HashMap<String, String>>,
75}
76
77/// Docker volume configuration
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct VolumeConfig {
80    /// Volume driver name (optional, defaults to local)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub driver: Option<String>,
83    /// Driver-specific options
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub driver_opts: Option<HashMap<String, String>>,
86}
87
88/// Health check configuration for service monitoring
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct HealthCheckConfig {
91    /// Health check test command (CMD format)
92    pub test: Vec<String>,
93    /// Interval between health checks (e.g., "30s")
94    pub interval: String,
95    /// Timeout for health check (e.g., "10s")
96    pub timeout: String,
97    /// Number of consecutive failures before marking unhealthy
98    pub retries: u32,
99}
100
101/// Mock service specification for docker-compose generation
102#[derive(Debug, Clone)]
103pub struct MockServiceSpec {
104    /// Service name identifier
105    pub name: String,
106    /// Port number to expose
107    pub port: u16,
108    /// Path to OpenAPI specification file (relative)
109    pub spec_path: Option<String>,
110    /// Path to MockForge configuration file (relative)
111    pub config_path: Option<String>,
112}
113
114/// Docker Compose file generator for mock services
115pub struct DockerComposeGenerator {
116    /// Name of the Docker network to create
117    network_name: String,
118    /// Base Docker image to use for mock services
119    base_image: String,
120}
121
122impl DockerComposeGenerator {
123    /// Create a new Docker Compose generator with a network name
124    ///
125    /// # Arguments
126    /// * `network_name` - Name of the Docker network to create
127    pub fn new(network_name: String) -> Self {
128        Self {
129            network_name,
130            base_image: "mockforge:latest".to_string(),
131        }
132    }
133
134    /// Set a custom base Docker image for generated services
135    ///
136    /// # Arguments
137    /// * `image` - Docker image name (e.g., "mockforge:1.0.0")
138    pub fn with_image(mut self, image: String) -> Self {
139        self.base_image = image;
140        self
141    }
142
143    /// Generate docker-compose configuration for multiple mock services
144    pub fn generate(&self, services: Vec<MockServiceSpec>) -> DockerComposeConfig {
145        let mut compose_services = HashMap::new();
146        let mut networks = HashMap::new();
147
148        // Add network configuration
149        networks.insert(
150            self.network_name.clone(),
151            NetworkConfig {
152                driver: "bridge".to_string(),
153                driver_opts: None,
154            },
155        );
156
157        // Generate service configurations
158        for service_spec in services.iter() {
159            let service_name = format!("mockforge-{}", service_spec.name);
160
161            let mut environment = HashMap::new();
162            environment.insert("RUST_LOG".to_string(), "info".to_string());
163            environment.insert("MOCKFORGE_PORT".to_string(), service_spec.port.to_string());
164
165            if let Some(spec_path) = &service_spec.spec_path {
166                environment
167                    .insert("MOCKFORGE_OPENAPI_SPEC".to_string(), format!("/specs/{}", spec_path));
168            }
169
170            let volumes = vec![
171                // Mount specs directory if spec path is provided
172                "./specs:/specs:ro".to_string(),
173                // Mount config directory if config path is provided
174                "./configs:/configs:ro".to_string(),
175            ];
176
177            if let Some(config_path) = &service_spec.config_path {
178                environment
179                    .insert("MOCKFORGE_CONFIG".to_string(), format!("/configs/{}", config_path));
180            }
181
182            let service_config = ServiceConfig {
183                image: self.base_image.clone(),
184                build: None,
185                ports: Some(vec![format!("{}:{}", service_spec.port, service_spec.port)]),
186                environment: Some(environment),
187                volumes: Some(volumes),
188                networks: Some(vec![self.network_name.clone()]),
189                depends_on: None,
190                healthcheck: Some(HealthCheckConfig {
191                    test: vec![
192                        "CMD".to_string(),
193                        "curl".to_string(),
194                        "-f".to_string(),
195                        format!("http://localhost:{}/health", service_spec.port),
196                    ],
197                    interval: "10s".to_string(),
198                    timeout: "5s".to_string(),
199                    retries: 3,
200                }),
201                command: Some(format!("mockforge http --port {}", service_spec.port)),
202            };
203
204            compose_services.insert(service_name, service_config);
205        }
206
207        DockerComposeConfig {
208            version: "3.8".to_string(),
209            services: compose_services,
210            networks: Some(networks),
211            volumes: None,
212        }
213    }
214
215    /// Generate docker-compose with dependencies between services
216    pub fn generate_with_dependencies(
217        &self,
218        services: Vec<MockServiceSpec>,
219        dependencies: HashMap<String, Vec<String>>,
220    ) -> DockerComposeConfig {
221        let mut config = self.generate(services);
222
223        // Add dependencies
224        for (service, deps) in dependencies {
225            let service_key = format!("mockforge-{}", service);
226            if let Some(service_config) = config.services.get_mut(&service_key) {
227                let formatted_deps: Vec<String> =
228                    deps.iter().map(|d| format!("mockforge-{}", d)).collect();
229                service_config.depends_on = Some(formatted_deps);
230            }
231        }
232
233        config
234    }
235
236    /// Export configuration to YAML string
237    pub fn to_yaml(&self, config: &DockerComposeConfig) -> Result<String, serde_yaml::Error> {
238        serde_yaml::to_string(config)
239    }
240
241    /// Generate a complete microservices testing setup
242    pub fn generate_microservices_setup(
243        &self,
244        api_services: Vec<(String, u16)>,
245    ) -> DockerComposeConfig {
246        let mut services = HashMap::new();
247        let mut networks = HashMap::new();
248
249        // Add network
250        networks.insert(
251            self.network_name.clone(),
252            NetworkConfig {
253                driver: "bridge".to_string(),
254                driver_opts: None,
255            },
256        );
257
258        // Generate mock services
259        for (name, port) in api_services {
260            let service_name = format!("mock-{}", name);
261
262            let mut environment = HashMap::new();
263            environment.insert("RUST_LOG".to_string(), "info".to_string());
264            environment.insert("MOCKFORGE_PORT".to_string(), port.to_string());
265            environment
266                .insert("MOCKFORGE_OPENAPI_SPEC".to_string(), format!("/specs/{}.yaml", name));
267
268            services.insert(
269                service_name.clone(),
270                ServiceConfig {
271                    image: self.base_image.clone(),
272                    build: None,
273                    ports: Some(vec![format!("{}:{}", port, port)]),
274                    environment: Some(environment),
275                    volumes: Some(vec![
276                        "./specs:/specs:ro".to_string(),
277                        "./configs:/configs:ro".to_string(),
278                        "./logs:/logs".to_string(),
279                    ]),
280                    networks: Some(vec![self.network_name.clone()]),
281                    depends_on: None,
282                    healthcheck: Some(HealthCheckConfig {
283                        test: vec![
284                            "CMD".to_string(),
285                            "curl".to_string(),
286                            "-f".to_string(),
287                            format!("http://localhost:{}/health", port),
288                        ],
289                        interval: "10s".to_string(),
290                        timeout: "5s".to_string(),
291                        retries: 3,
292                    }),
293                    command: Some(format!(
294                        "mockforge http --port {} --spec /specs/{}.yaml",
295                        port, name
296                    )),
297                },
298            );
299        }
300
301        DockerComposeConfig {
302            version: "3.8".to_string(),
303            services,
304            networks: Some(networks),
305            volumes: None,
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_docker_compose_generator_basic() {
316        let generator = DockerComposeGenerator::new("mockforge-network".to_string());
317
318        let services = vec![MockServiceSpec {
319            name: "api".to_string(),
320            port: 3000,
321            spec_path: Some("api.yaml".to_string()),
322            config_path: None,
323        }];
324
325        let config = generator.generate(services);
326
327        assert_eq!(config.version, "3.8");
328        assert_eq!(config.services.len(), 1);
329        assert!(config.services.contains_key("mockforge-api"));
330        assert!(config.networks.is_some());
331    }
332
333    #[test]
334    fn test_docker_compose_with_dependencies() {
335        let generator = DockerComposeGenerator::new("test-network".to_string());
336
337        let services = vec![
338            MockServiceSpec {
339                name: "auth".to_string(),
340                port: 3001,
341                spec_path: Some("auth.yaml".to_string()),
342                config_path: None,
343            },
344            MockServiceSpec {
345                name: "api".to_string(),
346                port: 3000,
347                spec_path: Some("api.yaml".to_string()),
348                config_path: None,
349            },
350        ];
351
352        let mut dependencies = HashMap::new();
353        dependencies.insert("api".to_string(), vec!["auth".to_string()]);
354
355        let config = generator.generate_with_dependencies(services, dependencies);
356
357        assert_eq!(config.services.len(), 2);
358
359        // Check that api depends on auth
360        let api_service = config.services.get("mockforge-api").unwrap();
361        assert!(api_service.depends_on.is_some());
362        assert_eq!(api_service.depends_on.as_ref().unwrap()[0], "mockforge-auth");
363    }
364
365    #[test]
366    fn test_microservices_setup_generation() {
367        let generator = DockerComposeGenerator::new("microservices".to_string());
368
369        let api_services = vec![
370            ("users".to_string(), 3001),
371            ("orders".to_string(), 3002),
372            ("payments".to_string(), 3003),
373        ];
374
375        let config = generator.generate_microservices_setup(api_services);
376
377        assert_eq!(config.services.len(), 3);
378        assert!(config.services.contains_key("mock-users"));
379        assert!(config.services.contains_key("mock-orders"));
380        assert!(config.services.contains_key("mock-payments"));
381    }
382
383    #[test]
384    fn test_yaml_export() {
385        let generator = DockerComposeGenerator::new("test-network".to_string());
386
387        let services = vec![MockServiceSpec {
388            name: "test".to_string(),
389            port: 3000,
390            spec_path: None,
391            config_path: None,
392        }];
393
394        let config = generator.generate(services);
395        let yaml = generator.to_yaml(&config);
396
397        assert!(yaml.is_ok());
398        let yaml_str = yaml.unwrap();
399        assert!(yaml_str.contains("version: '3.8'"));
400        assert!(yaml_str.contains("mockforge-test"));
401    }
402}