lmrc_docker/containers/
builder.rs

1//! Container builder for ergonomic container creation.
2
3use crate::DockerClient;
4use crate::containers::ContainerRef;
5use crate::error::{DockerError, Result};
6use bollard::container::*;
7use bollard::models::{
8    ContainerCreateBody, EndpointSettings, HostConfig, NetworkingConfig, PortBinding,
9};
10use std::collections::HashMap;
11use tracing::info;
12
13/// Builder for creating containers with a fluent API.
14pub struct ContainerBuilder<'a> {
15    client: &'a DockerClient,
16    image: String,
17    name: Option<String>,
18    config: ContainerCreateBody,
19    host_config: HostConfig,
20}
21
22impl<'a> ContainerBuilder<'a> {
23    pub(crate) fn new(client: &'a DockerClient, image: impl Into<String>) -> Self {
24        let image = image.into();
25        Self {
26            client,
27            config: ContainerCreateBody {
28                image: Some(image.clone()),
29                ..Default::default()
30            },
31            host_config: HostConfig::default(),
32            name: None,
33            image,
34        }
35    }
36
37    /// Set the container name.
38    pub fn name(mut self, name: impl Into<String>) -> Self {
39        self.name = Some(name.into());
40        self
41    }
42
43    /// Add an environment variable.
44    ///
45    /// # Example
46    ///
47    /// ```no_run
48    /// # use lmrc_docker::DockerClient;
49    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
50    /// client.containers()
51    ///     .create("nginx:latest")
52    ///     .env("ENV", "production")
53    ///     .env("DEBUG", "false")
54    ///     .build()
55    ///     .await?;
56    /// # Ok(())
57    /// # }
58    /// ```
59    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
60        let env_var = format!("{}={}", key.into(), value.into());
61        if let Some(ref mut env) = self.config.env {
62            env.push(env_var);
63        } else {
64            self.config.env = Some(vec![env_var]);
65        }
66        self
67    }
68
69    /// Map a port from host to container.
70    ///
71    /// # Arguments
72    ///
73    /// * `host_port` - Port on the host
74    /// * `container_port` - Port in the container
75    /// * `protocol` - Protocol ("tcp" or "udp")
76    ///
77    /// # Example
78    ///
79    /// ```no_run
80    /// # use lmrc_docker::DockerClient;
81    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
82    /// client.containers()
83    ///     .create("nginx:latest")
84    ///     .port(8080, 80, "tcp")
85    ///     .port(8443, 443, "tcp")
86    ///     .build()
87    ///     .await?;
88    /// # Ok(())
89    /// # }
90    /// ```
91    pub fn port(mut self, host_port: u16, container_port: u16, protocol: &str) -> Self {
92        // Add exposed port
93        let port_key = format!("{}/{}", container_port, protocol);
94        if let Some(ref mut exposed_ports) = self.config.exposed_ports {
95            exposed_ports.insert(port_key.clone(), HashMap::new());
96        } else {
97            let mut exposed = HashMap::new();
98            exposed.insert(port_key.clone(), HashMap::new());
99            self.config.exposed_ports = Some(exposed);
100        }
101
102        // Add port binding
103        let binding = vec![PortBinding {
104            host_ip: Some("0.0.0.0".to_string()),
105            host_port: Some(host_port.to_string()),
106        }];
107
108        if let Some(ref mut port_bindings) = self.host_config.port_bindings {
109            port_bindings.insert(port_key, Some(binding));
110        } else {
111            let mut bindings = HashMap::new();
112            bindings.insert(port_key, Some(binding));
113            self.host_config.port_bindings = Some(bindings);
114        }
115
116        self
117    }
118
119    /// Mount a volume.
120    ///
121    /// # Arguments
122    ///
123    /// * `host_path` - Path on the host
124    /// * `container_path` - Path in the container
125    ///
126    /// # Example
127    ///
128    /// ```no_run
129    /// # use lmrc_docker::DockerClient;
130    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
131    /// client.containers()
132    ///     .create("nginx:latest")
133    ///     .volume("/host/data", "/app/data")
134    ///     .build()
135    ///     .await?;
136    /// # Ok(())
137    /// # }
138    /// ```
139    pub fn volume(
140        mut self,
141        host_path: impl Into<String>,
142        container_path: impl Into<String>,
143    ) -> Self {
144        let binding = format!("{}:{}", host_path.into(), container_path.into());
145        if let Some(ref mut binds) = self.host_config.binds {
146            binds.push(binding);
147        } else {
148            self.host_config.binds = Some(vec![binding]);
149        }
150        self
151    }
152
153    /// Connect to a network.
154    pub fn network(mut self, network: impl Into<String>) -> Self {
155        let network = network.into();
156        let endpoint_config = EndpointSettings::default();
157
158        let mut endpoints = HashMap::new();
159        endpoints.insert(network, endpoint_config);
160
161        if let Some(ref mut networking_config) = self.config.networking_config {
162            if let Some(ref mut endpoints_config) = networking_config.endpoints_config {
163                endpoints_config.extend(endpoints);
164            } else {
165                networking_config.endpoints_config = Some(endpoints);
166            }
167        } else {
168            self.config.networking_config = Some(NetworkingConfig {
169                endpoints_config: Some(endpoints),
170            });
171        }
172
173        self
174    }
175
176    /// Set the command to run in the container.
177    ///
178    /// # Example
179    ///
180    /// ```no_run
181    /// # use lmrc_docker::DockerClient;
182    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
183    /// client.containers()
184    ///     .create("alpine:latest")
185    ///     .cmd(vec!["echo", "hello"])
186    ///     .build()
187    ///     .await?;
188    /// # Ok(())
189    /// # }
190    /// ```
191    pub fn cmd(mut self, cmd: Vec<impl Into<String>>) -> Self {
192        self.config.cmd = Some(cmd.into_iter().map(|s| s.into()).collect());
193        self
194    }
195
196    /// Set the entrypoint.
197    pub fn entrypoint(mut self, entrypoint: Vec<impl Into<String>>) -> Self {
198        self.config.entrypoint = Some(entrypoint.into_iter().map(|s| s.into()).collect());
199        self
200    }
201
202    /// Set the working directory.
203    pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
204        self.config.working_dir = Some(dir.into());
205        self
206    }
207
208    /// Set the restart policy to "always".
209    pub fn restart_always(mut self) -> Self {
210        self.host_config.restart_policy = Some(bollard::models::RestartPolicy {
211            name: Some(bollard::models::RestartPolicyNameEnum::ALWAYS),
212            maximum_retry_count: None,
213        });
214        self
215    }
216
217    /// Set the restart policy to "unless-stopped".
218    pub fn restart_unless_stopped(mut self) -> Self {
219        self.host_config.restart_policy = Some(bollard::models::RestartPolicy {
220            name: Some(bollard::models::RestartPolicyNameEnum::UNLESS_STOPPED),
221            maximum_retry_count: None,
222        });
223        self
224    }
225
226    /// Set the restart policy to "on-failure" with optional retry count.
227    pub fn restart_on_failure(mut self, max_retries: Option<i64>) -> Self {
228        self.host_config.restart_policy = Some(bollard::models::RestartPolicy {
229            name: Some(bollard::models::RestartPolicyNameEnum::ON_FAILURE),
230            maximum_retry_count: max_retries,
231        });
232        self
233    }
234
235    /// Set memory limit in bytes.
236    pub fn memory(mut self, bytes: i64) -> Self {
237        self.host_config.memory = Some(bytes);
238        self
239    }
240
241    /// Set CPU shares (relative weight).
242    pub fn cpu_shares(mut self, shares: i64) -> Self {
243        self.host_config.cpu_shares = Some(shares);
244        self
245    }
246
247    /// Add a label.
248    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
249        if self.config.labels.is_none() {
250            self.config.labels = Some(HashMap::new());
251        }
252        if let Some(ref mut labels) = self.config.labels {
253            labels.insert(key.into(), value.into());
254        }
255        self
256    }
257
258    /// Enable auto-remove (container is deleted when it stops).
259    pub fn auto_remove(mut self, enable: bool) -> Self {
260        self.host_config.auto_remove = Some(enable);
261        self
262    }
263
264    /// Enable privileged mode.
265    pub fn privileged(mut self, enable: bool) -> Self {
266        self.host_config.privileged = Some(enable);
267        self
268    }
269
270    /// Build and create the container.
271    ///
272    /// Returns a [`ContainerRef`] that can be used to interact with the container.
273    pub async fn build(self) -> Result<ContainerRef<'a>> {
274        info!("Creating container from image: {}", self.image);
275
276        let options = CreateContainerOptions {
277            name: self.name.as_deref().unwrap_or(""),
278            platform: None,
279        };
280
281        let config = ContainerCreateBody {
282            host_config: Some(self.host_config),
283            ..self.config
284        };
285
286        let response = self
287            .client
288            .docker
289            .create_container(Some(options), config)
290            .await
291            .map_err(|e| {
292                DockerError::ContainerOperationFailed(format!("Failed to create: {}", e))
293            })?;
294
295        info!("Container created with ID: {}", response.id);
296
297        Ok(ContainerRef::new(self.client, response.id))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_builder_env() {
307        let client = DockerClient::new().unwrap();
308        let builder = ContainerBuilder::new(&client, "alpine:latest")
309            .env("KEY1", "value1")
310            .env("KEY2", "value2");
311
312        assert_eq!(builder.config.env.as_ref().unwrap().len(), 2);
313        assert!(
314            builder
315                .config
316                .env
317                .as_ref()
318                .unwrap()
319                .contains(&"KEY1=value1".to_string())
320        );
321        assert!(
322            builder
323                .config
324                .env
325                .as_ref()
326                .unwrap()
327                .contains(&"KEY2=value2".to_string())
328        );
329    }
330
331    #[test]
332    fn test_builder_name() {
333        let client = DockerClient::new().unwrap();
334        let builder = ContainerBuilder::new(&client, "alpine:latest").name("test-container");
335
336        assert_eq!(builder.name, Some("test-container".to_string()));
337    }
338
339    #[test]
340    fn test_builder_port() {
341        let client = DockerClient::new().unwrap();
342        let builder = ContainerBuilder::new(&client, "nginx:latest").port(8080, 80, "tcp");
343
344        assert!(builder.config.exposed_ports.is_some());
345        assert!(builder.host_config.port_bindings.is_some());
346
347        let bindings = builder.host_config.port_bindings.as_ref().unwrap();
348        assert!(bindings.contains_key("80/tcp"));
349    }
350
351    #[test]
352    fn test_builder_volume() {
353        let client = DockerClient::new().unwrap();
354        let builder =
355            ContainerBuilder::new(&client, "alpine:latest").volume("/host/path", "/container/path");
356
357        assert!(builder.host_config.binds.is_some());
358        let binds = builder.host_config.binds.as_ref().unwrap();
359        assert_eq!(binds.len(), 1);
360        assert_eq!(binds[0], "/host/path:/container/path");
361    }
362
363    #[test]
364    fn test_builder_cmd() {
365        let client = DockerClient::new().unwrap();
366        let builder = ContainerBuilder::new(&client, "alpine:latest").cmd(vec!["echo", "hello"]);
367
368        assert!(builder.config.cmd.is_some());
369        let cmd = builder.config.cmd.as_ref().unwrap();
370        assert_eq!(cmd.len(), 2);
371        assert_eq!(cmd[0], "echo");
372        assert_eq!(cmd[1], "hello");
373    }
374
375    #[test]
376    fn test_builder_working_dir() {
377        let client = DockerClient::new().unwrap();
378        let builder = ContainerBuilder::new(&client, "alpine:latest").working_dir("/app");
379
380        assert_eq!(builder.config.working_dir, Some("/app".to_string()));
381    }
382
383    #[test]
384    fn test_builder_restart_policies() {
385        let client = DockerClient::new().unwrap();
386
387        let builder_always = ContainerBuilder::new(&client, "alpine:latest").restart_always();
388        assert!(builder_always.host_config.restart_policy.is_some());
389
390        let builder_unless =
391            ContainerBuilder::new(&client, "alpine:latest").restart_unless_stopped();
392        assert!(builder_unless.host_config.restart_policy.is_some());
393
394        let builder_failure =
395            ContainerBuilder::new(&client, "alpine:latest").restart_on_failure(Some(5));
396        assert!(builder_failure.host_config.restart_policy.is_some());
397        assert_eq!(
398            builder_failure
399                .host_config
400                .restart_policy
401                .as_ref()
402                .unwrap()
403                .maximum_retry_count,
404            Some(5)
405        );
406    }
407
408    #[test]
409    fn test_builder_memory() {
410        let client = DockerClient::new().unwrap();
411        let builder = ContainerBuilder::new(&client, "alpine:latest").memory(512 * 1024 * 1024);
412
413        assert_eq!(builder.host_config.memory, Some(512 * 1024 * 1024));
414    }
415
416    #[test]
417    fn test_builder_cpu_shares() {
418        let client = DockerClient::new().unwrap();
419        let builder = ContainerBuilder::new(&client, "alpine:latest").cpu_shares(512);
420
421        assert_eq!(builder.host_config.cpu_shares, Some(512));
422    }
423
424    #[test]
425    fn test_builder_labels() {
426        let client = DockerClient::new().unwrap();
427        let builder = ContainerBuilder::new(&client, "alpine:latest")
428            .label("env", "test")
429            .label("version", "1.0");
430
431        assert!(builder.config.labels.is_some());
432        let labels = builder.config.labels.as_ref().unwrap();
433        assert_eq!(labels.get("env"), Some(&"test".to_string()));
434        assert_eq!(labels.get("version"), Some(&"1.0".to_string()));
435    }
436
437    #[test]
438    fn test_builder_auto_remove() {
439        let client = DockerClient::new().unwrap();
440        let builder = ContainerBuilder::new(&client, "alpine:latest").auto_remove(true);
441
442        assert_eq!(builder.host_config.auto_remove, Some(true));
443    }
444
445    #[test]
446    fn test_builder_privileged() {
447        let client = DockerClient::new().unwrap();
448        let builder = ContainerBuilder::new(&client, "alpine:latest").privileged(true);
449
450        assert_eq!(builder.host_config.privileged, Some(true));
451    }
452
453    #[test]
454    fn test_builder_chaining() {
455        let client = DockerClient::new().unwrap();
456        let builder = ContainerBuilder::new(&client, "nginx:latest")
457            .name("web-server")
458            .env("ENV", "production")
459            .port(8080, 80, "tcp")
460            .volume("/data", "/app/data")
461            .memory(512 * 1024 * 1024)
462            .restart_always()
463            .label("app", "web");
464
465        assert_eq!(builder.name, Some("web-server".to_string()));
466        assert!(builder.config.env.is_some());
467        assert!(builder.config.exposed_ports.is_some());
468        assert!(builder.host_config.binds.is_some());
469        assert!(builder.host_config.memory.is_some());
470        assert!(builder.host_config.restart_policy.is_some());
471        assert!(builder.config.labels.is_some());
472    }
473}