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> {
#[cfg(windows)]
{
if let Ok(output) = Command::new("where")
.arg(binary)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(first) = stdout.lines().next() {
let path = first.trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
}
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("/var/lib/zlayer-test");
let paths = get_search_paths(&install_dir);
assert!(!paths.is_empty());
assert!(paths[0].starts_with("/var/lib/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]
#[allow(clippy::await_holding_lock)]
async fn test_find_in_path_exists() {
#[cfg(unix)]
let probe = "sh";
#[cfg(windows)]
let probe = "cmd.exe";
let _g = crate::TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = find_in_path(probe).await;
assert!(
result.is_some(),
"find_in_path({probe:?}) should find a system binary"
);
}
#[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('.'));
}
}
}
#[cfg(unix)]
pub mod buildd {
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use crate::error::{BuildError, Result};
pub const EXPECTED_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug)]
pub enum InstallOutcome {
AlreadyInstalled {
path: PathBuf,
version: String,
},
CopiedFromBundle {
source: PathBuf,
dest: PathBuf,
},
Downloaded {
url: String,
dest: PathBuf,
},
Skipped {
reason: String,
},
}
pub fn ensure_buildd_sidecar(install_dir: &Path) -> Result<InstallOutcome> {
if env::var_os("ZLAYER_BUILDD_BIN").is_some() {
return Ok(InstallOutcome::Skipped {
reason: "ZLAYER_BUILDD_BIN set; using that binary instead".into(),
});
}
let dest = install_dir.join("zlayer-buildd");
if dest.exists() && is_executable(&dest) {
if let Some(version) = read_version(&dest) {
if version == EXPECTED_VERSION {
return Ok(InstallOutcome::AlreadyInstalled {
path: dest,
version,
});
}
tracing::warn!(
"zlayer-buildd at {} reports version {} but daemon expects {}; reinstalling",
dest.display(),
version,
EXPECTED_VERSION
);
}
}
if let Some(bundled) = bundled_sidecar_path()? {
ensure_parent_dir(&dest)?;
fs::copy(&bundled, &dest)?;
mark_executable(&dest)?;
return Ok(InstallOutcome::CopiedFromBundle {
source: bundled,
dest,
});
}
Err(BuildError::NotSupported {
operation: format!(
"zlayer-buildd is not installed at {} and no bundled binary was found \
alongside the running zlayer executable. Either run `make release` in \
bin/zlayer-buildd/ and copy the result into {}, set ZLAYER_BUILDD_BIN, or \
install the release tarball that ships zlayer-buildd alongside zlayer.",
dest.display(),
install_dir.display(),
),
})
}
fn bundled_sidecar_path() -> Result<Option<PathBuf>> {
let exe = env::current_exe()?;
let Some(dir) = exe.parent() else {
return Ok(None);
};
let candidate = dir.join("zlayer-buildd");
if candidate.exists() && is_executable(&candidate) {
Ok(Some(candidate))
} else {
Ok(None)
}
}
fn read_version(binary: &Path) -> Option<String> {
let output = std::process::Command::new(binary)
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let key = "\"sidecar_version\":\"";
let start = stdout.find(key)? + key.len();
let rest = &stdout[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn is_executable(path: &Path) -> bool {
match fs::metadata(path) {
Ok(md) if md.is_file() => md.permissions().mode() & 0o111 != 0,
_ => false,
}
}
fn mark_executable(path: &Path) -> Result<()> {
let mut perms = fs::metadata(path)?.permissions();
let mode = perms.mode();
perms.set_mode(mode | 0o755);
fs::set_permissions(path, perms)?;
Ok(())
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Ok(())
}
#[cfg(test)]
#[allow(unsafe_code)]
mod tests {
use super::*;
use crate::TEST_ENV_LOCK;
use std::sync::PoisonError;
#[test]
fn env_override_yields_skipped() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
unsafe {
env::set_var("ZLAYER_BUILDD_BIN", "/tmp/whatever");
}
let tmp = tempfile::tempdir().unwrap();
let outcome = ensure_buildd_sidecar(tmp.path()).unwrap();
unsafe {
env::remove_var("ZLAYER_BUILDD_BIN");
}
assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
}
#[test]
fn missing_binary_returns_actionable_error() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
unsafe {
env::remove_var("ZLAYER_BUILDD_BIN");
}
let tmp = tempfile::tempdir().unwrap();
let err = ensure_buildd_sidecar(tmp.path());
if let Err(e) = err {
let msg = e.to_string();
assert!(
msg.contains("zlayer-buildd"),
"error did not mention the binary: {msg}"
);
}
}
}
}