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 pub fn new() -> Self {
90 let install_dir = default_install_dir();
91 Self {
92 install_dir,
93 min_version: MIN_BUILDAH_VERSION,
94 }
95 }
96
97 pub fn with_install_dir(dir: PathBuf) -> Self {
99 Self {
100 install_dir: dir,
101 min_version: MIN_BUILDAH_VERSION,
102 }
103 }
104
105 pub fn install_dir(&self) -> &Path {
107 &self.install_dir
108 }
109
110 pub fn min_version(&self) -> &str {
112 self.min_version
113 }
114
115 pub async fn find_existing(&self) -> Option<BuildahInstallation> {
124 let search_paths = get_search_paths(&self.install_dir);
126
127 for path in search_paths {
128 trace!("Checking for buildah at: {}", path.display());
129
130 if path.exists() && path.is_file() {
131 match Self::get_version(&path).await {
132 Ok(version) => {
133 info!("Found buildah at {} (version {})", path.display(), version);
134 return Some(BuildahInstallation { path, version });
135 }
136 Err(e) => {
137 debug!(
138 "Found buildah at {} but couldn't get version: {}",
139 path.display(),
140 e
141 );
142 }
143 }
144 }
145 }
146
147 if let Some(path) = find_in_path("buildah").await {
149 match Self::get_version(&path).await {
150 Ok(version) => {
151 info!(
152 "Found buildah in PATH at {} (version {})",
153 path.display(),
154 version
155 );
156 return Some(BuildahInstallation { path, version });
157 }
158 Err(e) => {
159 debug!(
160 "Found buildah in PATH at {} but couldn't get version: {}",
161 path.display(),
162 e
163 );
164 }
165 }
166 }
167
168 None
169 }
170
171 pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
175 let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
176
177 if !version_meets_minimum(&installation.version, self.min_version) {
179 return Err(InstallError::VersionTooOld {
180 found: installation.version,
181 required: self.min_version.to_string(),
182 });
183 }
184
185 Ok(installation)
186 }
187
188 pub async fn get_version(path: &Path) -> Result<String, InstallError> {
193 let output = Command::new(path)
194 .arg("--version")
195 .stdout(Stdio::piped())
196 .stderr(Stdio::piped())
197 .output()
198 .await
199 .map_err(|e| InstallError::ExecutionFailed(e.to_string()))?;
200
201 if !output.status.success() {
202 let stderr = String::from_utf8_lossy(&output.stderr);
203 return Err(InstallError::ExecutionFailed(format!(
204 "buildah --version failed: {}",
205 stderr.trim()
206 )));
207 }
208
209 let stdout = String::from_utf8_lossy(&output.stdout);
210 parse_version(&stdout)
211 }
212
213 pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
218 match self.check().await {
220 Ok(installation) => {
221 info!(
222 "Using buildah {} at {}",
223 installation.version,
224 installation.path.display()
225 );
226 Ok(installation)
227 }
228 Err(InstallError::VersionTooOld { found, required }) => {
229 warn!(
230 "Found buildah {} but minimum required version is {}",
231 found, required
232 );
233 Err(InstallError::VersionTooOld { found, required })
234 }
235 Err(InstallError::NotFound) => {
236 warn!("Buildah not found on system");
237 Err(InstallError::NotFound)
238 }
239 Err(e) => Err(e),
240 }
241 }
242
243 pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
248 let (os, arch) = current_platform();
249
250 if !is_platform_supported() {
252 return Err(InstallError::UnsupportedPlatform {
253 os: os.to_string(),
254 arch: arch.to_string(),
255 });
256 }
257
258 Err(InstallError::DownloadFailed(format!(
261 "Automatic download not yet implemented. {}",
262 install_instructions()
263 )))
264 }
265}
266
267pub fn current_platform() -> (&'static str, &'static str) {
269 let os = std::env::consts::OS; let arch = std::env::consts::ARCH; (os, arch)
272}
273
274pub fn is_platform_supported() -> bool {
279 let (os, arch) = current_platform();
280 matches!(
281 (os, arch),
282 ("linux", "x86_64" | "aarch64") | ("macos", "x86_64" | "aarch64")
283 )
284}
285
286pub fn install_instructions() -> String {
288 let (os, _arch) = current_platform();
289
290 match os {
291 "linux" => {
292 if let Some(distro) = detect_linux_distro() {
294 match distro.as_str() {
295 "ubuntu" | "debian" | "pop" | "mint" | "elementary" => {
296 "Install with: sudo apt install buildah".to_string()
297 }
298 "fedora" | "rhel" | "centos" | "rocky" | "alma" => {
299 "Install with: sudo dnf install buildah".to_string()
300 }
301 "arch" | "manjaro" | "endeavouros" => {
302 "Install with: sudo pacman -S buildah".to_string()
303 }
304 "opensuse" | "suse" => "Install with: sudo zypper install buildah".to_string(),
305 "alpine" => "Install with: sudo apk add buildah".to_string(),
306 "gentoo" => "Install with: sudo emerge app-containers/buildah".to_string(),
307 "void" => "Install with: sudo xbps-install buildah".to_string(),
308 _ => "Install buildah using your distribution's package manager.\n\
309 Common commands:\n\
310 - Debian/Ubuntu: sudo apt install buildah\n\
311 - Fedora/RHEL: sudo dnf install buildah\n\
312 - Arch: sudo pacman -S buildah\n\
313 - openSUSE: sudo zypper install buildah"
314 .to_string(),
315 }
316 } else {
317 "Install buildah using your distribution's package manager.\n\
318 Common commands:\n\
319 - Debian/Ubuntu: sudo apt install buildah\n\
320 - Fedora/RHEL: sudo dnf install buildah\n\
321 - Arch: sudo pacman -S buildah\n\
322 - openSUSE: sudo zypper install buildah"
323 .to_string()
324 }
325 }
326 "macos" => "Install with: brew install buildah\n\
327 Note: Buildah on macOS requires a Linux VM for container operations."
328 .to_string(),
329 "windows" => "Buildah is not natively supported on Windows.\n\
330 Consider using WSL2 with a Linux distribution and installing buildah there."
331 .to_string(),
332 _ => format!("Buildah is not supported on {os}. Use a Linux system."),
333 }
334}
335
336fn default_install_dir() -> PathBuf {
342 if let Some(home) = dirs::home_dir() {
344 return home.join(".zlayer").join("bin");
345 }
346
347 PathBuf::from("/usr/local/lib/zlayer")
349}
350
351fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
353 let mut paths = Vec::new();
354
355 paths.push(install_dir.join("buildah"));
357
358 if let Some(home) = dirs::home_dir() {
360 paths.push(home.join(".zlayer").join("bin").join("buildah"));
361 }
362
363 paths.push(PathBuf::from("/usr/local/lib/zlayer/buildah"));
365
366 paths.push(PathBuf::from("/usr/bin/buildah"));
368 paths.push(PathBuf::from("/usr/local/bin/buildah"));
369 paths.push(PathBuf::from("/bin/buildah"));
370
371 let mut seen = std::collections::HashSet::new();
373 paths.retain(|p| seen.insert(p.clone()));
374
375 paths
376}
377
378async fn find_in_path(binary: &str) -> Option<PathBuf> {
380 let output = Command::new("which")
381 .arg(binary)
382 .stdout(Stdio::piped())
383 .stderr(Stdio::null())
384 .output()
385 .await
386 .ok()?;
387
388 if output.status.success() {
389 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
390 if !path.is_empty() {
391 return Some(PathBuf::from(path));
392 }
393 }
394
395 let output = Command::new("sh")
397 .args(["-c", &format!("command -v {}", binary)])
398 .stdout(Stdio::piped())
399 .stderr(Stdio::null())
400 .output()
401 .await
402 .ok()?;
403
404 if output.status.success() {
405 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
406 if !path.is_empty() {
407 return Some(PathBuf::from(path));
408 }
409 }
410
411 None
412}
413
414fn parse_version(output: &str) -> Result<String, InstallError> {
421 let output = output.trim();
423
424 if let Some(pos) = output.to_lowercase().find("version") {
426 let after_version = &output[pos + "version".len()..].trim_start();
427
428 let version: String = after_version
431 .chars()
432 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
433 .collect();
434
435 let version = version.trim_end_matches('-');
437
438 if !version.is_empty() && version.contains('.') {
439 return Ok(version.to_string());
440 }
441 }
442
443 Err(InstallError::VersionParse(format!(
444 "Could not parse version from: {}",
445 output
446 )))
447}
448
449fn version_meets_minimum(version: &str, minimum: &str) -> bool {
453 let parse_version = |s: &str| -> Option<Vec<u32>> {
454 let clean = s.split('-').next()?;
456 clean
457 .split('.')
458 .map(|p| p.parse::<u32>().ok())
459 .collect::<Option<Vec<_>>>()
460 };
461
462 let version_parts = match parse_version(version) {
463 Some(v) => v,
464 None => return false,
465 };
466
467 let minimum_parts = match parse_version(minimum) {
468 Some(v) => v,
469 None => return true, };
471
472 for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
474 match v.cmp(m) {
475 std::cmp::Ordering::Greater => return true,
476 std::cmp::Ordering::Less => return false,
477 std::cmp::Ordering::Equal => continue,
478 }
479 }
480
481 version_parts.len() >= minimum_parts.len()
483}
484
485fn detect_linux_distro() -> Option<String> {
487 if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
489 for line in contents.lines() {
490 if let Some(id) = line.strip_prefix("ID=") {
491 return Some(id.trim_matches('"').to_lowercase());
492 }
493 }
494 }
495
496 if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
498 for line in contents.lines() {
499 if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
500 return Some(id.trim_matches('"').to_lowercase());
501 }
502 }
503 }
504
505 None
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_parse_version_full() {
514 let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
515 let version = parse_version(output).unwrap();
516 assert_eq!(version, "1.33.0");
517 }
518
519 #[test]
520 fn test_parse_version_simple() {
521 let output = "buildah version 1.28.0";
522 let version = parse_version(output).unwrap();
523 assert_eq!(version, "1.28.0");
524 }
525
526 #[test]
527 fn test_parse_version_with_newline() {
528 let output = "buildah version 1.33.0\n";
529 let version = parse_version(output).unwrap();
530 assert_eq!(version, "1.33.0");
531 }
532
533 #[test]
534 fn test_parse_version_dev() {
535 let output = "buildah version 1.34.0-dev";
536 let version = parse_version(output).unwrap();
537 assert_eq!(version, "1.34.0-dev");
538 }
539
540 #[test]
541 fn test_parse_version_invalid() {
542 let output = "some random output";
543 assert!(parse_version(output).is_err());
544 }
545
546 #[test]
547 fn test_version_meets_minimum_equal() {
548 assert!(version_meets_minimum("1.28.0", "1.28.0"));
549 }
550
551 #[test]
552 fn test_version_meets_minimum_greater_patch() {
553 assert!(version_meets_minimum("1.28.1", "1.28.0"));
554 }
555
556 #[test]
557 fn test_version_meets_minimum_greater_minor() {
558 assert!(version_meets_minimum("1.29.0", "1.28.0"));
559 }
560
561 #[test]
562 fn test_version_meets_minimum_greater_major() {
563 assert!(version_meets_minimum("2.0.0", "1.28.0"));
564 }
565
566 #[test]
567 fn test_version_meets_minimum_less_patch() {
568 assert!(!version_meets_minimum("1.27.5", "1.28.0"));
569 }
570
571 #[test]
572 fn test_version_meets_minimum_less_minor() {
573 assert!(!version_meets_minimum("1.20.0", "1.28.0"));
574 }
575
576 #[test]
577 fn test_version_meets_minimum_less_major() {
578 assert!(!version_meets_minimum("0.99.0", "1.28.0"));
579 }
580
581 #[test]
582 fn test_version_meets_minimum_with_dev_suffix() {
583 assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
584 }
585
586 #[test]
587 fn test_current_platform() {
588 let (os, arch) = current_platform();
589 assert!(!os.is_empty());
591 assert!(!arch.is_empty());
592 }
593
594 #[test]
595 fn test_is_platform_supported() {
596 let (os, arch) = current_platform();
598 let supported = is_platform_supported();
599
600 match (os, arch) {
601 ("linux", "x86_64" | "aarch64") => assert!(supported),
602 ("macos", "x86_64" | "aarch64") => assert!(supported),
603 _ => assert!(!supported),
604 }
605 }
606
607 #[test]
608 fn test_install_instructions_not_empty() {
609 let instructions = install_instructions();
610 assert!(!instructions.is_empty());
611 }
612
613 #[test]
614 fn test_default_install_dir() {
615 let dir = default_install_dir();
616 assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
618 }
619
620 #[test]
621 fn test_get_search_paths_not_empty() {
622 let install_dir = PathBuf::from("/tmp/zlayer-test");
623 let paths = get_search_paths(&install_dir);
624 assert!(!paths.is_empty());
625 assert!(paths[0].starts_with("/tmp/zlayer-test"));
627 }
628
629 #[test]
630 fn test_installer_creation() {
631 let installer = BuildahInstaller::new();
632 assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
633 }
634
635 #[test]
636 fn test_installer_with_custom_dir() {
637 let custom_dir = PathBuf::from("/custom/path");
638 let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
639 assert_eq!(installer.install_dir(), custom_dir);
640 }
641
642 #[tokio::test]
643 async fn test_find_in_path_nonexistent() {
644 let result = find_in_path("this_binary_should_not_exist_12345").await;
646 assert!(result.is_none());
647 }
648
649 #[tokio::test]
650 async fn test_find_in_path_exists() {
651 let result = find_in_path("sh").await;
653 assert!(result.is_some());
654 }
655
656 #[tokio::test]
658 #[ignore = "requires buildah to be installed"]
659 async fn test_find_existing_buildah() {
660 let installer = BuildahInstaller::new();
661 let result = installer.find_existing().await;
662 assert!(result.is_some());
663 let installation = result.unwrap();
664 assert!(installation.path.exists());
665 assert!(!installation.version.is_empty());
666 }
667
668 #[tokio::test]
669 #[ignore = "requires buildah to be installed"]
670 async fn test_check_buildah() {
671 let installer = BuildahInstaller::new();
672 let result = installer.check().await;
673 assert!(result.is_ok());
674 }
675
676 #[tokio::test]
677 #[ignore = "requires buildah to be installed"]
678 async fn test_ensure_buildah() {
679 let installer = BuildahInstaller::new();
680 let result = installer.ensure().await;
681 assert!(result.is_ok());
682 }
683
684 #[tokio::test]
685 #[ignore = "requires buildah to be installed"]
686 async fn test_get_version() {
687 let installer = BuildahInstaller::new();
688 if let Some(installation) = installer.find_existing().await {
689 let version = BuildahInstaller::get_version(&installation.path).await;
690 assert!(version.is_ok());
691 let version = version.unwrap();
692 assert!(version.contains('.'));
693 }
694 }
695}