1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use tokio::process::Command;
10use tracing::{debug, info, trace, warn};
11
12const MIN_BUILDAH_VERSION: &str = "1.28.0";
14
15#[derive(Debug, Clone)]
20pub struct BuildahInstaller {
21 install_dir: PathBuf,
23 min_version: &'static str,
25}
26
27#[derive(Debug, Clone)]
29pub struct BuildahInstallation {
30 pub path: PathBuf,
32 pub version: String,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum InstallError {
39 #[error("Buildah not found. {}", install_instructions())]
41 NotFound,
42
43 #[error("Buildah version {found} is below minimum required version {required}")]
45 VersionTooOld {
46 found: String,
48 required: String,
50 },
51
52 #[error("Unsupported platform: {os}/{arch}")]
54 UnsupportedPlatform {
55 os: String,
57 arch: String,
59 },
60
61 #[error("Download failed: {0}")]
63 DownloadFailed(String),
64
65 #[error("IO error: {0}")]
67 Io(#[from] std::io::Error),
68
69 #[error("Failed to parse buildah version output: {0}")]
71 VersionParse(String),
72
73 #[error("Failed to execute buildah: {0}")]
75 ExecutionFailed(String),
76}
77
78impl Default for BuildahInstaller {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl BuildahInstaller {
85 #[must_use]
90 pub fn new() -> Self {
91 let install_dir = default_install_dir();
92 Self {
93 install_dir,
94 min_version: MIN_BUILDAH_VERSION,
95 }
96 }
97
98 #[must_use]
100 pub fn with_install_dir(dir: PathBuf) -> Self {
101 Self {
102 install_dir: dir,
103 min_version: MIN_BUILDAH_VERSION,
104 }
105 }
106
107 #[must_use]
109 pub fn install_dir(&self) -> &Path {
110 &self.install_dir
111 }
112
113 #[must_use]
115 pub fn min_version(&self) -> &str {
116 self.min_version
117 }
118
119 pub async fn find_existing(&self) -> Option<BuildahInstallation> {
128 let search_paths = get_search_paths(&self.install_dir);
130
131 for path in search_paths {
132 trace!("Checking for buildah at: {}", path.display());
133
134 if path.exists() && path.is_file() {
135 match Self::get_version(&path).await {
136 Ok(version) => {
137 info!("Found buildah at {} (version {})", path.display(), version);
138 return Some(BuildahInstallation { path, version });
139 }
140 Err(e) => {
141 debug!(
142 "Found buildah at {} but couldn't get version: {}",
143 path.display(),
144 e
145 );
146 }
147 }
148 }
149 }
150
151 if let Some(path) = find_in_path("buildah").await {
153 match Self::get_version(&path).await {
154 Ok(version) => {
155 info!(
156 "Found buildah in PATH at {} (version {})",
157 path.display(),
158 version
159 );
160 return Some(BuildahInstallation { path, version });
161 }
162 Err(e) => {
163 debug!(
164 "Found buildah in PATH at {} but couldn't get version: {}",
165 path.display(),
166 e
167 );
168 }
169 }
170 }
171
172 None
173 }
174
175 pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
183 let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
184
185 if !version_meets_minimum(&installation.version, self.min_version) {
187 return Err(InstallError::VersionTooOld {
188 found: installation.version,
189 required: self.min_version.to_string(),
190 });
191 }
192
193 Ok(installation)
194 }
195
196 pub async fn get_version(path: &Path) -> Result<String, InstallError> {
205 let output = Command::new(path)
206 .arg("--version")
207 .stdout(Stdio::piped())
208 .stderr(Stdio::piped())
209 .output()
210 .await
211 .map_err(|e| InstallError::ExecutionFailed(e.to_string()))?;
212
213 if !output.status.success() {
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 return Err(InstallError::ExecutionFailed(format!(
216 "buildah --version failed: {}",
217 stderr.trim()
218 )));
219 }
220
221 let stdout = String::from_utf8_lossy(&output.stdout);
222 parse_version(&stdout)
223 }
224
225 pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
234 match self.check().await {
236 Ok(installation) => {
237 info!(
238 "Using buildah {} at {}",
239 installation.version,
240 installation.path.display()
241 );
242 Ok(installation)
243 }
244 Err(InstallError::VersionTooOld { found, required }) => {
245 warn!(
246 "Found buildah {} but minimum required version is {}",
247 found, required
248 );
249 Err(InstallError::VersionTooOld { found, required })
250 }
251 Err(InstallError::NotFound) => {
252 #[cfg(target_os = "macos")]
253 debug!("Buildah not available on macOS; will use native sandbox builder");
254 #[cfg(not(target_os = "macos"))]
255 warn!("Buildah not found on system");
256 Err(InstallError::NotFound)
257 }
258 Err(e) => Err(e),
259 }
260 }
261
262 #[allow(clippy::unused_async)]
271 pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
272 let (os, arch) = current_platform();
273
274 if !is_platform_supported() {
276 return Err(InstallError::UnsupportedPlatform {
277 os: os.to_string(),
278 arch: arch.to_string(),
279 });
280 }
281
282 Err(InstallError::DownloadFailed(format!(
285 "Automatic download not yet implemented. {}",
286 install_instructions()
287 )))
288 }
289}
290
291#[must_use]
293pub fn current_platform() -> (&'static str, &'static str) {
294 let os = std::env::consts::OS; let arch = std::env::consts::ARCH; (os, arch)
297}
298
299#[must_use]
304pub fn is_platform_supported() -> bool {
305 let (os, arch) = current_platform();
306 matches!((os, arch), ("linux" | "macos", "x86_64" | "aarch64"))
307}
308
309#[must_use]
311pub fn install_instructions() -> String {
312 let (os, _arch) = current_platform();
313
314 match os {
315 "linux" => {
316 if let Some(distro) = detect_linux_distro() {
318 match distro.as_str() {
319 "ubuntu" | "debian" | "pop" | "mint" | "elementary" => {
320 "Install with: sudo apt install buildah".to_string()
321 }
322 "fedora" | "rhel" | "centos" | "rocky" | "alma" => {
323 "Install with: sudo dnf install buildah".to_string()
324 }
325 "arch" | "manjaro" | "endeavouros" => {
326 "Install with: sudo pacman -S buildah".to_string()
327 }
328 "opensuse" | "suse" => "Install with: sudo zypper install buildah".to_string(),
329 "alpine" => "Install with: sudo apk add buildah".to_string(),
330 "gentoo" => "Install with: sudo emerge app-containers/buildah".to_string(),
331 "void" => "Install with: sudo xbps-install buildah".to_string(),
332 _ => "Install buildah using your distribution's package manager.\n\
333 Common commands:\n\
334 - Debian/Ubuntu: sudo apt install buildah\n\
335 - Fedora/RHEL: sudo dnf install buildah\n\
336 - Arch: sudo pacman -S buildah\n\
337 - openSUSE: sudo zypper install buildah"
338 .to_string(),
339 }
340 } else {
341 "Install buildah using your distribution's package manager.\n\
342 Common commands:\n\
343 - Debian/Ubuntu: sudo apt install buildah\n\
344 - Fedora/RHEL: sudo dnf install buildah\n\
345 - Arch: sudo pacman -S buildah\n\
346 - openSUSE: sudo zypper install buildah"
347 .to_string()
348 }
349 }
350 "macos" => "Buildah is not required on macOS -- ZLayer uses the native \
351 sandbox builder instead. If you prefer buildah, install with: \
352 brew install buildah (requires a Linux VM for container operations)."
353 .to_string(),
354 "windows" => "Buildah is not natively supported on Windows.\n\
355 Consider using WSL2 with a Linux distribution and installing buildah there."
356 .to_string(),
357 _ => format!("Buildah is not supported on {os}. Use a Linux system."),
358 }
359}
360
361fn default_install_dir() -> PathBuf {
367 zlayer_paths::ZLayerDirs::system_default().bin()
368}
369
370fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
372 let mut paths = vec![
373 install_dir.join("buildah"),
375 zlayer_paths::ZLayerDirs::system_default()
377 .bin()
378 .join("buildah"),
379 PathBuf::from("/usr/local/lib/zlayer/buildah"),
381 PathBuf::from("/usr/bin/buildah"),
383 PathBuf::from("/usr/local/bin/buildah"),
384 PathBuf::from("/bin/buildah"),
385 ];
386
387 let mut seen = std::collections::HashSet::new();
389 paths.retain(|p| seen.insert(p.clone()));
390
391 paths
392}
393
394async fn find_in_path(binary: &str) -> Option<PathBuf> {
398 #[cfg(windows)]
399 {
400 if let Ok(output) = Command::new("where")
401 .arg(binary)
402 .stdout(Stdio::piped())
403 .stderr(Stdio::null())
404 .output()
405 .await
406 {
407 if output.status.success() {
408 let stdout = String::from_utf8_lossy(&output.stdout);
409 if let Some(first) = stdout.lines().next() {
412 let path = first.trim().to_string();
413 if !path.is_empty() {
414 return Some(PathBuf::from(path));
415 }
416 }
417 }
418 }
419 }
420
421 let output = Command::new("which")
422 .arg(binary)
423 .stdout(Stdio::piped())
424 .stderr(Stdio::null())
425 .output()
426 .await
427 .ok()?;
428
429 if output.status.success() {
430 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
431 if !path.is_empty() {
432 return Some(PathBuf::from(path));
433 }
434 }
435
436 let output = Command::new("sh")
438 .args(["-c", &format!("command -v {binary}")])
439 .stdout(Stdio::piped())
440 .stderr(Stdio::null())
441 .output()
442 .await
443 .ok()?;
444
445 if output.status.success() {
446 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
447 if !path.is_empty() {
448 return Some(PathBuf::from(path));
449 }
450 }
451
452 None
453}
454
455fn parse_version(output: &str) -> Result<String, InstallError> {
462 let output = output.trim();
464
465 if let Some(pos) = output.to_lowercase().find("version") {
467 let after_version = &output[pos + "version".len()..].trim_start();
468
469 let version: String = after_version
472 .chars()
473 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
474 .collect();
475
476 let version = version.trim_end_matches('-');
478
479 if !version.is_empty() && version.contains('.') {
480 return Ok(version.to_string());
481 }
482 }
483
484 Err(InstallError::VersionParse(format!(
485 "Could not parse version from: {output}"
486 )))
487}
488
489fn version_meets_minimum(version: &str, minimum: &str) -> bool {
493 let parse_version = |s: &str| -> Option<Vec<u32>> {
494 let clean = s.split('-').next()?;
496 clean
497 .split('.')
498 .map(|p| p.parse::<u32>().ok())
499 .collect::<Option<Vec<_>>>()
500 };
501
502 let Some(version_parts) = parse_version(version) else {
503 return false;
504 };
505
506 let Some(minimum_parts) = parse_version(minimum) else {
507 return true; };
509
510 for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
512 match v.cmp(m) {
513 std::cmp::Ordering::Greater => return true,
514 std::cmp::Ordering::Less => return false,
515 std::cmp::Ordering::Equal => {}
516 }
517 }
518
519 version_parts.len() >= minimum_parts.len()
521}
522
523fn detect_linux_distro() -> Option<String> {
525 if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
527 for line in contents.lines() {
528 if let Some(id) = line.strip_prefix("ID=") {
529 return Some(id.trim_matches('"').to_lowercase());
530 }
531 }
532 }
533
534 if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
536 for line in contents.lines() {
537 if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
538 return Some(id.trim_matches('"').to_lowercase());
539 }
540 }
541 }
542
543 None
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn test_parse_version_full() {
552 let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
553 let version = parse_version(output).unwrap();
554 assert_eq!(version, "1.33.0");
555 }
556
557 #[test]
558 fn test_parse_version_simple() {
559 let output = "buildah version 1.28.0";
560 let version = parse_version(output).unwrap();
561 assert_eq!(version, "1.28.0");
562 }
563
564 #[test]
565 fn test_parse_version_with_newline() {
566 let output = "buildah version 1.33.0\n";
567 let version = parse_version(output).unwrap();
568 assert_eq!(version, "1.33.0");
569 }
570
571 #[test]
572 fn test_parse_version_dev() {
573 let output = "buildah version 1.34.0-dev";
574 let version = parse_version(output).unwrap();
575 assert_eq!(version, "1.34.0-dev");
576 }
577
578 #[test]
579 fn test_parse_version_invalid() {
580 let output = "some random output";
581 assert!(parse_version(output).is_err());
582 }
583
584 #[test]
585 fn test_version_meets_minimum_equal() {
586 assert!(version_meets_minimum("1.28.0", "1.28.0"));
587 }
588
589 #[test]
590 fn test_version_meets_minimum_greater_patch() {
591 assert!(version_meets_minimum("1.28.1", "1.28.0"));
592 }
593
594 #[test]
595 fn test_version_meets_minimum_greater_minor() {
596 assert!(version_meets_minimum("1.29.0", "1.28.0"));
597 }
598
599 #[test]
600 fn test_version_meets_minimum_greater_major() {
601 assert!(version_meets_minimum("2.0.0", "1.28.0"));
602 }
603
604 #[test]
605 fn test_version_meets_minimum_less_patch() {
606 assert!(!version_meets_minimum("1.27.5", "1.28.0"));
607 }
608
609 #[test]
610 fn test_version_meets_minimum_less_minor() {
611 assert!(!version_meets_minimum("1.20.0", "1.28.0"));
612 }
613
614 #[test]
615 fn test_version_meets_minimum_less_major() {
616 assert!(!version_meets_minimum("0.99.0", "1.28.0"));
617 }
618
619 #[test]
620 fn test_version_meets_minimum_with_dev_suffix() {
621 assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
622 }
623
624 #[test]
625 fn test_current_platform() {
626 let (os, arch) = current_platform();
627 assert!(!os.is_empty());
629 assert!(!arch.is_empty());
630 }
631
632 #[test]
633 fn test_is_platform_supported() {
634 let (os, arch) = current_platform();
636 let supported = is_platform_supported();
637
638 match (os, arch) {
639 ("linux" | "macos", "x86_64" | "aarch64") => assert!(supported),
640 _ => assert!(!supported),
641 }
642 }
643
644 #[test]
645 fn test_install_instructions_not_empty() {
646 let instructions = install_instructions();
647 assert!(!instructions.is_empty());
648 }
649
650 #[test]
651 fn test_default_install_dir() {
652 let dir = default_install_dir();
653 assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
655 }
656
657 #[test]
658 fn test_get_search_paths_not_empty() {
659 let install_dir = PathBuf::from("/tmp/zlayer-test");
660 let paths = get_search_paths(&install_dir);
661 assert!(!paths.is_empty());
662 assert!(paths[0].starts_with("/tmp/zlayer-test"));
664 }
665
666 #[test]
667 fn test_installer_creation() {
668 let installer = BuildahInstaller::new();
669 assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
670 }
671
672 #[test]
673 fn test_installer_with_custom_dir() {
674 let custom_dir = PathBuf::from("/custom/path");
675 let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
676 assert_eq!(installer.install_dir(), custom_dir);
677 }
678
679 #[tokio::test]
680 async fn test_find_in_path_nonexistent() {
681 let result = find_in_path("this_binary_should_not_exist_12345").await;
683 assert!(result.is_none());
684 }
685
686 #[tokio::test]
687 async fn test_find_in_path_exists() {
688 #[cfg(unix)]
690 let probe = "sh";
691 #[cfg(windows)]
692 let probe = "cmd.exe";
693 let result = find_in_path(probe).await;
694 assert!(
695 result.is_some(),
696 "find_in_path({probe:?}) should find a system binary"
697 );
698 }
699
700 #[tokio::test]
702 #[ignore = "requires buildah to be installed"]
703 async fn test_find_existing_buildah() {
704 let installer = BuildahInstaller::new();
705 let result = installer.find_existing().await;
706 assert!(result.is_some());
707 let installation = result.unwrap();
708 assert!(installation.path.exists());
709 assert!(!installation.version.is_empty());
710 }
711
712 #[tokio::test]
713 #[ignore = "requires buildah to be installed"]
714 async fn test_check_buildah() {
715 let installer = BuildahInstaller::new();
716 let result = installer.check().await;
717 assert!(result.is_ok());
718 }
719
720 #[tokio::test]
721 #[ignore = "requires buildah to be installed"]
722 async fn test_ensure_buildah() {
723 let installer = BuildahInstaller::new();
724 let result = installer.ensure().await;
725 assert!(result.is_ok());
726 }
727
728 #[tokio::test]
729 #[ignore = "requires buildah to be installed"]
730 async fn test_get_version() {
731 let installer = BuildahInstaller::new();
732 if let Some(installation) = installer.find_existing().await {
733 let version = BuildahInstaller::get_version(&installation.path).await;
734 assert!(version.is_ok());
735 let version = version.unwrap();
736 assert!(version.contains('.'));
737 }
738 }
739}