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 in PATH using the `which` command
395async fn find_in_path(binary: &str) -> Option<PathBuf> {
396    let output = Command::new("which")
397        .arg(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    // Also try `command -v` as a fallback (works in more shells)
412    let output = Command::new("sh")
413        .args(["-c", &format!("command -v {binary}")])
414        .stdout(Stdio::piped())
415        .stderr(Stdio::null())
416        .output()
417        .await
418        .ok()?;
419
420    if output.status.success() {
421        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
422        if !path.is_empty() {
423            return Some(PathBuf::from(path));
424        }
425    }
426
427    None
428}
429
430/// Parse version from buildah --version output
431///
432/// Expected formats:
433/// - "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
434/// - "buildah version 1.33.0"
435/// - "buildah version 1.34.0-dev"
436fn parse_version(output: &str) -> Result<String, InstallError> {
437    // Look for "buildah version X.Y.Z" pattern
438    let output = output.trim();
439
440    // Try to extract version after "version"
441    if let Some(pos) = output.to_lowercase().find("version") {
442        let after_version = &output[pos + "version".len()..].trim_start();
443
444        // Extract the version number (digits, dots, and optional suffix like -dev, -rc1)
445        // Version format: X.Y.Z or X.Y.Z-suffix
446        let version: String = after_version
447            .chars()
448            .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
449            .collect();
450
451        // Clean up: trim trailing hyphens if any
452        let version = version.trim_end_matches('-');
453
454        if !version.is_empty() && version.contains('.') {
455            return Ok(version.to_string());
456        }
457    }
458
459    Err(InstallError::VersionParse(format!(
460        "Could not parse version from: {output}"
461    )))
462}
463
464/// Check if a version string meets the minimum requirement
465///
466/// Uses simple semantic version comparison.
467fn version_meets_minimum(version: &str, minimum: &str) -> bool {
468    let parse_version = |s: &str| -> Option<Vec<u32>> {
469        // Strip any trailing non-numeric parts (like "-dev")
470        let clean = s.split('-').next()?;
471        clean
472            .split('.')
473            .map(|p| p.parse::<u32>().ok())
474            .collect::<Option<Vec<_>>>()
475    };
476
477    let Some(version_parts) = parse_version(version) else {
478        return false;
479    };
480
481    let Some(minimum_parts) = parse_version(minimum) else {
482        return true; // If we can't parse minimum, assume it's met
483    };
484
485    // Compare version parts
486    for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
487        match v.cmp(m) {
488            std::cmp::Ordering::Greater => return true,
489            std::cmp::Ordering::Less => return false,
490            std::cmp::Ordering::Equal => {}
491        }
492    }
493
494    // If all compared parts are equal, check if version has at least as many parts
495    version_parts.len() >= minimum_parts.len()
496}
497
498/// Detect the Linux distribution
499fn detect_linux_distro() -> Option<String> {
500    // Try /etc/os-release first (most modern distros)
501    if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
502        for line in contents.lines() {
503            if let Some(id) = line.strip_prefix("ID=") {
504                return Some(id.trim_matches('"').to_lowercase());
505            }
506        }
507    }
508
509    // Try /etc/lsb-release as fallback
510    if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
511        for line in contents.lines() {
512            if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
513                return Some(id.trim_matches('"').to_lowercase());
514            }
515        }
516    }
517
518    None
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_parse_version_full() {
527        let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
528        let version = parse_version(output).unwrap();
529        assert_eq!(version, "1.33.0");
530    }
531
532    #[test]
533    fn test_parse_version_simple() {
534        let output = "buildah version 1.28.0";
535        let version = parse_version(output).unwrap();
536        assert_eq!(version, "1.28.0");
537    }
538
539    #[test]
540    fn test_parse_version_with_newline() {
541        let output = "buildah version 1.33.0\n";
542        let version = parse_version(output).unwrap();
543        assert_eq!(version, "1.33.0");
544    }
545
546    #[test]
547    fn test_parse_version_dev() {
548        let output = "buildah version 1.34.0-dev";
549        let version = parse_version(output).unwrap();
550        assert_eq!(version, "1.34.0-dev");
551    }
552
553    #[test]
554    fn test_parse_version_invalid() {
555        let output = "some random output";
556        assert!(parse_version(output).is_err());
557    }
558
559    #[test]
560    fn test_version_meets_minimum_equal() {
561        assert!(version_meets_minimum("1.28.0", "1.28.0"));
562    }
563
564    #[test]
565    fn test_version_meets_minimum_greater_patch() {
566        assert!(version_meets_minimum("1.28.1", "1.28.0"));
567    }
568
569    #[test]
570    fn test_version_meets_minimum_greater_minor() {
571        assert!(version_meets_minimum("1.29.0", "1.28.0"));
572    }
573
574    #[test]
575    fn test_version_meets_minimum_greater_major() {
576        assert!(version_meets_minimum("2.0.0", "1.28.0"));
577    }
578
579    #[test]
580    fn test_version_meets_minimum_less_patch() {
581        assert!(!version_meets_minimum("1.27.5", "1.28.0"));
582    }
583
584    #[test]
585    fn test_version_meets_minimum_less_minor() {
586        assert!(!version_meets_minimum("1.20.0", "1.28.0"));
587    }
588
589    #[test]
590    fn test_version_meets_minimum_less_major() {
591        assert!(!version_meets_minimum("0.99.0", "1.28.0"));
592    }
593
594    #[test]
595    fn test_version_meets_minimum_with_dev_suffix() {
596        assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
597    }
598
599    #[test]
600    fn test_current_platform() {
601        let (os, arch) = current_platform();
602        // Just verify it returns non-empty strings
603        assert!(!os.is_empty());
604        assert!(!arch.is_empty());
605    }
606
607    #[test]
608    fn test_is_platform_supported() {
609        // This test will pass on Linux and macOS x86_64/aarch64
610        let (os, arch) = current_platform();
611        let supported = is_platform_supported();
612
613        match (os, arch) {
614            ("linux" | "macos", "x86_64" | "aarch64") => assert!(supported),
615            _ => assert!(!supported),
616        }
617    }
618
619    #[test]
620    fn test_install_instructions_not_empty() {
621        let instructions = install_instructions();
622        assert!(!instructions.is_empty());
623    }
624
625    #[test]
626    fn test_default_install_dir() {
627        let dir = default_install_dir();
628        // Should end with bin
629        assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
630    }
631
632    #[test]
633    fn test_get_search_paths_not_empty() {
634        let install_dir = PathBuf::from("/tmp/zlayer-test");
635        let paths = get_search_paths(&install_dir);
636        assert!(!paths.is_empty());
637        // First path should be in our custom install dir
638        assert!(paths[0].starts_with("/tmp/zlayer-test"));
639    }
640
641    #[test]
642    fn test_installer_creation() {
643        let installer = BuildahInstaller::new();
644        assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
645    }
646
647    #[test]
648    fn test_installer_with_custom_dir() {
649        let custom_dir = PathBuf::from("/custom/path");
650        let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
651        assert_eq!(installer.install_dir(), custom_dir);
652    }
653
654    #[tokio::test]
655    async fn test_find_in_path_nonexistent() {
656        // This binary should not exist
657        let result = find_in_path("this_binary_should_not_exist_12345").await;
658        assert!(result.is_none());
659    }
660
661    #[tokio::test]
662    async fn test_find_in_path_exists() {
663        // 'sh' should exist on any Unix system
664        let result = find_in_path("sh").await;
665        assert!(result.is_some());
666    }
667
668    // Integration test - only runs if buildah is installed
669    #[tokio::test]
670    #[ignore = "requires buildah to be installed"]
671    async fn test_find_existing_buildah() {
672        let installer = BuildahInstaller::new();
673        let result = installer.find_existing().await;
674        assert!(result.is_some());
675        let installation = result.unwrap();
676        assert!(installation.path.exists());
677        assert!(!installation.version.is_empty());
678    }
679
680    #[tokio::test]
681    #[ignore = "requires buildah to be installed"]
682    async fn test_check_buildah() {
683        let installer = BuildahInstaller::new();
684        let result = installer.check().await;
685        assert!(result.is_ok());
686    }
687
688    #[tokio::test]
689    #[ignore = "requires buildah to be installed"]
690    async fn test_ensure_buildah() {
691        let installer = BuildahInstaller::new();
692        let result = installer.ensure().await;
693        assert!(result.is_ok());
694    }
695
696    #[tokio::test]
697    #[ignore = "requires buildah to be installed"]
698    async fn test_get_version() {
699        let installer = BuildahInstaller::new();
700        if let Some(installation) = installer.find_existing().await {
701            let version = BuildahInstaller::get_version(&installation.path).await;
702            assert!(version.is_ok());
703            let version = version.unwrap();
704            assert!(version.contains('.'));
705        }
706    }
707}