use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::dockerfile::{
BaseImage, CopyFlags, Dockerfile, Healthcheck, Instruction, MountKind, RunMount, ShellOrExec,
Stage,
};
use super::subst::resolve_stage_instructions;
use crate::api::{Error, Image};
fn resolve_dockerfile(df: &Dockerfile, build_args: &BTreeMap<String, String>) -> Dockerfile {
Dockerfile {
global_args: df.global_args.clone(),
stages: df
.stages
.iter()
.map(|s| Stage {
base: s.base.clone(),
name: s.name.clone(),
platform: s.platform.clone(),
instructions: resolve_stage_instructions(
&s.instructions,
&df.global_args,
build_args,
),
})
.collect(),
}
}
const RUN_TIMEOUT: Duration = Duration::from_secs(1800);
const BUILD_MEMORY_DEFAULT_MIB: u32 = 8192;
fn resolve_build_memory(env: Option<&str>, base_mem: u32) -> u32 {
let want = env
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|&m| m >= 256)
.unwrap_or(BUILD_MEMORY_DEFAULT_MIB);
want.max(base_mem)
}
fn build_memory_mib(base_mem: u32) -> u32 {
resolve_build_memory(
std::env::var("SUPERMACHINE_BUILD_MEMORY_MIB")
.ok()
.as_deref(),
base_mem,
)
}
fn base_image_config(base: &Image) -> ImageConfig {
let snap = base.snapshot_path();
let meta_path = if snap.is_dir() {
snap.join("metadata.json")
} else {
match snap.parent() {
Some(p) => p.join("metadata.json"),
None => return ImageConfig::default(),
}
};
let Ok(text) = std::fs::read_to_string(&meta_path) else {
return ImageConfig::default();
};
let Ok(meta) = serde_json::from_str::<serde_json::Value>(&text) else {
return ImageConfig::default();
};
let env_obj = meta
.get("image_env")
.and_then(|v| v.as_object())
.or_else(|| {
meta.pointer("/native_bake_inputs/env/env")
.and_then(|v| v.as_object())
});
let mut config = ImageConfig::default();
if let Some(obj) = env_obj {
config.env = obj
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
config.env.sort_by(|a, b| a.0.cmp(&b.0));
}
let string_array = |key: &str| {
meta.get(key).and_then(|v| v.as_array()).map(|values| {
values
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect::<Vec<_>>()
})
};
config.cmd = string_array("cmd").map(ShellOrExec::Exec);
config.entrypoint = string_array("entrypoint").map(ShellOrExec::Exec);
config.workdir = meta
.get("working_dir")
.and_then(|v| v.as_str())
.map(str::to_owned);
config.user = meta.get("user").and_then(|v| v.as_str()).map(str::to_owned);
config.shell = string_array("shell");
config
}
fn seed_from_base(base: &Image) -> (BuildState, ImageConfig) {
let config = base_image_config(base);
let state = BuildState {
env: config.env.iter().cloned().collect(),
cwd: config.workdir.clone(),
user: config.user.clone(),
shell: config.shell.clone(),
};
(state, config)
}
fn from_oci_for_build(reference: &str) -> Result<Image, Error> {
let mem = build_memory_mib(0);
Image::builder(reference)
.with_memory_mib(mem)
.with_name(format!(
"{}__smbuild{mem}m",
crate::bake::snapshot_name_for_image(reference)
))
.build()
}
const AGENT_READY_RETRIES: u8 = 40;
fn await_agent_ready(vm: &crate::api::Vm) {
let _ = vm
.exec_builder()
.timeout(Duration::from_secs(30))
.argv(["true"].iter().copied())
.output_resilient(AGENT_READY_RETRIES);
}
#[derive(Default)]
struct BuildState {
env: BTreeMap<String, String>,
cwd: Option<String>,
user: Option<String>,
shell: Option<Vec<String>>,
}
#[derive(Debug, Default, Clone)]
pub struct ImageConfig {
pub env: Vec<(String, String)>,
pub workdir: Option<String>,
pub user: Option<String>,
pub entrypoint: Option<ShellOrExec>,
pub cmd: Option<ShellOrExec>,
pub exposed_ports: Vec<String>,
pub labels: Vec<(String, String)>,
pub shell: Option<Vec<String>>,
pub volumes: Vec<String>,
pub stop_signal: Option<String>,
pub healthcheck: Option<Healthcheck>,
pub onbuild: Vec<String>,
}
pub struct BuildOutcome {
pub image: Image,
pub config: ImageConfig,
pub layer_key: Option<String>,
pub layer_keys: Vec<String>,
}
pub fn build_linear(
df: &Dockerfile,
base: &Image,
context_dir: &Path,
dest: impl AsRef<Path>,
) -> Result<BuildOutcome, Error> {
let stage = df
.stages
.last()
.ok_or_else(|| Error::vm_msg("Dockerfile has no stages"))?;
let resolved_instrs =
resolve_stage_instructions(&stage.instructions, &df.global_args, &BTreeMap::new());
let pool = base
.pool()
.min(1)
.max(1)
.restore_on_release(false)
.for_build(true)
.with_memory_mib(build_memory_mib(base.memory_mib()))
.build()?;
let vm = pool.acquire()?;
await_agent_ready(&vm);
let (mut state, mut config) = seed_from_base(base);
for instr in &resolved_instrs {
apply_instr(
Some(&vm),
instr,
&mut state,
&mut config,
context_dir,
&[],
None,
)?;
}
let image = vm.snapshot(dest.as_ref())?;
Ok(BuildOutcome {
image,
config,
layer_key: None,
layer_keys: Vec::new(),
})
}
fn apply_instr(
vm: Option<&crate::api::Vm>,
instr: &Instruction,
state: &mut BuildState,
config: &mut ImageConfig,
context_dir: &Path,
prior: &[Option<StageBuilt>],
mount_cache: Option<&Path>,
) -> Result<(), Error> {
match instr {
Instruction::Run { run, mounts } => {
if let Some(vm) = vm {
run_with_mounts(vm, run, mounts, state, context_dir, prior, mount_cache)?;
}
}
Instruction::Env(pairs) => {
for (k, v) in pairs {
state.env.insert(k.clone(), v.clone());
config.env.push((k.clone(), v.clone()));
}
}
Instruction::Workdir(dir) => {
let resolved = if dir.starts_with('/') {
dir.clone()
} else {
let base = state.cwd.as_deref().unwrap_or("/");
format!("{}/{}", base.trim_end_matches('/'), dir)
};
if let Some(vm) = vm {
run_step(
vm,
&ShellOrExec::Exec(vec!["mkdir".into(), "-p".into(), resolved.clone()]),
state,
)?;
}
state.cwd = Some(resolved.clone());
config.workdir = Some(resolved);
}
Instruction::User(u) => {
state.user = Some(u.clone());
config.user = Some(u.clone());
}
Instruction::Entrypoint(e) => config.entrypoint = Some(e.clone()),
Instruction::Cmd(c) => config.cmd = Some(c.clone()),
Instruction::Expose(ports) => config.exposed_ports.extend(ports.iter().cloned()),
Instruction::Label(pairs) => config.labels.extend(pairs.iter().cloned()),
Instruction::Shell(argv) => {
state.shell = Some(argv.clone());
config.shell = Some(argv.clone());
}
Instruction::Volume(mounts) => config.volumes.extend(mounts.iter().cloned()),
Instruction::StopSignal(sig) => config.stop_signal = Some(sig.clone()),
Instruction::Healthcheck(hc) => config.healthcheck = Some(hc.clone()),
Instruction::Onbuild(trigger) => config.onbuild.push(trigger.clone()),
Instruction::Arg { .. } => {}
Instruction::Copy {
sources,
dest,
flags,
} => {
if let Some(vm) = vm {
stage_or_from(vm, sources, dest, flags, context_dir, state, prior)?;
}
}
Instruction::Add {
sources,
dest,
flags,
} => {
if let Some(vm) = vm {
let (urls, locals): (Vec<String>, Vec<String>) =
sources.iter().cloned().partition(|s| is_url(s));
if !urls.is_empty() {
add_urls(vm, &urls, dest, flags, state.cwd.as_deref())?;
}
if !locals.is_empty() {
stage_or_from(vm, &locals, dest, flags, context_dir, state, prior)?;
}
}
}
}
Ok(())
}
fn stage_or_from(
vm: &crate::api::Vm,
sources: &[String],
dest: &str,
flags: &CopyFlags,
context_dir: &Path,
state: &BuildState,
prior: &[Option<StageBuilt>],
) -> Result<(), Error> {
match &flags.from {
Some(name) => {
let src = resolve_stage(prior, name)?;
let source = Image::from_snapshot(src.image.snapshot_path())?;
copy_from_stage(vm, &source, sources, dest, flags, state.cwd.as_deref())
}
None => stage_copy(vm, sources, dest, flags, context_dir, state.cwd.as_deref()),
}
}
fn add_urls(
vm: &crate::api::Vm,
urls: &[String],
dest: &str,
flags: &CopyFlags,
workdir: Option<&str>,
) -> Result<(), Error> {
let wd = workdir.unwrap_or("/");
let abs_dest = if dest.starts_with('/') {
dest.to_string()
} else {
format!("{}/{}", wd.trim_end_matches('/'), dest)
};
let dest_is_dir = dest.ends_with('/') || urls.len() > 1;
for url in urls {
let bytes = super::fetch::http_get(url)?;
let target = if dest_is_dir {
let name = url_filename(url).ok_or_else(|| {
Error::vm_msg(format!("ADD url: cannot infer filename from {url}"))
})?;
format!("{}/{}", abs_dest.trim_end_matches('/'), name)
} else {
abs_dest.clone()
};
if let Some((parent, _)) = target.rsplit_once('/') {
if !parent.is_empty() {
let _ = vm
.exec_builder()
.argv(["mkdir", "-p", parent].iter().copied())
.output();
}
}
vm.write_file(&target, &bytes).map_err(Error::Io)?;
if let Some(chmod) = &flags.chmod {
let _ = vm
.exec_builder()
.argv(["chmod", chmod.as_str(), target.as_str()].iter().copied())
.output();
}
if let Some(chown) = &flags.chown {
let _ = vm
.exec_builder()
.argv(["chown", chown.as_str(), target.as_str()].iter().copied())
.output();
}
}
Ok(())
}
fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
fn url_filename(url: &str) -> Option<String> {
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let path = after_scheme.split(['?', '#']).next().unwrap_or("");
path.rsplit('/')
.find(|s| !s.is_empty())
.map(|s| s.to_owned())
}
fn scratch_base() -> Result<Image, Error> {
let reference = std::env::var("SUPERMACHINE_SCRATCH_BASE")
.unwrap_or_else(|_| "busybox:stable-musl".to_owned());
from_oci_for_build(&reference)
}
fn stage_dependencies(stage: &Stage, n: usize, names: &HashMap<&str, usize>) -> Vec<usize> {
let mut deps = Vec::new();
let push = |deps: &mut Vec<usize>, r: &str| {
if let Some(&i) = names.get(r) {
deps.push(i);
} else if let Ok(i) = r.parse::<usize>() {
if i < n {
deps.push(i);
}
}
};
match &stage.base {
BaseImage::Image(reference) if names.contains_key(reference.as_str()) => {
push(&mut deps, reference)
}
BaseImage::Stage(name) => push(&mut deps, name),
_ => {}
}
for instr in &stage.instructions {
let from = match instr {
Instruction::Copy { flags, .. } | Instruction::Add { flags, .. } => {
flags.from.as_deref()
}
_ => None,
};
if let Some(r) = from {
push(&mut deps, r);
}
}
deps.sort_unstable();
deps.dedup();
deps
}
fn resolve_stage<'a>(
prior: &'a [Option<StageBuilt>],
reference: &str,
) -> Result<&'a StageBuilt, Error> {
prior
.iter()
.flatten()
.rev()
.find(|s| s.name.as_deref() == Some(reference))
.or_else(|| {
reference
.parse::<usize>()
.ok()
.and_then(|i| prior.get(i))
.and_then(|o| o.as_ref())
})
.ok_or_else(|| Error::vm_msg(format!("COPY --from unknown stage `{reference}`")))
}
pub struct Builder {
cache_dir: PathBuf,
build_args: BTreeMap<String, String>,
}
struct StageBuilt {
name: Option<String>,
key: String,
image: Image,
layer_keys: Vec<String>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
struct CompactLayerManifest {
version: u32,
kind: String,
parent_snapshot: String,
rootfs: String,
diff_blob: Option<String>,
diff_id: Option<String>,
layer_digest: Option<String>,
layer_size: Option<u64>,
created_by_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FsEntryMeta {
kind: FsEntryKind,
mode: u32,
uid: u64,
gid: u64,
mtime: i64,
size: u64,
digest: Option<String>,
link_target: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FsEntryKind {
Dir,
File,
Symlink,
}
impl Builder {
pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
Self {
cache_dir: cache_dir.into(),
build_args: BTreeMap::new(),
}
}
pub fn build_args(mut self, args: BTreeMap<String, String>) -> Self {
self.build_args = args;
self
}
fn layer_dir(&self, key: &str) -> PathBuf {
self.cache_dir.join("layers").join(key)
}
pub fn build_dockerfile(
&self,
df: &Dockerfile,
context_dir: &Path,
) -> Result<BuildOutcome, Error> {
if df.stages.is_empty() {
return Err(Error::vm_msg("Dockerfile has no stages"));
}
let resolved = resolve_dockerfile(df, &self.build_args);
let df = &resolved;
let n = df.stages.len();
let names: HashMap<&str, usize> = df
.stages
.iter()
.enumerate()
.filter_map(|(i, s)| s.name.as_deref().map(|nm| (nm, i)))
.collect();
let deps: Vec<Vec<usize>> = df
.stages
.iter()
.map(|s| stage_dependencies(s, n, &names))
.collect();
let mut stages: Vec<Option<StageBuilt>> = (0..n).map(|_| None).collect();
let mut configs: Vec<Option<ImageConfig>> = (0..n).map(|_| None).collect();
let mut done = vec![false; n];
let mut remaining = n;
let parallelism = std::env::var("SUPERMACHINE_BUILD_PARALLELISM")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(4)
.max(1);
while remaining > 0 {
let ready: Vec<usize> = (0..n)
.filter(|&i| !done[i] && deps[i].iter().all(|&d| done[d]))
.collect();
if ready.is_empty() {
return Err(Error::vm_msg("Dockerfile has a stage dependency cycle"));
}
for chunk in ready.chunks(parallelism) {
let wave: Vec<(usize, Result<(StageBuilt, ImageConfig), Error>)> =
std::thread::scope(|scope| {
let handles: Vec<(usize, _)> = chunk
.iter()
.map(|&i| {
let stages_ref = &stages;
let names_ref = &names;
(
i,
scope.spawn(move || {
self.build_one_stage(
&df.stages[i],
context_dir,
names_ref,
stages_ref,
)
}),
)
})
.collect();
handles
.into_iter()
.map(|(i, h)| {
let r = h.join().unwrap_or_else(|_| {
Err(Error::vm_msg(format!("stage {i} build panicked")))
});
(i, r)
})
.collect()
});
for (i, r) in wave {
let (sb, cfg) = r?;
stages[i] = Some(sb);
configs[i] = Some(cfg);
done[i] = true;
remaining -= 1;
}
}
}
let final_idx = n - 1;
let final_stage = stages[final_idx].as_ref().expect("final stage built");
let layer_key = Some(final_stage.key.clone());
let layer_keys: Vec<String> = stages
.iter()
.filter_map(|stage| stage.as_ref())
.flat_map(|stage| stage.layer_keys.iter().cloned())
.collect();
let config = configs[final_idx].take().expect("final stage config");
let touches_run_config = effective_cmd(&config).is_some()
|| config.workdir.is_some()
|| config.user.is_some()
|| !config.env.is_empty()
|| config.stop_signal.is_some()
|| config.healthcheck.is_some()
|| !config.onbuild.is_empty();
if !touches_run_config {
let image = Image::from_snapshot(final_stage.image.snapshot_path())?;
return Ok(BuildOutcome {
image,
config,
layer_key,
layer_keys,
});
}
let image = self.finalize_baked_image(final_stage, &config, &layer_keys)?;
persist_run_config(image.snapshot_path(), &config)?;
Ok(BuildOutcome {
image,
config,
layer_key,
layer_keys,
})
}
fn finalize_baked_image(
&self,
final_stage: &StageBuilt,
config: &ImageConfig,
layer_keys: &[String],
) -> Result<Image, Error> {
let baked_dir = self.cache_dir.join("baked").join(&final_stage.key);
if let Some(parent) = baked_dir.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _baked_lock = CacheLock::acquire(&baked_dir);
let baked_name = format!(
"dfbuild_{}",
&final_stage.key[..final_stage.key.len().min(24)]
);
let snap_dir = baked_dir.join(&baked_name);
match Image::from_snapshot(&snap_dir) {
Ok(img) => return Ok(img),
Err(_) if snap_dir.exists() => {
}
Err(_) => {}
}
#[cfg(target_arch = "aarch64")]
const OCI_ARCH: &str = "arm64";
#[cfg(target_arch = "x86_64")]
const OCI_ARCH: &str = "amd64";
let layout_dir = baked_dir.join("oci-layout");
let _ = std::fs::remove_dir_all(&layout_dir);
export_oci(&final_stage.image, config, OCI_ARCH, &layout_dir)?;
if uses_full_rootfs_dockerfile_layers(&final_stage.image)
&& dockerfile_layer_prune_enabled()
{
let pruned = prune_dockerfile_layer_snapshots(&self.cache_dir, layer_keys)?;
if pruned > 0 {
eprintln!(
"supermachine(builder): pruned {pruned} Dockerfile layer snapshot(s) \
after OCI export to reduce peak bake disk usage"
);
}
}
let image = Image::builder("dockerfile-built")
.with_oci_layout(&layout_dir)
.with_snapshots_dir(&baked_dir)
.with_name(&baked_name)
.build()?;
let _ = std::fs::remove_dir_all(&layout_dir);
Ok(image)
}
fn build_one_stage(
&self,
stage: &Stage,
context_dir: &Path,
names: &HashMap<&str, usize>,
prior: &[Option<StageBuilt>],
) -> Result<(StageBuilt, ImageConfig), Error> {
let base = self.resolve_base(stage, names, prior)?;
let (image, config, key, layer_keys) =
self.build_stage(stage, &base, context_dir, prior)?;
let built = StageBuilt {
name: stage.name.clone(),
key,
image: Image::from_snapshot(image.snapshot_path())?,
layer_keys,
};
Ok((built, config))
}
fn resolve_base(
&self,
stage: &Stage,
names: &HashMap<&str, usize>,
prior: &[Option<StageBuilt>],
) -> Result<Image, Error> {
let from_stage = |di: usize| -> Result<Image, Error> {
let dep = prior[di]
.as_ref()
.ok_or_else(|| Error::vm_msg("FROM references a stage that isn't built"))?;
Image::from_snapshot(dep.image.snapshot_path())
};
match &stage.base {
BaseImage::Image(reference) => match names.get(reference.as_str()) {
Some(&di) => from_stage(di),
None => from_oci_for_build(reference),
},
BaseImage::Stage(name) => {
let di = *names
.get(name.as_str())
.ok_or_else(|| Error::vm_msg(format!("FROM unknown stage `{name}`")))?;
from_stage(di)
}
BaseImage::Scratch => scratch_base(),
}
}
pub fn build(
&self,
df: &Dockerfile,
base: &Image,
context_dir: &Path,
) -> Result<BuildOutcome, Error> {
let stage = df
.stages
.last()
.ok_or_else(|| Error::vm_msg("Dockerfile has no stages"))?;
let (image, config, key, layer_keys) = self.build_stage(stage, base, context_dir, &[])?;
persist_run_config(image.snapshot_path(), &config)?;
Ok(BuildOutcome {
image,
config,
layer_key: Some(key),
layer_keys,
})
}
fn build_stage(
&self,
stage: &super::dockerfile::Stage,
base: &Image,
context_dir: &Path,
prior: &[Option<StageBuilt>],
) -> Result<(Image, ImageConfig, String, Vec<String>), Error> {
let instrs = &stage.instructions;
let base_key = sha256_hex(base.snapshot_path().to_string_lossy().as_bytes());
let mut keys = Vec::with_capacity(instrs.len());
let mut prev = base_key.clone();
for instr in instrs {
let key = sha256_hex(
format!("{prev}\n{}", instr_repr(instr, context_dir, prior)?).as_bytes(),
);
keys.push(key.clone());
prev = key;
}
let mut resume = 0usize;
let mut resume_image: Option<Image> = None;
for (i, key) in keys.iter().enumerate() {
let dir = self.layer_dir(key);
if !is_snapshot_dir(&dir) {
break;
}
if !has_required_layer_runtime_metadata(&dir) {
eprintln!(
"[builder] cached layer {} missing runtime/network metadata; rebuilding from instruction {i}",
dir.display()
);
break;
}
if compact_layer_manifest_path(&dir).is_file() {
resume = i + 1;
resume_image = None;
continue;
}
match Image::from_snapshot(&dir) {
Ok(img) => {
resume = i + 1;
resume_image = Some(img);
}
Err(e) => {
eprintln!(
"[builder] cached layer {} unloadable ({e}); rebuilding from instruction {i}",
dir.display()
);
break;
}
}
}
let (mut state, mut config) = seed_from_base(base);
for instr in &instrs[..resume] {
apply_instr(
None,
instr,
&mut state,
&mut config,
context_dir,
prior,
None,
)?;
}
let final_key = keys.last().cloned().unwrap_or(base_key);
if resume > 0 && resume_image.is_none() {
let dir = self.layer_dir(&keys[resume - 1]);
materialize_compact_layer_if_needed(&dir)?;
resume_image = Some(Image::from_snapshot(&dir)?);
}
if resume == instrs.len() {
let image = match resume_image {
Some(img) => img,
None => Image::from_snapshot(base.snapshot_path())?,
};
return Ok((image, config, final_key, keys));
}
let boot_from: &Image = resume_image.as_ref().unwrap_or(base);
let pool = boot_from
.pool()
.min(1)
.max(1)
.restore_on_release(false)
.for_build(true)
.with_memory_mib(build_memory_mib(boot_from.memory_mib()))
.build()?;
let vm = pool.acquire()?;
await_agent_ready(&vm);
let run_cache = self.cache_dir.join("runmounts");
let mut prev_snapshot_dir: PathBuf = if resume > 0 {
self.layer_dir(&keys[resume - 1])
} else {
base.snapshot_path()
.parent()
.ok_or_else(|| Error::vm_msg("base snapshot has no parent directory"))?
.to_path_buf()
};
let mut last_full_snap: Option<PathBuf> = {
let p = prev_snapshot_dir.join("restore.snap");
is_full_kvm_snapshot(&p).then_some(p)
};
let prune_incremental_full_rootfs_layers =
uses_full_rootfs_dockerfile_layers(boot_from) && dockerfile_layer_prune_enabled();
let mut final_image: Option<Image> = None;
for i in resume..instrs.len() {
apply_instr(
Some(&vm),
&instrs[i],
&mut state,
&mut config,
context_dir,
prior,
Some(&run_cache),
)?;
let dir = self.layer_dir(&keys[i]);
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let _lock = CacheLock::acquire(&dir);
if is_snapshot_dir(&dir) && has_required_layer_runtime_metadata(&dir) {
final_image = Some(Image::from_snapshot(&dir)?);
} else {
if is_snapshot_dir(&dir) && !has_required_layer_runtime_metadata(&dir) {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(Error::Io)?;
}
if mutates_fs(&instrs[i]) {
let prev_snap = prev_snapshot_dir.join("restore.snap");
let image = if prev_snap.is_file() && base_is_diffable(&prev_snap) {
vm.snapshot_diff(&dir, &prev_snap)?
} else if let Some(full) = last_full_snap.as_ref().filter(|p| p.is_file()) {
vm.snapshot_diff(&dir, full)?
} else {
vm.snapshot(&dir)?
};
if prune_incremental_full_rootfs_layers {
commit_compact_layer(&dir, &prev_snapshot_dir, &self.cache_dir)?;
}
final_image = Some(image);
} else if prev_snapshot_dir.join("restore.snap").is_file() {
clone_snapshot_dir(&prev_snapshot_dir, &dir)?;
if prune_incremental_full_rootfs_layers {
commit_noop_compact_layer(&dir, &prev_snapshot_dir)?;
materialize_compact_layer_if_needed(&dir)?;
}
final_image = Some(Image::from_snapshot(&dir)?);
} else {
final_image = Some(vm.snapshot(&dir)?);
}
}
let snap = dir.join("restore.snap");
if is_full_kvm_snapshot(&snap) {
last_full_snap = Some(snap);
}
if prune_incremental_full_rootfs_layers && mutates_fs(&instrs[i]) && i > 0 {
for prev_dir in
prune_earlier_full_rootfs_layer_snapshots(&self.cache_dir, &keys, i, &dir)?
{
eprintln!(
"supermachine(builder): pruned earlier Dockerfile layer snapshot {} \
during build to reduce peak disk usage",
prev_dir.display()
);
}
}
prev_snapshot_dir = dir;
}
Ok((
final_image.expect("non-empty suffix"),
config,
final_key,
keys,
))
}
}
fn prune_earlier_full_rootfs_layer_snapshots(
cache_dir: &Path,
layer_keys: &[String],
i: usize,
current_dir: &Path,
) -> Result<Vec<PathBuf>, Error> {
if i == 0 {
return Ok(Vec::new());
}
let mut pruned = Vec::new();
let mut seen = BTreeSet::new();
for key in layer_keys[..i].iter().filter(|key| seen.insert(*key)) {
let prev_dir = cache_dir.join("layers").join(key);
if prev_dir == current_dir || !prev_dir.exists() {
continue;
}
match prune_layer_materialization_or_dir(&prev_dir) {
Ok(()) => pruned.push(prev_dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(Error::vm_msg(format!(
"prune earlier Dockerfile layer snapshot {}: {e}",
prev_dir.display()
)));
}
}
}
Ok(pruned)
}
fn compact_layer_manifest_path(dir: &Path) -> PathBuf {
dir.join("supermachine-layer.json")
}
fn read_compact_layer_manifest(dir: &Path) -> Result<Option<CompactLayerManifest>, Error> {
let path = compact_layer_manifest_path(dir);
if !path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&path).map_err(Error::Io)?;
let manifest = serde_json::from_str::<CompactLayerManifest>(&text)
.map_err(|e| Error::vm_msg(format!("parse {}: {e}", path.display())))?;
Ok(Some(manifest))
}
fn snapshot_rootfs_dir(dir: &Path) -> Result<Option<PathBuf>, Error> {
let meta_path = dir.join("metadata.json");
if !meta_path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&meta_path).map_err(Error::Io)?;
let meta = serde_json::from_str::<serde_json::Value>(&text)
.map_err(|e| Error::vm_msg(format!("parse {}: {e}", meta_path.display())))?;
let Some(backend) = meta.get("backend").and_then(|v| v.as_str()) else {
return Ok(None);
};
let key = match backend {
"sentry" => "sentry_rootfs",
"runsc" => "runsc_rootfs",
"kboxlike" => "kboxlike_rootfs",
_ => return Ok(None),
};
let Some(raw) = meta.get(key).and_then(|v| v.as_str()) else {
return Ok(None);
};
let path = PathBuf::from(raw);
Ok(Some(if path.is_absolute() {
path
} else {
dir.join(path)
}))
}
fn snapshot_rootfs_metadata_key(dir: &Path) -> Result<Option<&'static str>, Error> {
let meta_path = dir.join("metadata.json");
if !meta_path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&meta_path).map_err(Error::Io)?;
let meta = serde_json::from_str::<serde_json::Value>(&text)
.map_err(|e| Error::vm_msg(format!("parse {}: {e}", meta_path.display())))?;
Ok(match meta.get("backend").and_then(|v| v.as_str()) {
Some("sentry") => Some("sentry_rootfs"),
Some("runsc") => Some("runsc_rootfs"),
Some("kboxlike") => Some("kboxlike_rootfs"),
_ => None,
})
}
fn set_snapshot_rootfs_metadata(dir: &Path, rootfs: &str) -> Result<(), Error> {
let Some(key) = snapshot_rootfs_metadata_key(dir)? else {
return Ok(());
};
let meta_path = dir.join("metadata.json");
let text = std::fs::read_to_string(&meta_path).map_err(Error::Io)?;
let mut meta = serde_json::from_str::<serde_json::Value>(&text)
.map_err(|e| Error::vm_msg(format!("parse {}: {e}", meta_path.display())))?;
let obj = meta
.as_object_mut()
.ok_or_else(|| Error::vm_msg("metadata.json is not an object"))?;
obj.insert(key.to_owned(), serde_json::json!(rootfs));
let tmp = dir.join(format!(".metadata.json.tmp.{}", std::process::id()));
std::fs::write(
&tmp,
serde_json::to_vec_pretty(&meta)
.map_err(|e| Error::vm_msg(format!("serialize metadata.json: {e}")))?,
)
.map_err(Error::Io)?;
std::fs::rename(&tmp, &meta_path).map_err(Error::Io)?;
Ok(())
}
fn commit_compact_layer(
layer_dir: &Path,
parent_dir: &Path,
cache_dir: &Path,
) -> Result<(), Error> {
let Some(rootfs) = snapshot_rootfs_dir(layer_dir)? else {
return Ok(());
};
let Some(parent_rootfs) = snapshot_rootfs_dir(parent_dir)? else {
return Ok(());
};
if !rootfs.is_dir() || !parent_rootfs.is_dir() {
return Ok(());
}
let blobs = cache_dir.join("blobs/sha256");
let diff = write_rootfs_diff_layer(&parent_rootfs, &rootfs, &blobs)?;
let manifest = CompactLayerManifest {
version: 1,
kind: "supermachine-dockerfile-rootfs-diff".to_owned(),
parent_snapshot: parent_dir.to_string_lossy().into_owned(),
rootfs: rootfs
.strip_prefix(layer_dir)
.unwrap_or(rootfs.as_path())
.to_string_lossy()
.into_owned(),
diff_blob: Some(format!("blobs/sha256/{}", diff.layer_digest)),
diff_id: Some(diff.diff_id),
layer_digest: Some(format!("sha256:{}", diff.layer_digest)),
layer_size: Some(diff.layer_size),
created_by_version: env!("CARGO_PKG_VERSION").to_owned(),
};
let tmp = layer_dir.join(format!(
".supermachine-layer.json.tmp.{}",
std::process::id()
));
let text = serde_json::to_vec_pretty(&manifest)
.map_err(|e| Error::vm_msg(format!("serialize compact layer manifest: {e}")))?;
std::fs::write(&tmp, text).map_err(Error::Io)?;
std::fs::rename(&tmp, compact_layer_manifest_path(layer_dir)).map_err(Error::Io)?;
Ok(())
}
fn commit_noop_compact_layer(layer_dir: &Path, parent_dir: &Path) -> Result<(), Error> {
set_snapshot_rootfs_metadata(layer_dir, "rootfs")?;
let manifest = CompactLayerManifest {
version: 1,
kind: "supermachine-dockerfile-rootfs-diff".to_owned(),
parent_snapshot: parent_dir.to_string_lossy().into_owned(),
rootfs: "rootfs".to_owned(),
diff_blob: None,
diff_id: None,
layer_digest: None,
layer_size: Some(0),
created_by_version: env!("CARGO_PKG_VERSION").to_owned(),
};
let tmp = layer_dir.join(format!(
".supermachine-layer.json.tmp.{}",
std::process::id()
));
let text = serde_json::to_vec_pretty(&manifest)
.map_err(|e| Error::vm_msg(format!("serialize compact layer manifest: {e}")))?;
std::fs::write(&tmp, text).map_err(Error::Io)?;
std::fs::rename(&tmp, compact_layer_manifest_path(layer_dir)).map_err(Error::Io)?;
Ok(())
}
fn materialize_compact_layer_if_needed(layer_dir: &Path) -> Result<(), Error> {
let Some(manifest) = read_compact_layer_manifest(layer_dir)? else {
return Ok(());
};
let rootfs = layer_dir.join(&manifest.rootfs);
if rootfs.is_dir() {
return Ok(());
}
let parent = PathBuf::from(&manifest.parent_snapshot);
materialize_compact_layer_if_needed(&parent)?;
let parent_rootfs = snapshot_rootfs_dir(&parent)?.ok_or_else(|| {
Error::vm_msg(format!(
"compact layer {} parent has no host rootfs",
layer_dir.display()
))
})?;
if !parent_rootfs.is_dir() {
return Err(Error::vm_msg(format!(
"compact layer {} parent rootfs missing: {}",
layer_dir.display(),
parent_rootfs.display()
)));
}
let staging = layer_dir.join(format!(
".rootfs.materialize.{}.{}",
std::process::id(),
unique_layer_suffix()
));
let _ = std::fs::remove_dir_all(&staging);
copy_tree_preserving(&parent_rootfs, &staging).map_err(Error::Io)?;
if let Some(diff_blob) = &manifest.diff_blob {
let diff_path = if Path::new(diff_blob).is_absolute() {
PathBuf::from(diff_blob)
} else {
layer_dir
.parent()
.and_then(|p| p.parent())
.unwrap_or(layer_dir)
.join(diff_blob)
};
apply_oci_diff_layer(&diff_path, &staging)?;
}
if let Some(parent) = rootfs.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
match std::fs::rename(&staging, &rootfs) {
Ok(()) => {
if compact_layer_manifest_path(&parent).is_file() {
let _ = prune_layer_materialization_or_dir(&parent);
}
Ok(())
}
Err(e) => {
let _ = std::fs::remove_dir_all(&staging);
Err(Error::Io(e))
}
}
}
fn prune_layer_materialization_or_dir(layer_dir: &Path) -> std::io::Result<()> {
if let Ok(Some(manifest)) = read_compact_layer_manifest(layer_dir) {
let rootfs = layer_dir.join(manifest.rootfs);
match std::fs::remove_dir_all(rootfs) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
} else {
std::fs::remove_dir_all(layer_dir)
}
}
fn unique_layer_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
fn uses_full_rootfs_dockerfile_layers(image: &Image) -> bool {
image.is_runsc() || image.is_sentry() || image.is_kboxlike()
}
pub fn dockerfile_layer_prune_enabled() -> bool {
let value = std::env::var("SUPERMACHINE_PRUNE_DOCKERFILE_LAYERS")
.ok()
.or_else(|| std::env::var("SUPERMACHINE_RUNSC_PRUNE_DOCKERFILE_LAYERS").ok());
match value {
Some(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off" | "never"
),
None => true,
}
}
pub fn runsc_prune_dockerfile_layers_enabled() -> bool {
dockerfile_layer_prune_enabled()
}
pub fn prune_dockerfile_layer_snapshots(
cache_dir: &Path,
layer_keys: &[String],
) -> Result<usize, Error> {
let mut pruned = 0usize;
let mut seen = BTreeSet::new();
for key in layer_keys {
if !seen.insert(key) {
continue;
}
let dir = cache_dir.join("layers").join(key);
if !dir.exists() {
continue;
}
prune_layer_materialization_or_dir(&dir).map_err(|e| {
Error::vm_msg(format!(
"prune Dockerfile layer materialization {}: {e}",
dir.display()
))
})?;
pruned += 1;
}
Ok(pruned)
}
fn base_is_diffable(snap: &Path) -> bool {
use std::io::Read as _;
let mut magic = [0u8; 8];
match std::fs::File::open(snap).and_then(|mut f| f.read_exact(&mut magic)) {
Ok(()) => !(magic.starts_with(b"SMSNAP") && magic[7] == b'D'),
Err(_) => true,
}
}
fn is_full_kvm_snapshot(snap: &Path) -> bool {
use std::io::Read as _;
let mut magic = [0u8; 8];
std::fs::File::open(snap)
.and_then(|mut f| f.read_exact(&mut magic))
.is_ok()
&& magic.starts_with(b"SMSNAP")
&& magic[7].is_ascii_digit()
}
fn is_snapshot_dir(dir: &Path) -> bool {
let has_meta = dir.join("metadata.json").is_file();
if has_meta && compact_layer_manifest_path(dir).is_file() {
return true;
}
if has_meta && dir.join("restore.snap").is_file() {
return true;
}
has_meta
&& dir.join("rootfs").is_dir()
&& std::fs::read_to_string(dir.join("metadata.json"))
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| {
v.get("backend")
.and_then(|b| b.as_str())
.map(|s| s == "sentry")
})
.unwrap_or(false)
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
fn has_required_layer_runtime_metadata(dir: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(dir.join("metadata.json")) else {
return false;
};
let Ok(meta) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
match meta
.get("backend")
.and_then(|v| v.as_str())
.unwrap_or("hvf")
{
"hvf" => {
meta.get("baked_by_version")
.and_then(|v| v.as_str())
.is_some()
&& meta.get("runtime_sha16").and_then(|v| v.as_str()).is_some()
&& meta.get("tsi_token").and_then(|v| v.as_str()).is_some()
}
_ => true,
}
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
fn has_required_layer_runtime_metadata(_dir: &Path) -> bool {
true
}
fn mutates_fs(instr: &Instruction) -> bool {
matches!(
instr,
Instruction::Run { .. }
| Instruction::Copy { .. }
| Instruction::Add { .. }
| Instruction::Workdir(_)
)
}
fn clone_snapshot_dir(src: &Path, dst: &Path) -> Result<(), Error> {
std::fs::create_dir_all(dst).map_err(Error::Io)?;
for entry in std::fs::read_dir(src).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
if !entry.file_type().map_err(Error::Io)?.is_file() {
continue;
}
clone_or_copy_file(&entry.path(), &dst.join(entry.file_name()))?;
}
Ok(())
}
fn clone_or_copy_file(src: &Path, dst: &Path) -> Result<(), Error> {
let _ = std::fs::remove_file(dst);
#[cfg(target_os = "macos")]
{
use std::os::unix::ffi::OsStrExt;
if let (Ok(s), Ok(d)) = (
std::ffi::CString::new(src.as_os_str().as_bytes()),
std::ffi::CString::new(dst.as_os_str().as_bytes()),
) {
if unsafe { libc::clonefile(s.as_ptr(), d.as_ptr(), 0) } == 0 {
return Ok(());
}
}
}
std::fs::copy(src, dst).map_err(Error::Io)?;
Ok(())
}
fn shellexec_to_argv(s: &ShellOrExec) -> Vec<String> {
shellexec_to_argv_with(s, None)
}
fn shellexec_to_argv_with(s: &ShellOrExec, shell: Option<&[String]>) -> Vec<String> {
match s {
ShellOrExec::Exec(a) => a.clone(),
ShellOrExec::Shell(c) => {
let mut argv: Vec<String> = shell
.map(<[String]>::to_vec)
.unwrap_or_else(|| vec!["/bin/sh".into(), "-c".into()]);
argv.push(c.clone());
argv
}
}
}
fn effective_cmd(config: &ImageConfig) -> Option<Vec<String>> {
let shell = config.shell.as_deref();
let to_argv = |s: &ShellOrExec| shellexec_to_argv_with(s, shell);
match (&config.entrypoint, &config.cmd) {
(Some(ep @ ShellOrExec::Shell(_)), _) => Some(to_argv(ep)),
(Some(ShellOrExec::Exec(ep)), cmd) => {
let mut argv = ep.clone();
if let Some(ShellOrExec::Exec(c)) = cmd {
argv.extend(c.clone());
}
Some(argv)
}
(None, Some(cmd)) => Some(to_argv(cmd)),
(None, None) => None,
}
}
fn healthcheck_json(hc: &Healthcheck) -> serde_json::Value {
match hc {
Healthcheck::None => serde_json::json!({ "Test": ["NONE"] }),
Healthcheck::Cmd {
test,
interval,
timeout,
start_period,
start_interval,
retries,
} => {
let test_arr: Vec<String> = match test {
ShellOrExec::Shell(s) => vec!["CMD-SHELL".to_string(), s.clone()],
ShellOrExec::Exec(a) => {
let mut v = vec!["CMD".to_string()];
v.extend(a.iter().cloned());
v
}
};
let mut obj = serde_json::Map::new();
obj.insert("Test".into(), serde_json::json!(test_arr));
let mut put = |k: &str, val: &Option<String>| {
if let Some(v) = val {
obj.insert(k.into(), serde_json::json!(v));
}
};
put("Interval", interval);
put("Timeout", timeout);
put("StartPeriod", start_period);
put("StartInterval", start_interval);
put("Retries", retries);
serde_json::Value::Object(obj)
}
}
}
fn persist_run_config(snapshot_file: &Path, config: &ImageConfig) -> Result<(), Error> {
if effective_cmd(config).is_none()
&& config.workdir.is_none()
&& config.user.is_none()
&& config.env.is_empty()
&& config.stop_signal.is_none()
&& config.healthcheck.is_none()
&& config.onbuild.is_empty()
{
return Ok(());
}
let meta_path = if snapshot_file.is_dir() {
snapshot_file.join("metadata.json")
} else {
snapshot_file
.parent()
.ok_or_else(|| Error::vm_msg("snapshot path has no parent directory"))?
.join("metadata.json")
};
let text = std::fs::read_to_string(&meta_path).map_err(Error::Io)?;
let mut meta: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| Error::vm_msg(format!("parse metadata.json: {e}")))?;
let obj = meta
.as_object_mut()
.ok_or_else(|| Error::vm_msg("metadata.json is not an object"))?;
if let Some(cmd) = effective_cmd(config) {
obj.insert(
"cmd".into(),
serde_json::Value::Array(cmd.into_iter().map(serde_json::Value::String).collect()),
);
}
if let Some(wd) = &config.workdir {
obj.insert("working_dir".into(), serde_json::Value::String(wd.clone()));
}
if let Some(u) = &config.user {
obj.insert("user".into(), serde_json::Value::String(u.clone()));
}
if !config.env.is_empty() {
let env = obj
.entry("env")
.or_insert_with(|| serde_json::Value::Object(Default::default()));
if let Some(map) = env.as_object_mut() {
for (k, v) in &config.env {
map.insert(k.clone(), serde_json::Value::String(v.clone()));
}
}
}
if let Some(sig) = &config.stop_signal {
obj.insert("stop_signal".into(), serde_json::Value::String(sig.clone()));
}
if let Some(hc) = &config.healthcheck {
obj.insert("healthcheck".into(), healthcheck_json(hc));
}
if !config.onbuild.is_empty() {
obj.insert(
"onbuild".into(),
serde_json::Value::Array(
config
.onbuild
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
),
);
}
let out = serde_json::to_string_pretty(&meta)
.map_err(|e| Error::vm_msg(format!("serialize metadata.json: {e}")))?;
let parent = meta_path
.parent()
.ok_or_else(|| Error::vm_msg("metadata.json has no parent directory"))?;
let seq = META_TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = parent.join(format!(".metadata.json.tmp.{}.{seq}", std::process::id()));
std::fs::write(&tmp, out).map_err(Error::Io)?;
if let Err(e) = std::fs::rename(&tmp, &meta_path) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Io(e));
}
Ok(())
}
static META_TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[derive(Debug, Clone)]
pub struct CommitOutcome {
pub squashfs: PathBuf,
pub manifest: PathBuf,
pub bytes: u64,
pub sha256: String,
}
pub fn commit_squashfs(image: &Image, dest: &Path) -> Result<CommitOutcome, Error> {
use std::io::Read as _;
std::fs::create_dir_all(dest).map_err(Error::Io)?;
let stage = dest.join("rootfs");
let _ = std::fs::remove_dir_all(&stage);
std::fs::create_dir_all(&stage).map_err(Error::Io)?;
let tar_bytes = boot_and_tar_rootfs(image)?;
tar::Archive::new(&tar_bytes[..])
.unpack(&stage)
.map_err(Error::Io)?;
let squashfs = dest.join("rootfs.squashfs");
let _ = std::fs::remove_file(&squashfs);
crate::bake::squashfs::write_squashfs(
&stage,
&squashfs,
&crate::bake::squashfs::Ownership::AllRoot,
)
.map_err(|e| Error::vm_msg(format!("commit: squashfs: {e}")))?;
let _ = std::fs::remove_dir_all(&stage);
let size = std::fs::metadata(&squashfs).map_err(Error::Io)?.len();
let sha = {
use ring::digest::{Context, SHA256};
let mut f = std::fs::File::open(&squashfs).map_err(Error::Io)?;
let mut ctx = Context::new(&SHA256);
let mut buf = [0u8; 64 * 1024];
loop {
let n = f.read(&mut buf).map_err(Error::Io)?;
if n == 0 {
break;
}
ctx.update(&buf[..n]);
}
let d = ctx.finish();
use std::fmt::Write as _;
let mut s = String::with_capacity(64);
for b in d.as_ref() {
let _ = write!(s, "{b:02x}");
}
s
};
let manifest = dest.join("commit.json");
let manifest_json = serde_json::json!({
"kind": "supermachine-commit-squashfs",
"source_snapshot": image.snapshot_path().to_string_lossy(),
"squashfs": "rootfs.squashfs",
"bytes": size,
"sha256": sha,
"committed_by_version": env!("CARGO_PKG_VERSION"),
});
std::fs::write(
&manifest,
serde_json::to_string_pretty(&manifest_json).unwrap_or_default(),
)
.map_err(Error::Io)?;
Ok(CommitOutcome {
squashfs,
manifest,
bytes: size,
sha256: sha,
})
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub fn commit_kvm_bootable(image: &Image, dest: &Path) -> Result<(CommitOutcome, Image), Error> {
let commit = commit_squashfs(image, dest)?;
let bootable = Image::bake_kvm_from_squashfs_auto(&commit.squashfs, dest)?;
Ok((commit, bootable))
}
fn boot_and_tar_rootfs(image: &Image) -> Result<Vec<u8>, Error> {
let pool = image
.pool()
.min(1)
.max(1)
.restore_on_release(false)
.for_build(true)
.build()?;
let vm = pool.acquire()?;
await_agent_ready(&vm);
let _ = vm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(
[
"sh",
"-c",
"mkdir -p /tmp /run /var/tmp; chmod 1777 /tmp /var/tmp 2>/dev/null || true",
]
.iter()
.copied(),
)
.output();
let argv: Vec<&str> = vec![
"tar",
"-cf",
"-",
"--exclude=./proc",
"--exclude=./sys",
"--exclude=./dev",
"--exclude=./tmp/*",
"--exclude=./run/*",
"-C",
"/",
".",
];
let out = vm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(argv.iter().copied())
.output()
.map_err(Error::Io)?;
if !out.success() {
return Err(Error::vm_msg(format!(
"tar rootfs failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(out.stdout)
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
struct OciLayerStreamOutcome {
diff_id: String,
layer_digest: String,
layer_size: u64,
tar_bytes: u64,
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
struct HashingWriter<W> {
inner: W,
ctx: ring::digest::Context,
bytes: u64,
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
impl<W> HashingWriter<W> {
fn new(inner: W) -> Self {
Self {
inner,
ctx: ring::digest::Context::new(&ring::digest::SHA256),
bytes: 0,
}
}
fn finish(self) -> (W, String, u64) {
(self.inner, digest_to_hex(self.ctx.finish()), self.bytes)
}
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
impl<W: std::io::Write> std::io::Write for HashingWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.ctx.update(&buf[..n]);
self.bytes += n as u64;
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
fn digest_to_hex(d: ring::digest::Digest) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(64);
for b in d.as_ref() {
let _ = write!(s, "{b:02x}");
}
s
}
struct OciDiffWriter<W: std::io::Write> {
gz: flate2::write::GzEncoder<HashingWriter<W>>,
diff_ctx: ring::digest::Context,
tar_bytes: u64,
}
impl<W: std::io::Write> OciDiffWriter<W> {
fn new(inner: W) -> Self {
Self {
gz: flate2::write::GzEncoder::new(
HashingWriter::new(inner),
flate2::Compression::default(),
),
diff_ctx: ring::digest::Context::new(&ring::digest::SHA256),
tar_bytes: 0,
}
}
fn finish(self) -> std::io::Result<(W, String, String, u64, u64)> {
let hashing = self.gz.finish()?;
let (inner, layer_digest, layer_size) = hashing.finish();
Ok((
inner,
digest_to_hex(self.diff_ctx.finish()),
layer_digest,
layer_size,
self.tar_bytes,
))
}
}
impl<W: std::io::Write> std::io::Write for OciDiffWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.diff_ctx.update(buf);
self.tar_bytes += buf.len() as u64;
self.gz.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.gz.flush()
}
}
fn write_rootfs_diff_layer(
parent_rootfs: &Path,
current_rootfs: &Path,
blobs: &Path,
) -> Result<OciLayerStreamOutcome, Error> {
std::fs::create_dir_all(blobs).map_err(Error::Io)?;
let tmp = blobs.join(format!(
".diff-layer.tmp.{}.{}",
std::process::id(),
unique_layer_suffix()
));
let file = std::fs::File::create(&tmp).map_err(Error::Io)?;
let diff_writer = OciDiffWriter::new(file);
let mut tar = tar::Builder::new(diff_writer);
let parent = collect_rootfs_entries(parent_rootfs)?;
let current = collect_rootfs_entries(current_rootfs)?;
for (rel, old) in &parent {
match current.get(rel) {
None => append_whiteout(&mut tar, rel)?,
Some(new) if old.kind != new.kind => append_whiteout(&mut tar, rel)?,
_ => {}
}
}
for (rel, new) in ¤t {
if rel.as_os_str().is_empty() {
continue;
}
let changed = parent.get(rel) != Some(new);
let type_replaced = parent
.get(rel)
.map(|old| old.kind != new.kind)
.unwrap_or(false);
if changed || type_replaced {
append_fs_entry_to_tar(&mut tar, current_rootfs, rel, new)?;
}
}
let diff_writer = tar.into_inner().map_err(Error::Io)?;
let (file, diff_id, layer_digest, layer_size, tar_bytes) =
diff_writer.finish().map_err(Error::Io)?;
drop(file);
let final_path = blobs.join(&layer_digest);
if final_path.is_file() {
let _ = std::fs::remove_file(&tmp);
} else {
std::fs::rename(&tmp, &final_path).map_err(Error::Io)?;
}
Ok(OciLayerStreamOutcome {
diff_id,
layer_digest,
layer_size,
tar_bytes,
})
}
fn collect_rootfs_entries(root: &Path) -> Result<BTreeMap<PathBuf, FsEntryMeta>, Error> {
let mut out = BTreeMap::new();
let mut stack = vec![PathBuf::new()];
while let Some(rel) = stack.pop() {
let path = root.join(&rel);
let md = std::fs::symlink_metadata(&path).map_err(Error::Io)?;
let meta = fs_entry_meta(&path, &md)?;
out.insert(rel.clone(), meta);
if md.file_type().is_dir() {
for entry in std::fs::read_dir(&path).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
stack.push(rel.join(entry.file_name()));
}
}
}
Ok(out)
}
fn fs_entry_meta(path: &Path, md: &std::fs::Metadata) -> Result<FsEntryMeta, Error> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let ft = md.file_type();
let (kind, digest, link_target) = if ft.is_dir() {
(FsEntryKind::Dir, None, None)
} else if ft.is_file() {
(FsEntryKind::File, Some(file_sha256(path)?), None)
} else if ft.is_symlink() {
(
FsEntryKind::Symlink,
None,
Some(std::fs::read_link(path).map_err(Error::Io)?),
)
} else {
(FsEntryKind::File, Some(String::new()), None)
};
Ok(FsEntryMeta {
kind,
mode: md.permissions().mode(),
uid: md.uid() as u64,
gid: md.gid() as u64,
mtime: md.mtime(),
size: md.size(),
digest,
link_target,
})
}
fn file_sha256(path: &Path) -> Result<String, Error> {
let mut f = std::fs::File::open(path).map_err(Error::Io)?;
let mut ctx = ring::digest::Context::new(&ring::digest::SHA256);
let mut buf = [0u8; 128 * 1024];
loop {
let n = f.read(&mut buf).map_err(Error::Io)?;
if n == 0 {
break;
}
ctx.update(&buf[..n]);
}
Ok(digest_to_hex(ctx.finish()))
}
fn append_whiteout<W: std::io::Write>(tar: &mut tar::Builder<W>, rel: &Path) -> Result<(), Error> {
let Some(name) = rel.file_name() else {
return Ok(());
};
let marker_name = format!(".wh.{}", name.to_string_lossy());
let marker = rel
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.join(&marker_name))
.unwrap_or_else(|| PathBuf::from(marker_name));
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Regular);
header.set_size(0);
header.set_mode(0);
tar.append_data(&mut header, marker, std::io::empty())
.map_err(Error::Io)?;
Ok(())
}
fn append_fs_entry_to_tar<W: std::io::Write>(
tar: &mut tar::Builder<W>,
root: &Path,
rel: &Path,
meta: &FsEntryMeta,
) -> Result<(), Error> {
let full = root.join(rel);
let mut header = tar::Header::new_gnu();
header.set_mode(meta.mode);
header.set_uid(meta.uid);
header.set_gid(meta.gid);
header.set_mtime(meta.mtime.max(0) as u64);
match meta.kind {
FsEntryKind::Dir => {
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
tar.append_data(&mut header, rel, std::io::empty())
.map_err(Error::Io)?;
}
FsEntryKind::File => {
header.set_entry_type(tar::EntryType::Regular);
header.set_size(meta.size);
let mut f = std::fs::File::open(&full).map_err(Error::Io)?;
tar.append_data(&mut header, rel, &mut f)
.map_err(Error::Io)?;
}
FsEntryKind::Symlink => {
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
if let Some(target) = &meta.link_target {
tar.append_link(&mut header, rel, target)
.map_err(Error::Io)?;
} else {
tar.append_data(&mut header, rel, std::io::empty())
.map_err(Error::Io)?;
}
}
}
Ok(())
}
fn apply_oci_diff_layer(diff_blob: &Path, rootfs: &Path) -> Result<(), Error> {
let file = std::fs::File::open(diff_blob).map_err(Error::Io)?;
let gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().map_err(Error::Io)? {
let mut entry = entry.map_err(Error::Io)?;
let path = entry.path().map_err(Error::Io)?.to_path_buf();
if is_oci_whiteout_path(&path) {
apply_whiteout(rootfs, &path)?;
continue;
}
entry.unpack_in(rootfs).map_err(Error::Io)?;
}
Ok(())
}
fn is_oci_whiteout_path(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(".wh."))
.unwrap_or(false)
}
fn apply_whiteout(rootfs: &Path, marker: &Path) -> Result<(), Error> {
let Some(name) = marker.file_name().and_then(|n| n.to_str()) else {
return Ok(());
};
if name == ".wh..wh..opq" {
let dir = marker
.parent()
.map(|p| rootfs.join(p))
.unwrap_or_else(|| rootfs.to_path_buf());
if let Ok(read) = std::fs::read_dir(&dir) {
for entry in read.flatten() {
let path = entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name.starts_with(".wh.") {
continue;
}
let md = std::fs::symlink_metadata(&path).map_err(Error::Io)?;
if md.file_type().is_dir() {
std::fs::remove_dir_all(path).map_err(Error::Io)?;
} else {
std::fs::remove_file(path).map_err(Error::Io)?;
}
}
}
return Ok(());
}
let target_name = name.trim_start_matches(".wh.");
let target = marker
.parent()
.map(|p| rootfs.join(p).join(target_name))
.unwrap_or_else(|| rootfs.join(target_name));
match std::fs::symlink_metadata(&target) {
Ok(md) if md.file_type().is_dir() => std::fs::remove_dir_all(target).map_err(Error::Io)?,
Ok(_) => std::fs::remove_file(target).map_err(Error::Io)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::Io(e)),
}
Ok(())
}
fn copy_tree_preserving(src: &Path, dst: &Path) -> std::io::Result<()> {
use std::collections::HashMap;
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
fn set_owner(path: &Path, uid: u32, gid: u32, follow: bool) -> std::io::Result<()> {
let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
})?;
let r = unsafe {
if follow {
libc::chown(c.as_ptr(), uid as libc::uid_t, gid as libc::gid_t)
} else {
libc::lchown(c.as_ptr(), uid as libc::uid_t, gid as libc::gid_t)
}
};
if r == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
std::fs::create_dir_all(dst)?;
let root_md = std::fs::symlink_metadata(src)?;
std::fs::set_permissions(dst, root_md.permissions())?;
let _ = set_owner(dst, root_md.uid(), root_md.gid(), true);
let mut hardlinks: HashMap<(u64, u64), PathBuf> = HashMap::new();
let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())];
while let Some((s, d)) = stack.pop() {
for entry in std::fs::read_dir(&s)? {
let entry = entry?;
let sp = entry.path();
let dp = d.join(entry.file_name());
let md = std::fs::symlink_metadata(&sp)?;
let ft = md.file_type();
if ft.is_dir() {
std::fs::create_dir_all(&dp)?;
std::fs::set_permissions(&dp, md.permissions())?;
let _ = set_owner(&dp, md.uid(), md.gid(), true);
stack.push((sp, dp));
} else if ft.is_symlink() {
let target = std::fs::read_link(&sp)?;
let _ = std::fs::remove_file(&dp);
std::os::unix::fs::symlink(target, &dp)?;
let _ = set_owner(&dp, md.uid(), md.gid(), false);
} else if ft.is_file() {
if md.nlink() > 1 {
let key = (md.dev(), md.ino());
if let Some(first) = hardlinks.get(&key) {
let _ = std::fs::remove_file(&dp);
std::fs::hard_link(first, &dp)?;
let _ = set_owner(&dp, md.uid(), md.gid(), true);
continue;
}
hardlinks.insert(key, dp.clone());
}
if crate::bake::clonefile_one(&sp, &dp).is_err() {
let _ = std::fs::remove_file(&dp);
std::fs::copy(&sp, &dp)?;
}
std::fs::set_permissions(&dp, md.permissions())?;
let _ = set_owner(&dp, md.uid(), md.gid(), true);
}
}
}
Ok(())
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
fn stream_rootfs_to_oci_layer(image: &Image, blobs: &Path) -> Result<OciLayerStreamOutcome, Error> {
use std::io::{Read as _, Write as _};
let pool = image
.pool()
.min(1)
.max(1)
.restore_on_release(false)
.for_build(true)
.build()?;
let vm = pool.acquire()?;
await_agent_ready(&vm);
let _ = vm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(
[
"sh",
"-c",
"mkdir -p /tmp /run /var/tmp; chmod 1777 /tmp /var/tmp 2>/dev/null || true",
]
.iter()
.copied(),
)
.output();
let argv: Vec<&str> = vec![
"tar",
"-cf",
"-",
"--exclude=./proc",
"--exclude=./sys",
"--exclude=./dev",
"--exclude=./tmp/*",
"-C",
"/",
".",
];
let mut child = vm
.exec_builder()
.argv(argv.iter().copied())
.spawn()
.map_err(Error::Io)?;
drop(child.stdin());
let mut stdout = child
.stdout()
.ok_or_else(|| Error::vm_msg("tar rootfs: missing stdout stream"))?;
let stderr_handle = child.stderr().map(|mut stderr| {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf);
buf
})
});
std::fs::create_dir_all(blobs).map_err(Error::Io)?;
let tmp = blobs.join(format!(
".layer.tmp.{}.{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let file = std::fs::File::create(&tmp).map_err(Error::Io)?;
let hashing_file = HashingWriter::new(file);
let mut gz = flate2::write::GzEncoder::new(hashing_file, flate2::Compression::default());
let mut diff_ctx = ring::digest::Context::new(&ring::digest::SHA256);
let mut tar_bytes = 0u64;
let mut buf = [0u8; 128 * 1024];
loop {
let n = stdout.read(&mut buf).map_err(Error::Io)?;
if n == 0 {
break;
}
diff_ctx.update(&buf[..n]);
tar_bytes += n as u64;
gz.write_all(&buf[..n]).map_err(Error::Io)?;
}
let hashing_file = gz.finish().map_err(Error::Io)?;
let (file, layer_digest, layer_size) = hashing_file.finish();
drop(file);
let status = child.wait().map_err(Error::Io)?;
let stderr = stderr_handle
.and_then(|h| h.join().ok())
.unwrap_or_default();
if !status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(Error::vm_msg(format!(
"tar rootfs failed: {}",
String::from_utf8_lossy(&stderr).trim()
)));
}
let layer_path = blobs.join(&layer_digest);
std::fs::rename(&tmp, layer_path).map_err(Error::Io)?;
Ok(OciLayerStreamOutcome {
diff_id: digest_to_hex(diff_ctx.finish()),
layer_digest,
layer_size,
tar_bytes,
})
}
#[derive(Debug, Clone)]
pub struct OciExportOutcome {
pub layout_dir: PathBuf,
pub manifest_digest: String,
pub layer_bytes: u64,
}
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_arch = "x86_64")
))]
pub fn export_oci(
image: &Image,
config: &ImageConfig,
arch: &str,
dest: &Path,
) -> Result<OciExportOutcome, Error> {
let blobs = dest.join("blobs/sha256");
std::fs::create_dir_all(&blobs).map_err(Error::Io)?;
let write_blob = |digest_hex: &str, bytes: &[u8]| -> Result<(), Error> {
std::fs::write(blobs.join(digest_hex), bytes).map_err(Error::Io)
};
let layer = stream_rootfs_to_oci_layer(image, &blobs)?;
let diff_id = layer.diff_id;
let layer_digest = layer.layer_digest;
let layer_size = layer.layer_size;
let env: Vec<String> = config.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
let mut cfg = serde_json::Map::new();
if !env.is_empty() {
cfg.insert("Env".into(), serde_json::json!(env));
}
let shell = config.shell.as_deref();
if let Some(entry) = config
.entrypoint
.as_ref()
.map(|e| shellexec_to_argv_with(e, shell))
{
cfg.insert("Entrypoint".into(), serde_json::json!(entry));
}
if let Some(cmd) = config
.cmd
.as_ref()
.map(|c| shellexec_to_argv_with(c, shell))
{
cfg.insert("Cmd".into(), serde_json::json!(cmd));
}
if let Some(wd) = &config.workdir {
cfg.insert("WorkingDir".into(), serde_json::json!(wd));
}
if let Some(user) = &config.user {
cfg.insert("User".into(), serde_json::json!(user));
}
if !config.exposed_ports.is_empty() {
let ports: serde_json::Map<String, serde_json::Value> = config
.exposed_ports
.iter()
.map(|p| (format!("{p}/tcp"), serde_json::json!({})))
.collect();
cfg.insert("ExposedPorts".into(), serde_json::Value::Object(ports));
}
if !config.labels.is_empty() {
let labels: serde_json::Map<String, serde_json::Value> = config
.labels
.iter()
.map(|(k, v)| (k.clone(), serde_json::json!(v)))
.collect();
cfg.insert("Labels".into(), serde_json::Value::Object(labels));
}
if !config.volumes.is_empty() {
let vols: serde_json::Map<String, serde_json::Value> = config
.volumes
.iter()
.map(|v| (v.clone(), serde_json::json!({})))
.collect();
cfg.insert("Volumes".into(), serde_json::Value::Object(vols));
}
if let Some(sig) = &config.stop_signal {
cfg.insert("StopSignal".into(), serde_json::json!(sig));
}
if let Some(hc) = &config.healthcheck {
cfg.insert("Healthcheck".into(), healthcheck_json(hc));
}
if !config.onbuild.is_empty() {
cfg.insert("OnBuild".into(), serde_json::json!(config.onbuild));
}
if let Some(sh) = &config.shell {
cfg.insert("Shell".into(), serde_json::json!(sh));
}
let image_config = serde_json::json!({
"architecture": arch,
"os": "linux",
"config": serde_json::Value::Object(cfg),
"rootfs": { "type": "layers", "diff_ids": [format!("sha256:{diff_id}")] },
"history": [{ "created_by": "supermachine in-VM builder (squashed export)" }],
});
let config_bytes =
serde_json::to_vec(&image_config).map_err(|e| Error::vm_msg(format!("oci config: {e}")))?;
let config_digest = sha256_hex(&config_bytes);
let config_size = config_bytes.len() as u64;
write_blob(&config_digest, &config_bytes)?;
let manifest = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": format!("sha256:{config_digest}"),
"size": config_size,
},
"layers": [{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": format!("sha256:{layer_digest}"),
"size": layer_size,
}],
});
let manifest_bytes =
serde_json::to_vec(&manifest).map_err(|e| Error::vm_msg(format!("oci manifest: {e}")))?;
let manifest_digest = sha256_hex(&manifest_bytes);
write_blob(&manifest_digest, &manifest_bytes)?;
std::fs::write(dest.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#)
.map_err(Error::Io)?;
let index = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": format!("sha256:{manifest_digest}"),
"size": manifest_bytes.len(),
"platform": { "architecture": arch, "os": "linux" },
}],
});
std::fs::write(
dest.join("index.json"),
serde_json::to_vec_pretty(&index).map_err(|e| Error::vm_msg(format!("oci index: {e}")))?,
)
.map_err(Error::Io)?;
Ok(OciExportOutcome {
layout_dir: dest.to_path_buf(),
manifest_digest: format!("sha256:{manifest_digest}"),
layer_bytes: layer.tar_bytes,
})
}
fn sha256_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let d = ring::digest::digest(&ring::digest::SHA256, bytes);
let mut s = String::with_capacity(64);
for b in d.as_ref() {
let _ = write!(s, "{b:02x}");
}
s
}
fn instr_repr(
instr: &Instruction,
context_dir: &Path,
prior: &[Option<StageBuilt>],
) -> Result<String, Error> {
Ok(match instr {
Instruction::Run { run, mounts } => format!("RUN {mounts:?} {}", describe(run)),
Instruction::Env(p) => format!("ENV {p:?}"),
Instruction::Workdir(d) => format!("WORKDIR {d}"),
Instruction::User(u) => format!("USER {u}"),
Instruction::Entrypoint(e) => format!("ENTRYPOINT {}", describe(e)),
Instruction::Cmd(c) => format!("CMD {}", describe(c)),
Instruction::Expose(p) => format!("EXPOSE {p:?}"),
Instruction::Label(p) => format!("LABEL {p:?}"),
Instruction::Arg { name, default } => format!("ARG {name}={default:?}"),
Instruction::Volume(v) => format!("VOLUME {v:?}"),
Instruction::StopSignal(s) => format!("STOPSIGNAL {s}"),
Instruction::Shell(s) => format!("SHELL {s:?}"),
Instruction::Healthcheck(hc) => format!("HEALTHCHECK {hc:?}"),
Instruction::Onbuild(t) => format!("ONBUILD {t}"),
Instruction::Copy {
sources,
dest,
flags,
} => format!(
"COPY {flags:?} {sources:?} {dest} {}",
copy_cache_content(sources, context_dir, flags, prior)?
),
Instruction::Add {
sources,
dest,
flags,
} => format!(
"ADD {flags:?} {sources:?} {dest} {}",
copy_cache_content(sources, context_dir, flags, prior)?
),
})
}
fn copy_cache_content(
sources: &[String],
context_dir: &Path,
flags: &CopyFlags,
prior: &[Option<StageBuilt>],
) -> Result<String, Error> {
match &flags.from {
Some(name) => Ok(format!("from={}", resolve_stage(prior, name)?.key)),
None => copy_content_hash(sources, context_dir),
}
}
fn copy_from_stage(
current_vm: &crate::api::Vm,
source: &Image,
sources: &[String],
dest: &str,
flags: &CopyFlags,
workdir: Option<&str>,
) -> Result<(), Error> {
let tmp = std::env::temp_dir().join(format!(
"sm-copyfrom-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).map_err(Error::Io)?;
let result = (|| {
let pool = source
.pool()
.min(1)
.max(1)
.restore_on_release(false)
.for_build(true)
.build()?;
let svm = pool.acquire()?;
await_agent_ready(&svm);
let mut argv: Vec<String> = vec![
"tar".into(),
"-cf".into(),
"/tmp/.sm-from.tar".into(),
"-C".into(),
"/".into(),
];
for s in sources {
argv.push(s.trim_start_matches('/').to_string());
}
let out = svm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(argv.iter().map(String::as_str))
.output()
.map_err(Error::Io)?;
if !out.success() {
return Err(Error::vm_msg(format!(
"COPY --from tar failed in source stage: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let bytes = svm
.read_file_with_max_bytes("/tmp/.sm-from.tar", 4 * 1024 * 1024 * 1024)
.map_err(Error::Io)?;
tar::Archive::new(&bytes[..])
.unpack(&tmp)
.map_err(Error::Io)?;
let rel: Vec<String> = sources
.iter()
.map(|s| s.trim_start_matches('/').to_string())
.collect();
let clean = CopyFlags {
from: None,
chown: flags.chown.clone(),
chmod: flags.chmod.clone(),
};
stage_copy(current_vm, &rel, dest, &clean, &tmp, workdir)
})();
let _ = std::fs::remove_dir_all(&tmp);
result
}
fn confine_to_context(context_dir: &Path, src: &str) -> Result<PathBuf, Error> {
let rel = Path::new(src);
if rel.is_absolute()
|| rel
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(Error::vm_msg(format!(
"COPY/ADD source escapes the build context: {src}"
)));
}
let host = context_dir.join(rel);
if let (Ok(canon_ctx), Ok(canon_host)) = (context_dir.canonicalize(), host.canonicalize()) {
if !canon_host.starts_with(&canon_ctx) {
return Err(Error::vm_msg(format!(
"COPY/ADD source resolves outside the build context (symlink?): {src}"
)));
}
}
Ok(host)
}
fn copy_content_hash(sources: &[String], context_dir: &Path) -> Result<String, Error> {
let mut entries: Vec<(String, String)> = Vec::new();
for src in sources {
if is_url(src) {
continue;
}
let host = confine_to_context(context_dir, src)?;
let mut files = Vec::new();
if host.is_dir() {
walk_files(&host, &mut files).map_err(Error::Io)?;
} else if host.is_file() {
files.push(host.clone());
}
for f in files {
let rel = f
.strip_prefix(context_dir)
.unwrap_or(&f)
.to_string_lossy()
.into_owned();
let bytes = std::fs::read(&f).map_err(Error::Io)?;
entries.push((rel, sha256_hex(&bytes)));
}
}
entries.sort();
let mut buf = String::new();
for (rel, hash) in &entries {
buf.push_str(rel);
buf.push('\0');
buf.push_str(hash);
buf.push('\0');
}
Ok(sha256_hex(buf.as_bytes()))
}
fn run_step(vm: &crate::api::Vm, cmd: &ShellOrExec, state: &BuildState) -> Result<(), Error> {
let argv: Vec<String> = match cmd {
ShellOrExec::Shell(s) => {
let mut argv = state
.shell
.clone()
.unwrap_or_else(|| vec!["/bin/sh".into(), "-c".into()]);
argv.push(s.clone());
argv
}
ShellOrExec::Exec(a) => a.clone(),
};
let mut b = vm.exec_builder().timeout(RUN_TIMEOUT);
for (k, v) in &state.env {
b = b.env(k, v);
}
if let Some(cwd) = &state.cwd {
b = b.cwd(cwd);
}
let out = b
.argv(argv.iter().map(String::as_str))
.output()
.map_err(Error::Io)?;
if !out.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let shown = stderr.trim();
let tail = &shown[shown.len().saturating_sub(2000)..];
return Err(Error::vm_msg(format!(
"RUN step failed: `{}`\n--- stderr (tail) ---\n{tail}",
describe(cmd),
)));
}
Ok(())
}
fn run_with_mounts(
vm: &crate::api::Vm,
cmd: &ShellOrExec,
mounts: &[RunMount],
state: &BuildState,
context_dir: &Path,
prior: &[Option<StageBuilt>],
mount_cache: Option<&Path>,
) -> Result<(), Error> {
if mounts.is_empty() {
return run_step(vm, cmd, state);
}
let mut teardowns: Vec<Teardown> = Vec::new();
for m in mounts {
setup_mount(
vm,
m,
context_dir,
prior,
mount_cache,
state.user.as_deref(),
&mut teardowns,
)?;
}
let result = run_step(vm, cmd, state);
let mut teardown_err = None;
for t in teardowns.into_iter().rev() {
if let Err(e) = t.run(vm) {
teardown_err.get_or_insert(e);
}
}
match result {
Err(e) => Err(e), Ok(()) => teardown_err.map(Err).unwrap_or(Ok(())),
}
}
enum Teardown {
PersistCache {
guest_target: String,
host_cache: PathBuf,
stash: Option<String>,
_lock: Option<CacheLock>,
},
Remove {
guest_path: String,
stash: Option<String>,
},
}
impl Teardown {
fn run(self, vm: &crate::api::Vm) -> Result<(), Error> {
match self {
Teardown::PersistCache {
guest_target,
host_cache,
stash,
_lock,
} => {
persist_cache_from_guest(vm, &guest_target, &host_cache)?;
remove_guest_path(vm, &guest_target);
restore_stash(vm, stash.as_deref(), &guest_target);
Ok(())
}
Teardown::Remove { guest_path, stash } => {
remove_guest_path(vm, &guest_path);
restore_stash(vm, stash.as_deref(), &guest_path);
Ok(())
}
}
}
}
fn setup_mount(
vm: &crate::api::Vm,
m: &RunMount,
context_dir: &Path,
prior: &[Option<StageBuilt>],
mount_cache: Option<&Path>,
user: Option<&str>,
teardowns: &mut Vec<Teardown>,
) -> Result<(), Error> {
match &m.kind {
MountKind::Cache => {
let target = m
.target
.clone()
.ok_or_else(|| Error::vm_msg("RUN --mount=type=cache requires target"))?;
let stash = stash_target(vm, &target)?;
ensure_guest_dir(vm, &target)?;
match mount_cache {
Some(root) => {
let id =
m.id.clone()
.unwrap_or_else(|| sha256_hex(target.as_bytes()));
let host_cache = root.join(sanitize_id(&id));
let _lock = CacheLock::acquire(&host_cache);
if host_cache.is_dir() {
restore_cache_into_guest(vm, &host_cache, &target)?;
}
teardowns.push(Teardown::PersistCache {
guest_target: target,
host_cache,
stash,
_lock,
});
}
None => teardowns.push(Teardown::Remove {
guest_path: target,
stash,
}),
}
}
MountKind::Secret => {
let target = m
.target
.clone()
.unwrap_or_else(|| format!("/run/secrets/{}", m.id.as_deref().unwrap_or("secret")));
let stash = stash_target(vm, &target)?;
match secret_source(m) {
Some(src) if src.is_file() => {
let bytes = std::fs::read(&src).map_err(Error::Io)?;
write_guest_file(vm, &target, &bytes)?;
if let Some(u) = user {
let _ = vm
.exec_builder()
.argv(["chown", u, target.as_str()].iter().copied())
.output();
}
let _ = vm
.exec_builder()
.argv(["chmod", "0400", target.as_str()].iter().copied())
.output();
}
_ if m.required => {
restore_stash(vm, stash.as_deref(), &target);
return Err(Error::vm_msg(format!(
"RUN --mount=type=secret id={:?} is required but no source was provided \
(set SUPERMACHINE_BUILD_SECRET_<ID> or the mount's src=)",
m.id
)));
}
_ => write_guest_file(vm, &target, b"")?,
}
teardowns.push(Teardown::Remove {
guest_path: target,
stash,
});
}
MountKind::Bind => {
let target = m
.target
.clone()
.ok_or_else(|| Error::vm_msg("RUN --mount=type=bind requires target"))?;
let stash = stash_target(vm, &target)?;
match &m.from {
Some(stage_ref) => {
let src = resolve_stage(prior, stage_ref)?;
let source_img = Image::from_snapshot(src.image.snapshot_path())?;
let sub = m.source.clone().unwrap_or_else(|| "/".to_owned());
copy_from_stage(
vm,
&source_img,
&[sub],
&target,
&CopyFlags::default(),
None,
)?;
}
None => {
let sub = m.source.clone().unwrap_or_else(|| ".".to_owned());
ensure_guest_dir(vm, &target)?;
stage_copy(
vm,
&[sub],
&format!("{}/", target.trim_end_matches('/')),
&CopyFlags::default(),
context_dir,
None,
)?;
}
}
teardowns.push(Teardown::Remove {
guest_path: target,
stash,
});
}
MountKind::Tmpfs => {
let target = m
.target
.clone()
.ok_or_else(|| Error::vm_msg("RUN --mount=type=tmpfs requires target"))?;
let stash = stash_target(vm, &target)?;
ensure_guest_dir(vm, &target)?;
teardowns.push(Teardown::Remove {
guest_path: target,
stash,
});
}
MountKind::Ssh | MountKind::Other(_) => {}
}
Ok(())
}
pub(super) struct CacheLock {
fd: std::os::unix::io::RawFd,
}
impl CacheLock {
fn acquire(host_cache: &Path) -> Option<Self> {
use std::os::unix::io::IntoRawFd;
let mut lock_os = host_cache.as_os_str().to_owned();
lock_os.push(".lock");
let lock_path = PathBuf::from(lock_os);
if let Some(parent) = lock_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.ok()?;
let fd = file.into_raw_fd();
let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
if rc != 0 {
unsafe { libc::close(fd) };
return None;
}
Some(CacheLock { fd })
}
}
impl Drop for CacheLock {
fn drop(&mut self) {
unsafe {
libc::flock(self.fd, libc::LOCK_UN);
libc::close(self.fd);
}
}
}
fn stash_target(vm: &crate::api::Vm, target: &str) -> Result<Option<String>, Error> {
let t = target.trim_end_matches('/');
if t.is_empty() || t == "/" {
return Ok(None);
}
let exists = vm
.exec_builder()
.argv(["test", "-e", target].iter().copied())
.output()
.map(|o| o.success())
.unwrap_or(false);
if !exists {
return Ok(None);
}
let stash = format!("{t}.sm-mount-stash.{}", unique_suffix());
let out = vm
.exec_builder()
.argv(["mv", target, stash.as_str()].iter().copied())
.output()
.map_err(Error::Io)?;
if !out.success() {
return Err(Error::vm_msg(format!(
"RUN --mount: could not stash pre-existing {target}: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(Some(stash))
}
fn restore_stash(vm: &crate::api::Vm, stash: Option<&str>, target: &str) {
if let Some(s) = stash {
let _ = vm
.exec_builder()
.argv(["mv", s, target].iter().copied())
.output();
}
}
fn unique_suffix() -> String {
format!(
"{}.{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
)
}
fn secret_source(m: &RunMount) -> Option<PathBuf> {
if let Some(src) = &m.source {
return Some(PathBuf::from(src));
}
let id = m.id.as_deref()?;
let var = format!(
"SUPERMACHINE_BUILD_SECRET_{}",
id.to_ascii_uppercase().replace('-', "_")
);
std::env::var(var).ok().map(PathBuf::from)
}
fn sanitize_id(id: &str) -> String {
let cleaned: String = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if cleaned.is_empty() {
sha256_hex(id.as_bytes())
} else {
cleaned
}
}
fn ensure_guest_dir(vm: &crate::api::Vm, dir: &str) -> Result<(), Error> {
let out = vm
.exec_builder()
.argv(["mkdir", "-p", dir].iter().copied())
.output()
.map_err(Error::Io)?;
if !out.success() {
return Err(Error::vm_msg(format!(
"RUN --mount: mkdir {dir} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
fn write_guest_file(vm: &crate::api::Vm, path: &str, bytes: &[u8]) -> Result<(), Error> {
if let Some((parent, _)) = path.rsplit_once('/') {
if !parent.is_empty() {
ensure_guest_dir(vm, parent)?;
}
}
vm.write_file(path, bytes).map_err(Error::Io)
}
fn remove_guest_path(vm: &crate::api::Vm, path: &str) {
let p = path.trim_end_matches('/');
if p.is_empty() || p == "/" {
return;
}
let _ = vm
.exec_builder()
.argv(["rm", "-rf", path].iter().copied())
.output();
}
fn restore_cache_into_guest(
vm: &crate::api::Vm,
host_cache: &Path,
target: &str,
) -> Result<(), Error> {
let mut ar = tar::Builder::new(Vec::new());
ar.append_dir_all(".", host_cache).map_err(Error::Io)?;
let tarball = ar.into_inner().map_err(Error::Io)?;
if tarball.is_empty() {
return Ok(());
}
let staged = "/tmp/.sm-cache-in.tar";
vm.write_file(staged, &tarball).map_err(Error::Io)?;
let out = vm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(
["tar", "-xf", staged, "--no-same-owner", "-C", target]
.iter()
.copied(),
)
.output()
.map_err(Error::Io)?;
let _ = vm
.exec_builder()
.argv(["rm", "-f", staged].iter().copied())
.output();
if !out.success() {
return Err(Error::vm_msg(format!(
"RUN --mount=cache: restore extract failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
fn persist_cache_from_guest(
vm: &crate::api::Vm,
target: &str,
host_cache: &Path,
) -> Result<(), Error> {
let target_exists = vm
.exec_builder()
.argv(["test", "-d", target].iter().copied())
.output()
.map(|o| o.success())
.unwrap_or(false);
if !target_exists {
return Ok(());
}
ensure_guest_dir(vm, "/tmp")?;
let staged = "/tmp/.sm-cache-out.tar";
let out = vm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(["tar", "-cf", staged, "-C", target, "."].iter().copied())
.output()
.map_err(Error::Io)?;
if !out.success() {
return Err(Error::vm_msg(format!(
"RUN --mount=cache: persist tar failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let bytes = vm
.read_file_with_max_bytes(staged, 4 * 1024 * 1024 * 1024)
.map_err(Error::Io)?;
let _ = vm
.exec_builder()
.argv(["rm", "-f", staged].iter().copied())
.output();
std::fs::create_dir_all(host_cache).map_err(Error::Io)?;
tar::Archive::new(&bytes[..])
.unpack(host_cache)
.map_err(Error::Io)?;
Ok(())
}
fn describe(cmd: &ShellOrExec) -> String {
match cmd {
ShellOrExec::Shell(s) => s.clone(),
ShellOrExec::Exec(a) => a.join(" "),
}
}
fn stage_copy(
vm: &crate::api::Vm,
sources: &[String],
dest: &str,
flags: &CopyFlags,
context_dir: &Path,
workdir: Option<&str>,
) -> Result<(), Error> {
if flags.from.is_some() {
return Err(Error::vm_msg(
"COPY --from (multi-stage) is not yet supported by the executor",
));
}
let wd = workdir.unwrap_or("/");
let abs_dest = if dest.starts_with('/') {
dest.to_string()
} else {
format!("{}/{}", wd.trim_end_matches('/'), dest)
};
let single_dir = sources.len() == 1 && context_dir.join(&sources[0]).is_dir();
let dest_is_dir = dest.ends_with('/') || sources.len() > 1 || single_dir;
let mut pairs: Vec<(PathBuf, String)> = Vec::new();
for src in sources {
let host_src = confine_to_context(context_dir, src)?;
if host_src.is_dir() {
let mut files = Vec::new();
walk_files(&host_src, &mut files).map_err(Error::Io)?;
for f in files {
let rel = f.strip_prefix(&host_src).unwrap_or(&f);
let guest = format!(
"{}/{}",
abs_dest.trim_end_matches('/'),
rel.to_string_lossy()
);
pairs.push((f, guest));
}
} else if host_src.is_file() {
let guest = if dest_is_dir {
let name = host_src
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
format!("{}/{}", abs_dest.trim_end_matches('/'), name)
} else {
abs_dest.clone()
};
pairs.push((host_src, guest));
} else {
return Err(Error::vm_msg(format!(
"COPY/ADD source not found in build context: {src}"
)));
}
}
if pairs.is_empty() {
return Ok(());
}
let mut ar = tar::Builder::new(Vec::new());
for (host, guest) in &pairs {
let entry = guest.trim_start_matches('/');
append_host_file_to_tar(&mut ar, host, Path::new(entry))?;
}
let tarball = ar.into_inner().map_err(Error::Io)?;
let staged = "/tmp/.sm-copy.tar";
vm.write_file(staged, &tarball).map_err(Error::Io)?;
let extract = vm
.exec_builder()
.timeout(RUN_TIMEOUT)
.argv(
["tar", "-xf", staged, "--no-same-owner", "-C", "/"]
.iter()
.copied(),
)
.output()
.map_err(Error::Io)?;
let _ = vm
.exec_builder()
.argv(["rm", "-f", staged].iter().copied())
.output();
if !extract.success() {
return Err(Error::vm_msg(format!(
"COPY/ADD extract failed: {}",
String::from_utf8_lossy(&extract.stderr).trim()
)));
}
if let Some(chown) = &flags.chown {
let argv = ["chown", "-R", chown.as_str(), abs_dest.as_str()];
let _ = vm.exec_builder().argv(argv.iter().copied()).output();
}
if let Some(chmod) = &flags.chmod {
let argv = ["chmod", "-R", chmod.as_str(), abs_dest.as_str()];
let _ = vm.exec_builder().argv(argv.iter().copied()).output();
}
Ok(())
}
fn append_host_file_to_tar<W: std::io::Write>(
tar: &mut tar::Builder<W>,
host: &Path,
entry: &Path,
) -> Result<(), Error> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let md = std::fs::metadata(host).map_err(Error::Io)?;
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Regular);
header.set_size(md.len());
header.set_mode(md.permissions().mode());
header.set_uid(md.uid() as u64);
header.set_gid(md.gid() as u64);
header.set_mtime(md.mtime().max(0) as u64);
let mut file = std::fs::File::open(host).map_err(Error::Io)?;
tar.append_data(&mut header, entry, &mut file)
.map_err(Error::Io)?;
Ok(())
}
fn walk_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let ft = entry.file_type()?;
let path = entry.path();
if ft.is_dir() {
walk_files(&path, out)?;
} else if ft.is_file() {
out.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod shell_volume_stopsignal_tests {
use super::*;
use crate::builder::dockerfile::Instruction;
use std::path::Path;
fn apply(instr: &Instruction, state: &mut BuildState, config: &mut ImageConfig) {
apply_instr(None, instr, state, config, Path::new("."), &[], None).unwrap();
}
#[test]
fn shell_form_wraps_in_configured_shell() {
assert_eq!(
shellexec_to_argv_with(&ShellOrExec::Shell("echo hi".into()), None),
vec!["/bin/sh", "-c", "echo hi"]
);
let bash = [
"/bin/bash".to_string(),
"-eo".into(),
"pipefail".into(),
"-c".into(),
];
assert_eq!(
shellexec_to_argv_with(&ShellOrExec::Shell("set -x; make".into()), Some(&bash)),
vec!["/bin/bash", "-eo", "pipefail", "-c", "set -x; make"]
);
assert_eq!(
shellexec_to_argv_with(
&ShellOrExec::Exec(vec!["/usr/bin/true".into()]),
Some(&bash)
),
vec!["/usr/bin/true"]
);
}
#[test]
fn shell_instruction_sets_state_and_config() {
let mut state = BuildState::default();
let mut config = ImageConfig::default();
let sh = vec!["/bin/bash".to_string(), "-c".into()];
apply(&Instruction::Shell(sh.clone()), &mut state, &mut config);
assert_eq!(state.shell.as_deref(), Some(sh.as_slice()));
assert_eq!(config.shell.as_deref(), Some(sh.as_slice()));
}
#[test]
fn effective_cmd_honors_configured_shell() {
let mut config = ImageConfig {
cmd: Some(ShellOrExec::Shell("nginx -g 'daemon off;'".into())),
..Default::default()
};
assert_eq!(
effective_cmd(&config).unwrap(),
vec!["/bin/sh", "-c", "nginx -g 'daemon off;'"]
);
config.shell = Some(vec!["/bin/bash".into(), "-c".into()]);
assert_eq!(
effective_cmd(&config).unwrap(),
vec!["/bin/bash", "-c", "nginx -g 'daemon off;'"]
);
}
#[test]
fn base_image_config_reads_snapshot_metadata() {
let dir = std::env::temp_dir().join(format!(
"sm-baseenv-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("metadata.json"),
serde_json::json!({
"native_bake_inputs": { "env": { "env": {
"PLAYWRIGHT_BROWSERS_PATH": "/ms-playwright",
"LANG": "C.UTF-8",
"PATH": "/usr/local/bin:/usr/bin"
}, "secrets": {} } }
})
.to_string(),
)
.unwrap();
std::fs::write(dir.join("restore.snap"), b"x").unwrap();
let img = Image::from_snapshot(&dir).expect("load snapshot");
let env = base_image_config(&img).env;
assert_eq!(
env,
vec![
("LANG".to_string(), "C.UTF-8".to_string()),
("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string()),
(
"PLAYWRIGHT_BROWSERS_PATH".to_string(),
"/ms-playwright".to_string()
),
],
"sorted base env from native_bake_inputs.env.env"
);
std::fs::write(
dir.join("metadata.json"),
serde_json::json!({
"image_env": { "FOO": "bar" },
"native_bake_inputs": { "env": { "env": { "FOO": "ignored" } } }
})
.to_string(),
)
.unwrap();
let img = Image::from_snapshot(&dir).expect("reload");
assert_eq!(
base_image_config(&img).env,
vec![("FOO".to_string(), "bar".to_string())]
);
std::fs::write(
dir.join("metadata.json"),
serde_json::json!({ "cmd": ["sh"] }).to_string(),
)
.unwrap();
let img = Image::from_snapshot(&dir).expect("reload2");
let config = base_image_config(&img);
assert!(config.env.is_empty());
assert_eq!(config.cmd, Some(ShellOrExec::Exec(vec!["sh".into()])));
std::fs::write(
dir.join("metadata.json"),
serde_json::json!({
"cmd": ["postgres"],
"entrypoint": ["docker-entrypoint.sh"],
"working_dir": "/var/lib/postgresql",
"user": "postgres",
"shell": ["/bin/ash", "-c"]
})
.to_string(),
)
.unwrap();
let img = Image::from_snapshot(&dir).expect("reload3");
let config = base_image_config(&img);
assert_eq!(config.cmd, Some(ShellOrExec::Exec(vec!["postgres".into()])));
assert_eq!(
config.entrypoint,
Some(ShellOrExec::Exec(vec!["docker-entrypoint.sh".into()]))
);
assert_eq!(config.workdir.as_deref(), Some("/var/lib/postgresql"));
assert_eq!(config.user.as_deref(), Some("postgres"));
assert_eq!(
config.shell.as_deref(),
Some(["/bin/ash".to_string(), "-c".to_string()].as_slice())
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_memory_policy() {
assert_eq!(resolve_build_memory(None, 2048), BUILD_MEMORY_DEFAULT_MIB);
assert_eq!(resolve_build_memory(None, 16384), 16384);
assert_eq!(resolve_build_memory(Some("4096"), 2048), 4096);
assert_eq!(resolve_build_memory(Some("4096"), 6000), 6000);
assert_eq!(resolve_build_memory(Some(" 3000 "), 1024), 3000);
assert_eq!(
resolve_build_memory(Some("0"), 2048),
BUILD_MEMORY_DEFAULT_MIB
);
assert_eq!(
resolve_build_memory(Some("128"), 2048),
BUILD_MEMORY_DEFAULT_MIB
); assert_eq!(
resolve_build_memory(Some("xyz"), 2048),
BUILD_MEMORY_DEFAULT_MIB
);
assert_eq!(
resolve_build_memory(Some(""), 512),
BUILD_MEMORY_DEFAULT_MIB
);
}
#[test]
fn incremental_full_rootfs_prune_removes_earlier_layers_after_mutating_layer() {
let dir = std::env::temp_dir().join(format!(
"sm-full-rootfs-prune-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let layers = dir.join("layers");
let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
for key in &keys {
let layer = layers.join(key);
std::fs::create_dir_all(&layer).unwrap();
std::fs::write(layer.join("metadata.json"), "{}").unwrap();
}
let current = layers.join("b");
let pruned = prune_earlier_full_rootfs_layer_snapshots(&dir, &keys, 1, ¤t)
.expect("prune previous");
assert_eq!(pruned, vec![layers.join("a")]);
assert!(!layers.join("a").exists(), "previous layer was pruned");
assert!(layers.join("b").exists(), "current layer is kept");
assert!(layers.join("c").exists(), "future layer is untouched");
std::fs::create_dir_all(layers.join("a")).unwrap();
let current = layers.join("c");
let pruned = prune_earlier_full_rootfs_layer_snapshots(&dir, &keys, 2, ¤t)
.expect("prune all earlier");
assert_eq!(pruned, vec![layers.join("a"), layers.join("b")]);
assert!(!layers.join("a").exists(), "earlier layer was pruned");
assert!(!layers.join("b").exists(), "previous layer was pruned");
assert!(layers.join("c").exists(), "current layer is kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compact_rootfs_diff_round_trips_files_symlinks_and_deletions() {
let dir = unique_test_dir("sm-rootfs-diff-roundtrip");
let parent = dir.join("parent");
let current = dir.join("current");
let replay = dir.join("replay");
let blobs = dir.join("blobs/sha256");
std::fs::create_dir_all(parent.join("dir")).unwrap();
std::fs::write(parent.join("dir/changed.txt"), b"old").unwrap();
std::fs::write(parent.join("dir/deleted.txt"), b"delete me").unwrap();
std::fs::write(parent.join("same.txt"), b"same").unwrap();
std::fs::create_dir_all(current.join("dir")).unwrap();
std::fs::write(current.join("dir/changed.txt"), b"new").unwrap();
std::fs::write(current.join("dir/added.txt"), b"add me").unwrap();
std::fs::write(current.join("same.txt"), b"same").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("../same.txt", current.join("dir/link")).unwrap();
let layer = write_rootfs_diff_layer(&parent, ¤t, &blobs).expect("write diff");
assert!(blobs.join(&layer.layer_digest).is_file());
copy_tree_preserving(&parent, &replay).unwrap();
apply_oci_diff_layer(&blobs.join(&layer.layer_digest), &replay).expect("apply diff");
assert_eq!(
collect_rootfs_entries(&replay).unwrap(),
collect_rootfs_entries(¤t).unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compact_rootfs_diff_round_trips_long_paths() {
let dir = unique_test_dir("sm-rootfs-diff-long-path");
let parent = dir.join("parent");
let current = dir.join("current");
let replay = dir.join("replay");
let blobs = dir.join("blobs/sha256");
let long_dir = PathBuf::from(
"node_modules/playwright-core/.local-browsers/ms-playwright/chromium-1217/chrome-linux64/PrivacySandboxAttestationsPreloaded",
);
assert!(
long_dir.as_os_str().len() > 100,
"test must exceed the raw tar header path field"
);
std::fs::create_dir_all(&parent).unwrap();
std::fs::create_dir_all(current.join(&long_dir)).unwrap();
std::fs::write(current.join(&long_dir).join("manifest.json"), b"{}").unwrap();
std::fs::write(
current
.join(&long_dir)
.join("privacy-sandbox-attestations.dat"),
b"dat",
)
.unwrap();
let layer = write_rootfs_diff_layer(&parent, ¤t, &blobs).expect("write diff");
copy_tree_preserving(&parent, &replay).unwrap();
apply_oci_diff_layer(&blobs.join(&layer.layer_digest), &replay).expect("apply diff");
assert_eq!(
collect_rootfs_entries(&replay).unwrap(),
collect_rootfs_entries(¤t).unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compact_rootfs_diff_round_trips_node_base_npm_paths() {
let dir = unique_test_dir("sm-rootfs-diff-node-npm-path");
let parent = dir.join("parent");
let current = dir.join("current");
let replay = dir.join("replay");
let blobs = dir.join("blobs/sha256");
let npm_dir = PathBuf::from(
"usr/local/lib/node_modules/npm/node_modules/exponential-backoff/dist/delay/skip-first",
);
std::fs::create_dir_all(&parent).unwrap();
std::fs::create_dir_all(current.join(&npm_dir)).unwrap();
std::fs::write(
current.join(&npm_dir).join("skip-first.js"),
b"module.exports = true;\n",
)
.unwrap();
let layer = write_rootfs_diff_layer(&parent, ¤t, &blobs).expect("write diff");
copy_tree_preserving(&parent, &replay).unwrap();
apply_oci_diff_layer(&blobs.join(&layer.layer_digest), &replay).expect("apply diff");
assert_eq!(
collect_rootfs_entries(&replay).unwrap(),
collect_rootfs_entries(¤t).unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stage_copy_tar_round_trips_long_context_paths() {
let dir = unique_test_dir("sm-stage-copy-long-path");
let host = dir.join("Preview/index.tsx");
let entry = PathBuf::from(
"app/.claude/worktrees/agent-a2c1a7a53930819d4/app/components/funnels/FunnelEditContent/Preview/index.tsx",
);
let replay = dir.join("replay");
assert!(
entry.as_os_str().len() > 100,
"test must exceed the raw tar header path field"
);
std::fs::create_dir_all(host.parent().unwrap()).unwrap();
std::fs::write(&host, b"export const Preview = true;\n").unwrap();
let mut ar = tar::Builder::new(Vec::new());
append_host_file_to_tar(&mut ar, &host, &entry).expect("append long COPY path");
let tarball = ar.into_inner().unwrap();
std::fs::create_dir_all(&replay).unwrap();
tar::Archive::new(&tarball[..]).unpack(&replay).unwrap();
assert_eq!(
std::fs::read(replay.join(&entry)).unwrap(),
b"export const Preview = true;\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compact_layer_materializes_from_parent_and_prunes_only_rootfs() {
let dir = unique_test_dir("sm-compact-layer-materialize");
let cache = dir.join("cache");
let base = cache.join("base");
let layer = cache.join("layers/l1");
let base_rootfs = base.join("rootfs");
let layer_rootfs = layer.join("rootfs");
std::fs::create_dir_all(&base_rootfs).unwrap();
std::fs::write(base_rootfs.join("a.txt"), b"base").unwrap();
std::fs::write(
base.join("metadata.json"),
serde_json::json!({
"backend": "kboxlike",
"kboxlike_rootfs": "rootfs",
"memory_mib": 512,
"vcpus": 1
})
.to_string(),
)
.unwrap();
std::fs::create_dir_all(&layer_rootfs).unwrap();
std::fs::write(layer_rootfs.join("a.txt"), b"changed").unwrap();
std::fs::write(layer_rootfs.join("b.txt"), b"added").unwrap();
std::fs::write(
layer.join("metadata.json"),
serde_json::json!({
"backend": "kboxlike",
"kboxlike_rootfs": "rootfs",
"memory_mib": 512,
"vcpus": 1
})
.to_string(),
)
.unwrap();
std::fs::write(layer.join("restore.snap"), b"kboxlike-rootfs\n").unwrap();
let expected = collect_rootfs_entries(&layer_rootfs).unwrap();
commit_compact_layer(&layer, &base, &cache).expect("commit compact layer");
assert!(compact_layer_manifest_path(&layer).is_file());
prune_layer_materialization_or_dir(&layer).expect("prune materialized rootfs");
assert!(!layer_rootfs.exists());
assert!(compact_layer_manifest_path(&layer).is_file());
assert!(layer.join("metadata.json").is_file());
materialize_compact_layer_if_needed(&layer).expect("materialize compact layer");
assert_eq!(collect_rootfs_entries(&layer_rootfs).unwrap(), expected);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn noop_compact_layer_repoints_metadata_and_materializes_parent_rootfs() {
let dir = unique_test_dir("sm-noop-compact-layer");
let base = dir.join("base");
let layer = dir.join("layers/l1");
std::fs::create_dir_all(base.join("rootfs")).unwrap();
std::fs::write(base.join("rootfs/base.txt"), b"base").unwrap();
std::fs::write(
base.join("metadata.json"),
serde_json::json!({
"backend": "kboxlike",
"kboxlike_rootfs": "rootfs",
"memory_mib": 512,
"vcpus": 1
})
.to_string(),
)
.unwrap();
std::fs::create_dir_all(&layer).unwrap();
std::fs::write(
layer.join("metadata.json"),
serde_json::json!({
"backend": "kboxlike",
"kboxlike_rootfs": base.join("rootfs").to_string_lossy(),
"memory_mib": 512,
"vcpus": 1
})
.to_string(),
)
.unwrap();
std::fs::write(layer.join("restore.snap"), b"kboxlike-rootfs\n").unwrap();
commit_noop_compact_layer(&layer, &base).expect("commit no-op compact layer");
assert_eq!(
snapshot_rootfs_dir(&layer).unwrap().unwrap(),
layer.join("rootfs")
);
materialize_compact_layer_if_needed(&layer).expect("materialize no-op compact layer");
assert_eq!(
collect_rootfs_entries(&layer.join("rootfs")).unwrap(),
collect_rootfs_entries(&base.join("rootfs")).unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}
fn unique_test_dir(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"{prefix}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
))
}
#[test]
fn volume_and_stopsignal_collected_into_config() {
let mut state = BuildState::default();
let mut config = ImageConfig::default();
apply(
&Instruction::Volume(vec!["/data".into(), "/cache".into()]),
&mut state,
&mut config,
);
apply(
&Instruction::Volume(vec!["/more".into()]),
&mut state,
&mut config,
);
apply(
&Instruction::StopSignal("SIGQUIT".into()),
&mut state,
&mut config,
);
assert_eq!(config.volumes, vec!["/data", "/cache", "/more"]);
assert_eq!(config.stop_signal.as_deref(), Some("SIGQUIT"));
}
#[test]
fn healthcheck_and_onbuild_collected_into_config() {
use crate::builder::dockerfile::Healthcheck;
let mut state = BuildState::default();
let mut config = ImageConfig::default();
let hc = Healthcheck::Cmd {
test: ShellOrExec::Shell("curl -f http://localhost/ || exit 1".into()),
interval: Some("30s".into()),
timeout: Some("3s".into()),
start_period: None,
start_interval: None,
retries: Some("3".into()),
};
apply(
&Instruction::Healthcheck(hc.clone()),
&mut state,
&mut config,
);
apply(
&Instruction::Onbuild("COPY . /app/src".into()),
&mut state,
&mut config,
);
apply(
&Instruction::Onbuild("RUN make".into()),
&mut state,
&mut config,
);
apply(
&Instruction::Healthcheck(Healthcheck::None),
&mut state,
&mut config,
);
assert_eq!(config.healthcheck, Some(Healthcheck::None));
assert_eq!(config.onbuild, vec!["COPY . /app/src", "RUN make"]);
}
#[test]
fn persist_run_config_records_stopsignal_healthcheck_onbuild() {
use crate::builder::dockerfile::Healthcheck;
let dir = std::env::temp_dir().join(format!(
"sm-persistcfg-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("metadata.json"), "{}").unwrap();
std::fs::write(dir.join("restore.snap"), b"x").unwrap();
let config = ImageConfig {
stop_signal: Some("SIGQUIT".into()),
healthcheck: Some(Healthcheck::Cmd {
test: ShellOrExec::Shell("curl -f http://localhost/ || exit 1".into()),
interval: Some("30s".into()),
timeout: Some("3s".into()),
start_period: None,
start_interval: None,
retries: Some("3".into()),
}),
onbuild: vec!["RUN make".into()],
..Default::default()
};
persist_run_config(&dir.join("restore.snap"), &config).expect("persist");
let meta: serde_json::Value =
serde_json::from_slice(&std::fs::read(dir.join("metadata.json")).unwrap()).unwrap();
assert_eq!(
meta.get("stop_signal").and_then(|v| v.as_str()),
Some("SIGQUIT")
);
let hc = meta.get("healthcheck").expect("healthcheck recorded");
let test = hc
.get("Test")
.and_then(|v| v.as_array())
.expect("Test array");
assert_eq!(test[0].as_str(), Some("CMD-SHELL"));
assert!(test[1].as_str().unwrap().contains("curl -f"));
assert_eq!(hc.get("Interval").and_then(|v| v.as_str()), Some("30s"));
assert_eq!(hc.get("Retries").and_then(|v| v.as_str()), Some("3"));
let ob = meta
.get("onbuild")
.and_then(|v| v.as_array())
.expect("onbuild array");
assert_eq!(ob[0].as_str(), Some("RUN make"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn is_snapshot_dir_recognizes_both_backends() {
let root = std::env::temp_dir().join(format!(
"sm-issnap-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&root).unwrap();
let kvm = root.join("kvm");
std::fs::create_dir_all(&kvm).unwrap();
std::fs::write(kvm.join("restore.snap"), b"SMSNAP04....").unwrap();
std::fs::write(kvm.join("metadata.json"), b"{\"backend\":\"kvm\"}").unwrap();
assert!(is_snapshot_dir(&kvm), "KVM full dir is a complete snapshot");
let bare = root.join("bare");
std::fs::create_dir_all(bare.join("rootfs")).unwrap();
assert!(
!is_snapshot_dir(&bare),
"rootfs/ alone (no metadata) is incomplete"
);
let sentry = root.join("sentry");
std::fs::create_dir_all(sentry.join("rootfs")).unwrap();
std::fs::write(sentry.join("metadata.json"), b"{\"backend\":\"sentry\"}").unwrap();
assert!(
is_snapshot_dir(&sentry),
"sentry layer (backend=sentry + rootfs/) is complete"
);
let halfkvm = root.join("halfkvm");
std::fs::create_dir_all(halfkvm.join("rootfs")).unwrap();
std::fs::write(halfkvm.join("metadata.json"), b"{\"backend\":\"kvm\"}").unwrap();
assert!(
!is_snapshot_dir(&halfkvm),
"KVM metadata + rootfs/ but no restore.snap is NOT a complete layer"
);
let empty = root.join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(!is_snapshot_dir(&empty), "empty dir is not a snapshot");
let _ = std::fs::remove_dir_all(&root);
}
}