lmrc_docker/images/
builder.rs

1//! Image builder for ergonomic image building.
2
3use crate::DockerClient;
4use crate::error::{DockerError, Result};
5use bollard::image::BuildImageOptions;
6use bytes::Bytes;
7use futures_util::StreamExt;
8use http_body_util::{Either, Full};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tar::Builder as TarBuilder;
12use tracing::{debug, info, warn};
13
14/// Builder for building Docker images with a fluent API.
15pub struct ImageBuilder<'a> {
16    client: &'a DockerClient,
17    tag: String,
18    dockerfile: String,
19    context: PathBuf,
20    build_args: HashMap<String, String>,
21    labels: HashMap<String, String>,
22    target: Option<String>,
23    cache_from: Vec<String>,
24    rm: bool,
25    pull: bool,
26}
27
28impl<'a> ImageBuilder<'a> {
29    pub(crate) fn new(client: &'a DockerClient, tag: impl Into<String>) -> Self {
30        Self {
31            client,
32            tag: tag.into(),
33            dockerfile: "Dockerfile".to_string(),
34            context: PathBuf::from("."),
35            build_args: HashMap::new(),
36            labels: HashMap::new(),
37            target: None,
38            cache_from: Vec::new(),
39            rm: true,
40            pull: false,
41        }
42    }
43
44    /// Set the path to the Dockerfile (relative to context).
45    ///
46    /// # Example
47    ///
48    /// ```no_run
49    /// # use lmrc_docker::DockerClient;
50    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
51    /// client.images()
52    ///     .build("my-app:latest")
53    ///     .dockerfile("docker/app.Dockerfile")
54    ///     .context(std::path::Path::new("."))
55    ///     .execute()
56    ///     .await?;
57    /// # Ok(())
58    /// # }
59    /// ```
60    pub fn dockerfile(mut self, path: impl Into<String>) -> Self {
61        self.dockerfile = path.into();
62        self
63    }
64
65    /// Set the build context directory.
66    pub fn context(mut self, path: impl AsRef<Path>) -> Self {
67        self.context = path.as_ref().to_path_buf();
68        self
69    }
70
71    /// Add a build argument.
72    ///
73    /// # Example
74    ///
75    /// ```no_run
76    /// # use lmrc_docker::DockerClient;
77    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
78    /// client.images()
79    ///     .build("my-app:latest")
80    ///     .build_arg("RUNTIME_IMAGE", "alpine:latest")
81    ///     .build_arg("VERSION", "1.0.0")
82    ///     .execute()
83    ///     .await?;
84    /// # Ok(())
85    /// # }
86    /// ```
87    pub fn build_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
88        self.build_args.insert(key.into(), value.into());
89        self
90    }
91
92    /// Add a label to the image.
93    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
94        self.labels.insert(key.into(), value.into());
95        self
96    }
97
98    /// Set the target build stage (for multi-stage builds).
99    ///
100    /// # Example
101    ///
102    /// ```no_run
103    /// # use lmrc_docker::DockerClient;
104    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
105    /// client.images()
106    ///     .build("my-app:latest")
107    ///     .target("production")
108    ///     .execute()
109    ///     .await?;
110    /// # Ok(())
111    /// # }
112    /// ```
113    pub fn target(mut self, target: impl Into<String>) -> Self {
114        self.target = Some(target.into());
115        self
116    }
117
118    /// Add an image to use for build cache.
119    pub fn cache_from(mut self, image: impl Into<String>) -> Self {
120        self.cache_from.push(image.into());
121        self
122    }
123
124    /// Remove intermediate containers after build (default: true).
125    pub fn remove_intermediate(mut self, rm: bool) -> Self {
126        self.rm = rm;
127        self
128    }
129
130    /// Always pull the latest base image (default: false).
131    pub fn pull(mut self, pull: bool) -> Self {
132        self.pull = pull;
133        self
134    }
135
136    /// Execute the build and return the image tag.
137    ///
138    /// This will stream the build output via tracing.
139    pub async fn execute(self) -> Result<String> {
140        info!("Building image: {}", self.tag);
141
142        // Verify context exists
143        if !self.context.exists() {
144            return Err(DockerError::InvalidConfiguration(format!(
145                "Build context does not exist: {}",
146                self.context.display()
147            )));
148        }
149
150        // Verify Dockerfile exists
151        let dockerfile_path = self.context.join(&self.dockerfile);
152        if !dockerfile_path.exists() {
153            return Err(DockerError::InvalidConfiguration(format!(
154                "Dockerfile not found: {}",
155                dockerfile_path.display()
156            )));
157        }
158
159        debug!("Dockerfile: {}", dockerfile_path.display());
160        debug!("Context: {}", self.context.display());
161
162        // Create tar archive of build context
163        let tar_data = self.create_build_context()?;
164
165        // Prepare build options
166        let mut options = BuildImageOptions {
167            dockerfile: self.dockerfile.clone(),
168            t: self.tag.clone(),
169            rm: self.rm,
170            pull: self.pull,
171            ..Default::default()
172        };
173
174        if let Some(target) = self.target {
175            options.target = target;
176        }
177
178        if !self.cache_from.is_empty() {
179            options.cachefrom = vec![self.cache_from.join(",")];
180        }
181
182        // Convert build args and labels to JSON format expected by Bollard
183        let buildargs_json: HashMap<String, String> = self.build_args;
184        let labels_json: HashMap<String, String> = self.labels;
185
186        // Build the image
187        let body = Either::Left(Full::new(Bytes::from(tar_data)));
188        let config = bollard::image::BuildImageOptions {
189            buildargs: buildargs_json,
190            labels: labels_json,
191            ..options
192        };
193
194        let mut stream = self.client.docker.build_image(config, None, Some(body));
195
196        while let Some(msg) = stream.next().await {
197            match msg {
198                Ok(output) => {
199                    if let Some(stream) = output.stream {
200                        let line = stream.trim();
201                        if !line.is_empty() {
202                            debug!("{}", line);
203                        }
204                    }
205                    if let Some(error) = output.error {
206                        warn!("Build error: {}", error);
207                        return Err(DockerError::BuildFailed(error));
208                    }
209                }
210                Err(e) => {
211                    return Err(DockerError::BuildFailed(e.to_string()));
212                }
213            }
214        }
215
216        info!("Successfully built image: {}", self.tag);
217        Ok(self.tag)
218    }
219
220    /// Create a tar archive of the build context.
221    fn create_build_context(&self) -> Result<Vec<u8>> {
222        debug!("Creating build context from: {}", self.context.display());
223
224        let tar_data = Vec::new();
225        let mut tar_builder = TarBuilder::new(tar_data);
226
227        // Add entire context directory to tar
228        tar_builder
229            .append_dir_all(".", &self.context)
230            .map_err(DockerError::Io)?;
231
232        let tar_data = tar_builder.into_inner().map_err(DockerError::Io)?;
233
234        debug!("Build context size: {} bytes", tar_data.len());
235        Ok(tar_data)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_builder_new() {
245        let client = DockerClient::new().unwrap();
246        let builder = ImageBuilder::new(&client, "my-app:latest");
247
248        assert_eq!(builder.tag, "my-app:latest");
249        assert_eq!(builder.dockerfile, "Dockerfile");
250        assert_eq!(builder.context, PathBuf::from("."));
251        assert!(builder.rm);
252        assert!(!builder.pull);
253    }
254
255    #[test]
256    fn test_builder_dockerfile() {
257        let client = DockerClient::new().unwrap();
258        let builder =
259            ImageBuilder::new(&client, "test:latest").dockerfile("docker/custom.Dockerfile");
260
261        assert_eq!(builder.dockerfile, "docker/custom.Dockerfile");
262    }
263
264    #[test]
265    fn test_builder_context() {
266        let client = DockerClient::new().unwrap();
267        let builder = ImageBuilder::new(&client, "test:latest").context(Path::new("/app"));
268
269        assert_eq!(builder.context, PathBuf::from("/app"));
270    }
271
272    #[test]
273    fn test_builder_build_args() {
274        let client = DockerClient::new().unwrap();
275        let builder = ImageBuilder::new(&client, "test:latest")
276            .build_arg("VERSION", "1.0.0")
277            .build_arg("RUNTIME", "alpine");
278
279        assert_eq!(builder.build_args.len(), 2);
280        assert_eq!(
281            builder.build_args.get("VERSION"),
282            Some(&"1.0.0".to_string())
283        );
284        assert_eq!(
285            builder.build_args.get("RUNTIME"),
286            Some(&"alpine".to_string())
287        );
288    }
289
290    #[test]
291    fn test_builder_labels() {
292        let client = DockerClient::new().unwrap();
293        let builder = ImageBuilder::new(&client, "test:latest")
294            .label("maintainer", "test@example.com")
295            .label("version", "1.0");
296
297        assert_eq!(builder.labels.len(), 2);
298        assert_eq!(
299            builder.labels.get("maintainer"),
300            Some(&"test@example.com".to_string())
301        );
302        assert_eq!(builder.labels.get("version"), Some(&"1.0".to_string()));
303    }
304
305    #[test]
306    fn test_builder_target() {
307        let client = DockerClient::new().unwrap();
308        let builder = ImageBuilder::new(&client, "test:latest").target("production");
309
310        assert_eq!(builder.target, Some("production".to_string()));
311    }
312
313    #[test]
314    fn test_builder_cache_from() {
315        let client = DockerClient::new().unwrap();
316        let builder = ImageBuilder::new(&client, "test:latest")
317            .cache_from("cache:latest")
318            .cache_from("cache:previous");
319
320        assert_eq!(builder.cache_from.len(), 2);
321        assert!(builder.cache_from.contains(&"cache:latest".to_string()));
322        assert!(builder.cache_from.contains(&"cache:previous".to_string()));
323    }
324
325    #[test]
326    fn test_builder_remove_intermediate() {
327        let client = DockerClient::new().unwrap();
328        let builder = ImageBuilder::new(&client, "test:latest").remove_intermediate(false);
329
330        assert!(!builder.rm);
331    }
332
333    #[test]
334    fn test_builder_pull() {
335        let client = DockerClient::new().unwrap();
336        let builder = ImageBuilder::new(&client, "test:latest").pull(true);
337
338        assert!(builder.pull);
339    }
340
341    #[test]
342    fn test_builder_chaining() {
343        let client = DockerClient::new().unwrap();
344        let builder = ImageBuilder::new(&client, "my-app:v1.0")
345            .dockerfile("Dockerfile.prod")
346            .context(Path::new("/app"))
347            .build_arg("VERSION", "1.0.0")
348            .label("env", "production")
349            .target("prod")
350            .cache_from("my-app:cache")
351            .pull(true)
352            .remove_intermediate(true);
353
354        assert_eq!(builder.tag, "my-app:v1.0");
355        assert_eq!(builder.dockerfile, "Dockerfile.prod");
356        assert_eq!(builder.context, PathBuf::from("/app"));
357        assert_eq!(builder.build_args.len(), 1);
358        assert_eq!(builder.labels.len(), 1);
359        assert_eq!(builder.target, Some("prod".to_string()));
360        assert_eq!(builder.cache_from.len(), 1);
361        assert!(builder.pull);
362        assert!(builder.rm);
363    }
364}