use std::io::Write as _;
use std::path::Path;
use std::sync::mpsc::Sender;
use tokio_stream::StreamExt;
use tonic::Request;
use crate::backend::buildah_sidecar::proto;
use crate::backend::progress::InstructionProgress;
use crate::builder::{BuildOptions, BuiltImage, PullBaseMode};
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::BuildahSidecarBackend;
impl BuildahSidecarBackend {
pub(super) async fn build_image_impl(
&self,
context: &Path,
dockerfile: &Dockerfile,
options: &BuildOptions,
event_tx: Option<Sender<BuildEvent>>,
) -> Result<BuiltImage> {
tracing::info!(
platform = ?options.platform,
dockerfile = ?options.dockerfile,
"buildd build started"
);
let started_at = std::time::Instant::now();
let live = self.lifecycle.ensure().await?;
let mut client = live.client();
let (request, _rendered) = build_request_from(context, dockerfile, options, self.config())?;
let stream = client
.build(Request::new(request))
.await
.map_err(|s| grpc_err(&s))?
.into_inner();
let built = consume_build_stream(stream, event_tx, dockerfile, options, started_at).await?;
if options.push {
for tag in &options.tags {
self.push_image_impl(tag, options.registry_auth.as_ref())
.await?;
tracing::info!("Pushed image: {}", tag);
}
}
Ok(built)
}
}
fn build_request_from(
context: &Path,
dockerfile: &Dockerfile,
options: &BuildOptions,
config: &zlayer_types::builder::SidecarConfig,
) -> Result<(proto::BuildRequest, tempfile::NamedTempFile)> {
let context_dir = translate_context_path(context, config);
let mut effective_build_args = std::collections::BTreeMap::<String, String>::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 mut rendered = tempfile::Builder::new()
.prefix("zlayer-rendered-")
.tempfile_in(context)
.map_err(BuildError::from)?;
rendered
.write_all(text.as_bytes())
.map_err(BuildError::from)?;
rendered.flush().map_err(BuildError::from)?;
let dockerfile_path = translate_context_path(rendered.path(), config);
let secrets = collect_secret_ids(&ir);
let ssh = collect_ssh_ids(&ir);
let platforms = options
.platform
.as_deref()
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let cache_from = options.cache_from.clone().unwrap_or_default();
let cache_to = options.cache_to.clone().unwrap_or_default();
let build_args: std::collections::HashMap<String, String> =
effective_build_args.into_iter().collect();
let pull_policy = pull_policy_str(options.pull).to_string();
let format = options.format.clone().unwrap_or_default();
let target_stage = options.target.clone().unwrap_or_default();
let request = proto::BuildRequest {
request_id: String::new(),
context_dir,
dockerfile_paths: vec![dockerfile_path],
tags: options.tags.clone(),
platforms,
build_args,
secrets,
ssh,
target_stage,
host_network: options.host_network,
cache_from,
cache_to,
no_cache: options.no_cache,
squash: options.squash,
layers: options.layers,
format,
pull_policy,
labels: Vec::new(),
annotations: Vec::new(),
add_hosts: Vec::new(),
envs: Vec::new(),
shm_size: String::new(),
ulimits: Vec::new(),
volumes: Vec::new(),
source_date_epoch: 0,
rewrite_timestamp: false,
isolation: detect_default_isolation(),
};
Ok((request, rendered))
}
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
}
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 translate_context_path(path: &Path, config: &zlayer_types::builder::SidecarConfig) -> String {
if let Some((host_prefix, guest_prefix)) = config.context_mount.as_ref() {
if let Ok(rel) = path.strip_prefix(host_prefix) {
return guest_prefix.join(rel).to_string_lossy().into_owned();
}
}
path.to_string_lossy().into_owned()
}
fn detect_default_isolation() -> String {
#[cfg(unix)]
{
if nix::unistd::Uid::current().is_root() {
String::new()
} else {
"chroot".to_string()
}
}
#[cfg(not(unix))]
{
String::new()
}
}
fn pull_policy_str(mode: PullBaseMode) -> &'static str {
match mode {
PullBaseMode::Never => "never",
PullBaseMode::Always => "always",
PullBaseMode::Newer => "ifnewer",
}
}
async fn consume_build_stream(
mut stream: tonic::Streaming<proto::BuildEvent>,
event_tx: Option<Sender<BuildEvent>>,
dockerfile: &Dockerfile,
options: &BuildOptions,
started_at: std::time::Instant,
) -> Result<BuiltImage> {
let total_stages = dockerfile.stages.len();
let total_instructions: usize = dockerfile.stages.iter().map(|s| s.instructions.len()).sum();
if let Some(tx) = &event_tx {
let _ = tx.send(BuildEvent::BuildStarted {
total_stages,
total_instructions,
});
}
let planned_stages: Vec<PlannedStage> = dockerfile
.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 mut progress = InstructionProgress::from_planned_stages(&planned_stages);
if let Some(tx) = &event_tx {
let _ = tx.send(BuildEvent::BuildPlan {
stages: planned_stages,
});
for event in progress.start_first() {
let _ = tx.send(event);
}
}
let mut final_image_id: Option<String> = None;
let mut final_manifest_ref: Option<String> = None;
let mut final_error: Option<String> = None;
#[allow(clippy::items_after_statements)]
const STALL: std::time::Duration = std::time::Duration::from_secs(120);
loop {
match tokio::time::timeout(STALL, stream.next()).await {
Err(_elapsed) => {
tracing::error!(
stall_secs = STALL.as_secs(),
"buildd build stream stalled — no BuildEvent; aborting (likely fuse-overlayfs/virtiofs deadlock)"
);
return Err(BuildError::BuildahExecution {
command: "buildah-sidecar build".to_string(),
exit_code: 1,
stderr: format!(
"sidecar build stream stalled for {}s with no BuildEvent (likely fuse-overlayfs/virtiofs deadlock); aborting",
STALL.as_secs()
),
});
}
Ok(None) => break,
Ok(Some(message)) => {
let event = message.map_err(|s| grpc_err(&s))?;
let Some(ev) = event.event else {
continue;
};
dispatch_event(
ev,
event_tx.as_ref(),
&mut progress,
&mut final_image_id,
&mut final_manifest_ref,
&mut final_error,
);
}
}
}
if let Some(err) = final_error {
if let Some(tx) = &event_tx {
let _ = tx.send(BuildEvent::BuildFailed { error: err.clone() });
}
return Err(BuildError::BuildahExecution {
command: "buildah-sidecar build".to_string(),
exit_code: 1,
stderr: err,
});
}
let image_id = final_image_id.ok_or_else(|| BuildError::BuildahExecution {
command: "buildah-sidecar build".to_string(),
exit_code: 1,
stderr: "sidecar stream ended without Finished or Error event".to_string(),
})?;
if let Some(tx) = &event_tx {
let _ = tx.send(BuildEvent::BuildComplete {
image_id: image_id.clone(),
});
}
Ok(built_image_from(
image_id,
final_manifest_ref.as_deref(),
options,
started_at,
))
}
fn dispatch_event(
ev: proto::build_event::Event,
event_tx: Option<&Sender<BuildEvent>>,
progress: &mut InstructionProgress,
final_image_id: &mut Option<String>,
final_manifest_ref: &mut Option<String>,
final_error: &mut Option<String>,
) {
match ev {
proto::build_event::Event::StageStarted(s) => {
if let Some(tx) = event_tx {
let _ = tx.send(BuildEvent::StageStarted {
index: s.index as usize,
name: if s.name.is_empty() {
None
} else {
Some(s.name)
},
base_image: s.base_image,
});
}
}
proto::build_event::Event::StageFinished(s) => {
if let Some(tx) = event_tx {
let _ = tx.send(BuildEvent::StageComplete {
index: s.index as usize,
});
}
}
proto::build_event::Event::InstructionStarted(i) => {
if let Some(tx) = event_tx {
let _ = tx.send(BuildEvent::InstructionStarted {
stage: i.stage as usize,
index: i.index as usize,
instruction: i.instruction,
});
}
}
proto::build_event::Event::InstructionFinished(i) => {
if let Some(tx) = event_tx {
let _ = tx.send(BuildEvent::InstructionComplete {
stage: i.stage as usize,
index: i.index as usize,
cached: i.cached,
});
}
}
proto::build_event::Event::Log(line) => {
for event in progress.on_line(&line.line, line.is_stderr) {
if let Some(tx) = event_tx {
let _ = tx.send(event);
}
}
}
proto::build_event::Event::Warning(w) => {
tracing::warn!(message = %w.message, "buildd build warning");
if let Some(tx) = event_tx {
let _ = tx.send(BuildEvent::Output {
line: format!("warning: {}", w.message),
is_stderr: true,
});
}
}
proto::build_event::Event::Finished(f) => {
tracing::info!(
image_id = %f.image_id,
manifest_ref = %f.manifest_ref,
"buildd build finished"
);
*final_image_id = Some(f.image_id);
*final_manifest_ref = if f.manifest_ref.is_empty() {
None
} else {
Some(f.manifest_ref)
};
}
proto::build_event::Event::Error(e) => {
tracing::error!(kind = %e.kind, message = %e.message, "buildd build error");
*final_error = Some(if e.kind.is_empty() {
e.message
} else {
format!("{}: {}", e.kind, e.message)
});
}
}
}
fn built_image_from(
image_id: String,
manifest_ref: Option<&str>,
options: &BuildOptions,
started_at: std::time::Instant,
) -> BuiltImage {
let is_manifest =
manifest_ref.is_some() && options.platform.as_deref().is_some_and(|s| s.contains(','));
BuiltImage {
image_id,
tags: options.tags.clone(),
layer_count: 0,
size: 0,
build_time_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
is_manifest,
}
}
fn grpc_err(status: &tonic::Status) -> BuildError {
BuildError::BuildahExecution {
command: format!("buildah-sidecar rpc ({:?})", status.code()),
exit_code: 1,
stderr: status.message().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::PullBaseMode;
fn empty_dockerfile() -> Dockerfile {
Dockerfile::parse("FROM scratch\n").expect("trivial Dockerfile must parse")
}
#[test]
fn build_request_from_minimal_options() {
let ctx = tempfile::tempdir().expect("tempdir");
let context = ctx.path();
let df = empty_dockerfile();
let options = BuildOptions {
tags: vec!["test/img:latest".into()],
..BuildOptions::default()
};
let (req, rendered) = build_request_from(
context,
&df,
&options,
&zlayer_types::builder::SidecarConfig::default(),
)
.expect("build_request_from");
assert_eq!(req.context_dir, context.to_string_lossy());
assert_eq!(req.tags, vec!["test/img:latest".to_string()]);
assert!(req.platforms.is_empty());
assert_eq!(req.dockerfile_paths.len(), 1);
let rendered_path = Path::new(&req.dockerfile_paths[0]);
assert_eq!(rendered_path.parent(), Some(context));
assert!(rendered_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("zlayer-rendered-")));
assert!(rendered.path().exists());
assert_eq!(req.pull_policy, "ifnewer"); assert!(!req.no_cache);
assert!(!req.squash);
assert!(req.layers); assert_eq!(req.target_stage, "");
assert_eq!(req.format, "");
assert_eq!(req.cache_from, "");
assert_eq!(req.cache_to, "");
assert!(req.secrets.is_empty());
assert!(req.ssh.is_empty());
}
#[test]
fn build_request_dockerfile_path_is_rendered_temp_in_context() {
let ctx = tempfile::tempdir().expect("tempdir");
let context = ctx.path();
let df = empty_dockerfile();
let options = BuildOptions {
dockerfile: Some(Path::new("/custom/Dockerfile.web").into()),
..BuildOptions::default()
};
let (req, _rendered) = build_request_from(
context,
&df,
&options,
&zlayer_types::builder::SidecarConfig::default(),
)
.expect("build_request_from");
assert_eq!(req.dockerfile_paths.len(), 1);
let rendered_path = Path::new(&req.dockerfile_paths[0]);
assert_eq!(rendered_path.parent(), Some(context));
assert!(rendered_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("zlayer-rendered-")));
}
#[test]
fn build_request_populates_secrets_and_ssh_from_run_mounts() {
use crate::dockerfile::RunInstruction;
let ctx = tempfile::tempdir().expect("tempdir");
let context = ctx.path();
let mut run_secret = RunInstruction::shell("cat /run/secrets/foo");
run_secret.mounts.push(RunMount::Secret {
target: String::new(),
id: "foo".to_string(),
required: false,
});
let mut run_ssh = RunInstruction::shell("git fetch");
run_ssh.mounts.push(RunMount::Ssh {
target: String::new(),
id: Some("github".to_string()),
required: true,
});
let mut df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
df.stages[0].instructions = vec![Instruction::Run(run_secret), Instruction::Run(run_ssh)];
let options = BuildOptions::default();
let (req, _rendered) = build_request_from(
context,
&df,
&options,
&zlayer_types::builder::SidecarConfig::default(),
)
.expect("build_request_from");
assert_eq!(req.secrets, vec!["id=foo".to_string()]);
assert_eq!(req.ssh, vec!["github".to_string()]);
}
#[test]
fn build_request_translates_rendered_path_with_context_mount() {
let host_root = tempfile::tempdir().expect("tempdir");
let context = host_root.path();
let df = empty_dockerfile();
let options = BuildOptions::default();
let config = zlayer_types::builder::SidecarConfig {
context_mount: Some((
context.to_path_buf(),
std::path::PathBuf::from("/mnt/guest-ctx"),
)),
..zlayer_types::builder::SidecarConfig::default()
};
let (req, _rendered) =
build_request_from(context, &df, &options, &config).expect("build_request_from");
assert_eq!(req.context_dir.trim_end_matches('/'), "/mnt/guest-ctx");
let rendered_path = Path::new(&req.dockerfile_paths[0]);
assert_eq!(rendered_path.parent(), Some(Path::new("/mnt/guest-ctx")));
assert!(rendered_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("zlayer-rendered-")));
}
#[test]
fn build_request_splits_multi_platform_string() {
let ctx = tempfile::tempdir().expect("tempdir");
let context = ctx.path();
let df = empty_dockerfile();
let options = BuildOptions {
platform: Some(" linux/amd64 , linux/arm64 ".to_string()),
..BuildOptions::default()
};
let (req, _rendered) = build_request_from(
context,
&df,
&options,
&zlayer_types::builder::SidecarConfig::default(),
)
.expect("build_request_from");
assert_eq!(
req.platforms,
vec!["linux/amd64".to_string(), "linux/arm64".to_string()]
);
}
#[test]
fn build_request_merges_pipeline_vars_into_build_args() {
let ctx = tempfile::tempdir().expect("tempdir");
let context = ctx.path();
let df = empty_dockerfile();
let mut build_args = std::collections::HashMap::new();
build_args.insert("FOO".to_string(), "1".to_string());
let mut pipeline_vars = std::collections::HashMap::new();
pipeline_vars.insert("LTSC".to_string(), "ltsc2025".to_string());
let options = BuildOptions {
build_args,
pipeline_vars,
..BuildOptions::default()
};
let (req, _rendered) = build_request_from(
context,
&df,
&options,
&zlayer_types::builder::SidecarConfig::default(),
)
.expect("build_request_from");
assert_eq!(req.build_args.get("FOO"), Some(&"1".to_string()));
assert_eq!(req.build_args.get("LTSC"), Some(&"ltsc2025".to_string()));
}
#[test]
fn pull_policy_translations() {
assert_eq!(pull_policy_str(PullBaseMode::Never), "never");
assert_eq!(pull_policy_str(PullBaseMode::Always), "always");
assert_eq!(pull_policy_str(PullBaseMode::Newer), "ifnewer");
}
#[test]
fn detect_default_isolation_picks_chroot_when_unprivileged() {
#[cfg(unix)]
{
if nix::unistd::Uid::current().is_root() {
assert_eq!(detect_default_isolation(), "");
} else {
assert_eq!(detect_default_isolation(), "chroot");
}
}
#[cfg(not(unix))]
{
assert_eq!(detect_default_isolation(), "");
}
}
#[test]
fn built_image_carries_tags_and_id() {
let started = std::time::Instant::now();
let options = BuildOptions {
tags: vec!["a:b".into(), "c:d".into()],
..BuildOptions::default()
};
let img = built_image_from("sha256:abc".to_string(), None, &options, started);
assert_eq!(img.image_id, "sha256:abc");
assert_eq!(img.tags, vec!["a:b".to_string(), "c:d".to_string()]);
assert!(!img.is_manifest);
}
#[test]
fn built_image_flags_manifest_for_multi_arch_with_manifest_ref() {
let started = std::time::Instant::now();
let options = BuildOptions {
tags: vec!["a:b".into()],
platform: Some("linux/amd64,linux/arm64".to_string()),
..BuildOptions::default()
};
let img = built_image_from(
"sha256:abc".to_string(),
Some("registry/img@sha256:abc"),
&options,
started,
);
assert!(img.is_manifest);
}
#[test]
fn grpc_err_carries_status_message() {
let status = tonic::Status::internal("boom");
let err = grpc_err(&status);
match err {
BuildError::BuildahExecution {
command, stderr, ..
} => {
assert!(command.contains("Internal"));
assert_eq!(stderr, "boom");
}
other => panic!("unexpected variant: {other:?}"),
}
}
}