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::path::PathBuf;
7use std::process::Stdio;
8
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::Command;
11use tracing::{debug, error, instrument, trace};
12
13use crate::error::{BuildError, Result};
14
15use super::BuildahCommand;
16
17/// Output from a buildah command execution
18#[derive(Debug, Clone)]
19pub struct CommandOutput {
20    /// Standard output from the command
21    pub stdout: String,
22
23    /// Standard error from the command
24    pub stderr: String,
25
26    /// Exit code (0 = success)
27    pub exit_code: i32,
28}
29
30impl CommandOutput {
31    /// Returns true if the command succeeded (exit code 0)
32    #[must_use]
33    pub fn success(&self) -> bool {
34        self.exit_code == 0
35    }
36
37    /// Returns the combined stdout and stderr
38    #[must_use]
39    pub fn combined_output(&self) -> String {
40        if self.stderr.is_empty() {
41            self.stdout.clone()
42        } else if self.stdout.is_empty() {
43            self.stderr.clone()
44        } else {
45            format!("{}\n{}", self.stdout, self.stderr)
46        }
47    }
48}
49
50/// Executor for buildah commands
51#[derive(Debug, Clone)]
52pub struct BuildahExecutor {
53    /// Path to the buildah binary
54    buildah_path: PathBuf,
55
56    /// Default storage driver (if set)
57    storage_driver: Option<String>,
58
59    /// Root directory for buildah storage
60    root: Option<PathBuf>,
61
62    /// Run directory for buildah state
63    runroot: Option<PathBuf>,
64}
65
66impl Default for BuildahExecutor {
67    fn default() -> Self {
68        Self {
69            buildah_path: PathBuf::from("buildah"),
70            storage_driver: None,
71            root: None,
72            runroot: None,
73        }
74    }
75}
76
77impl BuildahExecutor {
78    /// Create a new `BuildahExecutor`, locating the buildah binary (sync version)
79    ///
80    /// This will search for buildah in common system locations and PATH.
81    /// For more comprehensive discovery with version checking, use [`new_async`].
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if buildah is not found in common system locations or PATH.
86    pub fn new() -> Result<Self> {
87        let buildah_path = which_buildah()?;
88        Ok(Self {
89            buildah_path,
90            storage_driver: None,
91            root: None,
92            runroot: None,
93        })
94    }
95
96    /// Create a new `BuildahExecutor` using the `BuildahInstaller`
97    ///
98    /// This async version uses [`BuildahInstaller`] to find buildah and verify
99    /// it meets minimum version requirements. If buildah is not found, it returns
100    /// a helpful error with installation instructions.
101    ///
102    /// # Example
103    ///
104    /// ```no_run
105    /// use zlayer_builder::BuildahExecutor;
106    ///
107    /// # async fn example() -> Result<(), zlayer_builder::BuildError> {
108    /// let executor = BuildahExecutor::new_async().await?;
109    /// let version = executor.version().await?;
110    /// println!("Using buildah version: {}", version);
111    /// # Ok(())
112    /// # }
113    /// ```
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if buildah is not installed or does not meet the minimum version.
118    pub async fn new_async() -> Result<Self> {
119        use super::install::BuildahInstaller;
120
121        let installer = BuildahInstaller::new();
122        let installation = installer
123            .ensure()
124            .await
125            .map_err(|e| BuildError::BuildahNotFound {
126                message: e.to_string(),
127            })?;
128
129        Ok(Self {
130            buildah_path: installation.path,
131            storage_driver: None,
132            root: None,
133            runroot: None,
134        })
135    }
136
137    /// Create a `BuildahExecutor` with a specific path to the buildah binary
138    pub fn with_path(path: impl Into<PathBuf>) -> Self {
139        Self {
140            buildah_path: path.into(),
141            storage_driver: None,
142            root: None,
143            runroot: None,
144        }
145    }
146
147    /// Set the storage driver
148    #[must_use]
149    pub fn storage_driver(mut self, driver: impl Into<String>) -> Self {
150        self.storage_driver = Some(driver.into());
151        self
152    }
153
154    /// Set the root directory for buildah storage
155    #[must_use]
156    pub fn root(mut self, root: impl Into<PathBuf>) -> Self {
157        self.root = Some(root.into());
158        self
159    }
160
161    /// Set the runroot directory for buildah state
162    #[must_use]
163    pub fn runroot(mut self, runroot: impl Into<PathBuf>) -> Self {
164        self.runroot = Some(runroot.into());
165        self
166    }
167
168    /// Get the path to the buildah binary
169    #[must_use]
170    pub fn buildah_path(&self) -> &PathBuf {
171        &self.buildah_path
172    }
173
174    /// Build the base tokio Command with global options
175    fn build_command(&self, cmd: &BuildahCommand) -> Command {
176        let mut command = Command::new(&self.buildah_path);
177
178        // Add global options before subcommand
179        if let Some(ref driver) = self.storage_driver {
180            command.arg("--storage-driver").arg(driver);
181        }
182
183        if let Some(ref root) = self.root {
184            command.arg("--root").arg(root);
185        }
186
187        if let Some(ref runroot) = self.runroot {
188            command.arg("--runroot").arg(runroot);
189        }
190
191        // Add command arguments
192        command.args(&cmd.args);
193
194        // Add environment variables
195        for (key, value) in &cmd.env {
196            command.env(key, value);
197        }
198
199        command
200    }
201
202    /// Execute a buildah command and wait for completion
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the buildah process fails to spawn.
207    #[instrument(skip(self), fields(command = %cmd.to_command_string()))]
208    pub async fn execute(&self, cmd: &BuildahCommand) -> Result<CommandOutput> {
209        debug!("Executing buildah command");
210        trace!("Full command: {:?}", cmd);
211
212        let mut command = self.build_command(cmd);
213        command.stdout(Stdio::piped()).stderr(Stdio::piped());
214
215        let output = command.output().await.map_err(|e| {
216            error!("Failed to spawn buildah process: {}", e);
217            BuildError::IoError(e)
218        })?;
219
220        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
221        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
222        let exit_code = output.status.code().unwrap_or(-1);
223
224        if !output.status.success() {
225            debug!(
226                "Buildah command failed with exit code {}: {}",
227                exit_code,
228                stderr.trim()
229            );
230        }
231
232        Ok(CommandOutput {
233            stdout,
234            stderr,
235            exit_code,
236        })
237    }
238
239    /// Execute a buildah command and return an error if it fails
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if the process fails to spawn or exits with a non-zero code.
244    pub async fn execute_checked(&self, cmd: &BuildahCommand) -> Result<CommandOutput> {
245        let output = self.execute(cmd).await?;
246
247        if !output.success() {
248            return Err(BuildError::BuildahExecution {
249                command: cmd.to_command_string(),
250                exit_code: output.exit_code,
251                stderr: output.stderr,
252            });
253        }
254
255        Ok(output)
256    }
257
258    /// Execute a buildah command with streaming output
259    ///
260    /// The callback is called for each line of output (both stdout and stderr).
261    /// The first parameter indicates whether it's stdout (true) or stderr (false).
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if the process fails to spawn or an I/O error occurs.
266    ///
267    /// # Panics
268    ///
269    /// Panics if stdout or stderr pipes are unexpectedly missing (should not happen
270    /// since they are explicitly configured as piped).
271    #[instrument(skip(self, on_output), fields(command = %cmd.to_command_string()))]
272    pub async fn execute_streaming<F>(
273        &self,
274        cmd: &BuildahCommand,
275        mut on_output: F,
276    ) -> Result<CommandOutput>
277    where
278        F: FnMut(bool, &str),
279    {
280        debug!("Executing buildah command with streaming output");
281
282        let mut command = self.build_command(cmd);
283        command.stdout(Stdio::piped()).stderr(Stdio::piped());
284
285        let mut child = command.spawn().map_err(|e| {
286            error!("Failed to spawn buildah process: {}", e);
287            BuildError::IoError(e)
288        })?;
289
290        let stdout = child.stdout.take().expect("stdout was piped");
291        let stderr = child.stderr.take().expect("stderr was piped");
292
293        let mut stdout_reader = BufReader::new(stdout).lines();
294        let mut stderr_reader = BufReader::new(stderr).lines();
295
296        let mut stdout_output = String::new();
297        let mut stderr_output = String::new();
298
299        // Read stdout and stderr concurrently
300        loop {
301            tokio::select! {
302                line = stdout_reader.next_line() => {
303                    match line {
304                        Ok(Some(line)) => {
305                            on_output(true, &line);
306                            stdout_output.push_str(&line);
307                            stdout_output.push('\n');
308                        }
309                        Ok(None) => {}
310                        Err(e) => {
311                            error!("Error reading stdout: {}", e);
312                        }
313                    }
314                }
315                line = stderr_reader.next_line() => {
316                    match line {
317                        Ok(Some(line)) => {
318                            on_output(false, &line);
319                            stderr_output.push_str(&line);
320                            stderr_output.push('\n');
321                        }
322                        Ok(None) => {}
323                        Err(e) => {
324                            error!("Error reading stderr: {}", e);
325                        }
326                    }
327                }
328                status = child.wait() => {
329                    let status = status.map_err(BuildError::IoError)?;
330                    let exit_code = status.code().unwrap_or(-1);
331
332                    // Drain remaining output
333                    while let Ok(Some(line)) = stdout_reader.next_line().await {
334                        on_output(true, &line);
335                        stdout_output.push_str(&line);
336                        stdout_output.push('\n');
337                    }
338                    while let Ok(Some(line)) = stderr_reader.next_line().await {
339                        on_output(false, &line);
340                        stderr_output.push_str(&line);
341                        stderr_output.push('\n');
342                    }
343
344                    return Ok(CommandOutput {
345                        stdout: stdout_output,
346                        stderr: stderr_output,
347                        exit_code,
348                    });
349                }
350            }
351        }
352    }
353
354    /// Check if buildah is available
355    pub async fn is_available(&self) -> bool {
356        let cmd = BuildahCommand::new("version");
357        self.execute(&cmd)
358            .await
359            .map(|o| o.success())
360            .unwrap_or(false)
361    }
362
363    /// Get buildah version information
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if the version command fails to execute.
368    pub async fn version(&self) -> Result<String> {
369        let cmd = BuildahCommand::new("version");
370        let output = self.execute_checked(&cmd).await?;
371        Ok(output.stdout.trim().to_string())
372    }
373}
374
375/// Find the buildah binary in PATH
376fn which_buildah() -> Result<PathBuf> {
377    // Check common locations
378    let candidates = ["/usr/bin/buildah", "/usr/local/bin/buildah", "/bin/buildah"];
379
380    for path in &candidates {
381        let path = PathBuf::from(path);
382        if path.exists() {
383            return Ok(path);
384        }
385    }
386
387    // Try using `which` command
388    let output = std::process::Command::new("which")
389        .arg("buildah")
390        .output()
391        .ok();
392
393    if let Some(output) = output {
394        if output.status.success() {
395            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
396            if !path.is_empty() {
397                return Ok(PathBuf::from(path));
398            }
399        }
400    }
401
402    Err(BuildError::IoError(std::io::Error::new(
403        std::io::ErrorKind::NotFound,
404        "buildah not found in PATH",
405    )))
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_command_output_success() {
414        let output = CommandOutput {
415            stdout: "success".to_string(),
416            stderr: String::new(),
417            exit_code: 0,
418        };
419        assert!(output.success());
420    }
421
422    #[test]
423    fn test_command_output_failure() {
424        let output = CommandOutput {
425            stdout: String::new(),
426            stderr: "error".to_string(),
427            exit_code: 1,
428        };
429        assert!(!output.success());
430    }
431
432    #[test]
433    fn test_command_output_combined() {
434        let output = CommandOutput {
435            stdout: "out".to_string(),
436            stderr: "err".to_string(),
437            exit_code: 0,
438        };
439        assert_eq!(output.combined_output(), "out\nerr");
440    }
441
442    #[test]
443    fn test_executor_builder() {
444        let executor = BuildahExecutor::with_path("/custom/buildah")
445            .storage_driver("overlay")
446            .root("/var/lib/containers")
447            .runroot("/run/containers");
448
449        assert_eq!(executor.buildah_path, PathBuf::from("/custom/buildah"));
450        assert_eq!(executor.storage_driver, Some("overlay".to_string()));
451        assert_eq!(executor.root, Some(PathBuf::from("/var/lib/containers")));
452        assert_eq!(executor.runroot, Some(PathBuf::from("/run/containers")));
453    }
454
455    // Integration tests would require buildah to be installed
456    #[tokio::test]
457    #[ignore = "requires buildah to be installed"]
458    async fn test_execute_version() {
459        let executor = BuildahExecutor::new().expect("buildah should be available");
460        let version = executor.version().await.expect("should get version");
461        assert!(!version.is_empty());
462    }
463
464    #[tokio::test]
465    #[ignore = "requires buildah to be installed"]
466    async fn test_execute_streaming() {
467        let executor = BuildahExecutor::new().expect("buildah should be available");
468        let cmd = BuildahCommand::new("version");
469
470        let mut lines = Vec::new();
471        let output = executor
472            .execute_streaming(&cmd, |_is_stdout, line| {
473                lines.push(line.to_string());
474            })
475            .await
476            .expect("should execute");
477
478        assert!(output.success());
479        assert!(!lines.is_empty());
480    }
481}