use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use a3s_box_core::error::{BoxError, Result};
use a3s_box_core::platform::Platform;
use super::cache::{
hash_context_sources, BuildCache, BuildCacheExportIdentity, BuildCacheTrace, CachedLayer,
RecordedBuildCache,
};
use super::dockerfile::{Dockerfile, Instruction, RunBindMount, RunCacheMount};
use super::dockerignore::DockerIgnore;
use super::layer::{sha256_bytes, sha256_file, LayerInfo};
use super::output::publish_single_build_output;
pub use super::output::{BuildOutputDescriptor, BuildResult, OCI_IMAGE_MANIFEST_MEDIA_TYPE};
use crate::oci::image::OciImageConfig;
use crate::oci::layers::extract_layer;
use crate::oci::store::ImageStore;
use crate::oci::{ImagePuller, RegistryAuth};
mod control;
mod handlers;
#[cfg(target_os = "linux")]
mod run_process;
mod stages;
mod utils;
#[cfg(test)]
mod tests;
use handlers::{
apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run,
handle_run_with_pool, instruction_to_string,
};
use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages};
use utils::{compute_diff_id, expand_args, format_size, resolve_path};
const REPRODUCIBLE_OCI_CREATED_AT: &str = "1970-01-01T00:00:00Z";
pub(super) use control::{BuildExecutionControl, BuildExecutionObserver, BuildImageCommitPermit};
pub(super) struct SupervisedBuildResult {
pub(super) output: BuildResult,
pub(super) cache: Option<RecordedBuildCache>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BuildNetworkPolicy {
#[default]
Outbound,
None,
}
impl BuildNetworkPolicy {
pub const fn as_acl(self) -> &'static str {
match self {
Self::Outbound => "outbound",
Self::None => "none",
}
}
pub(crate) fn parse_acl(value: &str) -> Option<Self> {
match value {
"outbound" => Some(Self::Outbound),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct BuildConfig {
pub context_dir: PathBuf,
pub dockerfile_path: PathBuf,
pub tag: Option<String>,
pub build_args: HashMap<String, String>,
pub quiet: bool,
pub platforms: Vec<Platform>,
pub target: Option<String>,
pub no_cache: bool,
pub network: BuildNetworkPolicy,
pub metrics: Option<crate::prom::RuntimeMetrics>,
pub run_pool: Option<BuildRunPoolConfig>,
}
#[derive(Debug, Clone)]
pub struct BuildRunPoolConfig {
pub socket: String,
pub image: Option<String>,
pub vcpus: u32,
pub memory_mb: u32,
pub guest_rootfs: String,
pub timeout_ns: u64,
pub run_cache_dir: PathBuf,
}
#[cfg_attr(not(feature = "pool"), allow(dead_code))]
struct BuildRunPoolSession {
guest_rootfs: String,
timeout_ns: u64,
run_cache_dir: PathBuf,
#[cfg(feature = "pool")]
lease: crate::pool::PoolLeaseClient,
}
impl BuildRunPoolSession {
async fn acquire(config: &BuildRunPoolConfig, rootfs_dir: &Path) -> Result<Self> {
#[cfg(feature = "pool")]
{
let rootfs_dir = rootfs_dir.canonicalize().map_err(|e| {
BoxError::BuildError(format!(
"Failed to canonicalize build RUN rootfs {}: {}",
rootfs_dir.display(),
e
))
})?;
let volume = format!("{}:{}:rw", rootfs_dir.display(), config.guest_rootfs);
let lease = crate::pool::PoolLeaseClient::acquire(crate::pool::PoolClientLease {
socket: config.socket.clone(),
image: config.image.clone(),
volumes: vec![volume],
vcpus: config.vcpus,
memory_mb: config.memory_mb,
})
.await
.map_err(|e| {
BoxError::BuildError(format!(
"Failed to lease warm-pool VM for Dockerfile RUN: {}",
e
))
})?;
Ok(Self {
guest_rootfs: config.guest_rootfs.clone(),
timeout_ns: config.timeout_ns,
run_cache_dir: config.run_cache_dir.clone(),
lease,
})
}
#[cfg(not(feature = "pool"))]
{
let _ = (config, rootfs_dir);
Err(BoxError::BuildError(
"Dockerfile RUN warm-pool execution requires the runtime 'pool' feature"
.to_string(),
))
}
}
async fn release(self) -> Result<()> {
#[cfg(feature = "pool")]
{
self.lease.release().await.map_err(|e| {
BoxError::BuildError(format!(
"Failed to release warm-pool Dockerfile RUN lease: {}",
e
))
})
}
#[cfg(not(feature = "pool"))]
{
Ok(())
}
}
}
fn run_bind_mount_input_hash(
context_dir: &Path,
completed_stages: &[(Option<String>, PathBuf)],
bind_mounts: &[RunBindMount],
) -> Option<String> {
let mut input = String::new();
for mount in bind_mounts {
if has_parent_component(&mount.source) {
return None;
}
let source = if mount.source.is_empty() {
"."
} else {
mount.source.as_str()
};
let (origin, source_root) = match mount.from.as_deref() {
Some(from_ref) => (
format!("stage:{from_ref}"),
resolve_stage_rootfs(from_ref, completed_stages).ok()?,
),
None => ("context".to_string(), context_dir),
};
let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
input.push_str(&origin);
input.push('\0');
input.push_str(source);
input.push('\0');
input.push_str(&source_hash);
input.push('\0');
if mount.from.is_none() {
let dockerignore = context_dir.join(".dockerignore");
if let Ok(bytes) = std::fs::read(&dockerignore) {
input.push_str(".dockerignore");
input.push('\0');
input.push_str(&sha256_bytes(&bytes));
input.push('\0');
}
}
}
Some(sha256_bytes(input.as_bytes()))
}
fn run_cache_mount_input_hash(
completed_stages: &[(Option<String>, PathBuf)],
cache_mounts: &[RunCacheMount],
) -> Option<String> {
let mut input = String::new();
let mut saw_seeded_cache = false;
for mount in cache_mounts {
let Some(from_ref) = mount.from.as_deref() else {
continue;
};
if has_parent_component(&mount.source) {
return None;
}
saw_seeded_cache = true;
let source = if mount.source.is_empty() {
"."
} else {
mount.source.as_str()
};
let source_root = resolve_stage_rootfs(from_ref, completed_stages).ok()?;
let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
input.push_str("cache-seed:");
input.push_str(from_ref);
input.push('\0');
input.push_str(source);
input.push('\0');
input.push_str(&source_hash);
input.push('\0');
}
saw_seeded_cache.then(|| sha256_bytes(input.as_bytes()))
}
fn run_mount_input_hash(
context_dir: &Path,
completed_stages: &[(Option<String>, PathBuf)],
cache_mounts: &[RunCacheMount],
bind_mounts: &[RunBindMount],
) -> Option<String> {
let bind_hash = if bind_mounts.is_empty() {
None
} else {
run_bind_mount_input_hash(context_dir, completed_stages, bind_mounts)
};
let cache_hash = run_cache_mount_input_hash(completed_stages, cache_mounts);
match (bind_hash, cache_hash) {
(None, None) => None,
(Some(hash), None) | (None, Some(hash)) => Some(hash),
(Some(bind_hash), Some(cache_hash)) => Some(sha256_bytes(
format!("bind\0{bind_hash}\0cache\0{cache_hash}").as_bytes(),
)),
}
}
fn has_parent_component(path: &str) -> bool {
Path::new(path)
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
}
async fn resolve_run_mount_source_roots(
completed_stages: &[(Option<String>, PathBuf)],
bind_mounts: &[RunBindMount],
cache_mounts: &[RunCacheMount],
store: &Arc<ImageStore>,
build_dir: &Path,
external_from_rootfs: &mut HashMap<String, PathBuf>,
) -> Result<Option<Vec<(Option<String>, PathBuf)>>> {
let mut roots: Option<Vec<(Option<String>, PathBuf)>> = None;
let mut external_refs = HashSet::new();
let mut from_refs: Vec<&str> = Vec::new();
from_refs.extend(bind_mounts.iter().filter_map(|mount| mount.from.as_deref()));
from_refs.extend(
cache_mounts
.iter()
.filter_map(|mount| mount.from.as_deref()),
);
for from_ref in from_refs {
if resolve_stage_rootfs(from_ref, completed_stages).is_ok()
|| roots
.as_deref()
.is_some_and(|resolved| resolve_stage_rootfs(from_ref, resolved).is_ok())
{
continue;
}
if !external_refs.insert(from_ref.to_string()) {
continue;
}
let rootfs = resolve_external_from_rootfs(
from_ref,
"RUN bind mount",
store,
build_dir,
external_from_rootfs,
)
.await?;
roots
.get_or_insert_with(|| completed_stages.to_vec())
.push((Some(from_ref.to_string()), rootfs));
}
Ok(roots)
}
pub(super) struct BuildState {
pub(super) workdir: String,
pub(super) env: Vec<(String, String)>,
pub(super) entrypoint: Option<Vec<String>>,
pub(super) cmd: Option<Vec<String>>,
pub(super) user: Option<String>,
pub(super) exposed_ports: Vec<String>,
pub(super) labels: HashMap<String, String>,
pub(super) layers: Vec<LayerInfo>,
pub(super) diff_ids: Vec<String>,
pub(super) history: Vec<HistoryEntry>,
pub(super) build_args: HashMap<String, String>,
pub(super) declared_args: HashSet<String>,
pub(super) shell: Vec<String>,
pub(super) stop_signal: Option<String>,
pub(super) health_check: Option<OciHealthCheck>,
pub(super) onbuild: Vec<String>,
pub(super) volumes: Vec<String>,
}
#[derive(Debug, Clone)]
pub(super) struct HistoryEntry {
pub(super) created_by: String,
pub(super) empty_layer: bool,
}
pub use crate::oci::image::OciHealthCheck;
impl BuildState {
fn new(build_args: HashMap<String, String>) -> Self {
Self {
workdir: "/".to_string(),
env: Vec::new(),
entrypoint: None,
cmd: None,
user: None,
exposed_ports: Vec::new(),
labels: HashMap::new(),
layers: Vec::new(),
diff_ids: Vec::new(),
history: Vec::new(),
build_args,
declared_args: HashSet::new(),
shell: vec!["/bin/sh".to_string(), "-c".to_string()],
stop_signal: None,
health_check: None,
onbuild: Vec::new(),
volumes: Vec::new(),
}
}
fn declared_build_args(&self) -> HashMap<String, String> {
self.build_args
.iter()
.filter(|(name, _)| self.declared_args.contains(*name))
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
}
fn expansion_vars(&self) -> HashMap<String, String> {
let mut vars = self.declared_build_args();
for (key, value) in &self.env {
vars.insert(key.clone(), value.clone());
}
vars
}
fn run_env(&self) -> Vec<(String, String)> {
let mut vars = self.declared_build_args();
for (key, value) in &self.env {
vars.insert(key.clone(), value.clone());
}
let mut pairs = vars.into_iter().collect::<Vec<_>>();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
pairs
}
fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
self.declared_args.insert(name.to_string());
if !self.build_args.contains_key(name) {
if let Some(val) = default {
self.build_args.insert(name.to_string(), val.to_string());
}
}
}
}
pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
validate_build_config(&config)?;
let build_dir = tempfile::TempDir::new()
.map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
build_in_workspace(config, store, build_dir.path(), None, None)
.await
.map(|result| result.output)
}
pub(super) async fn build_supervised(
config: BuildConfig,
store: Arc<ImageStore>,
workspace: &Path,
control: BuildExecutionControl,
cache_identity: Option<BuildCacheExportIdentity>,
) -> Result<SupervisedBuildResult> {
validate_build_config(&config)?;
control.ensure_active().await?;
build_in_workspace(config, store, workspace, Some(control), cache_identity).await
}
async fn build_in_workspace(
config: BuildConfig,
store: Arc<ImageStore>,
build_dir: &Path,
control: Option<BuildExecutionControl>,
cache_identity: Option<BuildCacheExportIdentity>,
) -> Result<SupervisedBuildResult> {
let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
let dockerignore = DockerIgnore::load(&config.context_dir);
if !config.quiet {
println!("Building from {}", config.dockerfile_path.display());
if !dockerignore.is_empty() {
println!("Using .dockerignore");
}
}
let stages = split_into_stages(&dockerfile.instructions);
let global_args = global_arg_decls(&dockerfile.instructions);
let total_stages = stages.len();
let output_stage_idx = match config.target.as_deref() {
Some(target) => stages
.iter()
.position(|s| s.alias.as_deref() == Some(target))
.or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
.ok_or_else(|| {
BoxError::BuildError(format!("target build stage '{}' not found", target))
})?,
None => total_stages - 1,
};
let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
let mut final_state = BuildState::new(config.build_args.clone());
let mut final_base_layers: Vec<LayerInfo> = Vec::new();
let mut final_base_diff_ids: Vec<String> = Vec::new();
let total_instructions = dockerfile.instructions.len();
let mut global_step = 0;
let cache = if config.no_cache {
None
} else {
BuildCache::open()
};
let mut cache_trace = cache_identity.as_ref().map(|_| BuildCacheTrace::default());
for (stage_idx, stage) in stages.iter().enumerate() {
if let Some(control) = &control {
control.ensure_active().await?;
}
let is_final_stage = stage_idx == output_stage_idx;
let rootfs_dir = build_dir.join(format!("rootfs_{}", stage_idx));
let layers_dir = build_dir.join(format!("layers_{}", stage_idx));
std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
})?;
std::fs::create_dir_all(&layers_dir).map_err(|e| {
BoxError::BuildError(format!("Failed to create layers directory: {}", e))
})?;
let mut state = BuildState::new(config.build_args.clone());
if stage_idx > 0 {
for (name, default) in &global_args {
state.seed_global_arg(name, default.as_deref());
}
}
let mut base_layers: Vec<LayerInfo> = Vec::new();
let mut base_diff_ids: Vec<String> = Vec::new();
let mut chain_key = String::new();
let mut cache_valid = true;
for instruction in &stage.instructions {
if let Some(control) = &control {
control.ensure_active().await?;
}
global_step += 1;
let step = global_step;
validate_instruction_network(instruction, config.network)?;
let run_mount_source_roots = if let Instruction::Run {
bind_mounts,
cache_mounts,
..
} = instruction
{
resolve_run_mount_source_roots(
&completed_stages,
bind_mounts,
cache_mounts,
&store,
build_dir,
&mut external_from_rootfs,
)
.await?
} else {
None
};
if !matches!(instruction, Instruction::From { .. }) {
let repr = match instruction {
Instruction::Env { vars } => {
let pairs: Vec<String> = vars
.iter()
.map(|(k, v)| {
format!("{}={}", k, expand_args(v, &state.expansion_vars()))
})
.collect();
format!("ENV {}", pairs.join(" "))
}
Instruction::Arg { name, default } => {
let effective = state
.build_args
.get(name)
.cloned()
.or_else(|| default.clone())
.unwrap_or_default();
format!("ARG {}={}", name, effective)
}
Instruction::Run { .. } => {
run_instruction_cache_repr(instruction, config.network)
}
other => instruction_to_string(other),
};
let input_hash = match instruction {
Instruction::Copy {
src, from: None, ..
} => hash_context_sources(&config.context_dir, src),
Instruction::Copy {
src,
from: Some(from_ref),
..
} => {
resolve_stage_rootfs(from_ref, &completed_stages)
.ok()
.and_then(|rootfs| hash_context_sources(rootfs, src))
}
Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
Instruction::Run {
cache_mounts,
bind_mounts,
..
} => run_mount_input_hash(
&config.context_dir,
run_mount_source_roots
.as_deref()
.unwrap_or(&completed_stages),
cache_mounts,
bind_mounts,
),
_ => None,
};
chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
}
match instruction {
Instruction::From { image, alias } => {
if !config.quiet {
if total_stages > 1 {
println!(
"Step {}/{}: FROM {} (stage {}/{}{})",
step,
total_instructions,
image,
stage_idx + 1,
total_stages,
alias
.as_ref()
.map(|a| format!(" as {}", a))
.unwrap_or_default()
);
} else {
println!("Step {}/{}: FROM {}", step, total_instructions, image);
}
}
let (layers, diff_ids, base_config) = handle_from(
image,
&rootfs_dir,
&layers_dir,
&store,
&state.declared_build_args(),
)
.await?;
base_layers = layers;
base_diff_ids = diff_ids;
chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
cache_valid = true;
apply_base_config(&mut state, &base_config);
if !base_config.onbuild.is_empty() && !config.quiet {
println!(
" Executing {} ONBUILD trigger(s) from base image",
base_config.onbuild.len()
);
}
for trigger in &base_config.onbuild {
execute_onbuild_trigger(
trigger,
&mut state,
&config,
&rootfs_dir,
&layers_dir,
&base_layers,
&completed_stages,
)?;
}
state.history.push(HistoryEntry {
created_by: format!("FROM {}", image),
empty_layer: true,
});
}
Instruction::Copy {
src,
dst,
from,
chown,
} => {
let created_by = if let Some(from_ref) = from {
format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
} else if let Some(owner) = chown {
format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
} else {
format!("COPY {} {}", src.join(" "), dst)
};
if let Some(cached) = try_reuse_cached_layer(
CachedLayerReuse {
cache_valid,
cache: cache.as_ref(),
chain_key: &chain_key,
rootfs_dir: &rootfs_dir,
layers_dir: &layers_dir,
layer_index: state.layers.len() + base_layers.len(),
created_by: &created_by,
},
&mut state,
)? {
if let Some(trace) = &mut cache_trace {
trace.record(&chain_key, &cached)?;
}
if !config.quiet {
println!(
"Step {}/{}: {} (CACHED)",
step, total_instructions, created_by
);
}
continue;
}
cache_valid = false;
if let Some(from_ref) = from {
if !config.quiet {
println!(
"Step {}/{}: COPY --from={} {} {}",
step,
total_instructions,
from_ref,
src.join(" "),
dst
);
}
let from_rootfs: PathBuf =
match resolve_stage_rootfs(from_ref, &completed_stages) {
Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
Err(_) => {
resolve_external_from_rootfs(
from_ref,
"COPY --from",
&store,
build_dir,
&mut external_from_rootfs,
)
.await?
}
};
let layer_info = handle_copy(
src,
dst,
chown.as_deref(),
&from_rootfs,
&rootfs_dir,
&layers_dir,
&state.workdir,
state.layers.len() + base_layers.len(),
None,
)?;
let diff_id = compute_diff_id(&layer_info.path)?;
store_cache_entry(
cache.as_ref(),
cache_trace.as_mut(),
&chain_key,
&layer_info,
&diff_id,
)?;
state.diff_ids.push(diff_id);
state.layers.push(layer_info);
state.history.push(HistoryEntry {
created_by: format!(
"COPY --from={} {} {}",
from_ref,
src.join(" "),
dst
),
empty_layer: false,
});
} else {
if !config.quiet {
println!(
"Step {}/{}: COPY {} {}",
step,
total_instructions,
src.join(" "),
dst
);
}
let layer_info = handle_copy(
src,
dst,
chown.as_deref(),
&config.context_dir,
&rootfs_dir,
&layers_dir,
&state.workdir,
state.layers.len() + base_layers.len(),
Some(&dockerignore),
)?;
let diff_id = compute_diff_id(&layer_info.path)?;
store_cache_entry(
cache.as_ref(),
cache_trace.as_mut(),
&chain_key,
&layer_info,
&diff_id,
)?;
state.diff_ids.push(diff_id);
state.layers.push(layer_info);
state.history.push(HistoryEntry {
created_by: format!("COPY {} {}", src.join(" "), dst),
empty_layer: false,
});
}
}
Instruction::Add { src, dst, chown } => {
let created_by = format!("ADD {} {}", src.join(" "), dst);
if let Some(cached) = try_reuse_cached_layer(
CachedLayerReuse {
cache_valid,
cache: cache.as_ref(),
chain_key: &chain_key,
rootfs_dir: &rootfs_dir,
layers_dir: &layers_dir,
layer_index: state.layers.len() + base_layers.len(),
created_by: &created_by,
},
&mut state,
)? {
if let Some(trace) = &mut cache_trace {
trace.record(&chain_key, &cached)?;
}
if !config.quiet {
println!(
"Step {}/{}: {} (CACHED)",
step, total_instructions, created_by
);
}
continue;
}
cache_valid = false;
if !config.quiet {
println!(
"Step {}/{}: ADD {} {}",
step,
total_instructions,
src.join(" "),
dst
);
}
let layer_info = handle_add(
src,
dst,
chown.as_deref(),
&config.context_dir,
&rootfs_dir,
&layers_dir,
&state.workdir,
state.layers.len() + base_layers.len(),
Some(&dockerignore),
)?;
let diff_id = compute_diff_id(&layer_info.path)?;
store_cache_entry(
cache.as_ref(),
cache_trace.as_mut(),
&chain_key,
&layer_info,
&diff_id,
)?;
state.diff_ids.push(diff_id);
state.layers.push(layer_info);
state.history.push(HistoryEntry {
created_by: format!("ADD {} {}", src.join(" "), dst),
empty_layer: false,
});
}
Instruction::Run {
command,
cache_mounts,
bind_mounts,
tmpfs_mounts,
} => {
let created_by = instruction_to_string(instruction);
if let Some(cached) = try_reuse_cached_layer(
CachedLayerReuse {
cache_valid,
cache: cache.as_ref(),
chain_key: &chain_key,
rootfs_dir: &rootfs_dir,
layers_dir: &layers_dir,
layer_index: state.layers.len() + base_layers.len(),
created_by: &created_by,
},
&mut state,
)? {
if let Some(trace) = &mut cache_trace {
trace.record(&chain_key, &cached)?;
}
if !config.quiet {
println!(
"Step {}/{}: {} (CACHED)",
step, total_instructions, created_by
);
}
continue;
}
cache_valid = false;
if !config.quiet {
println!("Step {}/{}: {}", step, total_instructions, created_by);
}
let layer_opt = if let Some(pool_config) = &config.run_pool {
let session =
BuildRunPoolSession::acquire(pool_config, &rootfs_dir).await?;
handle_run_with_pool(
command,
cache_mounts,
bind_mounts,
tmpfs_mounts,
&config.context_dir,
run_mount_source_roots
.as_deref()
.unwrap_or(&completed_stages),
&rootfs_dir,
&layers_dir,
&state.workdir,
&state.run_env(),
&state.shell,
state.user.as_deref(),
state.layers.len() + base_layers.len(),
config.quiet,
session,
Some(&dockerignore),
)
.await?
} else {
handle_run(
command,
cache_mounts,
bind_mounts,
tmpfs_mounts,
config.network,
&config.context_dir,
run_mount_source_roots
.as_deref()
.unwrap_or(&completed_stages),
&rootfs_dir,
&layers_dir,
&state.workdir,
&state.run_env(),
&state.shell,
state.layers.len() + base_layers.len(),
config.quiet,
Some(&dockerignore),
control.as_ref(),
)
.await?
};
if let Some(layer_info) = layer_opt {
let diff_id = compute_diff_id(&layer_info.path)?;
store_cache_entry(
cache.as_ref(),
cache_trace.as_mut(),
&chain_key,
&layer_info,
&diff_id,
)?;
state.diff_ids.push(diff_id);
state.layers.push(layer_info);
state.history.push(HistoryEntry {
created_by: created_by.clone(),
empty_layer: false,
});
} else {
state.history.push(HistoryEntry {
created_by: created_by.clone(),
empty_layer: true,
});
}
}
Instruction::Workdir { path } => {
if !config.quiet {
println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
}
let expanded_path = expand_args(path, &state.expansion_vars());
state.workdir = resolve_path(&state.workdir, &expanded_path);
crate::oci::rootfs::ensure_guest_directory(
&rootfs_dir,
state.workdir.trim_start_matches('/'),
)?;
state.history.push(HistoryEntry {
created_by: format!("WORKDIR {}", path),
empty_layer: true,
});
}
Instruction::Env { vars } => {
let display: Vec<String> =
vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
let display = display.join(" ");
if !config.quiet {
println!("Step {}/{}: ENV {}", step, total_instructions, display);
}
for (key, value) in vars {
let expanded_value = expand_args(value, &state.expansion_vars());
if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
existing.1 = expanded_value;
} else {
state.env.push((key.clone(), expanded_value));
}
}
state.history.push(HistoryEntry {
created_by: format!("ENV {}", display),
empty_layer: true,
});
}
Instruction::Entrypoint { exec } => {
if !config.quiet {
println!(
"Step {}/{}: ENTRYPOINT {:?}",
step, total_instructions, exec
);
}
state.entrypoint = Some(exec.clone());
state.history.push(HistoryEntry {
created_by: format!("ENTRYPOINT {:?}", exec),
empty_layer: true,
});
}
Instruction::Cmd { exec } => {
if !config.quiet {
println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
}
state.cmd = Some(exec.clone());
state.history.push(HistoryEntry {
created_by: format!("CMD {:?}", exec),
empty_layer: true,
});
}
Instruction::Expose { ports } => {
let joined = ports.join(" ");
if !config.quiet {
println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
}
for port in ports {
if !state.exposed_ports.contains(port) {
state.exposed_ports.push(port.clone());
}
}
state.history.push(HistoryEntry {
created_by: format!("EXPOSE {}", joined),
empty_layer: true,
});
}
Instruction::Label { pairs } => {
let joined = pairs
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(" ");
if !config.quiet {
println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
}
for (key, value) in pairs {
state.labels.insert(key.clone(), value.clone());
}
state.history.push(HistoryEntry {
created_by: format!("LABEL {}", joined),
empty_layer: true,
});
}
Instruction::User { user } => {
if !config.quiet {
println!("Step {}/{}: USER {}", step, total_instructions, user);
}
state.user = Some(user.clone());
state.history.push(HistoryEntry {
created_by: format!("USER {}", user),
empty_layer: true,
});
}
Instruction::Arg { name, default } => {
if !config.quiet {
println!("Step {}/{}: ARG {}", step, total_instructions, name);
}
state.declared_args.insert(name.clone());
if !state.build_args.contains_key(name) {
if let Some(val) = default {
state.build_args.insert(name.clone(), val.clone());
}
}
state.history.push(HistoryEntry {
created_by: format!("ARG {}", name),
empty_layer: true,
});
}
Instruction::Shell { exec } => {
if !config.quiet {
println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
}
state.shell = exec.clone();
state.history.push(HistoryEntry {
created_by: format!("SHELL {:?}", exec),
empty_layer: true,
});
}
Instruction::StopSignal { signal } => {
if !config.quiet {
println!(
"Step {}/{}: STOPSIGNAL {}",
step, total_instructions, signal
);
}
state.stop_signal = Some(signal.clone());
state.history.push(HistoryEntry {
created_by: format!("STOPSIGNAL {}", signal),
empty_layer: true,
});
}
Instruction::HealthCheck {
cmd,
interval,
timeout,
retries,
start_period,
} => {
if !config.quiet {
if cmd.is_some() {
println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
} else {
println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
}
}
state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
test: c.clone(),
interval: *interval,
timeout: *timeout,
retries: *retries,
start_period: *start_period,
});
state.history.push(HistoryEntry {
created_by: if cmd.is_some() {
"HEALTHCHECK CMD ...".to_string()
} else {
"HEALTHCHECK NONE".to_string()
},
empty_layer: true,
});
}
Instruction::OnBuild { instruction } => {
let trigger = format!("{:?}", instruction);
if !config.quiet {
println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
}
state.onbuild.push(instruction_to_string(instruction));
state.history.push(HistoryEntry {
created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
empty_layer: true,
});
}
Instruction::Volume { paths } => {
if !config.quiet {
println!(
"Step {}/{}: VOLUME {}",
step,
total_instructions,
paths.join(" ")
);
}
for p in paths {
if !state.volumes.contains(p) {
state.volumes.push(p.clone());
}
}
for p in paths {
crate::oci::rootfs::ensure_guest_directory(
&rootfs_dir,
p.trim_start_matches('/'),
)?;
}
state.history.push(HistoryEntry {
created_by: format!("VOLUME {}", paths.join(" ")),
empty_layer: true,
});
}
}
}
completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
if is_final_stage {
final_state = state;
final_base_layers = base_layers;
final_base_diff_ids = base_diff_ids;
break;
}
}
let reference = config
.tag
.clone()
.unwrap_or_else(|| "a3s-build:latest".to_string());
let final_layers_dir = build_dir.join(format!("layers_{}", output_stage_idx));
let target_platform = config
.platforms
.first()
.cloned()
.unwrap_or_else(default_target_platform);
if let Some(control) = &control {
control.ensure_active().await?;
}
let staged_cache = match cache_identity.as_ref() {
Some(identity) => {
let cache = cache.as_ref().ok_or_else(|| {
BoxError::BuildError(
"content-addressed build cache could not be opened for export".to_string(),
)
})?;
let trace = cache_trace.as_ref().ok_or_else(|| {
BoxError::BuildError(
"content-addressed build cache export lost its native trace".to_string(),
)
})?;
Some(cache.stage_export(trace, identity, &build_dir.join("_cache_export"))?)
}
None => None,
};
let result = assemble_image(
&reference,
&final_state,
&final_base_layers,
&final_base_diff_ids,
&final_layers_dir,
&store,
&target_platform,
control.as_ref(),
staged_cache,
)
.await?;
if !config.quiet {
println!(
"Successfully built {} ({} layers, {}, {})",
reference,
result.output.layer_count,
format_size(result.output.size),
target_platform,
);
}
if let Some(ref m) = config.metrics {
m.image_build_total.inc();
}
Ok(result)
}
fn store_cache_entry(
cache: Option<&BuildCache>,
trace: Option<&mut BuildCacheTrace>,
chain_key: &str,
layer: &LayerInfo,
diff_id: &str,
) -> Result<()> {
let Some(cache) = cache else {
if trace.is_some() {
return Err(BoxError::BuildError(
"content-addressed build cache could not be opened".to_string(),
));
}
return Ok(());
};
cache.store(chain_key, layer, diff_id);
if let Some(trace) = trace {
let cached = cache.lookup(chain_key).ok_or_else(|| {
BoxError::BuildError(format!(
"content-addressed build cache did not retain chain key sha256:{chain_key}"
))
})?;
trace.record(chain_key, &cached)?;
}
Ok(())
}
struct CachedLayerReuse<'a> {
cache_valid: bool,
cache: Option<&'a BuildCache>,
chain_key: &'a str,
rootfs_dir: &'a Path,
layers_dir: &'a Path,
layer_index: usize,
created_by: &'a str,
}
fn try_reuse_cached_layer(
request: CachedLayerReuse<'_>,
state: &mut BuildState,
) -> Result<Option<CachedLayer>> {
if !request.cache_valid {
return Ok(None);
}
let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
return Ok(None);
};
let local_layer = request.layers_dir.join(format!(
"cached_{}_{}.tar.gz",
request.layer_index, cached.digest
));
if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
tracing::warn!(
key = %request.chain_key,
source = %cached.blob_path.display(),
error = %error,
"Build cache blob disappeared before it could be materialized; rebuilding instruction"
);
return Ok(None);
}
extract_layer(&local_layer, request.rootfs_dir)?;
let local_size = std::fs::metadata(&local_layer)
.map(|metadata| metadata.len())
.unwrap_or(cached.size);
state.layers.push(LayerInfo {
path: local_layer,
digest: cached.digest.clone(),
size: local_size,
});
state.diff_ids.push(cached.diff_id.clone());
state.history.push(HistoryEntry {
created_by: request.created_by.to_string(),
empty_layer: false,
});
Ok(Some(cached))
}
async fn handle_from(
image: &str,
rootfs_dir: &Path,
_layers_dir: &Path,
store: &Arc<ImageStore>,
build_args: &HashMap<String, String>,
) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
let image_ref = expand_args(image, build_args);
if image_ref == "scratch" {
return Ok((Vec::new(), Vec::new(), scratch_config()));
}
let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
let oci_image = puller.pull(&image_ref).await?;
for layer_path in oci_image.layer_paths() {
extract_layer(layer_path, rootfs_dir)?;
}
let mut base_layers = Vec::new();
let mut base_diff_ids = Vec::new();
for layer_path in oci_image.layer_paths() {
let digest = sha256_file(layer_path)?;
let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
let diff_id = compute_diff_id(layer_path)?;
base_diff_ids.push(diff_id);
base_layers.push(LayerInfo {
path: layer_path.to_path_buf(),
digest,
size,
});
}
let config = oci_image.config().clone();
Ok((base_layers, base_diff_ids, config))
}
async fn resolve_external_from_rootfs(
image_ref: &str,
operation: &str,
store: &Arc<ImageStore>,
build_dir: &Path,
cache: &mut HashMap<String, PathBuf>,
) -> Result<PathBuf> {
if let Some(dir) = cache.get(image_ref) {
return Ok(dir.clone());
}
let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
std::fs::create_dir_all(&dir).map_err(|e| {
BoxError::BuildError(format!(
"Failed to create {operation} image rootfs {}: {}",
dir.display(),
e
))
})?;
let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
let oci_image = puller.pull(image_ref).await.map_err(|e| {
BoxError::BuildError(format!(
"{operation} from={}: not a build stage and could not be pulled as an image: {}",
image_ref, e
))
})?;
for layer_path in oci_image.layer_paths() {
extract_layer(layer_path, &dir)?;
}
cache.insert(image_ref.to_string(), dir.clone());
Ok(dir)
}
fn validate_build_config(config: &BuildConfig) -> Result<()> {
if config.platforms.len() > 1 {
return Err(BoxError::BuildError(
"Multi-platform builds are not implemented yet; pass a single target platform"
.to_string(),
));
}
for platform in &config.platforms {
if platform.os != "linux" {
return Err(BoxError::BuildError(format!(
"Only linux target platforms are supported for image builds, got {}",
platform
)));
}
}
if config.network == BuildNetworkPolicy::None && config.run_pool.is_some() {
return Err(BoxError::BuildError(
"network-none builds cannot use a warm RUN pool until the pool provides a generation-bound isolated network namespace"
.to_string(),
));
}
Ok(())
}
fn validate_instruction_network(
instruction: &Instruction,
network: BuildNetworkPolicy,
) -> Result<()> {
if network != BuildNetworkPolicy::None {
return Ok(());
}
if let Instruction::Add { src, .. } = instruction {
if src
.iter()
.any(|source| source.starts_with("http://") || source.starts_with("https://"))
{
return Err(BoxError::BuildError(
"network-none builds reject remote URL ADD; materialize the input as a content-addressed build-context artifact"
.to_string(),
));
}
}
Ok(())
}
fn run_instruction_cache_repr(instruction: &Instruction, network: BuildNetworkPolicy) -> String {
format!(
"{}\n#a3s.box.build.network={}",
instruction_to_string(instruction),
network.as_acl()
)
}
fn default_target_platform() -> Platform {
let host = Platform::host();
Platform::new("linux", host.architecture)
}
fn scratch_config() -> OciImageConfig {
OciImageConfig {
entrypoint: None,
cmd: None,
env: Vec::new(),
working_dir: None,
user: None,
exposed_ports: Vec::new(),
labels: HashMap::new(),
volumes: Vec::new(),
stop_signal: None,
health_check: None,
onbuild: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
async fn assemble_image(
reference: &str,
state: &BuildState,
base_layers: &[LayerInfo],
base_diff_ids: &[String],
layers_dir: &Path,
store: &Arc<ImageStore>,
target_platform: &Platform,
control: Option<&BuildExecutionControl>,
staged_cache: Option<RecordedBuildCache>,
) -> Result<SupervisedBuildResult> {
let output_dir = layers_dir.join("_output");
let blobs_dir = output_dir.join("blobs").join("sha256");
std::fs::create_dir_all(&blobs_dir)
.map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
let mut all_layer_descriptors = Vec::new();
let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
for layer in base_layers {
let blob_path = blobs_dir.join(&layer.digest);
if !blob_path.exists() {
copy_layer_blob(layer, &blob_path, "base layer")?;
}
all_layer_descriptors.push(serde_json::json!({
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": layer.prefixed_digest(),
"size": layer.size
}));
}
for (i, layer) in state.layers.iter().enumerate() {
let blob_path = blobs_dir.join(&layer.digest);
if !blob_path.exists() {
copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
}
all_layer_descriptors.push(serde_json::json!({
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": layer.prefixed_digest(),
"size": layer.size
}));
}
all_diff_ids.extend(state.diff_ids.iter().cloned());
let arch = target_platform.oci_arch();
let env_list: Vec<String> = state
.env
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
let mut config_obj = serde_json::json!({
"architecture": arch,
"os": "linux",
"created": REPRODUCIBLE_OCI_CREATED_AT,
"config": {},
"rootfs": {
"type": "layers",
"diff_ids": all_diff_ids.iter()
.map(|d| format!("sha256:{}", d))
.collect::<Vec<_>>()
},
"history": state.history.iter().map(|h| {
let mut entry = serde_json::json!({
"created": REPRODUCIBLE_OCI_CREATED_AT,
"created_by": h.created_by
});
if h.empty_layer {
entry["empty_layer"] = serde_json::json!(true);
}
entry
}).collect::<Vec<_>>()
});
if let Some(variant) = &target_platform.variant {
config_obj["variant"] = serde_json::json!(variant);
}
let config_section = config_obj["config"].as_object_mut().unwrap();
if !env_list.is_empty() {
config_section.insert("Env".to_string(), serde_json::json!(env_list));
}
if let Some(ref ep) = state.entrypoint {
config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
}
if let Some(ref cmd) = state.cmd {
config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
}
if state.workdir != "/" {
config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
}
if let Some(ref user) = state.user {
config_section.insert("User".to_string(), serde_json::json!(user));
}
if !state.exposed_ports.is_empty() {
let ports: HashMap<String, serde_json::Value> = state
.exposed_ports
.iter()
.map(|p| (p.clone(), serde_json::json!({})))
.collect();
config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
}
if !state.labels.is_empty() {
config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
}
if let Some(ref sig) = state.stop_signal {
config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
}
if let Some(ref hc) = state.health_check {
let mut hc_obj = serde_json::json!({
"Test": hc.test,
});
if let Some(interval) = hc.interval {
hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
}
if let Some(timeout) = hc.timeout {
hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
}
if let Some(retries) = hc.retries {
hc_obj["Retries"] = serde_json::json!(retries);
}
if let Some(start_period) = hc.start_period {
hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
}
config_section.insert("Healthcheck".to_string(), hc_obj);
}
if !state.onbuild.is_empty() {
config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
}
if !state.volumes.is_empty() {
let vols: HashMap<String, serde_json::Value> = state
.volumes
.iter()
.map(|v| (v.clone(), serde_json::json!({})))
.collect();
config_section.insert("Volumes".to_string(), serde_json::json!(vols));
}
let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
let config_digest = sha256_bytes(&config_bytes);
std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
.map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
let manifest = serde_json::json!({
"schemaVersion": 2,
"mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": format!("sha256:{}", config_digest),
"size": config_bytes.len()
},
"layers": all_layer_descriptors
});
let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
let manifest_digest = sha256_bytes(&manifest_bytes);
std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
.map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
let mut platform_obj = serde_json::json!({
"os": target_platform.os,
"architecture": target_platform.architecture
});
if let Some(ref variant) = target_platform.variant {
platform_obj["variant"] = serde_json::json!(variant);
}
let index = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{
"mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
"digest": format!("sha256:{}", manifest_digest),
"size": manifest_bytes.len(),
"platform": platform_obj
}]
});
std::fs::write(
output_dir.join("index.json"),
serde_json::to_string_pretty(&index)?,
)
.map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
std::fs::write(
output_dir.join("oci-layout"),
r#"{"imageLayoutVersion":"1.0.0"}"#,
)
.map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
let digest_str = format!("sha256:{}", manifest_digest);
let _commit_permit = match control {
Some(control) => Some(control.acquire_image_commit_permit().await?),
None => None,
};
let cache = match staged_cache {
Some(staged) => {
let control = control.ok_or_else(|| {
BoxError::BuildError(
"native cache export requires the recorded-build journal".to_string(),
)
})?;
Some(control.publish_cache_export(staged).await?)
}
None => None,
};
let output =
publish_single_build_output(reference, &digest_str, &output_dir, store, target_platform)
.await?;
Ok(SupervisedBuildResult { output, cache })
}
fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
if !layer.path.exists() {
return Err(BoxError::BuildError(format!(
"Failed to copy {label}: source layer {} for digest {} does not exist",
layer.path.display(),
layer.prefixed_digest()
)));
}
std::fs::copy(&layer.path, blob_path).map_err(|e| {
BoxError::BuildError(format!(
"Failed to copy {label} from {} to {} (digest {}): {}",
layer.path.display(),
blob_path.display(),
layer.prefixed_digest(),
e
))
})?;
Ok(())
}