1use 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#[derive(Debug, Clone)]
19pub struct CommandOutput {
20 pub stdout: String,
22
23 pub stderr: String,
25
26 pub exit_code: i32,
28}
29
30impl CommandOutput {
31 #[must_use]
33 pub fn success(&self) -> bool {
34 self.exit_code == 0
35 }
36
37 #[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#[derive(Debug, Clone)]
52pub struct BuildahExecutor {
53 buildah_path: PathBuf,
55
56 storage_driver: Option<String>,
58
59 root: Option<PathBuf>,
61
62 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 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 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 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 #[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 #[must_use]
156 pub fn root(mut self, root: impl Into<PathBuf>) -> Self {
157 self.root = Some(root.into());
158 self
159 }
160
161 #[must_use]
163 pub fn runroot(mut self, runroot: impl Into<PathBuf>) -> Self {
164 self.runroot = Some(runroot.into());
165 self
166 }
167
168 #[must_use]
170 pub fn buildah_path(&self) -> &PathBuf {
171 &self.buildah_path
172 }
173
174 fn build_command(&self, cmd: &BuildahCommand) -> Command {
176 let mut command = Command::new(&self.buildah_path);
177
178 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 command.args(&cmd.args);
193
194 for (key, value) in &cmd.env {
196 command.env(key, value);
197 }
198
199 command
200 }
201
202 #[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 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 #[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 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 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 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 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
375fn which_buildah() -> Result<PathBuf> {
377 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 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 #[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}