1use 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#[derive(Debug, Clone)]
20pub struct CommandOutput {
21 pub stdout: String,
23
24 pub stderr: String,
26
27 pub exit_code: i32,
29}
30
31impl CommandOutput {
32 #[must_use]
34 pub fn success(&self) -> bool {
35 self.exit_code == 0
36 }
37
38 #[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#[derive(Debug, Clone, Default)]
53pub enum BuildahTransport {
54 #[default]
56 Local,
57 Wsl {
59 distro: String,
61 },
62}
63
64#[derive(Debug, Clone)]
66pub struct BuildahExecutor {
67 buildah_path: PathBuf,
69
70 storage_driver: Option<String>,
72
73 root: Option<PathBuf>,
75
76 runroot: Option<PathBuf>,
78
79 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 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 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 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 #[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 #[must_use]
177 pub fn root(mut self, root: impl Into<PathBuf>) -> Self {
178 self.root = Some(root.into());
179 self
180 }
181
182 #[must_use]
184 pub fn runroot(mut self, runroot: impl Into<PathBuf>) -> Self {
185 self.runroot = Some(runroot.into());
186 self
187 }
188
189 #[must_use]
195 pub fn with_transport(mut self, t: BuildahTransport) -> Self {
196 self.transport = t;
197 self
198 }
199
200 #[must_use]
202 pub fn buildah_path(&self) -> &PathBuf {
203 &self.buildah_path
204 }
205
206 fn build_command(&self, cmd: &BuildahCommand) -> Command {
212 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 for (key, value) in &cmd.env {
243 command.env(key, value);
244 }
245
246 command
247 }
248 BuildahTransport::Wsl { distro } => {
249 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 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 for a in &argv {
277 let s = a.to_string_lossy();
278 let bytes = s.as_bytes();
279 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 #[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 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 pub async fn manifest_create_idempotent(&self, name: &str) -> Result<()> {
372 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 #[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 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 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 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 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
494fn which_buildah() -> Result<PathBuf> {
496 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 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 #[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}