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};
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,
}
}
pub fn dockerfile(mut self, path: impl Into<String>) -> Self {
self.dockerfile = path.into();
self
}
pub fn context(mut self, path: impl AsRef<Path>) -> Self {
self.context = path.as_ref().to_path_buf();
self
}
pub fn build_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.build_args.insert(key.into(), value.into());
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
pub fn cache_from(mut self, image: impl Into<String>) -> Self {
self.cache_from.push(image.into());
self
}
pub fn remove_intermediate(mut self, rm: bool) -> Self {
self.rm = rm;
self
}
pub fn pull(mut self, pull: bool) -> Self {
self.pull = pull;
self
}
pub async fn execute(self) -> Result<String> {
info!("Building image: {}", self.tag);
if !self.context.exists() {
return Err(DockerError::InvalidConfiguration(format!(
"Build context does not exist: {}",
self.context.display()
)));
}
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());
let tar_data = self.create_build_context()?;
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(",")];
}
let buildargs_json: HashMap<String, String> = self.build_args;
let labels_json: HashMap<String, String> = self.labels;
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)
}
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);
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);
}
}