Add --mount flag for 9p host directory sharing #9

Merged
hulthe merged 3 commits from feature/9p-mount into master 2026-09-11 09:36:00 +02:00
5 changed files with 192 additions and 7 deletions
+99
View File
@@ -136,6 +136,103 @@ EOF
rm -rf "$work" rm -rf "$work"
} }
test_mount() {
echo "=== Testing --mount (9p shares) ==="
work="$(mktemp -d)"
# --- Image 1: no WORKINGDIR, test host:guest and multi-mount ---
img="slim-test-mount"
share_dir="$work/share"
share_dir2="$work/share2"
guest_dir="/mnt/guest"
guest_dir2="/mnt/guest2"
mkdir -p "$share_dir" "$share_dir2"
echo "hello from host" > "$share_dir/testfile.txt"
echo "second share" > "$share_dir2/testfile2.txt"
cat > "$work/Containerfile" <<'EOF'
FROM alpine:latest
CMD ["/bin/sh", "-c", "poweroff -f"]
EOF
echo "-- Building container image (no WORKINGDIR)..."
if ! podman build --network=none -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then
report fail "--mount (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 "--mount (slim build failed)"
cleanup "$img" "$img"
rm -rf "$work"
return
fi
share_canon=$(readlink -f "$share_dir")
share_canon2=$(readlink -f "$share_dir2")
echo "-- Test 1: --mount host:guest (absolute guest path, multi-mount)..."
output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" \
--mount "${share_dir}:${guest_dir}" \
--mount "${share_dir2}:${guest_dir2}" \
--cmd "for f in /sys/bus/virtio/drivers/9pnet_virtio/virtio*/mount_tag; do [ -f \"\$f\" ] && echo \"\$f: \$(tr -d '\\0' < \"\$f\")\"; done; mount -v; cat ${guest_dir}/testfile.txt 2>/dev/null || echo NO_FILE; cat ${guest_dir2}/testfile2.txt 2>/dev/null || echo NO_FILE2; echo MOUNT_VERIFY_DONE; poweroff -f" \
2>&1 || true)
check_output "$output" "mount_tag: slim0" "--mount 9p share detected"
check_output "$output" "mount_tag: slim1" "--mount second 9p share detected"
check_output "$output" "type 9p" "--mount 9p filesystem in mount list"
check_output "$output" "hello from host" "--mount file accessible at guest path"
check_output "$output" "second share" "--mount second file accessible at guest path"
check_output "$output" "MOUNT_VERIFY_DONE" "--mount VM ran to completion"
cleanup "$img" "$img"
# --- Image 2: with WORKINGDIR, test relative guest path ---
img="slim-test-mount-wd"
share_dir3="$work/share3"
mkdir -p "$share_dir3"
echo "relative share" > "$share_dir3/relfile.txt"
cat > "$work/Containerfile.wd" <<'EOF'
FROM alpine:latest
WORKDIR /app
CMD ["/bin/sh", "-c", "poweroff -f"]
EOF
echo "-- Building container image (WORKINGDIR /app)..."
if ! podman build --network=none -t "$img" -f "$work/Containerfile.wd" >/dev/null 2>&1; then
report fail "--mount relative (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 "--mount relative (slim build failed)"
cleanup "$img" "$img"
rm -rf "$work"
return
fi
echo "-- Test 2: --mount host:relative-guest (resolved against WORKINGDIR)..."
output=$(timeout "$TIMEOUT" "$SLIM_BIN" run "$img" \
--mount "${share_dir3}:data" \
--cmd "mount -v; cat /app/data/relfile.txt 2>/dev/null || echo NO_REL_FILE; echo REL_VERIFY_DONE; poweroff -f" \
2>&1 || true)
check_output "$output" "type 9p" "--mount relative 9p filesystem in mount list"
check_output "$output" "relative share" "--mount relative file accessible at WORKINGDIR/data"
check_output "$output" "REL_VERIFY_DONE" "--mount relative VM ran to completion"
cleanup "$img" "$img"
rm -rf "$work"
}
test_service() { test_service() {
echo "=== Testing nextcloud service ===" echo "=== Testing nextcloud service ==="
@@ -206,6 +303,8 @@ test_distro "archlinux" "archlinux:latest" "RUN pacman -Sy --noconfirm iproute2
test_user test_user
test_mount
test_service test_service
echo "" echo ""
+6 -1
View File
@@ -78,7 +78,12 @@ fn build_inner(
let command = cmd.unwrap_or_else(|| inject::infer_command(&config)); let command = cmd.unwrap_or_else(|| inject::infer_command(&config));
let exec_script = let exec_script =
inject::build_exec_script(&command, &config.env, config.working_dir.as_deref()); inject::build_exec_script(&command, &config.env, config.working_dir.as_deref());
inject::inject(mount_path, &exec_script, config.user.as_deref())?; inject::inject(
mount_path,
&exec_script,
config.user.as_deref(),
config.working_dir.as_deref(),
)?;
println!("Injected /slim/ (init + exec)"); println!("Injected /slim/ (init + exec)");
match kind { match kind {
+12 -3
View File
@@ -100,9 +100,14 @@ fn install_into_rootfs(mount: &str, name: &str, mode: &str, content: &str) -> Re
Ok(()) Ok(())
} }
/// Inject /slim/init, /slim/exec, and optionally /slim/user into a mounted /// Inject /slim/init, /slim/exec, and optionally /slim/user and /slim/workdir
/// container image rootfs. /// into a mounted container image rootfs.
pub fn inject(mount_path: &Path, exec_script: &str, user: Option<&str>) -> Result<()> { pub fn inject(
mount_path: &Path,
exec_script: &str,
user: Option<&str>,
working_dir: Option<&str>,
) -> Result<()> {
let mount_str = mount_path.to_str().context("mount path is not UTF-8")?; let mount_str = mount_path.to_str().context("mount path is not UTF-8")?;
install_into_rootfs(mount_str, "init", "755", SLIM_INIT)?; install_into_rootfs(mount_str, "init", "755", SLIM_INIT)?;
@@ -112,5 +117,9 @@ pub fn inject(mount_path: &Path, exec_script: &str, user: Option<&str>) -> Resul
install_into_rootfs(mount_str, "user", "644", user)?; install_into_rootfs(mount_str, "user", "644", user)?;
} }
if let Some(dir) = working_dir.filter(|d| !d.is_empty()) {
install_into_rootfs(mount_str, "workdir", "644", dir)?;
}
Ok(()) Ok(())
} }
+38
View File
@@ -32,6 +32,13 @@ pub struct RunCmd {
/// cmdline as slim.cmd=<b64>). Overrides the CMD inferred at build time. /// cmdline as slim.cmd=<b64>). Overrides the CMD inferred at build time.
#[clap(long)] #[clap(long)]
cmd: Option<String>, cmd: Option<String>,
/// Share a host directory into the VM via 9p. Format:
/// `<host-path>` or `<host-path>:<guest-path>`. When the guest path is
/// omitted, the host path is used. Non-absolute guest paths are
/// relative to the image's WORKINGDIR. Can be repeated.
#[clap(long)]
mount: Vec<String>,
} }
pub(crate) fn run( pub(crate) fn run(
@@ -40,6 +47,7 @@ pub(crate) fn run(
memory, memory,
forward, forward,
cmd, cmd,
mount,
}: RunCmd, }: RunCmd,
) -> Result<()> { ) -> Result<()> {
let reg_dir = registry_dir(&name)?; let reg_dir = registry_dir(&name)?;
@@ -81,6 +89,35 @@ pub(crate) fn run(
cmdline.push(format!("slim.cmd={encoded}")); cmdline.push(format!("slim.cmd={encoded}"));
} }
// Build 9p shares for each --mount. Tags are short (slim0, slim1, …)
// because 9p mount_tag has a ~31-byte limit. The guest destination path
// is passed on the kernel cmdline as slim.mount=<tag>:<base64(guest_path)>.
// If no guest path is specified, the host path is used as the guest path.
let mut virtfs_args: Vec<String> = Vec::new();
for (i, spec) in mount.iter().enumerate() {
let (host_path, guest_path) = match spec.split_once(':') {
Some((h, g)) => (h, g),
None => (spec.as_str(), spec.as_str()),
};
let canonical = std::path::Path::new(host_path)
.canonicalize()
.with_context(|| format!("Cannot resolve mount path '{host_path}'"))?;
let host_str = canonical
.to_str()
.context("Mount path is not valid UTF-8")?;
let tag = format!("slim{i}");
let dest_b64 = base64::engine::general_purpose::STANDARD.encode(guest_path.as_bytes());
// QEMU's QemuOpts splits on commas — escape literal commas in the
// path as ",," per QEMU convention.
let host_escaped = host_str.replace(',', ",,");
virtfs_args.push("-virtfs".into());
virtfs_args.push(format!(
"local,path={host_escaped},mount_tag={tag},security_model=mapped-xattr"
));
cmdline.push(format!("slim.mount={tag}:{dest_b64}"));
}
println!("Booting {name} from registry: {}", reg_dir.display()); println!("Booting {name} from registry: {}", reg_dir.display());
let cmdline = cmdline.join(" "); let cmdline = cmdline.join(" ");
@@ -101,6 +138,7 @@ pub(crate) fn run(
.arg("-kernel") .arg("-kernel")
.arg(vmlinuz_path.as_os_str()) .arg(vmlinuz_path.as_os_str())
.args(&fs_args) .args(&fs_args)
.args(&virtfs_args)
.args(["-snapshot"]) .args(["-snapshot"])
.args(["-no-reboot"]) .args(["-no-reboot"])
.args(["-append", &cmdline]) .args(["-append", &cmdline])
+37 -3
View File
@@ -2,6 +2,11 @@
# slim universal init - distro-agnostic VM bootstrap. # slim universal init - distro-agnostic VM bootstrap.
# Injected by `slim build` at /slim/init and invoked via init=/slim/init. # Injected by `slim build` at /slim/init and invoked via init=/slim/init.
# === Helpers ===
b64dec() {
printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | openssl base64 -d 2>/dev/null
}
# === Devices & special filesystems === # === Devices & special filesystems ===
[ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 [ -c /dev/console ] || mknod -m 600 /dev/console c 5 1
mkdir -p /proc /sys /dev/pts /dev/shm mkdir -p /proc /sys /dev/pts /dev/shm
@@ -34,6 +39,37 @@ 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" 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 echo nameserver 10.0.2.3 > /etc/resolv.conf
# === 9p shares (host directories shared via --mount) ===
# Each share is passed as slim.mount=<tag>:<base64(guest_path)> on the
# kernel cmdline. The 9p tag is short (e.g. slim0) because mount_tag
# has a ~31-byte limit; the guest path is base64-encoded.
# Non-absolute guest paths are resolved against /slim/workdir (the
# image's WORKINGDIR).
SLIM_WORKDIR=""
[ -f /slim/workdir ] && SLIM_WORKDIR=$(cat /slim/workdir 2>/dev/null)
# 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.mount=*)
v=${tok#slim.mount=}
tag=${v%%:*}
dest_b64=${v#*:}
dest=$(b64dec "$dest_b64") || echo "base64 decode failed"
if [ -n "$tag" ] && [ -n "$dest" ]; then
# Resolve relative paths against the image's WORKINGDIR
case "$dest" in
/*) ;;
*) dest="${SLIM_WORKDIR:-/}/${dest}" ;;
esac
mkdir -p "$dest"
mount -t 9p "$tag" "$dest" -o trans=virtio,version=9p2000.L 2>/dev/null \
&& echo "mounted 9p '$tag' -> '$dest'" \
|| echo "failed to mount 9p '$tag' -> '$dest'"
fi
;;
esac
done
# === Drop privileges (respect USER directive from image config) === # === Drop privileges (respect USER directive from image config) ===
# /slim/user may contain a username, uid, or uid:gid (OCI image spec). # /slim/user may contain a username, uid, or uid:gid (OCI image spec).
# Numeric uids are resolved to a username via /etc/passwd because BusyBox # Numeric uids are resolved to a username via /etc/passwd because BusyBox
@@ -75,9 +111,7 @@ for tok in $(cat /proc/cmdline 2>/dev/null); do
case "$tok" in case "$tok" in
slim.cmd=*) slim.cmd=*)
v=${tok#slim.cmd=} v=${tok#slim.cmd=}
decoded=$(printf '%s' "$v" | base64 -d 2>/dev/null) \ decoded=$(b64dec "$v") || echo "base64 decode failed"
|| decoded=$(printf '%s' "$v" | openssl base64 -d 2>/dev/null) \
|| echo "base64 decode failed"
if [ -n "$decoded" ]; then if [ -n "$decoded" ]; then
slim_exec "$decoded" slim_exec "$decoded"
fi fi