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
5 changes: 5 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions crates/forkd-cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,69 @@ pub fn run(daemon_url: &str, daemon_token: Option<String>) -> 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<PreflightReport> {
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 {
Expand Down
179 changes: 179 additions & 0 deletions crates/forkd-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>)> = 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<bool> {
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.
///
Expand Down
Loading