From c2a9f00fc01ce1b5b262c95cf078062592660f2d Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 8 Sep 2026 09:12:26 +0200 Subject: [PATCH] Inject universal /slim/init instead of requiring container-specific setup slim now works with any Containerfile by injecting distro-agnostic scripts at build time: - /slim/init: mounts special filesystems, configures networking (static QEMU slirp), sets up cgroup2, timezone, and rootless container prereqs. Parses slim.cmd= from the kernel cmdline for runtime overrides, otherwise execs /slim/exec. - /slim/exec: generated from the image's CMD/ENTRYPOINT (via podman image inspect), overridable via --cmd at build and run time. Changes: - New src/inject.rs: inspect image config, infer command, generate /slim/exec script, inject /slim/ into mounted rootfs - New src/scripts/slim-init.sh: the universal init script (include_str!) - build.rs: rename --init to --cmd, inject scripts before packing, drop Meta::save, restructure to ensure unmount always runs - qemu.rs: hardcode init=/slim/init, add --cmd (base64 on cmdline), drop Meta::load, add -no-reboot - Remove src/meta.rs and meta.toml (no longer needed) - Cargo.toml: add serde_json + base64, remove unused walkdir + cpio + toml - Example Containerfiles simplified to plain FROM + CMD - New example/test.sh for manual verification - README updated for new workflow --- Cargo.lock | 140 ++++++------------------- Cargo.toml | 7 +- README.md | 20 +++- example/alpine/Containerfile | 9 +- example/alpine/init | 29 ------ example/archlinux/Containerfile | 25 +---- example/archlinux/init | 28 ----- example/test.sh | 178 ++++++++++++++++++++++++++++++++ src/build.rs | 96 +++++++++++------ src/inject.rs | 134 ++++++++++++++++++++++++ src/main.rs | 4 +- src/meta.rs | 28 ----- src/qemu.rs | 61 ++++++----- src/scripts/slim-init.sh | 54 ++++++++++ 14 files changed, 526 insertions(+), 287 deletions(-) delete mode 100644 example/alpine/init delete mode 100644 example/archlinux/init create mode 100755 example/test.sh create mode 100644 src/inject.rs delete mode 100644 src/meta.rs create mode 100644 src/scripts/slim-init.sh diff --git a/Cargo.lock b/Cargo.lock index 94e5293..287a218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.1" @@ -122,12 +128,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "cpio" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938e716cb1ade5d6c8f959c13a7248b889c07491fc7e41167c3afe20f8f0de1e" - [[package]] name = "crc32fast" version = "1.5.1" @@ -137,12 +137,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -180,34 +174,24 @@ dependencies = [ "r-efi", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "indexmap" -version = "2.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" -dependencies = [ - "equivalent", - "hashbrown", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.189" @@ -220,6 +204,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -279,15 +269,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "serde" version = "1.0.229" @@ -319,12 +300,16 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "1.1.1" +name = "serde_json" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "itoa", + "memchr", + "serde", "serde_core", + "zmij", ] [[package]] @@ -338,13 +323,12 @@ name = "slim" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", - "cpio", "flate2", "serde", + "serde_json", "tempfile", - "toml", - "walkdir", "xdg", ] @@ -378,45 +362,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -429,25 +374,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -463,14 +389,14 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" - [[package]] name = "xdg" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6678f4b..6118542 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,13 @@ edition = "2024" [dependencies] anyhow = "1.0.104" +base64 = "0.22" clap = { version = "4.6.6", features = ["derive"] } flate2 = "1.1.9" -walkdir = "2.5.0" -cpio = "0.4.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0" xdg = "3.0.0" tempfile = "3.27.0" -toml = "1.1.4" -serde = { version = "1.0.229", features = ["derive"] } [dev-dependencies] tempfile = "3.27.0" diff --git a/README.md b/README.md index 479dbbf..b3b18b6 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,31 @@ Turning containers into bootable VMs -## Example - Minimal Alpine Busybox +## Example ```sh # Build the container podman build ./example/alpine -t slim-alpine -# Make the container bootable by extracing initrd (initial ramdisk) -slim build qcow2 slim-alpine --init=/init +# Make the container bootable. slim injects /slim/init and /slim/exec, +# then packs the rootfs as a qcow2 disk. +slim build qcow2 slim-alpine -# Boot the image using QEMU, `./example/alpine/init` will drop you into an interactive shell. +# Boot the image using QEMU. The container's CMD runs automatically. slim run slim-alpine +# Override the command at runtime (base64-encoded on the kernel cmdline): +slim run slim-alpine --cmd '/bin/sh' + +# Override the command at build time (baked into /slim/exec): +slim build qcow2 slim-alpine --cmd '/bin/sh' + # Cleanup slim image rm slim-alpine podman image rm slim-alpine ``` + +## Kernel + +slim boots VMs using a shared kernel at `$XDG_DATA_HOME/slim-rs/registry/vmlinuz`. +Place a suitable x86 bzImage there before running `slim run`. diff --git a/example/alpine/Containerfile b/example/alpine/Containerfile index 4317e30..d3e8283 100644 --- a/example/alpine/Containerfile +++ b/example/alpine/Containerfile @@ -1,9 +1,2 @@ FROM alpine:latest -RUN mkdir -p /lib/apk/db /run -RUN apk add --no-cache --initdb linux-virt busybox util-linux -RUN cp /boot/vmlinuz-virt /vmlinuz && echo "Welcome to slim!" > /etc/motd - -# Use custom init script -COPY init /init -RUN chmod +x /init - +CMD ["/bin/sh"] diff --git a/example/alpine/init b/example/alpine/init deleted file mode 100644 index c29378f..0000000 --- a/example/alpine/init +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh - -# Create console device -# TODO: is this necessary? -[ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 - -# Mount special filesystems -mkdir -p /proc /sys -mount -t proc proc /proc -mount -t sysfs sysfs /sys - -# Networking -echo slim > /proc/sys/kernel/hostname -ip link set lo up 2>/dev/null -ip link set eth0 up 2>/dev/null -udhcpc -i eth0 -f -q # Get DHCP lease - -echo -echo 'Welcome to slim!' -echo - -# Run interactive shell -/bin/sh /dev/console 2>&1 - -poweroff -f -while true; do - sleep 1 - poweroff -f -done diff --git a/example/archlinux/Containerfile b/example/archlinux/Containerfile index 6d16cbf..18b1121 100644 --- a/example/archlinux/Containerfile +++ b/example/archlinux/Containerfile @@ -1,23 +1,2 @@ -# FROM archlinux:latest -FROM marvin - - -USER root - -# Update, install kernel, initramfs tools, and utilities -# 'linux' includes mkinitcpio hooks that auto-generate the initramfs -RUN pacman -Syu --noconfirm linux busybox util-linux dhcpcd; \ - pacman -Sc --noconfirm - -# Copy kernel and initramfs to root for VM extraction -RUN cp /boot/vmlinuz-linux /vmlinuz && \ - cp /boot/initramfs-linux.img /initrd.img - -# Allow root login without password -RUN passwd -d root - -# Set up timezone. Required to bypass archlinux's first-install setup. -RUN ln -s /usr/share/zoneinfo/Europe/Stockholm /etc/localtime - -COPY init /init -RUN chmod +x /init +FROM archlinux:latest +CMD ["/bin/sh"] diff --git a/example/archlinux/init b/example/archlinux/init deleted file mode 100644 index 7529f57..0000000 --- a/example/archlinux/init +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh - -# Mount special filesystems -mkdir -p /dev /proc /sys -[ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 -mount -t proc proc /proc -mount -t sysfs sysfs /sys - -# Networking -echo slim > /proc/sys/kernel/hostname -ip link set lo up 2>/dev/null -ip link set eth0 up 2>/dev/null -# udhcpc -i eth0 -f -q # Get DHCP lease - -echo -echo 'Welcome to slim!' -echo - -# Start systemd -exec /sbin/init - -# Or, just run a shell -# /bin/sh /dev/console 2>&1 -# poweroff -f -# while true; do -# sleep 1 -# poweroff -f -# done diff --git a/example/test.sh b/example/test.sh new file mode 100755 index 0000000..e4aaade --- /dev/null +++ b/example/test.sh @@ -0,0 +1,178 @@ +#!/bin/bash +# Test script for slim - boots containers and verifies CMD inference/override. +# Requires: podman, qemu-system-x86_64, KVM, curl, and a kernel at +# $XDG_DATA_HOME/slim-rs/registry/vmlinuz. +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARGET_DIR="${CARGO_TARGET_DIR:-$SCRIPT_DIR/../target}" +SLIM_BIN="$TARGET_DIR/debug/slim" +TIMEOUT=120 + +pass=0 +fail=0 + +report() { + if [ "$1" = "pass" ]; then + echo " PASS: $2" + pass=$((pass + 1)) + else + echo " FAIL: $2" + fail=$((fail + 1)) + fi +} + +check_output() { + output="$1" + marker="$2" + label="$3" + if echo "$output" | grep -q "$marker"; then + report pass "$label" + else + report fail "$label (expected marker: $marker)" + fi +} + +cleanup() { + "$SLIM_BIN" image rm "$1" >/dev/null 2>&1 || true + podman image rm "$2" >/dev/null 2>&1 || true +} + +test_distro() { + distro="$1" + from="$2" + extra_setup="$3" + + echo "=== Testing $distro ===" + + work="$(mktemp -d)" + + # === Test 1: CMD-inferred === + echo "-- Test 1: CMD-inferred" + cat > "$work/Containerfile.infer" </dev/null && echo CONN_OK; wget -q -O /dev/null http://example.org 2>/dev/null && echo DNS_OK; poweroff -f"] +EOF + img="slim-test-$distro-infer" + if podman build -t "$img" -f "$work/Containerfile.infer" >/dev/null 2>&1; then + "$SLIM_BIN" build qcow2 "$img" >/dev/null 2>&1 + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" 2>&1 || true) + check_output "$output" "CMD_INFERRED_OK" "$distro CMD-inferred" + check_output "$output" "CONN_OK" "$distro connect to 1.1.1.1" + check_output "$output" "DNS_OK" "$distro DNS lookup" + else + report fail "$distro CMD-inferred (podman build failed)" + fi + cleanup "$img" "$img" + + # === Test 2: slim run --cmd override === + echo "-- Test 2: slim run --cmd override" + cat > "$work/Containerfile.run" </dev/null 2>&1; then + "$SLIM_BIN" build qcow2 "$img" >/dev/null 2>&1 + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" --cmd 'echo RUN_OVERRIDE_OK; poweroff -f' 2>&1 || true) + check_output "$output" "RUN_OVERRIDE_OK" "$distro run --cmd override" + else + report fail "$distro run --cmd override (podman build failed)" + fi + cleanup "$img" "$img" + + # === Test 3: slim build --cmd override === + echo "-- Test 3: slim build --cmd override" + cat > "$work/Containerfile.build" </dev/null 2>&1; then + "$SLIM_BIN" build qcow2 "$img" --cmd 'echo BUILD_OVERRIDE_OK; poweroff -f' >/dev/null 2>&1 + output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" 2>&1 || true) + check_output "$output" "BUILD_OVERRIDE_OK" "$distro build --cmd override" + else + report fail "$distro build --cmd override (podman build failed)" + fi + cleanup "$img" "$img" + + rm -rf "$work" +} + +test_service() { + echo "=== Testing nextcloud service ===" + + img="slim-test-nextcloud" + host_port=18080 + work="$(mktemp -d)" + + cat > "$work/Containerfile" <<'EOF' +FROM docker.io/library/nextcloud:32-apache +RUN apt-get update && apt-get install -y --no-install-recommends iproute2 && rm -rf /var/lib/apt/lists/* +EOF + + echo "-- Building nextcloud container image..." + if ! podman build -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then + report fail "nextcloud service (podman build failed)" + rm -rf "$work" + return + fi + + echo "-- Building slim VM..." + if ! "$SLIM_BIN" build qcow2 "$img" >/dev/null 2>&1; then + report fail "nextcloud service (slim build failed)" + cleanup "$img" "$img" + rm -rf "$work" + return + fi + + echo "-- Booting VM with port forward (host :${host_port} -> guest :80)..." + setsid timeout 300 "$SLIM_BIN" run "$img" \ + --forward "tcp:0.0.0.0:${host_port}-:80" \ + --memory 2048M >/dev/null 2>&1 & + vm_pid=$! + + echo "-- Waiting for Nextcloud to start..." + up=0 + for _ in $(seq 1 60); do + if curl -s -o /dev/null -m 2 "http://localhost:${host_port}/" 2>/dev/null; then + up=1 + break + fi + sleep 3 + done + + if [ "$up" = "1" ]; then + response=$(curl -s -L -m 10 "http://localhost:${host_port}/" 2>/dev/null || true) + if echo "$response" | grep -qi "nextcloud"; then + report pass "nextcloud service (port forward + content)" + else + report fail "nextcloud service (port reachable but no 'nextcloud' in response)" + fi + else + report fail "nextcloud service (port not reachable within timeout)" + fi + + pkill -f "qemu-system-x86_64.*$img" 2>/dev/null || true + kill "$vm_pid" 2>/dev/null || true + wait "$vm_pid" 2>/dev/null || true + + cleanup "$img" "$img" + rm -rf "$work" +} + +echo "Building slim..." +cargo build 2>&1 + +test_distro "alpine" "alpine:latest" "" +test_distro "archlinux" "archlinux:latest" "RUN pacman -Sy --noconfirm iproute2 wget; pacman -Sc --noconfirm" + +test_service + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +[ "$fail" -eq 0 ] diff --git a/src/build.rs b/src/build.rs index af7d951..881eaa9 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,5 +1,5 @@ -//! Build: mount a podman image, extract vmlinuz, and pack the rootfs as a -//! gzipped cpio initrd in the registry. +//! Build: mount a podman image, inject /slim/ scripts, and pack the rootfs +//! as a gzipped cpio initrd or qcow2 disk in the registry. use anyhow::{Context, Result, bail}; use clap::{Args, ValueEnum}; @@ -11,7 +11,7 @@ use std::process::{Command, Stdio}; use tempfile::NamedTempFile; use crate::command::cmd; -use crate::meta::Meta; +use crate::inject; use crate::registry::registry_dir; #[derive(Args, Debug)] @@ -21,10 +21,10 @@ pub struct BuildCmd { /// Image tag / registry subdir name name: String, - /// The path to the `init` program for the VM. - // TODO: infer init based on `COMMAND`/`ENTRYPOINT`? - #[clap(long, default_value = "/sbin/init")] - init: String, + /// Override the command to exec in the VM. Inferred from the image's + /// CMD/ENTRYPOINT if not provided. + #[clap(long)] + cmd: Option, } #[derive(ValueEnum, Clone, Debug)] @@ -33,39 +33,77 @@ enum ImageKind { Initrd, } -pub(crate) fn build(BuildCmd { kind, name, init }: BuildCmd) -> Result<()> { +pub(crate) fn build( + BuildCmd { + kind, + name, + cmd: cmd_override, + }: BuildCmd, +) -> Result<()> { let image = &name; - let mount_path = mount_image(image)?; + let container = format!("slim-build-{}", image.replace(':', "-")); + + let mount_path = mount_container(image, &container)?; println!("Mounted at: {}", mount_path.display()); - // Keep the build result so the image is unmounted even when the build fails. - let result = match kind { - ImageKind::Initrd => build_initrd(image, &mount_path), - ImageKind::Qcow2 => build_qcow2(image, &mount_path), - }; + let result = build_inner(&kind, image, &mount_path, cmd_override); let unmounted = cmd(&[ - "podman", "unshare", "--", "podman", "image", "unmount", image, + "podman", + "unshare", + "--", + "podman", + "container", + "unmount", + &container, ]) .is_ok(); - if unmounted { - println!("Unmounted image."); + let removed = cmd(&["podman", "rm", &container]).is_ok(); + if unmounted && removed { + println!("Unmounted and removed container."); } else { - eprintln!("warning: failed to unmount image '{}'", image); + eprintln!("warning: failed to clean up container '{container}'"); } - Meta { init }.save(image)?; - result } -fn mount_image(image: &str) -> Result { - let mount_path = cmd(&["podman", "unshare", "--", "podman", "image", "mount", image])?; +fn build_inner( + kind: &ImageKind, + image: &str, + mount_path: &Path, + cmd: Option, +) -> Result<()> { + let config = inject::inspect_config(image)?; + let command = cmd.unwrap_or_else(|| inject::infer_command(&config)); + let env = inject::env(&config); + let working_dir = inject::working_dir(&config); + let exec_script = inject::build_exec_script(&command, &env, working_dir); + inject::inject(mount_path, &exec_script)?; + println!("Injected /slim/ (init + exec)"); + + match kind { + ImageKind::Initrd => build_initrd(image, mount_path), + ImageKind::Qcow2 => build_qcow2(image, mount_path), + } +} + +fn mount_container(image: &str, container: &str) -> Result { + cmd(&["podman", "create", "--name", container, image, "/bin/true"])?; + let mount_path = cmd(&[ + "podman", + "unshare", + "--", + "podman", + "container", + "mount", + container, + ])?; Ok(PathBuf::from(mount_path.trim())) } /// Copy a directory onto a new raw disk image with EXT4. -fn to_raw_ext4(dir: &Path) -> Result { +fn to_raw_ext4(dir: &Path, tmp_dir: &Path) -> Result { let dir = dir.to_str().context("Invalid UTF-8")?; let du_out = cmd(&["podman", "unshare", "--", "du", "-sk", dir])?; @@ -83,7 +121,7 @@ fn to_raw_ext4(dir: &Path) -> Result { + 64 * gb; // Add some spare capacity for activities let size = size.to_string(); - let raw_file = NamedTempFile::new()?; + let raw_file = NamedTempFile::new_in(tmp_dir)?; let raw_path = raw_file.path().to_str().context("Invalid UTF-8")?; cmd(&["podman", "unshare", "--", "truncate", "-s", &size, raw_path])?; cmd(&[ @@ -100,9 +138,9 @@ fn to_raw_ext4(dir: &Path) -> Result { } /// Copy a directory onto a new qcow2 disk image with EXT4. -fn to_qcow2_ext4(mount_path: &Path) -> Result { - let raw = to_raw_ext4(mount_path)?; - let qcow2 = NamedTempFile::new()?; +fn to_qcow2_ext4(mount_path: &Path, tmp_dir: &Path) -> Result { + let raw = to_raw_ext4(mount_path, tmp_dir)?; + let qcow2 = NamedTempFile::new_in(tmp_dir)?; let raw_path = raw.path().to_str().context("Invalid UTF-8")?; let qcow2_path = qcow2.path().to_str().context("Invalid UTF-8")?; cmd(&[ @@ -116,7 +154,7 @@ fn build_qcow2(image: &str, mount_path: &Path) -> Result<()> { fs::create_dir_all(®_dir)?; println!("Registry: {}", reg_dir.display()); - let qcow2 = to_qcow2_ext4(mount_path)?; + let qcow2 = to_qcow2_ext4(mount_path, ®_dir)?; fs::copy(qcow2.path(), reg_dir.join("image.qcow2")) .context("Failed to copy qcow2 image to registry")?; @@ -134,7 +172,7 @@ fn build_initrd(image: &str, mount_path: &Path) -> Result<()> { // Pack the rootfs as a gzipped newc cpio archive. The cpio pipeline // runs inside the namespace, its stdout is compressed here. let initrd_path = reg_dir.join("initrd"); - let script = r#"cd "$1" && find . -not -path ./vmlinuz | cpio -o -H newc"#; + let script = r#"cd "$1" && find . | cpio -o -H newc"#; let mut cpio = Command::new("podman") .args(["unshare", "--", "sh", "-c", script, "sh", mount_path]) .stdout(Stdio::piped()) diff --git a/src/inject.rs b/src/inject.rs new file mode 100644 index 0000000..7fbc152 --- /dev/null +++ b/src/inject.rs @@ -0,0 +1,134 @@ +//! Inject: inspect a podman image's config, infer the default command, and +//! inject distro-agnostic /slim/ scripts (init + exec) into the mounted rootfs. + +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; +use std::fs; +use std::path::Path; + +use crate::command::cmd; + +const SLIM_INIT: &str = include_str!("scripts/slim-init.sh"); + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Config { + #[serde(default)] + cmd: Option>, + #[serde(default)] + entrypoint: Option>, + #[serde(default)] + env: Option>, + #[serde(default)] + working_dir: Option, +} + +pub fn inspect_config(image: &str) -> Result { + let json = cmd(&[ + "podman", + "image", + "inspect", + image, + "--format", + "{{json .Config}}", + ])?; + let config: Config = serde_json::from_str(json.trim()) + .with_context(|| anyhow!("failed to parse image inspect output for '{image}'"))?; + Ok(config) +} + +/// Infer the command string from the image config. +/// +/// Concatenates ENTRYPOINT + CMD (Docker semantics). If neither is present, +/// falls back to `/bin/sh`. +pub fn infer_command(config: &Config) -> String { + let entrypoint = config.entrypoint.as_deref().filter(|e| !e.is_empty()); + let cmd = config.cmd.as_deref().filter(|c| !c.is_empty()); + + let parts: Vec = match (entrypoint, cmd) { + (Some(ep), Some(c)) => { + let mut parts = ep.to_vec(); + parts.extend(c.iter().cloned()); + parts + } + (Some(ep), None) => ep.to_vec(), + (None, Some(c)) => c.to_vec(), + (None, None) => vec!["/bin/sh".to_string()], + }; + + if parts.len() == 3 && (parts[0] == "/bin/sh" || parts[0] == "sh") && parts[1] == "-c" { + parts[2].clone() + } else { + shell_join(&parts) + } +} + +pub fn env(config: &Config) -> Vec { + config.env.clone().unwrap_or_default() +} + +pub fn working_dir(config: &Config) -> Option<&str> { + config.working_dir.as_deref() +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +fn shell_join(parts: &[String]) -> String { + parts + .iter() + .map(|p| shell_escape(p)) + .collect::>() + .join(" ") +} + +/// Generate the /slim/exec script from a command string, env vars, and working dir. +pub fn build_exec_script(command: &str, env: &[String], working_dir: Option<&str>) -> String { + let mut lines = String::from("#!/bin/sh\n"); + if let Some(dir) = working_dir.filter(|d| !d.is_empty()) { + lines.push_str(&format!("cd {} 2>/dev/null\n", shell_escape(dir))); + } + for var in env { + if let Some((key, val)) = var.split_once('=') { + lines.push_str(&format!( + "export {}={}\n", + shell_escape(key), + shell_escape(val) + )); + } + } + lines.push_str(&format!("exec /bin/sh -c {}\n", shell_escape(command))); + lines +} + +/// Inject /slim/init and /slim/exec into a mounted container image rootfs. +pub fn inject(mount_path: &Path, exec_script: &str) -> Result<()> { + let mount_str = mount_path.to_str().context("mount path is not UTF-8")?; + + let init_temp = tempfile::NamedTempFile::new()?; + let exec_temp = tempfile::NamedTempFile::new()?; + fs::write(init_temp.path(), SLIM_INIT)?; + fs::write(exec_temp.path(), exec_script)?; + + let init_src = init_temp + .path() + .to_str() + .context("temp path is not UTF-8")?; + let exec_src = exec_temp + .path() + .to_str() + .context("temp path is not UTF-8")?; + + let init_dest = format!("{mount_str}/slim/init"); + let exec_dest = format!("{mount_str}/slim/exec"); + + cmd(&[ + "podman", "unshare", "--", "install", "-D", "-m", "755", init_src, &init_dest, + ])?; + cmd(&[ + "podman", "unshare", "--", "install", "-D", "-m", "755", exec_src, &exec_dest, + ])?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 626642a..3151ff7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod build; mod command; mod forward; mod image; -mod meta; +mod inject; mod qemu; mod registry; @@ -12,7 +12,7 @@ use clap::{Parser, Subcommand}; use crate::{build::BuildCmd, image::ImageCmd, qemu::RunCmd}; #[derive(Parser, Debug)] -#[command(about = "Build bootable initrd VMs from container images")] +#[command(about = "Turn containers into bootable VMs")] struct Cli { #[command(subcommand)] command: Commands, diff --git a/src/meta.rs b/src/meta.rs deleted file mode 100644 index cb5774a..0000000 --- a/src/meta.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::fs; - -use serde::{Deserialize, Serialize}; - -use crate::registry::registry_dir; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Meta { - /// Path to the init program - pub init: String, -} - -const FILENAME: &str = "meta.toml"; - -impl Meta { - pub fn load(image: &str) -> anyhow::Result { - let path = registry_dir(image)?.join(FILENAME); - let s = fs::read_to_string(path)?; - Ok(toml::from_str(&s)?) - } - - pub fn save(&self, image: &str) -> anyhow::Result<()> { - let path = registry_dir(image)?.join(FILENAME); - let s = toml::to_string_pretty(self)?; - fs::write(path, &s)?; - Ok(()) - } -} diff --git a/src/qemu.rs b/src/qemu.rs index b617382..b230042 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -1,13 +1,17 @@ //! QEMU: boot a registry entry with qemu-system-x86_64 using direct kernel -//! boot (-kernel/-initrd). +//! boot (-kernel/-initrd or -drive). The kernel is shared across all VMs +//! at $XDG_DATA_HOME/slim-rs/registry/vmlinuz. A universal /slim/init script (injected +//! at build time) handles VM bootstrap; /slim/exec runs the container's +//! CMD/ENTRYPOINT. Runtime overrides are delivered as `slim.cmd=` +//! on the kernel cmdline. use anyhow::{Context, Result, bail}; +use base64::Engine; use clap::Args; use std::{fmt::Write as _, process::Command}; use crate::{ forward::PortForward, - meta::Meta, registry::{registry_base_dir, registry_dir}, }; @@ -23,6 +27,11 @@ pub struct RunCmd { /// Forward ports from host to guest. Example: `tcp:0.0.0.0:80-:8080` #[clap(long)] forward: Vec, + + /// Override the command to exec in the VM (base64-encoded on the kernel + /// cmdline as slim.cmd=). Overrides the CMD inferred at build time. + #[clap(long)] + cmd: Option, } pub(crate) fn run( @@ -30,6 +39,7 @@ pub(crate) fn run( name, memory, forward, + cmd, }: RunCmd, ) -> Result<()> { let reg_dir = registry_dir(&name)?; @@ -38,40 +48,40 @@ pub(crate) fn run( let qcow2_path = reg_dir.join("image.qcow2"); if !vmlinuz_path.exists() { // TODO: guide user in how to set up a kernel - bail!("No kernel available.") + bail!( + "No kernel available. Place a vmlinuz at {}", + vmlinuz_path.display() + ); } - let meta = Meta::load(&name).context("Failed to load meta.toml. Run `slim build` first.")?; - // TODO: sanity-check for spaces - let init_arg = format!("init={}", meta.init); - - let fs_args; - let mut cmdline = vec![ - "console=ttyS0,115200", - "rw", - "earlyprintk=serial", - "nokaslr", - &init_arg, - "devtmpfs.mount=1", // Automatically mount /dev at boot + let mut cmdline: Vec = vec![ + "console=ttyS0,115200".into(), + "rw".into(), + "earlyprintk=serial".into(), + "nokaslr".into(), + "init=/slim/init".into(), + "devtmpfs.mount=1".into(), // Automatically mount /dev at boot ]; + let fs_args; let qcow2_arg = format!("file={},format=qcow2,if=virtio", qcow2_path.display()); if qcow2_path.exists() { - fs_args = vec!["-drive", &qcow2_arg]; - cmdline.extend_from_slice(&["root=/dev/vda", "rootfstype=ext4"]); + fs_args = vec!["-drive".to_string(), qcow2_arg]; + cmdline.extend(["root=/dev/vda".to_string(), "rootfstype=ext4".to_string()]); } else if initrd_path.exists() { - fs_args = vec!["-initrd", initrd_path.to_str().context("Invalid UTF-8")?]; - cmdline.push("root=/dev/ram0"); + let initrd_str = initrd_path.to_str().context("Invalid UTF-8")?.to_string(); + fs_args = vec!["-initrd".to_string(), initrd_str]; + cmdline.push("root=/dev/ram0".into()); } else { bail!("Registry missing rootfs/initrd for '{name}'. Run `slim build` first."); } + if let Some(cmd) = &cmd { + let encoded = base64::engine::general_purpose::STANDARD.encode(cmd.as_bytes()); + cmdline.push(format!("slim.cmd={encoded}")); + } + println!("Booting {name} from registry: {}", reg_dir.display()); - println!( - " qemu-system-x86_64 -m 256 -nographic -kernel {} -initrd {} -append 'console=ttyS0'", - vmlinuz_path.display(), - initrd_path.display() - ); let cmdline = cmdline.join(" "); @@ -90,8 +100,9 @@ pub(crate) fn run( .args(["-m", &memory]) .arg("-kernel") .arg(vmlinuz_path.as_os_str()) - .args(fs_args) + .args(&fs_args) .args(["-snapshot"]) + .args(["-no-reboot"]) .args(["-append", &cmdline]) .args(["-nographic"]) .args(["-nic", &network]); diff --git a/src/scripts/slim-init.sh b/src/scripts/slim-init.sh new file mode 100644 index 0000000..e976080 --- /dev/null +++ b/src/scripts/slim-init.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# slim universal init - distro-agnostic VM bootstrap. +# Injected by `slim build` at /slim/init and invoked via init=/slim/init. + +# === Devices & special filesystems === +[ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 +mkdir -p /proc /sys /dev/pts /dev/shm +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devpts devpts /dev/pts +mount -t tmpfs tmpfs /dev/shm +mount -t tmpfs tmpfs /run -o mode=755 +mount -t tmpfs tmpfs /tmp -o mode=1777 + +# === cgroup2 === +mkdir -p /sys/fs/cgroup +mount -t cgroup2 none /sys/fs/cgroup 2>/dev/null || echo "mount cgroup2 failed" +# shellcheck disable=SC2013 # word-splitting is intentional: controllers are space-separated +for c in $(cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null); do + echo "+$c" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || echo "enable cgroup controller $c failed" +done + +# === Rootless container prerequisites (best-effort) === +mount --make-rshared / 2>/dev/null || echo "make-rshared / failed" +chmod u+s /usr/bin/newuidmap /usr/bin/newgidmap 2>/dev/null || echo "chmod newuidmap/newgidmap failed" + +# === Timezone === +ln -sf /usr/share/zoneinfo/Europe/Stockholm /etc/localtime 2>/dev/null || echo "set timezone failed" + +# === Networking (QEMU slirp: guest 10.0.2.15/24, gw 10.0.2.2, dns 10.0.2.3) === +ip link set lo up 2>/dev/null || echo "ip link set lo up failed" +ip link set eth0 up 2>/dev/null || echo "ip link set eth0 up failed" +ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || echo "ip addr add eth0 failed" +ip route add default via 10.0.2.2 2>/dev/null || echo "ip route add default failed" +echo nameserver 10.0.2.3 > /etc/resolv.conf + +# === Execute the configured command === +# If slim.cmd= is on the kernel cmdline, decode and exec it. +# Otherwise, exec /slim/exec (generated from the image's CMD/ENTRYPOINT). +# shellcheck disable=SC2013 # word-splitting is intentional: cmdline tokens are space-separated +for tok in $(cat /proc/cmdline 2>/dev/null); do + case "$tok" in + slim.cmd=*) + v=${tok#slim.cmd=} + decoded=$(printf '%s' "$v" | base64 -d 2>/dev/null) \ + || decoded=$(printf '%s' "$v" | openssl base64 -d 2>/dev/null) \ + || echo "base64 decode failed" + if [ -n "$decoded" ]; then + exec /bin/sh -c "$decoded" + fi + ;; + esac +done +exec /slim/exec -- 2.54.0