lmrc-docker 0.3.16

Docker client library for the LMRC Stack - ergonomic fluent APIs for containers, images, networks, volumes, and registry management
Documentation
//! Image builder for ergonomic image building.

use crate::DockerClient;
use crate::error::{DockerError, Result};
use bollard::image::BuildImageOptions;
use bytes::Bytes;
use futures_util::StreamExt;
use http_body_util::{Either, Full};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tar::Builder as TarBuilder;
use tracing::{debug, info, warn};

/// Builder for building Docker images with a fluent API.
pub struct ImageBuilder<'a> {
    client: &'a DockerClient,
    tag: String,
    dockerfile: String,
    context: PathBuf,
    build_args: HashMap<String, String>,
    labels: HashMap<String, String>,
    target: Option<String>,
    cache_from: Vec<String>,
    rm: bool,
    pull: bool,
}

impl<'a> ImageBuilder<'a> {
    pub(crate) fn new(client: &'a DockerClient, tag: impl Into<String>) -> Self {
        Self {
            client,
            tag: tag.into(),
            dockerfile: "Dockerfile".to_string(),
            context: PathBuf::from("."),
            build_args: HashMap::new(),
            labels: HashMap::new(),
            target: None,
            cache_from: Vec::new(),
            rm: true,
            pull: false,
        }
    }

    /// Set the path to the Dockerfile (relative to context).
    ///
    /// # Example
    ///
    /// ```no_run
    /// # use lmrc_docker::DockerClient;
    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
    /// client.images()
    ///     .build("my-app:latest")
    ///     .dockerfile("docker/app.Dockerfile")
    ///     .context(std::path::Path::new("."))
    ///     .execute()
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn dockerfile(mut self, path: impl Into<String>) -> Self {
        self.dockerfile = path.into();
        self
    }

    /// Set the build context directory.
    pub fn context(mut self, path: impl AsRef<Path>) -> Self {
        self.context = path.as_ref().to_path_buf();
        self
    }

    /// Add a build argument.
    ///
    /// # Example
    ///
    /// ```no_run
    /// # use lmrc_docker::DockerClient;
    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
    /// client.images()
    ///     .build("my-app:latest")
    ///     .build_arg("RUNTIME_IMAGE", "alpine:latest")
    ///     .build_arg("VERSION", "1.0.0")
    ///     .execute()
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn build_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.build_args.insert(key.into(), value.into());
        self
    }

    /// Add a label to the image.
    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.labels.insert(key.into(), value.into());
        self
    }

    /// Set the target build stage (for multi-stage builds).
    ///
    /// # Example
    ///
    /// ```no_run
    /// # use lmrc_docker::DockerClient;
    /// # async fn example(client: &DockerClient) -> Result<(), Box<dyn std::error::Error>> {
    /// client.images()
    ///     .build("my-app:latest")
    ///     .target("production")
    ///     .execute()
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn target(mut self, target: impl Into<String>) -> Self {
        self.target = Some(target.into());
        self
    }

    /// Add an image to use for build cache.
    pub fn cache_from(mut self, image: impl Into<String>) -> Self {
        self.cache_from.push(image.into());
        self
    }

    /// Remove intermediate containers after build (default: true).
    pub fn remove_intermediate(mut self, rm: bool) -> Self {
        self.rm = rm;
        self
    }

    /// Always pull the latest base image (default: false).
    pub fn pull(mut self, pull: bool) -> Self {
        self.pull = pull;
        self
    }

    /// Execute the build and return the image tag.
    ///
    /// This will stream the build output via tracing.
    pub async fn execute(self) -> Result<String> {
        info!("Building image: {}", self.tag);

        // Verify context exists
        if !self.context.exists() {
            return Err(DockerError::InvalidConfiguration(format!(
                "Build context does not exist: {}",
                self.context.display()
            )));
        }

        // Verify Dockerfile exists
        let dockerfile_path = self.context.join(&self.dockerfile);
        if !dockerfile_path.exists() {
            return Err(DockerError::InvalidConfiguration(format!(
                "Dockerfile not found: {}",
                dockerfile_path.display()
            )));
        }

        debug!("Dockerfile: {}", dockerfile_path.display());
        debug!("Context: {}", self.context.display());

        // Create tar archive of build context
        let tar_data = self.create_build_context()?;

        // Prepare build options
        let mut options = BuildImageOptions {
            dockerfile: self.dockerfile.clone(),
            t: self.tag.clone(),
            rm: self.rm,
            pull: self.pull,
            ..Default::default()
        };

        if let Some(target) = self.target {
            options.target = target;
        }

        if !self.cache_from.is_empty() {
            options.cachefrom = vec![self.cache_from.join(",")];
        }

        // Convert build args and labels to JSON format expected by Bollard
        let buildargs_json: HashMap<String, String> = self.build_args;
        let labels_json: HashMap<String, String> = self.labels;

        // Build the image
        let body = Either::Left(Full::new(Bytes::from(tar_data)));
        let config = bollard::image::BuildImageOptions {
            buildargs: buildargs_json,
            labels: labels_json,
            ..options
        };

        let mut stream = self.client.docker.build_image(config, None, Some(body));

        while let Some(msg) = stream.next().await {
            match msg {
                Ok(output) => {
                    if let Some(stream) = output.stream {
                        let line = stream.trim();
                        if !line.is_empty() {
                            debug!("{}", line);
                        }
                    }
                    if let Some(error) = output.error {
                        warn!("Build error: {}", error);
                        return Err(DockerError::BuildFailed(error));
                    }
                }
                Err(e) => {
                    return Err(DockerError::BuildFailed(e.to_string()));
                }
            }
        }

        info!("Successfully built image: {}", self.tag);
        Ok(self.tag)
    }

    /// Create a tar archive of the build context.
    fn create_build_context(&self) -> Result<Vec<u8>> {
        debug!("Creating build context from: {}", self.context.display());

        let tar_data = Vec::new();
        let mut tar_builder = TarBuilder::new(tar_data);

        // Add entire context directory to tar
        tar_builder
            .append_dir_all(".", &self.context)
            .map_err(DockerError::Io)?;

        let tar_data = tar_builder.into_inner().map_err(DockerError::Io)?;

        debug!("Build context size: {} bytes", tar_data.len());
        Ok(tar_data)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_builder_new() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "my-app:latest");

        assert_eq!(builder.tag, "my-app:latest");
        assert_eq!(builder.dockerfile, "Dockerfile");
        assert_eq!(builder.context, PathBuf::from("."));
        assert!(builder.rm);
        assert!(!builder.pull);
    }

    #[test]
    fn test_builder_dockerfile() {
        let client = DockerClient::new().unwrap();
        let builder =
            ImageBuilder::new(&client, "test:latest").dockerfile("docker/custom.Dockerfile");

        assert_eq!(builder.dockerfile, "docker/custom.Dockerfile");
    }

    #[test]
    fn test_builder_context() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest").context(Path::new("/app"));

        assert_eq!(builder.context, PathBuf::from("/app"));
    }

    #[test]
    fn test_builder_build_args() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest")
            .build_arg("VERSION", "1.0.0")
            .build_arg("RUNTIME", "alpine");

        assert_eq!(builder.build_args.len(), 2);
        assert_eq!(
            builder.build_args.get("VERSION"),
            Some(&"1.0.0".to_string())
        );
        assert_eq!(
            builder.build_args.get("RUNTIME"),
            Some(&"alpine".to_string())
        );
    }

    #[test]
    fn test_builder_labels() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest")
            .label("maintainer", "test@example.com")
            .label("version", "1.0");

        assert_eq!(builder.labels.len(), 2);
        assert_eq!(
            builder.labels.get("maintainer"),
            Some(&"test@example.com".to_string())
        );
        assert_eq!(builder.labels.get("version"), Some(&"1.0".to_string()));
    }

    #[test]
    fn test_builder_target() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest").target("production");

        assert_eq!(builder.target, Some("production".to_string()));
    }

    #[test]
    fn test_builder_cache_from() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest")
            .cache_from("cache:latest")
            .cache_from("cache:previous");

        assert_eq!(builder.cache_from.len(), 2);
        assert!(builder.cache_from.contains(&"cache:latest".to_string()));
        assert!(builder.cache_from.contains(&"cache:previous".to_string()));
    }

    #[test]
    fn test_builder_remove_intermediate() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest").remove_intermediate(false);

        assert!(!builder.rm);
    }

    #[test]
    fn test_builder_pull() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "test:latest").pull(true);

        assert!(builder.pull);
    }

    #[test]
    fn test_builder_chaining() {
        let client = DockerClient::new().unwrap();
        let builder = ImageBuilder::new(&client, "my-app:v1.0")
            .dockerfile("Dockerfile.prod")
            .context(Path::new("/app"))
            .build_arg("VERSION", "1.0.0")
            .label("env", "production")
            .target("prod")
            .cache_from("my-app:cache")
            .pull(true)
            .remove_intermediate(true);

        assert_eq!(builder.tag, "my-app:v1.0");
        assert_eq!(builder.dockerfile, "Dockerfile.prod");
        assert_eq!(builder.context, PathBuf::from("/app"));
        assert_eq!(builder.build_args.len(), 1);
        assert_eq!(builder.labels.len(), 1);
        assert_eq!(builder.target, Some("prod".to_string()));
        assert_eq!(builder.cache_from.len(), 1);
        assert!(builder.pull);
        assert!(builder.rm);
    }
}