use std::collections::BTreeMap;
use std::path::Path;
use std::sync::mpsc;
use tracing::{debug, info, warn};
use crate::buildah::{BuildahCommand, BuildahExecutor};
use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
use crate::dockerfile::{
expand_dockerfile, forward_build_arg_env, merge_default_cache_mounts, render_dockerfile,
Dockerfile, Instruction, RunMount,
};
use crate::error::{BuildError, Result};
use crate::tui::{BuildEvent, PlannedStage};
use super::progress::InstructionProgress;
use super::BuildBackend;
pub struct BuildahBackend {
executor: BuildahExecutor,
}
impl BuildahBackend {
pub async fn try_new() -> Result<Self> {
let executor = BuildahExecutor::new_async().await?;
if !executor.is_available().await {
return Err(crate::error::BuildError::BuildahNotFound {
message: "buildah is installed but not responding".into(),
});
}
Ok(Self { executor })
}
pub async fn new() -> Result<Self> {
let executor = BuildahExecutor::new_async().await?;
Ok(Self { executor })
}
#[must_use]
pub fn with_executor(executor: BuildahExecutor) -> Self {
Self { executor }
}
#[must_use]
pub fn executor(&self) -> &BuildahExecutor {
&self.executor
}
async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
let cmd = BuildahCommand::tag(image, tag);
self.executor.execute_checked(&cmd).await?;
Ok(())
}
async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
let creds = auth.map(|auth| format!("{}:{}", auth.username, auth.password));
let cmd = BuildahCommand::push_with_creds(tag, creds.as_deref());
self.executor.execute_checked(&cmd).await?;
Ok(())
}
fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
if let Some(tx) = event_tx {
let _ = tx.send(event);
}
}
}
#[async_trait::async_trait]
impl BuildBackend for BuildahBackend {
#[allow(clippy::too_many_lines)]
async fn build_image(
&self,
context: &Path,
dockerfile: &Dockerfile,
options: &BuildOptions,
event_tx: Option<mpsc::Sender<BuildEvent>>,
) -> Result<BuiltImage> {
let start_time = std::time::Instant::now();
debug!(
"BuildahBackend: starting build ({} stages)",
dockerfile.stages.len()
);
let mut effective_build_args: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in &options.build_args {
effective_build_args.insert(k.clone(), v.clone());
}
for (k, v) in &options.pipeline_vars {
effective_build_args.insert(k.clone(), v.clone());
}
forward_build_arg_env(dockerfile, &mut effective_build_args);
let expand_args: std::collections::HashMap<String, String> = effective_build_args
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let mut ir = expand_dockerfile(dockerfile, &expand_args);
merge_default_cache_mounts(&mut ir, options);
let text = render_dockerfile(&ir);
let ssh_ids = collect_ssh_ids(&ir);
let secret_ids = collect_secret_ids(&ir);
let mut rendered = tempfile::Builder::new()
.prefix("zlayer-rendered-")
.tempfile_in(context)
.map_err(BuildError::from)?;
{
use std::io::Write as _;
rendered
.write_all(text.as_bytes())
.map_err(BuildError::from)?;
rendered.flush().map_err(BuildError::from)?;
}
let dockerfile_path = rendered.path().to_path_buf();
let planned_stages: Vec<PlannedStage> = ir
.stages
.iter()
.map(|stage| PlannedStage {
name: stage.name.clone(),
base_image: stage.base_image.to_string(),
instructions: stage
.instructions
.iter()
.map(|instruction| format!("{instruction:?}"))
.collect(),
})
.collect();
let total_stages = planned_stages.len();
let total_instructions: usize = planned_stages.iter().map(|s| s.instructions.len()).sum();
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildStarted {
total_stages,
total_instructions,
},
);
let mut progress = InstructionProgress::from_planned_stages(&planned_stages);
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildPlan {
stages: planned_stages,
},
);
for event in progress.start_first() {
Self::send_event(event_tx.as_ref(), event);
}
let cmd = BuildahCommand::build(
&dockerfile_path,
context,
options,
&effective_build_args,
&ssh_ids,
&secret_ids,
);
let max_attempts = options.retries + 1;
let mut last_output = None;
for attempt in 1..=max_attempts {
if attempt > 1 {
warn!("Retrying build (attempt {}/{})...", attempt, max_attempts);
Self::send_event(
event_tx.as_ref(),
BuildEvent::Output {
line: format!("⟳ Retrying build (attempt {attempt}/{max_attempts})..."),
is_stderr: false,
},
);
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
let event_tx_clone = event_tx.clone();
let progress_ref = &mut progress;
let output = self
.executor
.execute_streaming(&cmd, |is_stdout, line| {
let events = progress_ref.on_line(line, !is_stdout);
for event in events {
Self::send_event(event_tx_clone.as_ref(), event);
}
})
.await?;
if output.success() {
last_output = Some(output);
break;
}
last_output = Some(output);
}
let output = last_output.expect("retry loop runs at least once");
if !output.success() {
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildFailed {
error: output.stderr.clone(),
},
);
return Err(BuildError::buildah_execution(
cmd.to_command_string(),
output.exit_code,
output.stderr,
));
}
let image_id = output
.stdout
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.map_or_else(
|| options.tags.first().cloned().unwrap_or_default(),
ToString::to_string,
);
info!("Built image: {}", image_id);
if options.push {
for tag in &options.tags {
self.push_image_internal(tag, options.registry_auth.as_ref())
.await?;
info!("Pushed image: {}", tag);
}
}
#[allow(clippy::cast_possible_truncation)]
let build_time_ms = start_time.elapsed().as_millis() as u64;
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildComplete {
image_id: image_id.clone(),
},
);
info!(
"Build completed in {}ms: {} with {} tags",
build_time_ms,
image_id,
options.tags.len()
);
drop(rendered);
Ok(BuiltImage {
image_id,
tags: options.tags.clone(),
layer_count: total_instructions,
size: 0, build_time_ms,
is_manifest: options.platform.as_deref().is_some_and(|s| s.contains(',')),
})
}
async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
self.push_image_internal(tag, auth).await
}
async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
self.tag_image_internal(image, new_tag).await
}
async fn manifest_create(&self, name: &str) -> Result<()> {
self.executor.manifest_create_idempotent(name).await
}
async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
let cmd = BuildahCommand::manifest_add(manifest, image);
self.executor.execute_checked(&cmd).await?;
Ok(())
}
async fn manifest_push(
&self,
name: &str,
destination: &str,
auth: Option<&RegistryAuth>,
) -> Result<()> {
let creds = auth.map(|auth| format!("{}:{}", auth.username, auth.password));
let cmd = BuildahCommand::manifest_push_with_creds(name, destination, creds.as_deref());
self.executor.execute_checked(&cmd).await?;
Ok(())
}
async fn is_available(&self) -> bool {
self.executor.is_available().await
}
fn name(&self) -> &'static str {
"buildah"
}
}
fn collect_ssh_ids(df: &Dockerfile) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
for stage in &df.stages {
for instruction in &stage.instructions {
let Instruction::Run(run) = instruction else {
continue;
};
for mount in &run.mounts {
if let RunMount::Ssh { id, .. } = mount {
let id = id.clone().unwrap_or_else(|| "default".to_string());
if !ids.contains(&id) {
ids.push(id);
}
}
}
}
}
ids
}
fn collect_secret_ids(df: &Dockerfile) -> Vec<String> {
let mut specs: Vec<String> = Vec::new();
for stage in &df.stages {
for instruction in &stage.instructions {
let Instruction::Run(run) = instruction else {
continue;
};
for mount in &run.mounts {
if let RunMount::Secret { id, .. } = mount {
let spec = format!("id={id}");
if !specs.contains(&spec) {
specs.push(spec);
}
}
}
}
}
specs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dockerfile::{CacheSharing, RunInstruction, RunMount};
#[test]
fn collect_ssh_ids_dedups_and_defaults() {
let mut run_default = RunInstruction::shell("git fetch");
run_default.mounts.push(RunMount::Ssh {
target: String::new(),
id: None,
required: false,
});
let mut run_named = RunInstruction::shell("git fetch again");
run_named.mounts.push(RunMount::Ssh {
target: String::new(),
id: Some("github".to_string()),
required: true,
});
let mut run_default2 = RunInstruction::shell("git fetch thrice");
run_default2.mounts.push(RunMount::Ssh {
target: String::new(),
id: None,
required: false,
});
let df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
let mut df = df;
df.stages[0].instructions = vec![
Instruction::Run(run_default),
Instruction::Run(run_named),
Instruction::Run(run_default2),
];
assert_eq!(collect_ssh_ids(&df), vec!["default", "github"]);
}
#[test]
fn collect_ssh_ids_empty_when_no_ssh_mounts() {
let mut run = RunInstruction::shell("apt-get update");
run.mounts.push(RunMount::Cache {
target: "/var/cache/apt".to_string(),
id: None,
sharing: CacheSharing::Shared,
readonly: false,
});
let mut df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
df.stages[0].instructions = vec![Instruction::Run(run)];
assert!(collect_ssh_ids(&df).is_empty());
}
}