Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions crates/forkd-cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,25 @@ pub(crate) fn preflight() -> anyhow::Result<PreflightReport> {
];
let blocked = gates.iter().any(|c| c.status == Status::Fail);

// Non-gating rows: a red ✗ followed by quickstart proceeding anyway
// reads as a contradiction. Downgrade them to warnings with a hint
// that quickstart itself is the fix.
let soften = |c: Check| -> Check {
if c.status == Status::Fail {
Check::warn(
c.name,
c.detail,
"quickstart sets this up (with your consent)",
)
} else {
c
}
};

let mut all = gates;
all.push(kernel);
all.push(tap);
all.push(docker);
all.push(soften(kernel));
all.push(soften(tap));
all.push(soften(docker));
print_report(&all);

if blocked {
Expand Down
120 changes: 120 additions & 0 deletions crates/forkd-cli/src/hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,19 +533,71 @@ pub fn unpack(pack_path: &Path, dest_dir: &Path) -> Result<Manifest> {
for entry in &manifest.files {
verify_one(&dest_dir.join(&entry.path), entry)?;
}
rewrite_snapshot_paths(dest_dir)?;
} else {
// v2 chain layout.
for link in &manifest.chain {
let link_dir = dest_dir.join(&link.tag);
for entry in &link.files {
verify_one(&link_dir.join(&entry.path), entry)?;
}
rewrite_snapshot_paths(&link_dir)?;
}
}

Ok(manifest)
}

/// Rewrite `snapshot.json`'s absolute `vmstate` / `memory` paths to point
/// into the directory the snapshot was just extracted into.
///
/// Packs carry the *packing host's* absolute paths (e.g.
/// `/home/<packer>/.local/share/forkd/snapshots/<tag>/vmstate`), which are
/// meaningless on the pulling machine — without this fixup the first
/// restore dies with Firecracker's "Failed to open snapshot file: No such
/// file or directory". Runs after sha256 verification so the integrity
/// check still sees the bytes the packer signed.
///
/// Operates on `serde_json::Value` rather than the typed
/// `forkd_vmm::Snapshot` so fields this forkd version doesn't know about
/// survive the round-trip. Volume host paths are left untouched — they
/// reference paths outside the snapshot dir that we can't relocate.
///
/// Idempotent, and `pub(crate)` because callers that extract into a
/// staging dir and `rename(2)` to the final location (`unpack_into`)
/// must run it AGAIN post-move — the in-`unpack` pass points paths at
/// the staging dir, which goes stale the moment the rename happens.
pub(crate) fn rewrite_snapshot_paths(dir: &Path) -> Result<()> {
let sj = dir.join("snapshot.json");
if !sj.exists() {
return Ok(());
}
let raw = std::fs::read_to_string(&sj)
.with_context(|| format!("read {} for path fixup", sj.display()))?;
let mut v: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("parse {} for path fixup", sj.display()))?;
if let Some(obj) = v.as_object_mut() {
for key in ["vmstate", "memory"] {
let Some(s) = obj.get(key).and_then(|x| x.as_str()) else {
continue;
};
let Some(name) = Path::new(s).file_name() else {
continue;
};
let local = dir.join(name);
if local.exists() && local.to_str() != Some(s) {
obj.insert(
key.to_string(),
serde_json::Value::String(local.to_string_lossy().into_owned()),
);
}
}
}
let out = serde_json::to_string_pretty(&v).context("re-serialize snapshot.json")?;
std::fs::write(&sj, out).with_context(|| format!("write fixed-up {}", sj.display()))?;
Ok(())
}

/// Verify a single extracted file against its [`FileEntry`] manifest
/// declaration. Shared between v1 and v2 unpack paths.
fn verify_one(path: &Path, entry: &FileEntry) -> Result<()> {
Expand Down Expand Up @@ -896,6 +948,74 @@ fn epoch_to_ymd_hms(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
mod tests {
use super::*;

/// Regression: packs bake the packing host's absolute paths into
/// snapshot.json. `unpack` must rewrite `vmstate` / `memory` to the
/// extraction dir or the first restore on any other machine fails
/// with "Failed to open snapshot file". Unknown fields and volume
/// paths must survive untouched.
#[test]
fn rewrite_snapshot_paths_relocates_vmstate_and_memory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("vmstate"), b"x").unwrap();
std::fs::write(dir.join("memory.bin"), b"x").unwrap();
std::fs::write(
dir.join("snapshot.json"),
r#"{
"vmstate": "/home/packer/.local/share/forkd/snapshots/t/vmstate",
"memory": "/home/packer/.local/share/forkd/snapshots/t/memory.bin",
"volumes": [{"host_path": "/data/vol.ext4"}],
"some_future_field": 42
}"#,
)
.unwrap();

rewrite_snapshot_paths(dir).unwrap();

let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("snapshot.json")).unwrap())
.unwrap();
assert_eq!(
v["vmstate"].as_str().unwrap(),
dir.join("vmstate").to_str().unwrap()
);
assert_eq!(
v["memory"].as_str().unwrap(),
dir.join("memory.bin").to_str().unwrap()
);
// Untouched: volumes + unknown fields.
assert_eq!(
v["volumes"][0]["host_path"].as_str().unwrap(),
"/data/vol.ext4"
);
assert_eq!(v["some_future_field"].as_i64().unwrap(), 42);
}

/// When the referenced file isn't in the extraction dir (or there is
/// no snapshot.json at all), rewrite must be a no-op rather than
/// pointing paths at nonexistent files.
#[test]
fn rewrite_snapshot_paths_noop_when_files_absent() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
// No snapshot.json: must not error.
rewrite_snapshot_paths(dir).unwrap();

// snapshot.json present but vmstate/memory files absent: keep
// the original paths.
std::fs::write(
dir.join("snapshot.json"),
r#"{"vmstate": "/elsewhere/vmstate", "memory": "/elsewhere/memory.bin"}"#,
)
.unwrap();
rewrite_snapshot_paths(dir).unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("snapshot.json")).unwrap())
.unwrap();
assert_eq!(v["vmstate"].as_str().unwrap(), "/elsewhere/vmstate");
assert_eq!(v["memory"].as_str().unwrap(), "/elsewhere/memory.bin");
}

#[test]
#[allow(clippy::assertions_on_constants)]
fn pack_format_versions_are_in_lockstep() {
Expand Down
50 changes: 50 additions & 0 deletions crates/forkd-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,9 @@ fn unpack_into(
std::fs::create_dir_all(dest.parent().unwrap()).ok();
std::fs::rename(tmp, &dest)
.with_context(|| format!("move {} → {}", tmp.display(), dest.display()))?;
// The staging-dir paths hub::unpack wrote into snapshot.json went
// stale with the rename — point them at the final location.
hub::rewrite_snapshot_paths(&dest)?;
eprintln!("✓ unpacked tag '{final_tag}' at {}", dest.display());
eprintln!(" next: forkd fork --tag {final_tag} -n <N>");
Ok(())
Expand Down Expand Up @@ -1317,6 +1320,9 @@ fn unpack_chain_into(
let src = tmp.join(&link.tag);
std::fs::rename(&src, dest)
.with_context(|| format!("move {} → {} (chain link)", src.display(), dest.display()))?;
// Re-point snapshot.json at the post-rename location (the
// in-unpack pass wrote staging-dir paths).
hub::rewrite_snapshot_paths(dest)?;
eprintln!(
" ✓ link '{}' → '{final_tag}' at {}{}",
link.tag,
Expand Down Expand Up @@ -1701,6 +1707,49 @@ fn parent_build_cmd(
const QS_INSTALL_KERNEL_SH: &str = include_str!("../../../scripts/install-guest-kernel.sh");
const QS_HOST_TAP_SH: &str = include_str!("../../../scripts/host-tap.sh");
const QS_NETNS_SETUP_SH: &str = include_str!("../../../scripts/netns-setup.sh");
const QS_BUILD_ROOTFS_SH: &str = include_str!("../../../scripts/build-rootfs.sh");
const QS_FORKD_INIT_SH: &str = include_str!("../../../rootfs-init/forkd-init.sh");
const QS_FORKD_AGENT_PY: &str = include_str!("../../../rootfs-init/forkd-agent.py");

/// Make `find_script` succeed on tarball installs that have no repo
/// checkout: stage the embedded helper scripts on disk and point
/// `FORKD_SCRIPTS_DIR` at them — unless the variable is already set or
/// the script resolves on its own (repo checkouts and packaged layouts
/// keep winning).
///
/// Layout matters: `build-rootfs.sh` finds the guest init + agent via
/// `$(dirname "$0")/../rootfs-init`, so the scripts go in `<base>/scripts/`
/// and the init files in the sibling `<base>/rootfs-init/`. Get this
/// wrong and the guest boots with no init → kernel panic (error -2) at
/// warmup, long before the first fork.
fn ensure_scripts_dir_for_quickstart() -> Result<()> {
if find_script("build-rootfs.sh").is_ok() {
return Ok(());
}
let base = std::env::temp_dir().join(format!("forkd-quickstart-{}", std::process::id()));
let scripts = base.join("scripts");
let rootfs_init = base.join("rootfs-init");
std::fs::create_dir_all(&scripts).context("create embedded scripts/ dir")?;
std::fs::create_dir_all(&rootfs_init).context("create embedded rootfs-init/ dir")?;
for (name, body) in [
("build-rootfs.sh", QS_BUILD_ROOTFS_SH),
("install-guest-kernel.sh", QS_INSTALL_KERNEL_SH),
("host-tap.sh", QS_HOST_TAP_SH),
("netns-setup.sh", QS_NETNS_SETUP_SH),
] {
std::fs::write(scripts.join(name), body)
.with_context(|| format!("write embedded {name}"))?;
}
for (name, body) in [
("forkd-init.sh", QS_FORKD_INIT_SH),
("forkd-agent.py", QS_FORKD_AGENT_PY),
] {
std::fs::write(rootfs_init.join(name), body)
.with_context(|| format!("write embedded {name}"))?;
}
std::env::set_var("FORKD_SCRIPTS_DIR", &scripts);
Ok(())
}

fn quickstart_cmd(n: usize, image: String, yes: bool) -> Result<()> {
eprintln!("==> forkd quickstart — host preflight");
Expand Down Expand Up @@ -1764,6 +1813,7 @@ fn quickstart_cmd(n: usize, image: String, yes: bool) -> Result<()> {
eprintln!("==> reusing existing snapshot '{tag}'");
} else if pf.docker_ok {
eprintln!("==> baking snapshot '{tag}' from {image} (first run 1-3 min; rootfs is cached)");
ensure_scripts_dir_for_quickstart()?;
from_image_cmd(
image,
tag.clone(),
Expand Down
45 changes: 27 additions & 18 deletions scripts/build-rootfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@

set -euo pipefail

# Privilege shim: when already root (e.g. `forkd quickstart` in a
# container, or invoked via `sudo -E forkd parent build`), `sudo` may
# not be installed at all — run the privileged commands directly.
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
else
SUDO="sudo"
fi

IMAGE="${1:-ubuntu:24.04}"
OUTPUT="${2:-rootfs.ext4}"
SIZE_MB="${3:-2048}"
Expand All @@ -30,11 +39,11 @@ say() { printf "\033[1;34m==>\033[0m %s\n" "$*"; }
die() { printf "\033[1;31merror:\033[0m %s\n" "$*" >&2; cleanup; exit 1; }

cleanup() {
sudo umount "$WORK/dev" 2>/dev/null || true
sudo umount "$WORK/sys" 2>/dev/null || true
sudo umount "$WORK/proc" 2>/dev/null || true
$SUDO umount "$WORK/dev" 2>/dev/null || true
$SUDO umount "$WORK/sys" 2>/dev/null || true
$SUDO umount "$WORK/proc" 2>/dev/null || true
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
sudo rm -rf "$WORK" 2>/dev/null || true
$SUDO rm -rf "$WORK" 2>/dev/null || true
}
trap cleanup EXIT

Expand All @@ -54,20 +63,20 @@ docker create --name "$CONTAINER" "$IMAGE" /bin/true >/dev/null
# ----------------------------------------------------------------------------
say "[2/5] exporting container filesystem to $WORK..."
mkdir -p "$WORK"
docker export "$CONTAINER" | sudo tar -xf - -C "$WORK"
sudo du -sh "$WORK"
docker export "$CONTAINER" | $SUDO tar -xf - -C "$WORK"
$SUDO du -sh "$WORK"

# ----------------------------------------------------------------------------
if [ "${#EXTRA_PKGS[@]}" -gt 0 ]; then
say "[3/5] chroot apt install: ${EXTRA_PKGS[*]}"

# bring up host DNS + bind /proc /sys /dev for apt to work
sudo cp /etc/resolv.conf "$WORK/etc/resolv.conf"
sudo mount --bind /proc "$WORK/proc"
sudo mount --bind /sys "$WORK/sys"
sudo mount --bind /dev "$WORK/dev"
$SUDO cp /etc/resolv.conf "$WORK/etc/resolv.conf"
$SUDO mount --bind /proc "$WORK/proc"
$SUDO mount --bind /sys "$WORK/sys"
$SUDO mount --bind /dev "$WORK/dev"

sudo chroot "$WORK" /bin/bash -e <<EOF
$SUDO chroot "$WORK" /bin/bash -e <<EOF
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ${EXTRA_PKGS[*]} 2>&1 | tail -5
Expand All @@ -76,9 +85,9 @@ apt-get clean
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
EOF

sudo umount "$WORK/dev" || true
sudo umount "$WORK/sys" || true
sudo umount "$WORK/proc" || true
$SUDO umount "$WORK/dev" || true
$SUDO umount "$WORK/sys" || true
$SUDO umount "$WORK/proc" || true
else
say "[3/5] skipping apt install (no extra pkgs requested)"
fi
Expand All @@ -88,15 +97,15 @@ say "[4/5] installing forkd init + agent..."
# Copy the init script and the Python agent into the rootfs.
INIT_SRC="$(dirname "$(readlink -f "$0")")/../rootfs-init"
if [ -d "$INIT_SRC" ]; then
sudo cp "$INIT_SRC/forkd-init.sh" "$WORK/forkd-init.sh"
sudo cp "$INIT_SRC/forkd-agent.py" "$WORK/forkd-agent.py"
sudo chmod 755 "$WORK/forkd-init.sh" "$WORK/forkd-agent.py"
$SUDO cp "$INIT_SRC/forkd-init.sh" "$WORK/forkd-init.sh"
$SUDO cp "$INIT_SRC/forkd-agent.py" "$WORK/forkd-agent.py"
$SUDO chmod 755 "$WORK/forkd-init.sh" "$WORK/forkd-agent.py"
say " installed /forkd-init.sh and /forkd-agent.py"
else
say " rootfs-init/ not found at $INIT_SRC — guest will boot without forkd agent"
fi
# Empty root password for development convenience.
sudo chroot "$WORK" /bin/bash -c "passwd -d root 2>/dev/null || true"
$SUDO chroot "$WORK" /bin/bash -c "passwd -d root 2>/dev/null || true"

# ----------------------------------------------------------------------------
say "[5/5] building ext4 image ($SIZE_MB MiB)..."
Expand Down
4 changes: 3 additions & 1 deletion scripts/host-tap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ set -euo pipefail

TAP="${TAP:-forkd-tap0}"
TAP_IP="${TAP_IP:-10.42.0.1}"
USER_OWNS="${USER_OWNS:-${SUDO_USER:-$USER}}"
# $USER is unset in non-login shells (docker exec, some CI) and this
# script runs under `set -u` — fall back through id(1).
USER_OWNS="${USER_OWNS:-${SUDO_USER:-${USER:-$(id -un)}}}"

[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
command -v ip >/dev/null || { echo "ip(8) required" >&2; exit 1; }
Expand Down
Loading
Loading