1use std::collections::BTreeMap;
13use std::path::Path;
14use std::sync::mpsc;
15
16use tracing::{debug, info, warn};
17
18use crate::buildah::{BuildahCommand, BuildahExecutor};
19use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
20use crate::dockerfile::{
21 expand_dockerfile, forward_build_arg_env, merge_default_cache_mounts, render_dockerfile,
22 Dockerfile, Instruction, RunMount,
23};
24use crate::error::{BuildError, Result};
25use crate::tui::{BuildEvent, PlannedStage};
26
27use super::progress::InstructionProgress;
28use super::BuildBackend;
29
30pub struct BuildahBackend {
36 executor: BuildahExecutor,
37}
38
39impl BuildahBackend {
40 pub async fn try_new() -> Result<Self> {
48 let executor = BuildahExecutor::new_async().await?;
49 if !executor.is_available().await {
50 return Err(crate::error::BuildError::BuildahNotFound {
51 message: "buildah is installed but not responding".into(),
52 });
53 }
54 Ok(Self { executor })
55 }
56
57 pub async fn new() -> Result<Self> {
63 let executor = BuildahExecutor::new_async().await?;
64 Ok(Self { executor })
65 }
66
67 #[must_use]
69 pub fn with_executor(executor: BuildahExecutor) -> Self {
70 Self { executor }
71 }
72
73 #[must_use]
75 pub fn executor(&self) -> &BuildahExecutor {
76 &self.executor
77 }
78
79 async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
85 let cmd = BuildahCommand::tag(image, tag);
86 self.executor.execute_checked(&cmd).await?;
87 Ok(())
88 }
89
90 async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
92 let creds = auth.map(|auth| format!("{}:{}", auth.username, auth.password));
97 let cmd = BuildahCommand::push_with_creds(tag, creds.as_deref());
98 self.executor.execute_checked(&cmd).await?;
99 Ok(())
100 }
101
102 fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
104 if let Some(tx) = event_tx {
105 let _ = tx.send(event);
106 }
107 }
108}
109
110#[async_trait::async_trait]
111impl BuildBackend for BuildahBackend {
112 #[allow(clippy::too_many_lines)]
113 async fn build_image(
114 &self,
115 context: &Path,
116 dockerfile: &Dockerfile,
117 options: &BuildOptions,
118 event_tx: Option<mpsc::Sender<BuildEvent>>,
119 ) -> Result<BuiltImage> {
120 let start_time = std::time::Instant::now();
121
122 debug!(
123 "BuildahBackend: starting build ({} stages)",
124 dockerfile.stages.len()
125 );
126
127 let mut effective_build_args: BTreeMap<String, String> = BTreeMap::new();
133 for (k, v) in &options.build_args {
134 effective_build_args.insert(k.clone(), v.clone());
135 }
136 for (k, v) in &options.pipeline_vars {
137 effective_build_args.insert(k.clone(), v.clone());
138 }
139 forward_build_arg_env(dockerfile, &mut effective_build_args);
144
145 let expand_args: std::collections::HashMap<String, String> = effective_build_args
147 .iter()
148 .map(|(k, v)| (k.clone(), v.clone()))
149 .collect();
150
151 let mut ir = expand_dockerfile(dockerfile, &expand_args);
152 merge_default_cache_mounts(&mut ir, options);
153 let text = render_dockerfile(&ir);
154
155 let ssh_ids = collect_ssh_ids(&ir);
160 let secret_ids = collect_secret_ids(&ir);
161
162 let mut rendered = tempfile::Builder::new()
167 .prefix("zlayer-rendered-")
168 .tempfile_in(context)
169 .map_err(BuildError::from)?;
170 {
171 use std::io::Write as _;
172 rendered
173 .write_all(text.as_bytes())
174 .map_err(BuildError::from)?;
175 rendered.flush().map_err(BuildError::from)?;
176 }
177 let dockerfile_path = rendered.path().to_path_buf();
178
179 let planned_stages: Vec<PlannedStage> = ir
183 .stages
184 .iter()
185 .map(|stage| PlannedStage {
186 name: stage.name.clone(),
187 base_image: stage.base_image.to_string(),
188 instructions: stage
189 .instructions
190 .iter()
191 .map(|instruction| format!("{instruction:?}"))
192 .collect(),
193 })
194 .collect();
195
196 let total_stages = planned_stages.len();
197 let total_instructions: usize = planned_stages.iter().map(|s| s.instructions.len()).sum();
198
199 Self::send_event(
200 event_tx.as_ref(),
201 BuildEvent::BuildStarted {
202 total_stages,
203 total_instructions,
204 },
205 );
206
207 let mut progress = InstructionProgress::from_planned_stages(&planned_stages);
208 Self::send_event(
209 event_tx.as_ref(),
210 BuildEvent::BuildPlan {
211 stages: planned_stages,
212 },
213 );
214 for event in progress.start_first() {
215 Self::send_event(event_tx.as_ref(), event);
216 }
217
218 let cmd = BuildahCommand::build(
221 &dockerfile_path,
222 context,
223 options,
224 &effective_build_args,
225 &ssh_ids,
226 &secret_ids,
227 );
228
229 let max_attempts = options.retries + 1;
230 let mut last_output = None;
231
232 for attempt in 1..=max_attempts {
233 if attempt > 1 {
234 warn!("Retrying build (attempt {}/{})...", attempt, max_attempts);
235 Self::send_event(
236 event_tx.as_ref(),
237 BuildEvent::Output {
238 line: format!("⟳ Retrying build (attempt {attempt}/{max_attempts})..."),
239 is_stderr: false,
240 },
241 );
242 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
243 }
244
245 let event_tx_clone = event_tx.clone();
246 let progress_ref = &mut progress;
247 let output = self
248 .executor
249 .execute_streaming(&cmd, |is_stdout, line| {
250 let events = progress_ref.on_line(line, !is_stdout);
254 for event in events {
255 Self::send_event(event_tx_clone.as_ref(), event);
256 }
257 })
258 .await?;
259
260 if output.success() {
261 last_output = Some(output);
262 break;
263 }
264 last_output = Some(output);
265 }
266
267 let output = last_output.expect("retry loop runs at least once");
268 if !output.success() {
269 Self::send_event(
270 event_tx.as_ref(),
271 BuildEvent::BuildFailed {
272 error: output.stderr.clone(),
273 },
274 );
275 return Err(BuildError::buildah_execution(
276 cmd.to_command_string(),
277 output.exit_code,
278 output.stderr,
279 ));
280 }
281
282 let image_id = output
287 .stdout
288 .lines()
289 .rev()
290 .map(str::trim)
291 .find(|l| !l.is_empty())
292 .map_or_else(
293 || options.tags.first().cloned().unwrap_or_default(),
294 ToString::to_string,
295 );
296
297 info!("Built image: {}", image_id);
298
299 if options.push {
306 for tag in &options.tags {
307 self.push_image_internal(tag, options.registry_auth.as_ref())
308 .await?;
309 info!("Pushed image: {}", tag);
310 }
311 }
312
313 #[allow(clippy::cast_possible_truncation)]
314 let build_time_ms = start_time.elapsed().as_millis() as u64;
315
316 Self::send_event(
317 event_tx.as_ref(),
318 BuildEvent::BuildComplete {
319 image_id: image_id.clone(),
320 },
321 );
322
323 info!(
324 "Build completed in {}ms: {} with {} tags",
325 build_time_ms,
326 image_id,
327 options.tags.len()
328 );
329
330 drop(rendered);
332
333 Ok(BuiltImage {
334 image_id,
335 tags: options.tags.clone(),
336 layer_count: total_instructions,
337 size: 0, build_time_ms,
339 is_manifest: options.platform.as_deref().is_some_and(|s| s.contains(',')),
340 })
341 }
342
343 async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
344 self.push_image_internal(tag, auth).await
345 }
346
347 async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
348 self.tag_image_internal(image, new_tag).await
349 }
350
351 async fn manifest_create(&self, name: &str) -> Result<()> {
352 self.executor.manifest_create_idempotent(name).await
356 }
357
358 async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
359 let cmd = BuildahCommand::manifest_add(manifest, image);
360 self.executor.execute_checked(&cmd).await?;
361 Ok(())
362 }
363
364 async fn manifest_push(
365 &self,
366 name: &str,
367 destination: &str,
368 auth: Option<&RegistryAuth>,
369 ) -> Result<()> {
370 let creds = auth.map(|auth| format!("{}:{}", auth.username, auth.password));
374 let cmd = BuildahCommand::manifest_push_with_creds(name, destination, creds.as_deref());
375 self.executor.execute_checked(&cmd).await?;
376 Ok(())
377 }
378
379 async fn is_available(&self) -> bool {
380 self.executor.is_available().await
381 }
382
383 fn name(&self) -> &'static str {
384 "buildah"
385 }
386}
387
388fn collect_ssh_ids(df: &Dockerfile) -> Vec<String> {
398 let mut ids: Vec<String> = Vec::new();
399 for stage in &df.stages {
400 for instruction in &stage.instructions {
401 let Instruction::Run(run) = instruction else {
402 continue;
403 };
404 for mount in &run.mounts {
405 if let RunMount::Ssh { id, .. } = mount {
406 let id = id.clone().unwrap_or_else(|| "default".to_string());
407 if !ids.contains(&id) {
408 ids.push(id);
409 }
410 }
411 }
412 }
413 }
414 ids
415}
416
417fn collect_secret_ids(df: &Dockerfile) -> Vec<String> {
418 let mut specs: Vec<String> = Vec::new();
419 for stage in &df.stages {
420 for instruction in &stage.instructions {
421 let Instruction::Run(run) = instruction else {
422 continue;
423 };
424 for mount in &run.mounts {
425 if let RunMount::Secret { id, .. } = mount {
426 let spec = format!("id={id}");
427 if !specs.contains(&spec) {
428 specs.push(spec);
429 }
430 }
431 }
432 }
433 }
434 specs
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::dockerfile::{CacheSharing, RunInstruction, RunMount};
441
442 #[test]
443 fn collect_ssh_ids_dedups_and_defaults() {
444 let mut run_default = RunInstruction::shell("git fetch");
445 run_default.mounts.push(RunMount::Ssh {
446 target: String::new(),
447 id: None,
448 required: false,
449 });
450
451 let mut run_named = RunInstruction::shell("git fetch again");
452 run_named.mounts.push(RunMount::Ssh {
453 target: String::new(),
454 id: Some("github".to_string()),
455 required: true,
456 });
457
458 let mut run_default2 = RunInstruction::shell("git fetch thrice");
460 run_default2.mounts.push(RunMount::Ssh {
461 target: String::new(),
462 id: None,
463 required: false,
464 });
465
466 let df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
467 let mut df = df;
468 df.stages[0].instructions = vec![
469 Instruction::Run(run_default),
470 Instruction::Run(run_named),
471 Instruction::Run(run_default2),
472 ];
473
474 assert_eq!(collect_ssh_ids(&df), vec!["default", "github"]);
475 }
476
477 #[test]
478 fn collect_ssh_ids_empty_when_no_ssh_mounts() {
479 let mut run = RunInstruction::shell("apt-get update");
480 run.mounts.push(RunMount::Cache {
481 target: "/var/cache/apt".to_string(),
482 id: None,
483 sharing: CacheSharing::Shared,
484 readonly: false,
485 });
486 let mut df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
487 df.stages[0].instructions = vec![Instruction::Run(run)];
488 assert!(collect_ssh_ids(&df).is_empty());
489 }
490}