use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug)]
pub struct ConversionResult {
pub rootfs_path: PathBuf,
#[allow(dead_code)]
pub size_mb: u64,
}
pub fn convert_image_to_rootfs(
image: &str,
output_dir: &Path,
guest_agent_path: Option<&Path>,
) -> Result<ConversionResult> {
std::fs::create_dir_all(output_dir)?;
let rootfs_name = image_to_rootfs_name(image);
let rootfs_path = output_dir.join(&rootfs_name);
if rootfs_path.exists() {
let metadata = std::fs::metadata(&rootfs_path)?;
eprintln!("Using cached rootfs: {}", rootfs_path.display());
return Ok(ConversionResult {
rootfs_path,
size_mb: metadata.len() / (1024 * 1024),
});
}
eprintln!("Converting Docker image '{}' to rootfs...", image);
let agent_path = find_guest_agent(guest_agent_path)?;
eprintln!(" Using guest agent: {}", agent_path.display());
let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
let temp_path = temp_dir.path();
let image_tar = temp_path.join("image.tar");
eprintln!(" Exporting Docker image...");
export_docker_image(image, &image_tar)?;
eprintln!(" Creating ext4 rootfs (256MB)...");
let size_mb = 256u64;
run_conversion_container(&image_tar, &rootfs_path, &agent_path, size_mb)?;
eprintln!(
" Rootfs created: {} ({}MB)",
rootfs_path.display(),
size_mb
);
Ok(ConversionResult {
rootfs_path,
size_mb,
})
}
fn image_to_rootfs_name(image: &str) -> String {
let safe_name = image.replace(['/', ':', '@'], "-");
format!("{}.ext4", safe_name)
}
fn find_guest_agent(explicit_path: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = explicit_path {
if path.exists() {
return Ok(path.to_path_buf());
}
bail!("Guest agent not found at: {}", path.display());
}
let dev_paths = [
"images/rootfs/agent",
"target/x86_64-unknown-linux-musl/release/agent",
];
for path in dev_paths {
let p = PathBuf::from(path);
if p.exists() {
return Ok(p);
}
}
if let Some(home) = std::env::var_os("HOME") {
let installed = PathBuf::from(home).join(".local/share/agentkernel/bin/agent");
if installed.exists() {
return Ok(installed);
}
}
bail!(
"Guest agent binary not found. Build it with:\n\
cd guest-agent && cargo build --release --target x86_64-unknown-linux-musl"
)
}
fn export_docker_image(image: &str, output: &Path) -> Result<()> {
let output_str = output.to_string_lossy();
let result = Command::new("docker")
.args(["save", "-o", &output_str, image])
.output()
.context("Failed to run docker save")?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
bail!("docker save failed: {}", stderr);
}
Ok(())
}
fn run_conversion_container(
image_tar: &Path,
output_rootfs: &Path,
agent_path: &Path,
size_mb: u64,
) -> Result<()> {
let script = format!(
r#"
set -euo pipefail
# Install required tools
apk add --no-cache e2fsprogs >/dev/null 2>&1
# Create ext4 image
dd if=/dev/zero of=/output/rootfs.ext4 bs=1M count={size_mb} status=none
mkfs.ext4 -F -q /output/rootfs.ext4
# Mount the rootfs
mkdir -p /mnt/rootfs
mount -o loop /output/rootfs.ext4 /mnt/rootfs
# Extract Docker image layers
mkdir -p /tmp/image
cd /tmp/image
tar xf /input/image.tar
# Extract all layer tarballs (Docker OCI format uses blobs/sha256/* or */layer.tar)
# First try the OCI blob format
if [ -d "blobs/sha256" ]; then
for blob in blobs/sha256/*; do
# Try to extract if it's a tarball (file command not always available, just try)
tar xf "$blob" -C /mnt/rootfs 2>/dev/null || true
done
fi
# Also try the traditional Docker format with manifest.json
for layer in */layer.tar; do
if [ -f "$layer" ]; then
tar xf "$layer" -C /mnt/rootfs 2>/dev/null || true
fi
done
# Create essential directories (must come after layer extraction to avoid overwrite)
mkdir -p /mnt/rootfs/dev /mnt/rootfs/proc /mnt/rootfs/sys /mnt/rootfs/tmp /mnt/rootfs/run /mnt/rootfs/root /mnt/rootfs/app /mnt/rootfs/usr/bin /mnt/rootfs/etc
chmod 1777 /mnt/rootfs/tmp
# Create device nodes
mknod -m 622 /mnt/rootfs/dev/console c 5 1 2>/dev/null || true
mknod -m 666 /mnt/rootfs/dev/null c 1 3 2>/dev/null || true
mknod -m 666 /mnt/rootfs/dev/zero c 1 5 2>/dev/null || true
mknod -m 666 /mnt/rootfs/dev/tty c 5 0 2>/dev/null || true
mknod -m 666 /mnt/rootfs/dev/random c 1 8 2>/dev/null || true
mknod -m 666 /mnt/rootfs/dev/urandom c 1 9 2>/dev/null || true
# Install guest agent
cp /input/agent /mnt/rootfs/usr/bin/agent
chmod +x /mnt/rootfs/usr/bin/agent
# Create init script
cat > /mnt/rootfs/init << 'INIT'
#!/bin/sh
# Mount essential filesystems
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true
# Set hostname
hostname agentkernel
# Start guest agent in background
/usr/bin/agent &
AGENT_PID=$!
echo "Agentkernel guest ready"
# If arguments given, run them; otherwise wait for guest agent
if [ $# -gt 0 ]; then
exec "$@"
else
wait $AGENT_PID
fi
INIT
chmod +x /mnt/rootfs/init
# Set up /etc files if not present
if [ ! -f /mnt/rootfs/etc/hostname ]; then
echo "agentkernel" > /mnt/rootfs/etc/hostname
fi
# Unmount
umount /mnt/rootfs
# Fix ownership to match the calling user
if [ -n "$HOST_UID" ] && [ -n "$HOST_GID" ]; then
chown "$HOST_UID:$HOST_GID" /output/rootfs.ext4
fi
echo "Conversion complete"
"#,
size_mb = size_mb
);
let uid = Command::new("id")
.arg("-u")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "1000".to_string());
let gid = Command::new("id")
.arg("-g")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| uid.clone());
let image_tar_abs = image_tar
.canonicalize()
.context("Failed to get absolute path for image tar")?;
let agent_abs = agent_path
.canonicalize()
.context("Failed to get absolute path for guest agent")?;
let output_dir = output_rootfs
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid output path"))?;
std::fs::create_dir_all(output_dir)?;
let output_dir_abs = output_dir
.canonicalize()
.context("Failed to get absolute path for output directory")?;
let result = Command::new("docker")
.args([
"run",
"--rm",
"--privileged",
"-e",
&format!("HOST_UID={}", uid),
"-e",
&format!("HOST_GID={}", gid),
"-v",
&format!("{}:/input/image.tar:ro", image_tar_abs.display()),
"-v",
&format!("{}:/input/agent:ro", agent_abs.display()),
"-v",
&format!("{}:/output", output_dir_abs.display()),
"alpine:3.20",
"sh",
"-c",
&script,
])
.output()
.context("Failed to run conversion container")?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let stdout = String::from_utf8_lossy(&result.stdout);
bail!(
"Rootfs conversion failed:\nstdout: {}\nstderr: {}",
stdout,
stderr
);
}
let temp_rootfs = output_dir_abs.join("rootfs.ext4");
if temp_rootfs.exists() {
std::fs::rename(&temp_rootfs, output_rootfs).context("Failed to rename rootfs file")?;
}
Ok(())
}
#[allow(dead_code)]
pub fn needs_conversion(image: &str, output_dir: &Path) -> bool {
let rootfs_name = image_to_rootfs_name(image);
let rootfs_path = output_dir.join(&rootfs_name);
!rootfs_path.exists()
}
#[allow(dead_code)]
pub fn rootfs_path_for_image(image: &str, output_dir: &Path) -> PathBuf {
let rootfs_name = image_to_rootfs_name(image);
output_dir.join(&rootfs_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_to_rootfs_name() {
assert_eq!(image_to_rootfs_name("alpine:3.20"), "alpine-3.20.ext4");
assert_eq!(
image_to_rootfs_name("my-app/image:latest"),
"my-app-image-latest.ext4"
);
assert_eq!(
image_to_rootfs_name("agentkernel-project:abc123"),
"agentkernel-project-abc123.ext4"
);
}
#[test]
fn test_needs_conversion() {
let temp_dir = tempfile::tempdir().unwrap();
assert!(needs_conversion("test:latest", temp_dir.path()));
let rootfs_path = temp_dir.path().join("test-latest.ext4");
std::fs::write(&rootfs_path, "fake rootfs").unwrap();
assert!(!needs_conversion("test:latest", temp_dir.path()));
}
}