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                warn!("Buildah not found on system");
253                Err(InstallError::NotFound)
254            }
255            Err(e) => Err(e),
256        }
257    }
258
259    /// Download buildah binary for current platform
260    ///
261    /// Currently returns an error with installation instructions.
262    /// Future versions may download static binaries from GitHub releases.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the platform is unsupported or automatic download is unavailable.
267    #[allow(clippy::unused_async)]
268    pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
269        let (os, arch) = current_platform();
270
271        // Check if platform is supported
272        if !is_platform_supported() {
273            return Err(InstallError::UnsupportedPlatform {
274                os: os.to_string(),
275                arch: arch.to_string(),
276            });
277        }
278
279        // For now, return helpful error with installation instructions
280        // Future: Download static binary from GitHub releases
281        Err(InstallError::DownloadFailed(format!(
282            "Automatic download not yet implemented. {}",
283            install_instructions()
284        )))
285    }
286}
287
288/// Get the current platform (OS and architecture)
289#[must_use]
290pub fn current_platform() -> (&'static str, &'static str) {
291    let os = std::env::consts::OS; // "linux", "macos", "windows"
292    let arch = std::env::consts::ARCH; // "x86_64", "aarch64"
293    (os, arch)
294}
295
296/// Check if the current platform is supported for buildah
297///
298/// Buildah is primarily a Linux tool, though it can work on macOS
299/// through virtualization.
300#[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/// Get installation instructions for the current platform
307#[must_use]
308pub fn install_instructions() -> String {
309    let (os, _arch) = current_platform();
310
311    match os {
312        "linux" => {
313            // Try to detect the Linux distribution
314            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
357// ============================================================================
358// Internal Helper Functions
359// ============================================================================
360
361/// Get the default install directory for `ZLayer` binaries
362fn default_install_dir() -> PathBuf {
363    // Try user directory first
364    if let Some(home) = dirs::home_dir() {
365        return home.join(".zlayer").join("bin");
366    }
367
368    // Fall back to system directory
369    PathBuf::from("/usr/local/lib/zlayer")
370}
371
372/// Get all paths to search for buildah
373fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
374    let mut paths = Vec::new();
375
376    // Custom install directory
377    paths.push(install_dir.join("buildah"));
378
379    // User ZLayer directory
380    if let Some(home) = dirs::home_dir() {
381        paths.push(home.join(".zlayer").join("bin").join("buildah"));
382    }
383
384    // System ZLayer directory
385    paths.push(PathBuf::from("/usr/local/lib/zlayer/buildah"));
386
387    // Standard system paths
388    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    // Deduplicate while preserving order
393    let mut seen = std::collections::HashSet::new();
394    paths.retain(|p| seen.insert(p.clone()));
395
396    paths
397}
398
399/// Find a binary in PATH using the `which` command
400async 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    // Also try `command -v` as a fallback (works in more shells)
417    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
435/// Parse version from buildah --version output
436///
437/// Expected formats:
438/// - "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
439/// - "buildah version 1.33.0"
440/// - "buildah version 1.34.0-dev"
441fn parse_version(output: &str) -> Result<String, InstallError> {
442    // Look for "buildah version X.Y.Z" pattern
443    let output = output.trim();
444
445    // Try to extract version after "version"
446    if let Some(pos) = output.to_lowercase().find("version") {
447        let after_version = &output[pos + "version".len()..].trim_start();
448
449        // Extract the version number (digits, dots, and optional suffix like -dev, -rc1)
450        // Version format: X.Y.Z or X.Y.Z-suffix
451        let version: String = after_version
452            .chars()
453            .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
454            .collect();
455
456        // Clean up: trim trailing hyphens if any
457        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
469/// Check if a version string meets the minimum requirement
470///
471/// Uses simple semantic version comparison.
472fn version_meets_minimum(version: &str, minimum: &str) -> bool {
473    let parse_version = |s: &str| -> Option<Vec<u32>> {
474        // Strip any trailing non-numeric parts (like "-dev")
475        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; // If we can't parse minimum, assume it's met
488    };
489
490    // Compare version parts
491    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    // If all compared parts are equal, check if version has at least as many parts
500    version_parts.len() >= minimum_parts.len()
501}
502
503/// Detect the Linux distribution
504fn detect_linux_distro() -> Option<String> {
505    // Try /etc/os-release first (most modern distros)
506    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    // Try /etc/lsb-release as fallback
515    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        // Just verify it returns non-empty strings
608        assert!(!os.is_empty());
609        assert!(!arch.is_empty());
610    }
611
612    #[test]
613    fn test_is_platform_supported() {
614        // This test will pass on Linux and macOS x86_64/aarch64
615        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        // Should end with bin
635        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        // First path should be in our custom install dir
644        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        // This binary should not exist
663        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        // 'sh' should exist on any Unix system
670        let result = find_in_path("sh").await;
671        assert!(result.is_some());
672    }
673
674    // Integration test - only runs if buildah is installed
675    #[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}