Skip to main content

zlayer_builder/buildah/
install.rs

1//! Buildah installation and discovery
2//!
3//! This module provides functionality to find existing buildah installations
4//! or provide helpful error messages for installing buildah on various platforms.
5
6use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use tokio::process::Command;
10use tracing::{debug, info, trace, warn};
11
12/// Minimum required buildah version
13const MIN_BUILDAH_VERSION: &str = "1.28.0";
14
15/// Buildah installation manager
16///
17/// Handles finding existing buildah installations and providing
18/// installation guidance when buildah is not found.
19#[derive(Debug, Clone)]
20pub struct BuildahInstaller {
21    /// Where to store downloaded buildah binary (for future use)
22    install_dir: PathBuf,
23    /// Minimum required buildah version
24    min_version: &'static str,
25}
26
27/// Information about a discovered buildah installation
28#[derive(Debug, Clone)]
29pub struct BuildahInstallation {
30    /// Path to buildah binary
31    pub path: PathBuf,
32    /// Installed version
33    pub version: String,
34}
35
36/// Errors that can occur during buildah installation/discovery
37#[derive(Debug, thiserror::Error)]
38pub enum InstallError {
39    /// Buildah was not found on the system
40    #[error("Buildah not found. {}", install_instructions())]
41    NotFound,
42
43    /// Buildah version is too old
44    #[error("Buildah version {found} is below minimum required version {required}")]
45    VersionTooOld {
46        /// The version that was found
47        found: String,
48        /// The minimum required version
49        required: String,
50    },
51
52    /// Platform is not supported
53    #[error("Unsupported platform: {os}/{arch}")]
54    UnsupportedPlatform {
55        /// Operating system
56        os: String,
57        /// CPU architecture
58        arch: String,
59    },
60
61    /// Download failed (for future binary download support)
62    #[error("Download failed: {0}")]
63    DownloadFailed(String),
64
65    /// IO error during installation
66    #[error("IO error: {0}")]
67    Io(#[from] std::io::Error),
68
69    /// Failed to parse version output
70    #[error("Failed to parse buildah version output: {0}")]
71    VersionParse(String),
72
73    /// Failed to execute buildah
74    #[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    /// Create installer with default paths
86    ///
87    /// User install directory: `~/.zlayer/bin/`
88    /// System install directory: `/usr/local/lib/zlayer/`
89    #[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    /// Create with custom install directory
99    #[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    /// Get the install directory
108    #[must_use]
109    pub fn install_dir(&self) -> &Path {
110        &self.install_dir
111    }
112
113    /// Get the minimum required version
114    #[must_use]
115    pub fn min_version(&self) -> &str {
116        self.min_version
117    }
118
119    /// Find existing buildah installation
120    ///
121    /// Searches for buildah in the following locations (in order):
122    /// 1. PATH environment variable
123    /// 2. `~/.zlayer/bin/buildah`
124    /// 3. `/usr/local/lib/zlayer/buildah`
125    /// 4. `/usr/bin/buildah`
126    /// 5. `/usr/local/bin/buildah`
127    pub async fn find_existing(&self) -> Option<BuildahInstallation> {
128        // Search paths in priority order
129        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        // Also try using `which` to find buildah in PATH
152        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    /// Check if buildah is installed and meets version requirements
176    ///
177    /// Returns the installation if found and valid, otherwise returns an error.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if buildah is not found or if the version is below the minimum.
182    pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
183        let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
184
185        // Check version meets minimum requirements
186        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    /// Get buildah version from a binary
197    ///
198    /// Runs `buildah --version` and parses the output.
199    /// Expected format: "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the binary cannot be executed or the version output cannot be parsed.
204    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    /// Ensure buildah is available (find existing or return helpful error)
226    ///
227    /// This is the primary entry point for ensuring buildah is available.
228    /// If buildah is not found, it returns an error with installation instructions.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if buildah is not found or the version is insufficient.
233    pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
234        // First try to find existing installation
235        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    /// Download buildah binary for current platform
263    ///
264    /// Currently returns an error with installation instructions.
265    /// Future versions may download static binaries from GitHub releases.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the platform is unsupported or automatic download is unavailable.
270    #[allow(clippy::unused_async)]
271    pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
272        let (os, arch) = current_platform();
273
274        // Check if platform is supported
275        if !is_platform_supported() {
276            return Err(InstallError::UnsupportedPlatform {
277                os: os.to_string(),
278                arch: arch.to_string(),
279            });
280        }
281
282        // For now, return helpful error with installation instructions
283        // Future: Download static binary from GitHub releases
284        Err(InstallError::DownloadFailed(format!(
285            "Automatic download not yet implemented. {}",
286            install_instructions()
287        )))
288    }
289}
290
291/// Get the current platform (OS and architecture)
292#[must_use]
293pub fn current_platform() -> (&'static str, &'static str) {
294    let os = std::env::consts::OS; // "linux", "macos", "windows"
295    let arch = std::env::consts::ARCH; // "x86_64", "aarch64"
296    (os, arch)
297}
298
299/// Check if the current platform is supported for buildah
300///
301/// Buildah is primarily a Linux tool, though it can work on macOS
302/// through virtualization.
303#[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/// Get installation instructions for the current platform
310#[must_use]
311pub fn install_instructions() -> String {
312    let (os, _arch) = current_platform();
313
314    match os {
315        "linux" => {
316            // Try to detect the Linux distribution
317            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
361// ============================================================================
362// Internal Helper Functions
363// ============================================================================
364
365/// Get the default install directory for `ZLayer` binaries
366fn default_install_dir() -> PathBuf {
367    zlayer_paths::ZLayerDirs::system_default().bin()
368}
369
370/// Get all paths to search for buildah
371fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
372    let mut paths = vec![
373        // Custom install directory
374        install_dir.join("buildah"),
375        // User ZLayer directory
376        zlayer_paths::ZLayerDirs::system_default()
377            .bin()
378            .join("buildah"),
379        // System ZLayer directory
380        PathBuf::from("/usr/local/lib/zlayer/buildah"),
381        // Standard system paths
382        PathBuf::from("/usr/bin/buildah"),
383        PathBuf::from("/usr/local/bin/buildah"),
384        PathBuf::from("/bin/buildah"),
385    ];
386
387    // Deduplicate while preserving order
388    let mut seen = std::collections::HashSet::new();
389    paths.retain(|p| seen.insert(p.clone()));
390
391    paths
392}
393
394/// Find a binary on PATH. Uses platform-native lookup:
395/// - Windows: `where.exe` (always available in System32).
396/// - Unix: `which`, then `sh -c "command -v"` as a fallback.
397async 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                // `where.exe` prints one path per line — first hit is the
410                // one that would run.
411                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    // Also try `command -v` as a fallback (works in more shells)
437    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
455/// Parse version from buildah --version output
456///
457/// Expected formats:
458/// - "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
459/// - "buildah version 1.33.0"
460/// - "buildah version 1.34.0-dev"
461fn parse_version(output: &str) -> Result<String, InstallError> {
462    // Look for "buildah version X.Y.Z" pattern
463    let output = output.trim();
464
465    // Try to extract version after "version"
466    if let Some(pos) = output.to_lowercase().find("version") {
467        let after_version = &output[pos + "version".len()..].trim_start();
468
469        // Extract the version number (digits, dots, and optional suffix like -dev, -rc1)
470        // Version format: X.Y.Z or X.Y.Z-suffix
471        let version: String = after_version
472            .chars()
473            .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
474            .collect();
475
476        // Clean up: trim trailing hyphens if any
477        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
489/// Check if a version string meets the minimum requirement
490///
491/// Uses simple semantic version comparison.
492fn version_meets_minimum(version: &str, minimum: &str) -> bool {
493    let parse_version = |s: &str| -> Option<Vec<u32>> {
494        // Strip any trailing non-numeric parts (like "-dev")
495        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; // If we can't parse minimum, assume it's met
508    };
509
510    // Compare version parts
511    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    // If all compared parts are equal, check if version has at least as many parts
520    version_parts.len() >= minimum_parts.len()
521}
522
523/// Detect the Linux distribution
524fn detect_linux_distro() -> Option<String> {
525    // Try /etc/os-release first (most modern distros)
526    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    // Try /etc/lsb-release as fallback
535    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        // Just verify it returns non-empty strings
628        assert!(!os.is_empty());
629        assert!(!arch.is_empty());
630    }
631
632    #[test]
633    fn test_is_platform_supported() {
634        // This test will pass on Linux and macOS x86_64/aarch64
635        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        // Should end with bin
654        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        // First path should be in our custom install dir
663        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        // This binary should not exist
682        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        // Probe for a binary guaranteed to exist on each platform.
689        #[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    // Integration test - only runs if buildah is installed
701    #[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}