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 pub fn success(&self) -> bool {
33 self.exit_code == 0
34 }
35
36 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#[derive(Debug, Clone)]
50pub struct BuildahExecutor {
51 buildah_path: PathBuf,
53
54 storage_driver: Option<String>,
56
57 root: Option<PathBuf>,
59
60 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 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 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 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 pub fn storage_driver(mut self, driver: impl Into<String>) -> Self {
139 self.storage_driver = Some(driver.into());
140 self
141 }
142
143 pub fn root(mut self, root: impl Into<PathBuf>) -> Self {
145 self.root = Some(root.into());
146 self
147 }
148
149 pub fn runroot(mut self, runroot: impl Into<PathBuf>) -> Self {
151 self.runroot = Some(runroot.into());
152 self
153 }
154
155 pub fn buildah_path(&self) -> &PathBuf {
157 &self.buildah_path
158 }
159
160 fn build_command(&self, cmd: &BuildahCommand) -> Command {
162 let mut command = Command::new(&self.buildah_path);
163
164 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 command.args(&cmd.args);
179
180 for (key, value) in &cmd.env {
182 command.env(key, value);
183 }
184
185 command
186 }
187
188 #[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 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 #[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 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 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 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 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
340fn which_buildah() -> Result<PathBuf> {
342 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 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 #[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}