Skip to main content

zlayer_builder/backend/
buildah.rs

1//! Buildah-backed build backend.
2//!
3//! Wraps [`BuildahExecutor`] to implement the [`BuildBackend`] trait.
4//!
5//! The build path renders the parsed [`Dockerfile`] IR back to canonical
6//! Dockerfile text (after build-arg expansion + default-cache-mount merge) and
7//! drives buildah's NATIVE frontend (`buildah build -f <rendered>`), instead of
8//! the legacy `buildah from` → per-instruction translate → `buildah commit`
9//! loop. buildah's own parser/executor then handles stages, base-image
10//! resolution, `COPY --from` (including external image refs), and layer caching.
11
12use 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
30// ---------------------------------------------------------------------------
31// BuildahBackend
32// ---------------------------------------------------------------------------
33
34/// Build backend that delegates to the `buildah` CLI.
35pub struct BuildahBackend {
36    executor: BuildahExecutor,
37}
38
39impl BuildahBackend {
40    /// Try to create a new `BuildahBackend`.
41    ///
42    /// Returns `Ok` if buildah is found and functional, `Err` otherwise.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if buildah is not installed or is not responding.
47    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    /// Create a new `BuildahBackend`, returning an error if buildah is not available.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if buildah is not installed or cannot be initialized.
62    pub async fn new() -> Result<Self> {
63        let executor = BuildahExecutor::new_async().await?;
64        Ok(Self { executor })
65    }
66
67    /// Create a `BuildahBackend` from an existing executor.
68    #[must_use]
69    pub fn with_executor(executor: BuildahExecutor) -> Self {
70        Self { executor }
71    }
72
73    /// Borrow the inner executor (useful for low-level operations).
74    #[must_use]
75    pub fn executor(&self) -> &BuildahExecutor {
76        &self.executor
77    }
78
79    // -----------------------------------------------------------------------
80    // Build helpers
81    // -----------------------------------------------------------------------
82
83    /// Tag an image with an additional tag.
84    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    /// Push an image to a registry.
91    async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
92        // buildah requires options (`--creds`) BEFORE the positional image arg
93        // (`buildah push [options] IMAGE`); appending them after the image
94        // fails with "no options (--creds) can be specified after the image or
95        // container name". `push_with_creds` orders the flags correctly.
96        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    /// Send an event to the TUI (if configured).
103    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        // 1) Prepare the final IR.
128        //
129        // `effective_build_args` mirrors how the legacy loop sourced ARG
130        // bindings: the explicit build args overlaid with pipeline vars. We
131        // keep it sorted (BTreeMap) so `--build-arg` flags are deterministic.
132        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        // Docker `--build-arg FOO` (no `=value`) semantics: any build-arg DECLARED
140        // in the IR (`ARG`) but left empty/unset is populated from a matching
141        // non-empty process-env var. Applied before deriving the expansion map so
142        // both the `--build-arg` flags and in-Dockerfile `${VAR}` references see it.
143        forward_build_arg_env(dockerfile, &mut effective_build_args);
144
145        // `expand_dockerfile` wants a HashMap; reuse the merged bindings.
146        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        // Collect distinct RUN ssh ids from the final IR so we can pass
156        // `--ssh <id>` for each. `buildah build` requires the ssh socket to be
157        // authorized per-id; the bare `RUN --mount=type=ssh` (no id) maps to
158        // the conventional `default` id.
159        let ssh_ids = collect_ssh_ids(&ir);
160        let secret_ids = collect_secret_ids(&ir);
161
162        // 2) Write the rendered Dockerfile INSIDE the context dir so buildah can
163        //    resolve the `-f` path AND so a context-less (ZImagefile-only) build
164        //    still finds it. Keep the NamedTempFile alive until the build
165        //    finishes (dropping it deletes the file).
166        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        // 3) Emit the up-front plan so the TUI has a stable denominator and a
180        //    full instruction list. `format!("{instruction:?}")` matches the
181        //    sidecar backend's planned-instruction text byte-for-byte.
182        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        // 4) Run a single `buildah build`, wrapped in a whole-build retry loop
219        //    honoring `options.retries` (replaces the old per-RUN retry).
220        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                    // Feed each line to the shared progress cursor to reconstruct
251                    // per-instruction events from buildah's commit markers, and
252                    // forward every event (Output + progress) to the TUI.
253                    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        // 5) Resolve the resulting image id. `buildah build --iidfile` would be
283        //    cleaner, but we keep it simple: the image id is the last non-empty
284        //    line buildah prints on stdout (the committed image SHA), and if
285        //    tags were requested the first tag is the human-facing name.
286        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        // Note: `buildah build` applies ALL `--tag`s itself, so there is no
300        // separate tag-application step here (the legacy commit→tag loop is
301        // gone). `tag_image_internal` remains for the `BuildBackend::tag_image`
302        // trait method and ad-hoc retagging.
303
304        // 6) Push if requested.
305        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        // Keep the rendered Dockerfile alive until here, then drop it.
331        drop(rendered);
332
333        Ok(BuiltImage {
334            image_id,
335            tags: options.tags.clone(),
336            layer_count: total_instructions,
337            size: 0, // buildah build does not report size; an inspect RPC would.
338            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        // Idempotent: clears any stale manifest list / plain image of this name
353        // first so a re-run after a partial build doesn't hit "name already in
354        // use" (exit 125). See `BuildahExecutor::manifest_create_idempotent`.
355        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        // Same ordering rule as `push`: options (`--creds`) must precede the
371        // positional list/destination args. `manifest_push_with_creds` places
372        // every flag before the positionals.
373        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
388// ---------------------------------------------------------------------------
389// Helpers
390// ---------------------------------------------------------------------------
391
392/// Collect the distinct `RUN --mount=type=ssh` ids across the whole IR, in
393/// first-seen order, so the build can pass one `--ssh <id>` per id.
394///
395/// A bare `RUN --mount=type=ssh` (no `id=`) maps to the conventional `default`
396/// id that `buildah build --ssh default` authorizes.
397fn 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        // A second bare ssh mount must NOT add a duplicate `default`.
459        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}