Skip to main content

zlayer_builder/buildah/
executor.rs

1//! Buildah command execution
2//!
3//! This module provides functionality to execute buildah commands,
4//! with support for both synchronous and streaming output.
5
6use std::ffi::OsString;
7use std::path::PathBuf;
8use std::process::Stdio;
9
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command;
12use tracing::{debug, error, instrument, trace};
13
14use crate::error::{BuildError, Result};
15
16use super::BuildahCommand;
17
18/// Output from a buildah command execution
19#[derive(Debug, Clone)]
20pub struct CommandOutput {
21    /// Standard output from the command
22    pub stdout: String,
23
24    /// Standard error from the command
25    pub stderr: String,
26
27    /// Exit code (0 = success)
28    pub exit_code: i32,
29}
30
31impl CommandOutput {
32    /// Returns true if the command succeeded (exit code 0)
33    #[must_use]
34    pub fn success(&self) -> bool {
35        self.exit_code == 0
36    }
37
38    /// Returns the combined stdout and stderr
39    #[must_use]
40    pub fn combined_output(&self) -> String {
41        if self.stderr.is_empty() {
42            self.stdout.clone()
43        } else if self.stdout.is_empty() {
44            self.stderr.clone()
45        } else {
46            format!("{}\n{}", self.stdout, self.stderr)
47        }
48    }
49}
50
51/// How buildah commands are executed.
52#[derive(Debug, Clone, Default)]
53pub enum BuildahTransport {
54    /// Run buildah as a local process (default; current behavior).
55    #[default]
56    Local,
57    /// Run buildah inside a WSL2 distro: `wsl.exe -d <distro> -- buildah ...`.
58    Wsl {
59        /// Name of the WSL2 distribution to run buildah inside.
60        distro: String,
61    },
62}
63
64/// Executor for buildah commands
65#[derive(Debug, Clone)]
66pub struct BuildahExecutor {
67    /// Path to the buildah binary
68    buildah_path: PathBuf,
69
70    /// Default storage driver (if set)
71    storage_driver: Option<String>,
72
73    /// Root directory for buildah storage
74    root: Option<PathBuf>,
75
76    /// Run directory for buildah state
77    runroot: Option<PathBuf>,
78
79    /// How buildah commands are executed (local process or inside WSL2).
80    transport: BuildahTransport,
81}
82
83impl Default for BuildahExecutor {
84    fn default() -> Self {
85        Self {
86            buildah_path: PathBuf::from("buildah"),
87            storage_driver: None,
88            root: None,
89            runroot: None,
90            transport: BuildahTransport::Local,
91        }
92    }
93}
94
95impl BuildahExecutor {
96    /// Create a new `BuildahExecutor`, locating the buildah binary (sync version)
97    ///
98    /// This will search for buildah in common system locations and PATH.
99    /// For more comprehensive discovery with version checking, use [`new_async`].
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if buildah is not found in common system locations or PATH.
104    pub fn new() -> Result<Self> {
105        let buildah_path = which_buildah()?;
106        Ok(Self {
107            buildah_path,
108            storage_driver: None,
109            root: None,
110            runroot: None,
111            transport: BuildahTransport::Local,
112        })
113    }
114
115    /// Create a new `BuildahExecutor` using the `BuildahInstaller`
116    ///
117    /// This async version uses [`BuildahInstaller`] to find buildah and verify
118    /// it meets minimum version requirements. If buildah is not found, it returns
119    /// a helpful error with installation instructions.
120    ///
121    /// # Example
122    ///
123    /// ```no_run
124    /// use zlayer_builder::BuildahExecutor;
125    ///
126    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
127    /// let executor = BuildahExecutor::new_async().await?;
128    /// let version = executor.version().await?;
129    /// println!("Using buildah version: {}", version);
130    /// # Ok(())
131    /// # }
132    /// ```
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if buildah is not installed or does not meet the minimum version.
137    pub async fn new_async() -> Result<Self> {
138        use super::install::BuildahInstaller;
139
140        let installer = BuildahInstaller::new();
141        let installation = installer
142            .ensure()
143            .await
144            .map_err(|e| BuildError::BuildahNotFound {
145                message: e.to_string(),
146            })?;
147
148        Ok(Self {
149            buildah_path: installation.path,
150            storage_driver: None,
151            root: None,
152            runroot: None,
153            transport: BuildahTransport::Local,
154        })
155    }
156
157    /// Create a `BuildahExecutor` with a specific path to the buildah binary
158    pub fn with_path(path: impl Into<PathBuf>) -> Self {
159        Self {
160            buildah_path: path.into(),
161            storage_driver: None,
162            root: None,
163            runroot: None,
164            transport: BuildahTransport::Local,
165        }
166    }
167
168    /// Set the storage driver
169    #[must_use]
170    pub fn storage_driver(mut self, driver: impl Into<String>) -> Self {
171        self.storage_driver = Some(driver.into());
172        self
173    }
174
175    /// Set the root directory for buildah storage
176    #[must_use]
177    pub fn root(mut self, root: impl Into<PathBuf>) -> Self {
178        self.root = Some(root.into());
179        self
180    }
181
182    /// Set the runroot directory for buildah state
183    #[must_use]
184    pub fn runroot(mut self, runroot: impl Into<PathBuf>) -> Self {
185        self.runroot = Some(runroot.into());
186        self
187    }
188
189    /// Set the transport used to run buildah commands.
190    ///
191    /// Defaults to [`BuildahTransport::Local`] (run buildah as a local
192    /// process). Use [`BuildahTransport::Wsl`] to run every `buildah`
193    /// invocation inside a WSL2 distro via `wsl.exe -d <distro> -- buildah …`.
194    #[must_use]
195    pub fn with_transport(mut self, t: BuildahTransport) -> Self {
196        self.transport = t;
197        self
198    }
199
200    /// Get the path to the buildah binary
201    #[must_use]
202    pub fn buildah_path(&self) -> &PathBuf {
203        &self.buildah_path
204    }
205
206    /// Build the base tokio Command with global options.
207    ///
208    /// The "global flags + subcommand args" assembly is built once into a
209    /// single `argv` vector that BOTH transports consume, so the local and
210    /// WSL paths cannot drift in flag/arg ordering.
211    fn build_command(&self, cmd: &BuildahCommand) -> Command {
212        // Assemble global options (before the subcommand) followed by the
213        // command's own arguments. Identical for every transport.
214        let mut argv: Vec<OsString> = Vec::new();
215
216        if let Some(ref driver) = self.storage_driver {
217            argv.push(OsString::from("--storage-driver"));
218            argv.push(OsString::from(driver));
219        }
220
221        if let Some(ref root) = self.root {
222            argv.push(OsString::from("--root"));
223            argv.push(root.clone().into_os_string());
224        }
225
226        if let Some(ref runroot) = self.runroot {
227            argv.push(OsString::from("--runroot"));
228            argv.push(runroot.clone().into_os_string());
229        }
230
231        for arg in &cmd.args {
232            argv.push(OsString::from(arg));
233        }
234
235        match &self.transport {
236            BuildahTransport::Local => {
237                let mut command = Command::new(&self.buildah_path);
238                command.args(&argv);
239
240                // Local processes inherit the parent env; per-command vars are
241                // injected directly via `.env(K, V)`.
242                for (key, value) in &cmd.env {
243                    command.env(key, value);
244                }
245
246                command
247            }
248            BuildahTransport::Wsl { distro } => {
249                // `wsl.exe -d <distro> -- [env K=V ...] buildah <flags> <args>`
250                //
251                // WSL does NOT inherit the Win32 process environment into the
252                // distro, so any per-command env vars must be passed as
253                // `env K=V` argv tokens INSIDE the distro (prepended to the
254                // buildah argv), not via `.env()` on the `wsl.exe` Command.
255                let mut command = Command::new("wsl.exe");
256                command.arg("-d").arg(distro).arg("--");
257
258                if !cmd.env.is_empty() {
259                    command.arg("env");
260                    // Sort keys for deterministic argv ordering (HashMap
261                    // iteration order is unspecified).
262                    let mut keys: Vec<&String> = cmd.env.keys().collect();
263                    keys.sort();
264                    for key in keys {
265                        if let Some(value) = cmd.env.get(key) {
266                            command.arg(format!("{key}={value}"));
267                        }
268                    }
269                }
270
271                command.arg(&self.buildah_path);
272                // Translate Windows drive-rooted paths (`C:\...` / `C:/...`) to
273                // their `/mnt/<drive>/...` form so the in-distro buildah can read
274                // the Windows-side build context + rendered Dockerfile. Image
275                // refs (`name:tag`) and flags must pass through untouched.
276                for a in &argv {
277                    let s = a.to_string_lossy();
278                    let bytes = s.as_bytes();
279                    // Translate ONLY a true drive root (`X:\` or `X:/`), never a
280                    // bare `X:` (which would corrupt refs like `c:latest`).
281                    let is_drive_root = bytes.len() >= 3
282                        && bytes[0].is_ascii_alphabetic()
283                        && bytes[1] == b':'
284                        && (bytes[2] == b'\\' || bytes[2] == b'/');
285                    if is_drive_root {
286                        if let Some(w) =
287                            zlayer_wsl::paths::windows_to_wsl(std::path::Path::new(&*s))
288                        {
289                            command.arg(w);
290                            continue;
291                        }
292                    }
293                    command.arg(a);
294                }
295
296                command
297            }
298        }
299    }
300
301    /// Execute a buildah command and wait for completion
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the buildah process fails to spawn.
306    #[instrument(skip(self), fields(command = %cmd.to_command_string()))]
307    pub async fn execute(&self, cmd: &BuildahCommand) -> Result<CommandOutput> {
308        debug!("Executing buildah command");
309        trace!("Full command: {:?}", cmd);
310
311        let mut command = self.build_command(cmd);
312        command.stdout(Stdio::piped()).stderr(Stdio::piped());
313
314        let output = command.output().await.map_err(|e| {
315            error!("Failed to spawn buildah process: {}", e);
316            BuildError::IoError(e)
317        })?;
318
319        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
320        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
321        let exit_code = output.status.code().unwrap_or(-1);
322
323        if !output.status.success() {
324            debug!(
325                "Buildah command failed with exit code {}: {}",
326                exit_code,
327                stderr.trim()
328            );
329        }
330
331        Ok(CommandOutput {
332            stdout,
333            stderr,
334            exit_code,
335        })
336    }
337
338    /// Execute a buildah command and return an error if it fails
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if the process fails to spawn or exits with a non-zero code.
343    pub async fn execute_checked(&self, cmd: &BuildahCommand) -> Result<CommandOutput> {
344        let output = self.execute(cmd).await?;
345
346        if !output.success() {
347            return Err(BuildError::BuildahExecution {
348                command: cmd.to_command_string(),
349                exit_code: output.exit_code,
350                stderr: output.stderr,
351            });
352        }
353
354        Ok(output)
355    }
356
357    /// Create a manifest list, removing any stale list or plain image of the
358    /// same name first so the operation is idempotent / re-runnable.
359    ///
360    /// `buildah manifest create <ref>` fails (exit 125) when a manifest list —
361    /// or a plain image left behind by a partially-completed prior run — is
362    /// already associated with `<ref>` (`image name "…" is already in use`).
363    /// To make multi-arch assembly re-runnable after such a partial run, we
364    /// best-effort remove any pre-existing manifest list (`manifest rm`) AND
365    /// any plain image (`rmi -f`) of that exact name before creating fresh.
366    /// Both removals ignore "not found"/non-zero — a clean store no-ops them.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error only if the final `manifest create` itself fails.
371    pub async fn manifest_create_idempotent(&self, name: &str) -> Result<()> {
372        // Best-effort cleanup of stale state — ignore errors (e.g. "not found").
373        let _ = self.execute(&BuildahCommand::manifest_rm(name)).await;
374        let _ = self.execute(&BuildahCommand::rmi_force(name)).await;
375        self.execute_checked(&BuildahCommand::manifest_create(name))
376            .await?;
377        Ok(())
378    }
379
380    /// Execute a buildah command with streaming output
381    ///
382    /// The callback is called for each line of output (both stdout and stderr).
383    /// The first parameter indicates whether it's stdout (true) or stderr (false).
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if the process fails to spawn or an I/O error occurs.
388    ///
389    /// # Panics
390    ///
391    /// Panics if stdout or stderr pipes are unexpectedly missing (should not happen
392    /// since they are explicitly configured as piped).
393    #[instrument(skip(self, on_output), fields(command = %cmd.to_command_string()))]
394    pub async fn execute_streaming<F>(
395        &self,
396        cmd: &BuildahCommand,
397        mut on_output: F,
398    ) -> Result<CommandOutput>
399    where
400        F: FnMut(bool, &str),
401    {
402        debug!("Executing buildah command with streaming output");
403
404        let mut command = self.build_command(cmd);
405        command.stdout(Stdio::piped()).stderr(Stdio::piped());
406
407        let mut child = command.spawn().map_err(|e| {
408            error!("Failed to spawn buildah process: {}", e);
409            BuildError::IoError(e)
410        })?;
411
412        let stdout = child.stdout.take().expect("stdout was piped");
413        let stderr = child.stderr.take().expect("stderr was piped");
414
415        let mut stdout_reader = BufReader::new(stdout).lines();
416        let mut stderr_reader = BufReader::new(stderr).lines();
417
418        let mut stdout_output = String::new();
419        let mut stderr_output = String::new();
420
421        // Read stdout and stderr concurrently
422        loop {
423            tokio::select! {
424                line = stdout_reader.next_line() => {
425                    match line {
426                        Ok(Some(line)) => {
427                            on_output(true, &line);
428                            stdout_output.push_str(&line);
429                            stdout_output.push('\n');
430                        }
431                        Ok(None) => {}
432                        Err(e) => {
433                            error!("Error reading stdout: {}", e);
434                        }
435                    }
436                }
437                line = stderr_reader.next_line() => {
438                    match line {
439                        Ok(Some(line)) => {
440                            on_output(false, &line);
441                            stderr_output.push_str(&line);
442                            stderr_output.push('\n');
443                        }
444                        Ok(None) => {}
445                        Err(e) => {
446                            error!("Error reading stderr: {}", e);
447                        }
448                    }
449                }
450                status = child.wait() => {
451                    let status = status.map_err(BuildError::IoError)?;
452                    let exit_code = status.code().unwrap_or(-1);
453
454                    // Drain remaining output
455                    while let Ok(Some(line)) = stdout_reader.next_line().await {
456                        on_output(true, &line);
457                        stdout_output.push_str(&line);
458                        stdout_output.push('\n');
459                    }
460                    while let Ok(Some(line)) = stderr_reader.next_line().await {
461                        on_output(false, &line);
462                        stderr_output.push_str(&line);
463                        stderr_output.push('\n');
464                    }
465
466                    return Ok(CommandOutput {
467                        stdout: stdout_output,
468                        stderr: stderr_output,
469                        exit_code,
470                    });
471                }
472            }
473        }
474    }
475
476    /// Check if buildah is available
477    pub async fn is_available(&self) -> bool {
478        let cmd = BuildahCommand::new("version");
479        self.execute(&cmd).await.is_ok_and(|o| o.success())
480    }
481
482    /// Get buildah version information
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if the version command fails to execute.
487    pub async fn version(&self) -> Result<String> {
488        let cmd = BuildahCommand::new("version");
489        let output = self.execute_checked(&cmd).await?;
490        Ok(output.stdout.trim().to_string())
491    }
492}
493
494/// Find the buildah binary in PATH
495fn which_buildah() -> Result<PathBuf> {
496    // Check common locations
497    let candidates = ["/usr/bin/buildah", "/usr/local/bin/buildah", "/bin/buildah"];
498
499    for path in &candidates {
500        let path = PathBuf::from(path);
501        if path.exists() {
502            return Ok(path);
503        }
504    }
505
506    // Try using `which` command
507    let output = std::process::Command::new("which")
508        .arg("buildah")
509        .output()
510        .ok();
511
512    if let Some(output) = output {
513        if output.status.success() {
514            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
515            if !path.is_empty() {
516                return Ok(PathBuf::from(path));
517            }
518        }
519    }
520
521    Err(BuildError::IoError(std::io::Error::new(
522        std::io::ErrorKind::NotFound,
523        "buildah not found in PATH",
524    )))
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_command_output_success() {
533        let output = CommandOutput {
534            stdout: "success".to_string(),
535            stderr: String::new(),
536            exit_code: 0,
537        };
538        assert!(output.success());
539    }
540
541    #[test]
542    fn test_command_output_failure() {
543        let output = CommandOutput {
544            stdout: String::new(),
545            stderr: "error".to_string(),
546            exit_code: 1,
547        };
548        assert!(!output.success());
549    }
550
551    #[test]
552    fn test_command_output_combined() {
553        let output = CommandOutput {
554            stdout: "out".to_string(),
555            stderr: "err".to_string(),
556            exit_code: 0,
557        };
558        assert_eq!(output.combined_output(), "out\nerr");
559    }
560
561    #[test]
562    fn test_executor_builder() {
563        let executor = BuildahExecutor::with_path("/custom/buildah")
564            .storage_driver("overlay")
565            .root("/var/lib/containers")
566            .runroot("/run/containers");
567
568        assert_eq!(executor.buildah_path, PathBuf::from("/custom/buildah"));
569        assert_eq!(executor.storage_driver, Some("overlay".to_string()));
570        assert_eq!(executor.root, Some(PathBuf::from("/var/lib/containers")));
571        assert_eq!(executor.runroot, Some(PathBuf::from("/run/containers")));
572    }
573
574    // Integration tests would require buildah to be installed
575    #[tokio::test]
576    #[ignore = "requires buildah to be installed"]
577    async fn test_execute_version() {
578        let executor = BuildahExecutor::new().expect("buildah should be available");
579        let version = executor.version().await.expect("should get version");
580        assert!(!version.is_empty());
581    }
582
583    #[tokio::test]
584    #[ignore = "requires buildah to be installed"]
585    async fn test_execute_streaming() {
586        let executor = BuildahExecutor::new().expect("buildah should be available");
587        let cmd = BuildahCommand::new("version");
588
589        let mut lines = Vec::new();
590        let output = executor
591            .execute_streaming(&cmd, |_is_stdout, line| {
592                lines.push(line.to_string());
593            })
594            .await
595            .expect("should execute");
596
597        assert!(output.success());
598        assert!(!lines.is_empty());
599    }
600}