use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
use tracing::{debug, info, trace, warn};
const MIN_BUILDAH_VERSION: &str = "1.28.0";
#[derive(Debug, Clone)]
pub struct BuildahInstaller {
install_dir: PathBuf,
min_version: &'static str,
}
#[derive(Debug, Clone)]
pub struct BuildahInstallation {
pub path: PathBuf,
pub version: String,
}
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
#[error("Buildah not found. {}", install_instructions())]
NotFound,
#[error("Buildah version {found} is below minimum required version {required}")]
VersionTooOld {
found: String,
required: String,
},
#[error("Unsupported platform: {os}/{arch}")]
UnsupportedPlatform {
os: String,
arch: String,
},
#[error("Download failed: {0}")]
DownloadFailed(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse buildah version output: {0}")]
VersionParse(String),
#[error("Failed to execute buildah: {0}")]
ExecutionFailed(String),
}
impl Default for BuildahInstaller {
fn default() -> Self {
Self::new()
}
}
impl BuildahInstaller {
#[must_use]
pub fn new() -> Self {
let install_dir = default_install_dir();
Self {
install_dir,
min_version: MIN_BUILDAH_VERSION,
}
}
#[must_use]
pub fn with_install_dir(dir: PathBuf) -> Self {
Self {
install_dir: dir,
min_version: MIN_BUILDAH_VERSION,
}
}
#[must_use]
pub fn install_dir(&self) -> &Path {
&self.install_dir
}
#[must_use]
pub fn min_version(&self) -> &str {
self.min_version
}
pub async fn find_existing(&self) -> Option<BuildahInstallation> {
let search_paths = get_search_paths(&self.install_dir);
for path in search_paths {
trace!("Checking for buildah at: {}", path.display());
if path.exists() && path.is_file() {
match Self::get_version(&path).await {
Ok(version) => {
info!("Found buildah at {} (version {})", path.display(), version);
return Some(BuildahInstallation { path, version });
}
Err(e) => {
debug!(
"Found buildah at {} but couldn't get version: {}",
path.display(),
e
);
}
}
}
}
if let Some(path) = find_in_path("buildah").await {
match Self::get_version(&path).await {
Ok(version) => {
info!(
"Found buildah in PATH at {} (version {})",
path.display(),
version
);
return Some(BuildahInstallation { path, version });
}
Err(e) => {
debug!(
"Found buildah in PATH at {} but couldn't get version: {}",
path.display(),
e
);
}
}
}
None
}
pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
if !version_meets_minimum(&installation.version, self.min_version) {
return Err(InstallError::VersionTooOld {
found: installation.version,
required: self.min_version.to_string(),
});
}
Ok(installation)
}
pub async fn get_version(path: &Path) -> Result<String, InstallError> {
let output = Command::new(path)
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| InstallError::ExecutionFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(InstallError::ExecutionFailed(format!(
"buildah --version failed: {}",
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_version(&stdout)
}
pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
match self.check().await {
Ok(installation) => {
info!(
"Using buildah {} at {}",
installation.version,
installation.path.display()
);
Ok(installation)
}
Err(InstallError::VersionTooOld { found, required }) => {
warn!(
"Found buildah {} but minimum required version is {}",
found, required
);
Err(InstallError::VersionTooOld { found, required })
}
Err(InstallError::NotFound) => {
#[cfg(target_os = "macos")]
debug!("Buildah not available on macOS; will use native sandbox builder");
#[cfg(not(target_os = "macos"))]
warn!("Buildah not found on system");
Err(InstallError::NotFound)
}
Err(e) => Err(e),
}
}
#[allow(clippy::unused_async)]
pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
let (os, arch) = current_platform();
if !is_platform_supported() {
return Err(InstallError::UnsupportedPlatform {
os: os.to_string(),
arch: arch.to_string(),
});
}
Err(InstallError::DownloadFailed(format!(
"Automatic download not yet implemented. {}",
install_instructions()
)))
}
}
#[must_use]
pub fn current_platform() -> (&'static str, &'static str) {
let os = std::env::consts::OS; let arch = std::env::consts::ARCH; (os, arch)
}
#[must_use]
pub fn is_platform_supported() -> bool {
let (os, arch) = current_platform();
matches!((os, arch), ("linux" | "macos", "x86_64" | "aarch64"))
}
#[must_use]
pub fn install_instructions() -> String {
let (os, _arch) = current_platform();
match os {
"linux" => {
if let Some(distro) = detect_linux_distro() {
match distro.as_str() {
"ubuntu" | "debian" | "pop" | "mint" | "elementary" => {
"Install with: sudo apt install buildah".to_string()
}
"fedora" | "rhel" | "centos" | "rocky" | "alma" => {
"Install with: sudo dnf install buildah".to_string()
}
"arch" | "manjaro" | "endeavouros" => {
"Install with: sudo pacman -S buildah".to_string()
}
"opensuse" | "suse" => "Install with: sudo zypper install buildah".to_string(),
"alpine" => "Install with: sudo apk add buildah".to_string(),
"gentoo" => "Install with: sudo emerge app-containers/buildah".to_string(),
"void" => "Install with: sudo xbps-install buildah".to_string(),
_ => "Install buildah using your distribution's package manager.\n\
Common commands:\n\
- Debian/Ubuntu: sudo apt install buildah\n\
- Fedora/RHEL: sudo dnf install buildah\n\
- Arch: sudo pacman -S buildah\n\
- openSUSE: sudo zypper install buildah"
.to_string(),
}
} else {
"Install buildah using your distribution's package manager.\n\
Common commands:\n\
- Debian/Ubuntu: sudo apt install buildah\n\
- Fedora/RHEL: sudo dnf install buildah\n\
- Arch: sudo pacman -S buildah\n\
- openSUSE: sudo zypper install buildah"
.to_string()
}
}
"macos" => "Buildah is not required on macOS -- ZLayer uses the native \
sandbox builder instead. If you prefer buildah, install with: \
brew install buildah (requires a Linux VM for container operations)."
.to_string(),
"windows" => "Buildah is not natively supported on Windows.\n\
Consider using WSL2 with a Linux distribution and installing buildah there."
.to_string(),
_ => format!("Buildah is not supported on {os}. Use a Linux system."),
}
}
fn default_install_dir() -> PathBuf {
zlayer_paths::ZLayerDirs::system_default().bin()
}
fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
let mut paths = vec![
install_dir.join("buildah"),
zlayer_paths::ZLayerDirs::system_default()
.bin()
.join("buildah"),
PathBuf::from("/usr/local/lib/zlayer/buildah"),
PathBuf::from("/usr/bin/buildah"),
PathBuf::from("/usr/local/bin/buildah"),
PathBuf::from("/bin/buildah"),
];
let mut seen = std::collections::HashSet::new();
paths.retain(|p| seen.insert(p.clone()));
paths
}
async fn find_in_path(binary: &str) -> Option<PathBuf> {
let output = Command::new("which")
.arg(binary)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
let output = Command::new("sh")
.args(["-c", &format!("command -v {binary}")])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
None
}
fn parse_version(output: &str) -> Result<String, InstallError> {
let output = output.trim();
if let Some(pos) = output.to_lowercase().find("version") {
let after_version = &output[pos + "version".len()..].trim_start();
let version: String = after_version
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
.collect();
let version = version.trim_end_matches('-');
if !version.is_empty() && version.contains('.') {
return Ok(version.to_string());
}
}
Err(InstallError::VersionParse(format!(
"Could not parse version from: {output}"
)))
}
fn version_meets_minimum(version: &str, minimum: &str) -> bool {
let parse_version = |s: &str| -> Option<Vec<u32>> {
let clean = s.split('-').next()?;
clean
.split('.')
.map(|p| p.parse::<u32>().ok())
.collect::<Option<Vec<_>>>()
};
let Some(version_parts) = parse_version(version) else {
return false;
};
let Some(minimum_parts) = parse_version(minimum) else {
return true; };
for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
match v.cmp(m) {
std::cmp::Ordering::Greater => return true,
std::cmp::Ordering::Less => return false,
std::cmp::Ordering::Equal => {}
}
}
version_parts.len() >= minimum_parts.len()
}
fn detect_linux_distro() -> Option<String> {
if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
for line in contents.lines() {
if let Some(id) = line.strip_prefix("ID=") {
return Some(id.trim_matches('"').to_lowercase());
}
}
}
if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
for line in contents.lines() {
if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
return Some(id.trim_matches('"').to_lowercase());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version_full() {
let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
let version = parse_version(output).unwrap();
assert_eq!(version, "1.33.0");
}
#[test]
fn test_parse_version_simple() {
let output = "buildah version 1.28.0";
let version = parse_version(output).unwrap();
assert_eq!(version, "1.28.0");
}
#[test]
fn test_parse_version_with_newline() {
let output = "buildah version 1.33.0\n";
let version = parse_version(output).unwrap();
assert_eq!(version, "1.33.0");
}
#[test]
fn test_parse_version_dev() {
let output = "buildah version 1.34.0-dev";
let version = parse_version(output).unwrap();
assert_eq!(version, "1.34.0-dev");
}
#[test]
fn test_parse_version_invalid() {
let output = "some random output";
assert!(parse_version(output).is_err());
}
#[test]
fn test_version_meets_minimum_equal() {
assert!(version_meets_minimum("1.28.0", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_greater_patch() {
assert!(version_meets_minimum("1.28.1", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_greater_minor() {
assert!(version_meets_minimum("1.29.0", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_greater_major() {
assert!(version_meets_minimum("2.0.0", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_less_patch() {
assert!(!version_meets_minimum("1.27.5", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_less_minor() {
assert!(!version_meets_minimum("1.20.0", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_less_major() {
assert!(!version_meets_minimum("0.99.0", "1.28.0"));
}
#[test]
fn test_version_meets_minimum_with_dev_suffix() {
assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
}
#[test]
fn test_current_platform() {
let (os, arch) = current_platform();
assert!(!os.is_empty());
assert!(!arch.is_empty());
}
#[test]
fn test_is_platform_supported() {
let (os, arch) = current_platform();
let supported = is_platform_supported();
match (os, arch) {
("linux" | "macos", "x86_64" | "aarch64") => assert!(supported),
_ => assert!(!supported),
}
}
#[test]
fn test_install_instructions_not_empty() {
let instructions = install_instructions();
assert!(!instructions.is_empty());
}
#[test]
fn test_default_install_dir() {
let dir = default_install_dir();
assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
}
#[test]
fn test_get_search_paths_not_empty() {
let install_dir = PathBuf::from("/tmp/zlayer-test");
let paths = get_search_paths(&install_dir);
assert!(!paths.is_empty());
assert!(paths[0].starts_with("/tmp/zlayer-test"));
}
#[test]
fn test_installer_creation() {
let installer = BuildahInstaller::new();
assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
}
#[test]
fn test_installer_with_custom_dir() {
let custom_dir = PathBuf::from("/custom/path");
let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
assert_eq!(installer.install_dir(), custom_dir);
}
#[tokio::test]
async fn test_find_in_path_nonexistent() {
let result = find_in_path("this_binary_should_not_exist_12345").await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_find_in_path_exists() {
let result = find_in_path("sh").await;
assert!(result.is_some());
}
#[tokio::test]
#[ignore = "requires buildah to be installed"]
async fn test_find_existing_buildah() {
let installer = BuildahInstaller::new();
let result = installer.find_existing().await;
assert!(result.is_some());
let installation = result.unwrap();
assert!(installation.path.exists());
assert!(!installation.version.is_empty());
}
#[tokio::test]
#[ignore = "requires buildah to be installed"]
async fn test_check_buildah() {
let installer = BuildahInstaller::new();
let result = installer.check().await;
assert!(result.is_ok());
}
#[tokio::test]
#[ignore = "requires buildah to be installed"]
async fn test_ensure_buildah() {
let installer = BuildahInstaller::new();
let result = installer.ensure().await;
assert!(result.is_ok());
}
#[tokio::test]
#[ignore = "requires buildah to be installed"]
async fn test_get_version() {
let installer = BuildahInstaller::new();
if let Some(installation) = installer.find_existing().await {
let version = BuildahInstaller::get_version(&installation.path).await;
assert!(version.is_ok());
let version = version.unwrap();
assert!(version.contains('.'));
}
}
}