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 warn!("Buildah not found on system");
253 Err(InstallError::NotFound)
254 }
255 Err(e) => Err(e),
256 }
257 }
258
259 #[allow(clippy::unused_async)]
268 pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
269 let (os, arch) = current_platform();
270
271 if !is_platform_supported() {
273 return Err(InstallError::UnsupportedPlatform {
274 os: os.to_string(),
275 arch: arch.to_string(),
276 });
277 }
278
279 Err(InstallError::DownloadFailed(format!(
282 "Automatic download not yet implemented. {}",
283 install_instructions()
284 )))
285 }
286}
287
288#[must_use]
290pub fn current_platform() -> (&'static str, &'static str) {
291 let os = std::env::consts::OS; let arch = std::env::consts::ARCH; (os, arch)
294}
295
296#[must_use]
301pub fn is_platform_supported() -> bool {
302 let (os, arch) = current_platform();
303 matches!((os, arch), ("linux" | "macos", "x86_64" | "aarch64"))
304}
305
306#[must_use]
308pub fn install_instructions() -> String {
309 let (os, _arch) = current_platform();
310
311 match os {
312 "linux" => {
313 if let Some(distro) = detect_linux_distro() {
315 match distro.as_str() {
316 "ubuntu" | "debian" | "pop" | "mint" | "elementary" => {
317 "Install with: sudo apt install buildah".to_string()
318 }
319 "fedora" | "rhel" | "centos" | "rocky" | "alma" => {
320 "Install with: sudo dnf install buildah".to_string()
321 }
322 "arch" | "manjaro" | "endeavouros" => {
323 "Install with: sudo pacman -S buildah".to_string()
324 }
325 "opensuse" | "suse" => "Install with: sudo zypper install buildah".to_string(),
326 "alpine" => "Install with: sudo apk add buildah".to_string(),
327 "gentoo" => "Install with: sudo emerge app-containers/buildah".to_string(),
328 "void" => "Install with: sudo xbps-install buildah".to_string(),
329 _ => "Install buildah using your distribution's package manager.\n\
330 Common commands:\n\
331 - Debian/Ubuntu: sudo apt install buildah\n\
332 - Fedora/RHEL: sudo dnf install buildah\n\
333 - Arch: sudo pacman -S buildah\n\
334 - openSUSE: sudo zypper install buildah"
335 .to_string(),
336 }
337 } else {
338 "Install buildah using your distribution's package manager.\n\
339 Common commands:\n\
340 - Debian/Ubuntu: sudo apt install buildah\n\
341 - Fedora/RHEL: sudo dnf install buildah\n\
342 - Arch: sudo pacman -S buildah\n\
343 - openSUSE: sudo zypper install buildah"
344 .to_string()
345 }
346 }
347 "macos" => "Install with: brew install buildah\n\
348 Note: Buildah on macOS requires a Linux VM for container operations."
349 .to_string(),
350 "windows" => "Buildah is not natively supported on Windows.\n\
351 Consider using WSL2 with a Linux distribution and installing buildah there."
352 .to_string(),
353 _ => format!("Buildah is not supported on {os}. Use a Linux system."),
354 }
355}
356
357fn default_install_dir() -> PathBuf {
363 if let Some(home) = dirs::home_dir() {
365 return home.join(".zlayer").join("bin");
366 }
367
368 PathBuf::from("/usr/local/lib/zlayer")
370}
371
372fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
374 let mut paths = Vec::new();
375
376 paths.push(install_dir.join("buildah"));
378
379 if let Some(home) = dirs::home_dir() {
381 paths.push(home.join(".zlayer").join("bin").join("buildah"));
382 }
383
384 paths.push(PathBuf::from("/usr/local/lib/zlayer/buildah"));
386
387 paths.push(PathBuf::from("/usr/bin/buildah"));
389 paths.push(PathBuf::from("/usr/local/bin/buildah"));
390 paths.push(PathBuf::from("/bin/buildah"));
391
392 let mut seen = std::collections::HashSet::new();
394 paths.retain(|p| seen.insert(p.clone()));
395
396 paths
397}
398
399async fn find_in_path(binary: &str) -> Option<PathBuf> {
401 let output = Command::new("which")
402 .arg(binary)
403 .stdout(Stdio::piped())
404 .stderr(Stdio::null())
405 .output()
406 .await
407 .ok()?;
408
409 if output.status.success() {
410 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
411 if !path.is_empty() {
412 return Some(PathBuf::from(path));
413 }
414 }
415
416 let output = Command::new("sh")
418 .args(["-c", &format!("command -v {binary}")])
419 .stdout(Stdio::piped())
420 .stderr(Stdio::null())
421 .output()
422 .await
423 .ok()?;
424
425 if output.status.success() {
426 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
427 if !path.is_empty() {
428 return Some(PathBuf::from(path));
429 }
430 }
431
432 None
433}
434
435fn parse_version(output: &str) -> Result<String, InstallError> {
442 let output = output.trim();
444
445 if let Some(pos) = output.to_lowercase().find("version") {
447 let after_version = &output[pos + "version".len()..].trim_start();
448
449 let version: String = after_version
452 .chars()
453 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
454 .collect();
455
456 let version = version.trim_end_matches('-');
458
459 if !version.is_empty() && version.contains('.') {
460 return Ok(version.to_string());
461 }
462 }
463
464 Err(InstallError::VersionParse(format!(
465 "Could not parse version from: {output}"
466 )))
467}
468
469fn version_meets_minimum(version: &str, minimum: &str) -> bool {
473 let parse_version = |s: &str| -> Option<Vec<u32>> {
474 let clean = s.split('-').next()?;
476 clean
477 .split('.')
478 .map(|p| p.parse::<u32>().ok())
479 .collect::<Option<Vec<_>>>()
480 };
481
482 let Some(version_parts) = parse_version(version) else {
483 return false;
484 };
485
486 let Some(minimum_parts) = parse_version(minimum) else {
487 return true; };
489
490 for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
492 match v.cmp(m) {
493 std::cmp::Ordering::Greater => return true,
494 std::cmp::Ordering::Less => return false,
495 std::cmp::Ordering::Equal => {}
496 }
497 }
498
499 version_parts.len() >= minimum_parts.len()
501}
502
503fn detect_linux_distro() -> Option<String> {
505 if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
507 for line in contents.lines() {
508 if let Some(id) = line.strip_prefix("ID=") {
509 return Some(id.trim_matches('"').to_lowercase());
510 }
511 }
512 }
513
514 if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
516 for line in contents.lines() {
517 if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
518 return Some(id.trim_matches('"').to_lowercase());
519 }
520 }
521 }
522
523 None
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_parse_version_full() {
532 let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
533 let version = parse_version(output).unwrap();
534 assert_eq!(version, "1.33.0");
535 }
536
537 #[test]
538 fn test_parse_version_simple() {
539 let output = "buildah version 1.28.0";
540 let version = parse_version(output).unwrap();
541 assert_eq!(version, "1.28.0");
542 }
543
544 #[test]
545 fn test_parse_version_with_newline() {
546 let output = "buildah version 1.33.0\n";
547 let version = parse_version(output).unwrap();
548 assert_eq!(version, "1.33.0");
549 }
550
551 #[test]
552 fn test_parse_version_dev() {
553 let output = "buildah version 1.34.0-dev";
554 let version = parse_version(output).unwrap();
555 assert_eq!(version, "1.34.0-dev");
556 }
557
558 #[test]
559 fn test_parse_version_invalid() {
560 let output = "some random output";
561 assert!(parse_version(output).is_err());
562 }
563
564 #[test]
565 fn test_version_meets_minimum_equal() {
566 assert!(version_meets_minimum("1.28.0", "1.28.0"));
567 }
568
569 #[test]
570 fn test_version_meets_minimum_greater_patch() {
571 assert!(version_meets_minimum("1.28.1", "1.28.0"));
572 }
573
574 #[test]
575 fn test_version_meets_minimum_greater_minor() {
576 assert!(version_meets_minimum("1.29.0", "1.28.0"));
577 }
578
579 #[test]
580 fn test_version_meets_minimum_greater_major() {
581 assert!(version_meets_minimum("2.0.0", "1.28.0"));
582 }
583
584 #[test]
585 fn test_version_meets_minimum_less_patch() {
586 assert!(!version_meets_minimum("1.27.5", "1.28.0"));
587 }
588
589 #[test]
590 fn test_version_meets_minimum_less_minor() {
591 assert!(!version_meets_minimum("1.20.0", "1.28.0"));
592 }
593
594 #[test]
595 fn test_version_meets_minimum_less_major() {
596 assert!(!version_meets_minimum("0.99.0", "1.28.0"));
597 }
598
599 #[test]
600 fn test_version_meets_minimum_with_dev_suffix() {
601 assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
602 }
603
604 #[test]
605 fn test_current_platform() {
606 let (os, arch) = current_platform();
607 assert!(!os.is_empty());
609 assert!(!arch.is_empty());
610 }
611
612 #[test]
613 fn test_is_platform_supported() {
614 let (os, arch) = current_platform();
616 let supported = is_platform_supported();
617
618 match (os, arch) {
619 ("linux", "x86_64" | "aarch64") => assert!(supported),
620 ("macos", "x86_64" | "aarch64") => assert!(supported),
621 _ => assert!(!supported),
622 }
623 }
624
625 #[test]
626 fn test_install_instructions_not_empty() {
627 let instructions = install_instructions();
628 assert!(!instructions.is_empty());
629 }
630
631 #[test]
632 fn test_default_install_dir() {
633 let dir = default_install_dir();
634 assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
636 }
637
638 #[test]
639 fn test_get_search_paths_not_empty() {
640 let install_dir = PathBuf::from("/tmp/zlayer-test");
641 let paths = get_search_paths(&install_dir);
642 assert!(!paths.is_empty());
643 assert!(paths[0].starts_with("/tmp/zlayer-test"));
645 }
646
647 #[test]
648 fn test_installer_creation() {
649 let installer = BuildahInstaller::new();
650 assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
651 }
652
653 #[test]
654 fn test_installer_with_custom_dir() {
655 let custom_dir = PathBuf::from("/custom/path");
656 let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
657 assert_eq!(installer.install_dir(), custom_dir);
658 }
659
660 #[tokio::test]
661 async fn test_find_in_path_nonexistent() {
662 let result = find_in_path("this_binary_should_not_exist_12345").await;
664 assert!(result.is_none());
665 }
666
667 #[tokio::test]
668 async fn test_find_in_path_exists() {
669 let result = find_in_path("sh").await;
671 assert!(result.is_some());
672 }
673
674 #[tokio::test]
676 #[ignore = "requires buildah to be installed"]
677 async fn test_find_existing_buildah() {
678 let installer = BuildahInstaller::new();
679 let result = installer.find_existing().await;
680 assert!(result.is_some());
681 let installation = result.unwrap();
682 assert!(installation.path.exists());
683 assert!(!installation.version.is_empty());
684 }
685
686 #[tokio::test]
687 #[ignore = "requires buildah to be installed"]
688 async fn test_check_buildah() {
689 let installer = BuildahInstaller::new();
690 let result = installer.check().await;
691 assert!(result.is_ok());
692 }
693
694 #[tokio::test]
695 #[ignore = "requires buildah to be installed"]
696 async fn test_ensure_buildah() {
697 let installer = BuildahInstaller::new();
698 let result = installer.ensure().await;
699 assert!(result.is_ok());
700 }
701
702 #[tokio::test]
703 #[ignore = "requires buildah to be installed"]
704 async fn test_get_version() {
705 let installer = BuildahInstaller::new();
706 if let Some(installation) = installer.find_existing().await {
707 let version = BuildahInstaller::get_version(&installation.path).await;
708 assert!(version.is_ok());
709 let version = version.unwrap();
710 assert!(version.contains('.'));
711 }
712 }
713}