diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9f47212..efb9773 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -59,3 +59,8 @@ jobs: with: packages-dir: sdk/python/dist print-hash: true + # Idempotent under force-pushed / re-run tags: a 400 "File + # already exists" on PyPI is a no-op, not a release failure. + # PyPI immutability means we never overwrite an existing + # version — re-pushing a tag just becomes a green skip. + skip-existing: true diff --git a/README.md b/README.md index cee595e..a6de706 100644 --- a/README.md +++ b/README.md @@ -427,13 +427,35 @@ Measured CoW overhead at N=100 is **0.12 MiB / child** on top of the parent ([be ## Quick start -Requires: x86_64 Linux with KVM, Ubuntu 22.04 or newer. Two steps to a real fork: set up the host (one-time), then `forkd pull` + fork (~30 s). The sections after that show alternative entry points for custom recipes. +Requires: x86_64 Linux with KVM, Ubuntu 22.04 or newer. -### Confirm your host is ready +### One command + +```bash +# CLI + daemon binaries (pre-built tarball, no Rust toolchain needed): +curl -sSL https://github.com/deeplethe/forkd/releases/download/v0.5.2/forkd-v0.5.2-x86_64-linux.tar.gz \ + | sudo tar -xz -C /usr/local/bin/ + +sudo -E forkd quickstart +``` + +`quickstart` preflights the host (KVM, Firecracker, kernel image), tells +you what it wants to set up (guest kernel download, tap device, +per-child netns — nothing happens without your consent or `--yes`), +bakes a Python snapshot from `python:3.12-slim` with your local +Firecracker (or pulls a prebaked one from the hub when Docker is +absent), then forks 10 microVM children from it and prints per-child +timings. Idempotent — re-running reuses the snapshot and skips +completed setup. + +The sections below are the same flow broken into individual verbs, for +when you want control over each step. + +### Manual path: confirm your host is ready ```bash # 1. CLI + daemon binaries (pre-built tarball, no Rust toolchain needed): -curl -sSL https://github.com/deeplethe/forkd/releases/download/v0.3.4/forkd-v0.3.4-x86_64-linux.tar.gz \ +curl -sSL https://github.com/deeplethe/forkd/releases/download/v0.5.2/forkd-v0.5.2-x86_64-linux.tar.gz \ | sudo tar -xz -C /usr/local/bin/ # 2. Host bring-up: diff --git a/crates/forkd-cli/src/doctor.rs b/crates/forkd-cli/src/doctor.rs index 0d68478..be2f819 100644 --- a/crates/forkd-cli/src/doctor.rs +++ b/crates/forkd-cli/src/doctor.rs @@ -109,6 +109,69 @@ pub fn run(daemon_url: &str, daemon_token: Option) -> anyhow::Result<()> Ok(()) } +/// What `preflight()` learned about the host, beyond pass/fail. +/// `quickstart` uses these to pick a snapshot route (Docker bake vs +/// hub pull) and to decide which setup steps to offer. +pub(crate) struct PreflightReport { + pub docker_ok: bool, + pub tap_ok: bool, + pub kernel_ok: bool, +} + +/// Hard-gate subset of [`run`] for `forkd quickstart`. Prints the same +/// report format. Only platform / virtualization / KVM / firecracker +/// failures block — kernel, tap, and Docker problems are returned as +/// data because quickstart can heal the first two (with consent) and +/// route around the third (hub pull instead of local bake). +pub(crate) fn preflight() -> anyhow::Result { + let kernel = check_kernel_image(); + let tap = check_tap_device("forkd-tap0"); + let docker = check_docker_daemon(); + let report = PreflightReport { + docker_ok: docker.status == Status::Pass, + tap_ok: tap.status == Status::Pass, + kernel_ok: kernel.status == Status::Pass, + }; + + let gates = vec![ + check_platform(), + check_hw_virt(), + check_kvm(), + check_firecracker_binary(), + ]; + let blocked = gates.iter().any(|c| c.status == Status::Fail); + + let mut all = gates; + all.push(kernel); + all.push(tap); + all.push(docker); + print_report(&all); + + if blocked { + anyhow::bail!( + "quickstart preflight failed — fix the ✗ items above \ + (scripts/setup-host.sh covers most of them) and re-run" + ); + } + Ok(report) +} + +/// Number of provisioned `forkd-child-*` netns. Same probe as the +/// doctor's "per-child netns" row, exposed for quickstart's fan-out +/// sizing. +pub(crate) fn netns_count() -> usize { + let nsdir = Path::new("/var/run/netns"); + let mut count = 0usize; + if let Ok(rd) = std::fs::read_dir(nsdir) { + for e in rd.flatten() { + if e.file_name().to_string_lossy().starts_with("forkd-child-") { + count += 1; + } + } + } + count +} + fn print_report(checks: &[Check]) { let max_name = checks.iter().map(|c| c.name.len()).max().unwrap_or(0); for c in checks { diff --git a/crates/forkd-cli/src/main.rs b/crates/forkd-cli/src/main.rs index e6e8792..7ab2931 100644 --- a/crates/forkd-cli/src/main.rs +++ b/crates/forkd-cli/src/main.rs @@ -42,6 +42,26 @@ struct Cli { #[derive(Subcommand)] enum Cmd { + /// Zero-to-first-fork in one command. Preflights the host, heals + /// what's missing with your consent (guest kernel download, tap + /// device, per-child netns), bakes a Python snapshot from Docker + /// (or pulls one from the hub when Docker is absent), then forks + /// N children and prints per-child timings. + /// + /// Run as: sudo -E forkd quickstart + Quickstart { + /// How many children to fork for the demo. + #[arg(long, short, default_value_t = 10)] + n: usize, + /// Docker image the parent snapshot is baked from. Ignored when + /// Docker is unavailable (hub pull route). + #[arg(long, default_value = "python:3.12-slim")] + image: String, + /// Answer yes to all setup prompts (kernel download, tap + /// creation, netns provisioning) — for non-interactive use. + #[arg(long, short)] + yes: bool, + }, /// Boot a parent VM, warm it up, snapshot to disk — or, with /// `--from-sandbox`, snapshot a running child sandbox into a new /// tag via the controller daemon (sandbox branching). @@ -817,6 +837,7 @@ fn main() -> Result<()> { extra, } => parent_build_cmd(image, output, size_mib, extra), }, + Cmd::Quickstart { n, image, yes } => quickstart_cmd(n, image, yes), Cmd::Run { image, extra, @@ -1669,6 +1690,164 @@ fn parent_build_cmd( Ok(()) } +// ---------------------------------------------------------------------- +// quickstart +// ---------------------------------------------------------------------- + +/// Embedded copies of the host-setup scripts so `forkd quickstart` +/// works from a tarball install where the repo's `scripts/` directory +/// isn't on disk. `include_str!` keeps them in lockstep with the repo +/// copies at compile time — there is exactly one source of truth. +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"); + +fn quickstart_cmd(n: usize, image: String, yes: bool) -> Result<()> { + eprintln!("==> forkd quickstart — host preflight"); + let pf = doctor::preflight()?; + eprintln!(); + + // Collect the setup steps this host still needs. Each is one of + // the repo's idempotent root-gated scripts, embedded above. + let mut fixes: Vec<(String, &str, Vec)> = Vec::new(); + if !pf.kernel_ok { + fixes.push(( + "download guest kernel vmlinux-6.1.141 (~40 MiB → /var/lib/forkd/kernels/)".into(), + QS_INSTALL_KERNEL_SH, + vec![], + )); + } + if !pf.tap_ok { + fixes.push(( + "create host tap forkd-tap0 (10.42.0.1/24) + enable ip_forward".into(), + QS_HOST_TAP_SH, + vec![], + )); + } + let have_ns = doctor::netns_count(); + if have_ns < n { + fixes.push(( + format!( + "provision {n} per-child netns + forkd-br0 bridge + MASQUERADE \ + ({have_ns} present)" + ), + QS_NETNS_SETUP_SH, + vec![n.to_string()], + )); + } + + if !fixes.is_empty() { + ensure_root("quickstart needs root for host setup; run: sudo -E forkd quickstart")?; + eprintln!("==> quickstart wants to set up:"); + for (label, ..) in &fixes { + eprintln!(" • {label}"); + } + if !yes && !confirm(" proceed? [y/N] ")? { + bail!( + "aborted — nothing was changed. Re-run with --yes, or run the \ + equivalent scripts/ steps by hand" + ); + } + for (label, script, args) in &fixes { + eprintln!("==> {label}"); + run_embedded_script(script, args)?; + } + eprintln!(); + } + + // Snapshot: reuse > local Docker bake > hub pull. The local bake is + // preferred because it uses *this host's* Firecracker, so the + // vmstate can never hit cross-version restore errors — the exact + // failure mode hub-distributed snapshots are exposed to. + let tag = "quickstart".to_string(); + if snapshot_dir(&tag).join("vmstate").exists() { + eprintln!("==> reusing existing snapshot '{tag}'"); + } else if pf.docker_ok { + eprintln!("==> baking snapshot '{tag}' from {image} (first run 1-3 min; rootfs is cached)"); + from_image_cmd( + image, + tag.clone(), + vec![], + 1536, + PathBuf::from("/var/cache/forkd"), + None, + "forkd-tap0".to_string(), + 10, + None, + )?; + } else { + eprintln!("==> Docker unavailable — pulling a prebaked snapshot from the hub"); + eprintln!( + " note: hub snapshots are baked against a specific Firecracker; if the \ + restore below fails, install Docker and re-run quickstart for a local bake." + ); + pull_cmd( + "deeplethe/python-numpy".to_string(), + Some(tag.clone()), + false, + None, + )?; + } + eprintln!(); + + ensure_root("forking with per-child netns needs root; run: sudo -E forkd quickstart")?; + eprintln!("==> forking {n} children from '{tag}'"); + fork_cmd(tag.clone(), n, 2, true, None, false, false, false)?; + + eprintln!(); + eprintln!("✓ quickstart complete — {n} microVMs forked from one warm snapshot."); + eprintln!(); + eprintln!(" where to go next:"); + eprintln!(" sudo -E forkd fork --tag {tag} -n 100 --per-child-netns # go wider"); + eprintln!( + " sudo -E forkd exec --child forkd-child-1 -- python3 -c 'print(\"hi from a fork\")'" + ); + eprintln!(" forkd images # snapshots on disk"); + eprintln!(" https://github.com/deeplethe/forkd/tree/main/recipes # CI fan-out, DB fixtures, agent sandboxes"); + Ok(()) +} + +/// Bail with `msg` unless we're already root. The setup scripts and +/// netns-scoped forks both need it; checking up front beats a partial +/// run that dies halfway with a scarier error. +fn ensure_root(msg: &str) -> Result<()> { + #[cfg(unix)] + { + if unsafe { libc::geteuid() } != 0 { + bail!("{msg}"); + } + } + Ok(()) +} + +/// Prompt on stderr, read one stdin line, accept y/Y/yes. +fn confirm(prompt: &str) -> Result { + use std::io::{BufRead, Write}; + eprint!("{prompt}"); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + let t = line.trim().to_ascii_lowercase(); + Ok(t == "y" || t == "yes") +} + +/// Write an embedded setup script to a temp file and run it via bash. +/// stdout/stderr inherit so the operator sees exactly what it does. +fn run_embedded_script(body: &str, args: &[String]) -> Result<()> { + let tmp = std::env::temp_dir().join(format!("forkd-quickstart-{}.sh", std::process::id())); + std::fs::write(&tmp, body).context("write embedded setup script")?; + let status = std::process::Command::new("bash") + .arg(&tmp) + .args(args) + .status() + .context("spawn bash for embedded setup script")?; + std::fs::remove_file(&tmp).ok(); + if !status.success() { + bail!("setup script failed ({status}); host may be partially configured"); + } + Ok(()) +} + /// `forkd run` — one-shot sandbox: build (if needed) → snapshot → fork → exec → kill. /// `forkd from-image` — Docker image → ext4 (cached) → snapshot tag. ///