use super::{InitCtx, log_task_error, task_start};
use crate::disk::{BackingFormat, Disk, DiskFormat, Qcow2Helper};
use crate::images::{ContainerImageConfig, ImageDiskManager, ImageManager, ResolvedImage};
use crate::litebox::init::types::{ContainerRootfsPrepResult, USE_DISK_ROOTFS, USE_OVERLAYFS};
use crate::pipeline::PipelineTask;
use crate::runtime::layout::BoxFilesystemLayout;
use crate::runtime::options::RootfsSpec;
use crate::runtime::rt_impl::SharedRuntimeImpl;
use async_trait::async_trait;
use boxlite_shared::errors::{BoxliteError, BoxliteResult};
pub struct ContainerRootfsTask;
#[async_trait]
impl PipelineTask<InitCtx> for ContainerRootfsTask {
async fn run(self: Box<Self>, ctx: InitCtx) -> BoxliteResult<()> {
let task_name = self.name();
let box_id = task_start(&ctx, task_name).await;
let (
rootfs_spec,
env,
runtime,
layout,
reuse_rootfs,
disk_size_gb,
entrypoint_override,
cmd_override,
user_override,
working_dir_override,
built_from,
) = {
let ctx = ctx.lock().await;
let layout = ctx
.layout
.clone()
.ok_or_else(|| BoxliteError::Internal("filesystem task must run first".into()))?;
let mut env = ctx.config.options.env.clone();
env.extend(ctx.config.options.secrets.iter().map(|s| s.env_pair()));
(
ctx.config.options.rootfs.clone(),
env,
ctx.runtime.clone(),
layout,
ctx.reuse_rootfs,
ctx.config.options.disk_size_gb,
ctx.config.options.entrypoint.clone(),
ctx.config.options.cmd.clone(),
ctx.config.options.user.clone(),
ctx.config.options.working_dir.clone(),
ctx.built_from.clone(),
)
};
let (container_image_config, disk, resolved_image) = run_container_rootfs(
&rootfs_spec,
&env,
&runtime,
&layout,
reuse_rootfs,
disk_size_gb,
entrypoint_override.as_deref(),
cmd_override.as_deref(),
user_override.as_deref(),
working_dir_override.as_deref(),
built_from.as_ref(),
)
.await
.inspect_err(|e| log_task_error(&box_id, task_name, e))?;
let mut ctx = ctx.lock().await;
ctx.container_image_config = Some(container_image_config);
ctx.container_disk = Some(disk);
ctx.resolved_image = resolved_image;
Ok(())
}
fn name(&self) -> &str {
"container_rootfs_prep"
}
}
#[allow(clippy::too_many_arguments)]
async fn run_container_rootfs(
rootfs_spec: &RootfsSpec,
env: &[(String, String)],
runtime: &SharedRuntimeImpl,
layout: &BoxFilesystemLayout,
reuse_rootfs: bool,
disk_size_gb: Option<u64>,
entrypoint_override: Option<&[String]>,
cmd_override: Option<&[String]>,
user_override: Option<&str>,
working_dir_override: Option<&str>,
built_from: Option<&ResolvedImage>,
) -> BoxliteResult<(ContainerImageConfig, Disk, Option<ResolvedImage>)> {
let disk_path = layout.disk_path();
if reuse_rootfs {
tracing::info!(
disk_path = %disk_path.display(),
"Restart mode: reusing existing container rootfs disk"
);
if !disk_path.exists() {
return Err(BoxliteError::Storage(format!(
"Cannot restart: container rootfs disk not found at {}",
disk_path.display()
)));
}
let disk = Disk::new(disk_path.clone(), DiskFormat::Qcow2, true);
let image = match rootfs_spec {
RootfsSpec::Image(r) => {
image_for_restart(&runtime.image_manager, r, built_from).await?
}
RootfsSpec::RootfsPath(path) => {
let bundle_dir = std::path::Path::new(path);
if !bundle_dir.exists() {
return Err(BoxliteError::Storage(format!(
"Rootfs path does not exist: {}",
path
)));
}
runtime
.image_manager
.load_from_local(bundle_dir.to_path_buf(), format!("local:{}", path))
.await?
}
};
let image_config = image.load_config().await?;
let mut container_image_config = ContainerImageConfig::from_oci_config(&image_config)?;
if !env.is_empty() {
container_image_config.merge_env(env.to_vec());
}
apply_user_overrides(
&mut container_image_config,
entrypoint_override,
cmd_override,
user_override,
working_dir_override,
);
return Ok((
container_image_config,
disk,
resolved_image_of(rootfs_spec, reuse_rootfs, &image),
));
}
let image = match rootfs_spec {
RootfsSpec::Image(r) => image_for_new_disk(&runtime.image_manager, r).await?,
RootfsSpec::RootfsPath(path) => {
let bundle_dir = std::path::Path::new(path);
if !bundle_dir.exists() {
return Err(BoxliteError::Storage(format!(
"Rootfs path does not exist: {}",
path
)));
}
runtime
.image_manager
.load_from_local(bundle_dir.to_path_buf(), format!("local:{}", path))
.await?
}
};
let rootfs_result = if USE_DISK_ROOTFS {
prepare_disk_rootfs(&runtime.image_disk_mgr, &image).await?
} else if USE_OVERLAYFS {
prepare_overlayfs_layers(&image).await?
} else {
return Err(BoxliteError::Storage(
"Merged rootfs not supported. Use overlayfs or disk rootfs.".into(),
));
};
let image_config = image.load_config().await?;
let mut container_image_config = ContainerImageConfig::from_oci_config(&image_config)?;
if !env.is_empty() {
container_image_config.merge_env(env.to_vec());
}
apply_user_overrides(
&mut container_image_config,
entrypoint_override,
cmd_override,
user_override,
working_dir_override,
);
let disk = create_cow_disk(&rootfs_result, layout, disk_size_gb)?;
Ok((
container_image_config,
disk,
resolved_image_of(rootfs_spec, reuse_rootfs, &image),
))
}
fn create_cow_disk(
rootfs_result: &ContainerRootfsPrepResult,
layout: &crate::runtime::layout::BoxFilesystemLayout,
disk_size_gb: Option<u64>,
) -> BoxliteResult<Disk> {
match rootfs_result {
ContainerRootfsPrepResult::DiskImage {
base_disk_path,
disk_size: base_disk_size,
} => {
let target_disk_size = if let Some(size_gb) = disk_size_gb {
let user_size_bytes = size_gb * 1024 * 1024 * 1024;
std::cmp::max(user_size_bytes, *base_disk_size)
} else {
*base_disk_size
};
let cow_disk_path = layout.disk_path();
let temp_disk = Qcow2Helper::create_cow_child_disk(
base_disk_path,
BackingFormat::Raw,
&cow_disk_path,
target_disk_size,
)?;
let disk_path = temp_disk.leak(); let disk = Disk::new(disk_path, DiskFormat::Qcow2, true);
tracing::info!(
cow_disk = %cow_disk_path.display(),
base_disk = %base_disk_path.display(),
virtual_size_mb = target_disk_size / (1024 * 1024),
"Created container rootfs COW overlay (persistent)"
);
Ok(disk)
}
ContainerRootfsPrepResult::Layers { .. } => Err(BoxliteError::Internal(
"Layers mode requires overlayfs - disk creation not applicable".into(),
)),
ContainerRootfsPrepResult::Merged(_) => {
Err(BoxliteError::Internal("Merged mode not supported".into()))
}
}
}
fn apply_user_overrides(
config: &mut ContainerImageConfig,
entrypoint_override: Option<&[String]>,
cmd_override: Option<&[String]>,
user_override: Option<&str>,
working_dir_override: Option<&str>,
) {
if let Some(ep) = entrypoint_override {
config.entrypoint = ep.to_vec();
if !ep.is_empty() {
config.cmd.clear();
}
}
if let Some(cmd) = cmd_override.filter(|cmd| !cmd.is_empty()) {
config.cmd = cmd.to_vec();
}
if let Some(user) = user_override {
config.user = user.to_string();
}
if let Some(wd) = working_dir_override {
config.working_dir = wd.to_string();
}
}
async fn image_for_new_disk(
images: &ImageManager,
image_ref: &str,
) -> BoxliteResult<crate::images::ImageObject> {
if names_one_build(image_ref) {
images.pull(image_ref).await
} else {
images.refresh(image_ref).await
}
}
async fn image_for_restart(
images: &ImageManager,
image_ref: &str,
built_from: Option<&ResolvedImage>,
) -> BoxliteResult<crate::images::ImageObject> {
if let Some(pinned) = built_from.map(|build| pinned_ref(image_ref, &build.manifest_digest)) {
match images.pull(&pinned).await {
Ok(image) => return Ok(image),
Err(e) => tracing::warn!(
image_ref,
pinned = %pinned,
error = %e,
"cannot read the build this box's disk was made from; reading the image by reference"
),
}
}
images.pull(image_ref).await
}
fn pinned_ref(image_ref: &str, digest: &str) -> String {
let name = image_ref
.split_once('@')
.map_or(image_ref, |(name, _)| name);
let name = match name.rsplit_once(':') {
Some((repository, tag)) if !tag.contains('/') => repository,
_ => name,
};
format!("{name}@{digest}")
}
fn names_one_build(image_ref: &str) -> bool {
image_ref
.parse::<oci_client::Reference>()
.is_ok_and(|reference| reference.digest().is_some())
}
fn resolved_image_of(
rootfs_spec: &RootfsSpec,
reuse_rootfs: bool,
image: &crate::images::ImageObject,
) -> Option<ResolvedImage> {
records_resolved_image(rootfs_spec, reuse_rootfs).then(|| ResolvedImage {
manifest_digest: image.manifest_digest().to_string(),
total_layer_size: image.total_layer_size(),
})
}
fn records_resolved_image(rootfs_spec: &RootfsSpec, reuse_rootfs: bool) -> bool {
matches!(rootfs_spec, RootfsSpec::Image(_)) && !reuse_rootfs
}
async fn prepare_overlayfs_layers(
image: &crate::images::ImageObject,
) -> BoxliteResult<ContainerRootfsPrepResult> {
let layer_paths = image.layer_extracted().await?;
if layer_paths.is_empty() {
return Err(BoxliteError::Storage(
"No layers found for overlayfs".into(),
));
}
let layers_dir = layer_paths[0]
.parent()
.ok_or_else(|| BoxliteError::Storage("Layer path has no parent directory".into()))?
.to_path_buf();
let layer_names: Vec<String> = layer_paths
.iter()
.map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
})
.collect();
tracing::info!(
"Prepared {} layers for guest-side overlayfs",
layer_names.len()
);
Ok(ContainerRootfsPrepResult::Layers {
layers_dir,
layer_names,
})
}
async fn prepare_disk_rootfs(
image_disk_mgr: &ImageDiskManager,
image: &crate::images::ImageObject,
) -> BoxliteResult<ContainerRootfsPrepResult> {
let disk = image_disk_mgr.get_or_create(image).await?;
let disk_path = disk.path().to_path_buf();
let disk_size = std::fs::metadata(&disk_path)
.map(|m| m.len())
.unwrap_or(64 * 1024 * 1024);
let _ = disk.leak();
Ok(ContainerRootfsPrepResult::DiskImage {
base_disk_path: disk_path,
disk_size,
})
}
#[cfg(test)]
mod tests {
use super::{
apply_user_overrides, image_for_new_disk, image_for_restart, names_one_build, pinned_ref,
records_resolved_image,
};
use crate::db::Database;
use crate::images::test_support::{registry_answering, seed_cached_build};
use crate::images::{ContainerImageConfig, ImageManager, ResolvedImage};
use crate::runtime::options::{ImageRegistry, RootfsSpec};
const DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const FIRST: &str = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0";
const NEWER: &str = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee1";
#[test]
fn entrypoint_and_cmd_overrides_resolve_image_defaults() {
let strings = |args: &[&str]| {
args.iter()
.map(|arg| (*arg).to_string())
.collect::<Vec<_>>()
};
let cases = [
(None, None, vec!["image-init", "sleep", "infinity"]),
(None, Some(vec!["--help"]), vec!["image-init", "--help"]),
(
Some(vec!["python3", "-m", "http.server"]),
None,
vec!["python3", "-m", "http.server"],
),
(
Some(vec!["python3"]),
Some(vec!["--version"]),
vec!["python3", "--version"],
),
(Some(vec!["python3"]), Some(vec![]), vec!["python3"]),
(None, Some(vec![]), vec!["image-init", "sleep", "infinity"]),
(Some(vec![]), None, vec!["sleep", "infinity"]),
(
Some(vec![]),
Some(vec!["echo", "hello"]),
vec!["echo", "hello"],
),
(Some(vec![]), Some(vec![]), vec!["sleep", "infinity"]),
];
for image_has_entrypoint in [false, true] {
for (entrypoint, cmd, expected) in &cases {
let mut config = ContainerImageConfig {
entrypoint: if image_has_entrypoint {
strings(&["image-init"])
} else {
vec![]
},
cmd: strings(&["sleep", "infinity"]),
..Default::default()
};
let entrypoint = entrypoint.as_deref().map(strings);
let cmd = cmd.as_deref().map(strings);
let expected = if !image_has_entrypoint && entrypoint.is_none() {
&expected[1..]
} else {
expected.as_slice()
};
apply_user_overrides(
&mut config,
entrypoint.as_deref(),
cmd.as_deref(),
None,
None,
);
assert_eq!(
config.final_cmd(),
expected,
"image_has_entrypoint={image_has_entrypoint}, entrypoint={entrypoint:?}, cmd={cmd:?}"
);
}
}
}
async fn images_behind_a_404() -> (tempfile::TempDir, ImageManager, String) {
let host = registry_answering(404).await;
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("test.db")).unwrap();
let images = ImageManager::new(
dir.path().join("images"),
db,
vec![ImageRegistry::http(&host)],
)
.unwrap();
(dir, images, host)
}
fn recorded(digest: &str) -> ResolvedImage {
ResolvedImage {
manifest_digest: digest.to_string(),
total_layer_size: 0,
}
}
#[tokio::test]
async fn a_new_disk_from_a_tag_asks_the_registry() {
let (_dir, images, host) = images_behind_a_404().await;
let tag = format!("{host}/acme/app:v1");
seed_cached_build(&images, &tag, FIRST).await;
let answer = image_for_new_disk(&images, &tag).await;
assert!(answer.is_err(), "the registry's 404 must reach the caller");
}
#[tokio::test]
async fn a_new_disk_from_a_digest_is_answered_from_the_cache() {
let (_dir, images, host) = images_behind_a_404().await;
let pinned = format!("{host}/acme/app@{FIRST}");
seed_cached_build(&images, &pinned, FIRST).await;
let image = image_for_new_disk(&images, &pinned).await.unwrap();
assert_eq!(image.manifest_digest(), FIRST);
}
#[tokio::test]
async fn a_restart_reads_the_build_its_disk_was_made_from() {
let (_dir, images, host) = images_behind_a_404().await;
let tag = format!("{host}/acme/app:v1");
seed_cached_build(&images, &tag, FIRST).await;
seed_cached_build(&images, &format!("{host}/acme/app@{NEWER}"), NEWER).await;
let image = image_for_restart(&images, &tag, Some(&recorded(NEWER)))
.await
.unwrap();
assert_eq!(image.manifest_digest(), NEWER);
}
#[tokio::test]
async fn a_restart_whose_build_cannot_be_read_reads_by_ref() {
let (_dir, images, host) = images_behind_a_404().await;
let tag = format!("{host}/acme/app:v1");
seed_cached_build(&images, &tag, FIRST).await;
let image = image_for_restart(&images, &tag, Some(&recorded(NEWER)))
.await
.unwrap();
assert_eq!(image.manifest_digest(), FIRST);
}
#[tokio::test]
async fn a_restart_through_a_search_registry_reads_its_own_build() {
let host = registry_answering(404).await;
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("test.db")).unwrap();
let images = ImageManager::new(
dir.path().join("images"),
db,
vec![ImageRegistry::http(&host).with_search(true)],
)
.unwrap();
let latest = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee2";
seed_cached_build(&images, &format!("{host}/library/app:v1"), FIRST).await;
seed_cached_build(&images, &format!("{host}/library/app@{NEWER}"), NEWER).await;
seed_cached_build(&images, &format!("{host}/library/app:latest"), latest).await;
let image = image_for_restart(&images, "app:v1", Some(&recorded(NEWER)))
.await
.unwrap();
assert_eq!(image.manifest_digest(), NEWER);
}
#[tokio::test]
async fn a_restart_with_nothing_recorded_reads_by_ref() {
let (_dir, images, host) = images_behind_a_404().await;
let tag = format!("{host}/acme/app:v1");
seed_cached_build(&images, &tag, FIRST).await;
let image = image_for_restart(&images, &tag, None).await.unwrap();
assert_eq!(image.manifest_digest(), FIRST);
}
#[test]
fn a_digest_names_one_build() {
assert!(names_one_build(&format!("quay.io/acme/app@{DIGEST}")));
assert!(names_one_build(&format!("quay.io/acme/app:v1@{DIGEST}")));
}
#[test]
fn a_tag_does_not() {
assert!(!names_one_build("quay.io/acme/app:v1"));
assert!(!names_one_build("alpine"));
}
#[test]
fn a_restart_asks_for_the_recorded_build() {
let already_pinned = format!("quay.io/acme/app:v1@sha256:{}", "f".repeat(64));
for (image_ref, pinned) in [
("quay.io/acme/app:v1", format!("quay.io/acme/app@{DIGEST}")),
("quay.io/acme/app", format!("quay.io/acme/app@{DIGEST}")),
(
"127.0.0.1:25000/acme/app:v1",
format!("127.0.0.1:25000/acme/app@{DIGEST}"),
),
(
"127.0.0.1:25000/acme/app",
format!("127.0.0.1:25000/acme/app@{DIGEST}"),
),
("alpine:3.21", format!("alpine@{DIGEST}")),
(
already_pinned.as_str(),
format!("quay.io/acme/app@{DIGEST}"),
),
] {
assert_eq!(
pinned_ref(image_ref, DIGEST),
pinned,
"image_ref={image_ref}"
);
}
}
#[test]
fn the_start_that_builds_the_disk_records_what_it_pulled() {
assert!(records_resolved_image(
&RootfsSpec::Image("alpine:3.21".into()),
false
));
}
#[test]
fn a_restart_records_nothing_because_it_built_nothing() {
assert!(!records_resolved_image(
&RootfsSpec::Image("alpine:3.21".into()),
true
));
}
#[test]
fn a_local_rootfs_names_nothing_a_registry_could_resolve() {
assert!(!records_resolved_image(
&RootfsSpec::RootfsPath("/tmp/bundle".into()),
false
));
}
}