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