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    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    /// Create with custom install directory
98    pub fn with_install_dir(dir: PathBuf) -> Self {
99        Self {
100            install_dir: dir,
101            min_version: MIN_BUILDAH_VERSION,
102        }
103    }
104
105    /// Get the install directory
106    pub fn install_dir(&self) -> &Path {
107        &self.install_dir
108    }
109
110    /// Get the minimum required version
111    pub fn min_version(&self) -> &str {
112        self.min_version
113    }
114
115    /// Find existing buildah installation
116    ///
117    /// Searches for buildah in the following locations (in order):
118    /// 1. PATH environment variable
119    /// 2. `~/.zlayer/bin/buildah`
120    /// 3. `/usr/local/lib/zlayer/buildah`
121    /// 4. `/usr/bin/buildah`
122    /// 5. `/usr/local/bin/buildah`
123    pub async fn find_existing(&self) -> Option<BuildahInstallation> {
124        // Search paths in priority order
125        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        // Also try using `which` to find buildah in PATH
148        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    /// Check if buildah is installed and meets version requirements
172    ///
173    /// Returns the installation if found and valid, otherwise returns an error.
174    pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
175        let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
176
177        // Check version meets minimum requirements
178        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    /// Get buildah version from a binary
189    ///
190    /// Runs `buildah --version` and parses the output.
191    /// Expected format: "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
192    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    /// Ensure buildah is available (find existing or return helpful error)
214    ///
215    /// This is the primary entry point for ensuring buildah is available.
216    /// If buildah is not found, it returns an error with installation instructions.
217    pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
218        // First try to find existing installation
219        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    /// Download buildah binary for current platform
244    ///
245    /// Currently returns an error with installation instructions.
246    /// Future versions may download static binaries from GitHub releases.
247    pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
248        let (os, arch) = current_platform();
249
250        // Check if platform is supported
251        if !is_platform_supported() {
252            return Err(InstallError::UnsupportedPlatform {
253                os: os.to_string(),
254                arch: arch.to_string(),
255            });
256        }
257
258        // For now, return helpful error with installation instructions
259        // Future: Download static binary from GitHub releases
260        Err(InstallError::DownloadFailed(format!(
261            "Automatic download not yet implemented. {}",
262            install_instructions()
263        )))
264    }
265}
266
267/// Get the current platform (OS and architecture)
268pub fn current_platform() -> (&'static str, &'static str) {
269    let os = std::env::consts::OS; // "linux", "macos", "windows"
270    let arch = std::env::consts::ARCH; // "x86_64", "aarch64"
271    (os, arch)
272}
273
274/// Check if the current platform is supported for buildah
275///
276/// Buildah is primarily a Linux tool, though it can work on macOS
277/// through virtualization.
278pub 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
286/// Get installation instructions for the current platform
287pub fn install_instructions() -> String {
288    let (os, _arch) = current_platform();
289
290    match os {
291        "linux" => {
292            // Try to detect the Linux distribution
293            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
336// ============================================================================
337// Internal Helper Functions
338// ============================================================================
339
340/// Get the default install directory for ZLayer binaries
341fn default_install_dir() -> PathBuf {
342    // Try user directory first
343    if let Some(home) = dirs::home_dir() {
344        return home.join(".zlayer").join("bin");
345    }
346
347    // Fall back to system directory
348    PathBuf::from("/usr/local/lib/zlayer")
349}
350
351/// Get all paths to search for buildah
352fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
353    let mut paths = Vec::new();
354
355    // Custom install directory
356    paths.push(install_dir.join("buildah"));
357
358    // User ZLayer directory
359    if let Some(home) = dirs::home_dir() {
360        paths.push(home.join(".zlayer").join("bin").join("buildah"));
361    }
362
363    // System ZLayer directory
364    paths.push(PathBuf::from("/usr/local/lib/zlayer/buildah"));
365
366    // Standard system paths
367    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    // Deduplicate while preserving order
372    let mut seen = std::collections::HashSet::new();
373    paths.retain(|p| seen.insert(p.clone()));
374
375    paths
376}
377
378/// Find a binary in PATH using the `which` command
379async 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    // Also try `command -v` as a fallback (works in more shells)
396    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
414/// Parse version from buildah --version output
415///
416/// Expected formats:
417/// - "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
418/// - "buildah version 1.33.0"
419/// - "buildah version 1.34.0-dev"
420fn parse_version(output: &str) -> Result<String, InstallError> {
421    // Look for "buildah version X.Y.Z" pattern
422    let output = output.trim();
423
424    // Try to extract version after "version"
425    if let Some(pos) = output.to_lowercase().find("version") {
426        let after_version = &output[pos + "version".len()..].trim_start();
427
428        // Extract the version number (digits, dots, and optional suffix like -dev, -rc1)
429        // Version format: X.Y.Z or X.Y.Z-suffix
430        let version: String = after_version
431            .chars()
432            .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
433            .collect();
434
435        // Clean up: trim trailing hyphens if any
436        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
449/// Check if a version string meets the minimum requirement
450///
451/// Uses simple semantic version comparison.
452fn version_meets_minimum(version: &str, minimum: &str) -> bool {
453    let parse_version = |s: &str| -> Option<Vec<u32>> {
454        // Strip any trailing non-numeric parts (like "-dev")
455        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, // If we can't parse minimum, assume it's met
470    };
471
472    // Compare version parts
473    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    // If all compared parts are equal, check if version has at least as many parts
482    version_parts.len() >= minimum_parts.len()
483}
484
485/// Detect the Linux distribution
486fn detect_linux_distro() -> Option<String> {
487    // Try /etc/os-release first (most modern distros)
488    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    // Try /etc/lsb-release as fallback
497    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        // Just verify it returns non-empty strings
590        assert!(!os.is_empty());
591        assert!(!arch.is_empty());
592    }
593
594    #[test]
595    fn test_is_platform_supported() {
596        // This test will pass on Linux and macOS x86_64/aarch64
597        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        // Should end with bin
617        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        // First path should be in our custom install dir
626        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        // This binary should not exist
645        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        // 'sh' should exist on any Unix system
652        let result = find_in_path("sh").await;
653        assert!(result.is_some());
654    }
655
656    // Integration test - only runs if buildah is installed
657    #[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}