1use 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
14pub 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 pub fn dockerfile(mut self, path: impl Into<String>) -> Self {
61 self.dockerfile = path.into();
62 self
63 }
64
65 pub fn context(mut self, path: impl AsRef<Path>) -> Self {
67 self.context = path.as_ref().to_path_buf();
68 self
69 }
70
71 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 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 pub fn target(mut self, target: impl Into<String>) -> Self {
114 self.target = Some(target.into());
115 self
116 }
117
118 pub fn cache_from(mut self, image: impl Into<String>) -> Self {
120 self.cache_from.push(image.into());
121 self
122 }
123
124 pub fn remove_intermediate(mut self, rm: bool) -> Self {
126 self.rm = rm;
127 self
128 }
129
130 pub fn pull(mut self, pull: bool) -> Self {
132 self.pull = pull;
133 self
134 }
135
136 pub async fn execute(self) -> Result<String> {
140 info!("Building image: {}", self.tag);
141
142 if !self.context.exists() {
144 return Err(DockerError::InvalidConfiguration(format!(
145 "Build context does not exist: {}",
146 self.context.display()
147 )));
148 }
149
150 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 let tar_data = self.create_build_context()?;
164
165 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 let buildargs_json: HashMap<String, String> = self.build_args;
184 let labels_json: HashMap<String, String> = self.labels;
185
186 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 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 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}