diff --git a/.github/workflows/e2e-daemon.yml b/.github/workflows/e2e-daemon.yml index b4dd8b6..2797b9a 100644 --- a/.github/workflows/e2e-daemon.yml +++ b/.github/workflows/e2e-daemon.yml @@ -1,35 +1,40 @@ name: e2e (daemon / gRPC) -# The daemon is a gRPC front end over the `bsdkrun` CLI — it links no -# hypervisor and boots nothing itself, so its tests need neither /dev/kvm nor -# libkrun and run on ordinary runners. The Rust suite drives a real server over -# a real socket against a stub CLI, which keeps it hermetic while still -# asserting the exact command lines the service produces. +# The daemon links the bsdkrun engine but not its `boot` half, so it still needs +# neither /dev/kvm nor libkrun and still runs on ordinary runners. What it reads +# — machines, images, volumes, networks — it reads in-process, so the tests seed +# a real database under a temp BSDKRUN_STATE; what it cannot do in-process it +# hands to `bsdkrun-supervisor`, which a stub stands in for so the suite can +# assert the command it was given without booting anything. # # Both hosts are covered because the interactive path differs between them: -# remote shells run the CLI under a pty, and pty behaviour is where macOS and -# Linux diverge most. +# remote shells run under a pty, and pty behaviour is where macOS and Linux +# diverge most. on: workflow_dispatch: {} pull_request: paths: - "daemon/**" + - "core/**" + - "supervisor/**" + - "Cargo.toml" + - "Cargo.lock" - ".github/workflows/e2e-daemon.yml" push: branches: - "main" paths: - "daemon/**" + - "core/**" + - "supervisor/**" + - "Cargo.toml" + - "Cargo.lock" - ".github/workflows/e2e-daemon.yml" permissions: contents: read -defaults: - run: - working-directory: daemon - jobs: test: name: ${{ matrix.os }} @@ -47,8 +52,6 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - with: - workspaces: daemon # tonic-prost-build shells out to protoc to compile the .proto. - name: Install protoc (Linux) @@ -64,17 +67,20 @@ jobs: - name: Formatting run: cargo fmt --check + # `-p`, not `--workspace`: cargo unifies features across one build, so + # pulling the CLI or the supervisor in would compile bsdkrun-core with + # `boot` on and demand libkrun on a runner that has none. - name: Clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy -p bsdkrun-daemon --all-targets -- -D warnings - name: Unit tests - run: cargo test --lib + run: cargo test -p bsdkrun-daemon --lib - name: End-to-end tests (gRPC) - run: cargo test --test e2e + run: cargo test -p bsdkrun-daemon --test e2e - name: End-to-end tests (GraphQL) - run: cargo test --test graphql + run: cargo test -p bsdkrun-daemon --test graphql # Exercises the actual shipped binary over the wire with a third-party client, # which is the only way to prove the parts no Rust test touches: that @@ -95,8 +101,6 @@ jobs: node-version: "22" - uses: Swatinem/rust-cache@v2 - with: - workspaces: daemon - name: Install protoc and grpcurl run: | @@ -111,30 +115,41 @@ jobs: grpcurl --version - name: Build the daemon - run: cargo build --release --bins + run: cargo build --release -p bsdkrun-daemon - - name: Start the daemon against a stub CLI + - name: Start the daemon run: | set -eux - # A stub stands in for the real CLI: this job is about the wire - # protocol, not about booting VMs. - cat > /tmp/bsdkrun <<'EOF' + # A stub stands in for bsdkrun-supervisor: this job is about the wire + # protocol, not about booting VMs, and the supervisor is the only half + # that needs a hypervisor. + cat > /tmp/bsdkrun-supervisor <<'EOF' #!/bin/sh - case "$1" in - --version) echo "bsdkrun 9.9.9-stub" ;; - ps) echo '[]' ;; - probe) echo "probe ok" ;; + case "$2" in + *'"Probe"'*) echo "probe ok"; exit 0 ;; # Backs the interactive-shell test: echo a marker, then read stdin # so keystrokes sent over GraphQL have something to reach. - exec) echo "EXEC_OK"; cat ;; - *) echo "unknown: $1" >&2; exit 2 ;; + *'"Exec"'*|*'"Shell"'*) echo "EXEC_OK"; cat; exit 0 ;; esac + if [ "$1" = cli ]; then + shift; [ "$1" = -- ] && shift + case "$1" in + probe) echo "probe ok"; exit 0 ;; + *) echo "unknown: $1" >&2; exit 2 ;; + esac + fi + echo "unknown: $2" >&2; exit 2 EOF - chmod +x /tmp/bsdkrun + chmod +x /tmp/bsdkrun-supervisor + + # A state directory of its own, so the daemon reads a database this + # job owns rather than whatever the runner happens to have. + export BSDKRUN_STATE=/tmp/bsdkrun-state + mkdir -p "$BSDKRUN_STATE" BSDKRUN_TOKEN=ci-secret-token \ ./target/release/bsdkrund --bind 127.0.0.1:50077 \ - --graphql-bind 127.0.0.1:50078 --bsdkrun /tmp/bsdkrun \ + --graphql-bind 127.0.0.1:50078 --supervisor /tmp/bsdkrun-supervisor \ > /tmp/daemon.log 2>&1 & echo $! > /tmp/daemon.pid @@ -184,7 +199,10 @@ jobs: set -eux grpcurl -plaintext -H 'authorization: Bearer ci-secret-token' -d '{}' \ 127.0.0.1:50077 bsdkrun.v1.Bsdkrun/Info | tee /tmp/info.json - grep -q "9.9.9-stub" /tmp/info.json + # The engine is linked in, so its version is reported directly rather + # than read back out of another binary. + grep -q '"cliVersion"' /tmp/info.json + grep -q '"daemonVersion"' /tmp/info.json - name: The x-bsdkrun-token header works too run: | diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ae942b4..a093405 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -53,19 +53,26 @@ jobs: BSDKRUN_IMPURE: "1" run: nix build -L ${{ matrix.flags }} .#bsdkrun - # The daemon links the bsdkrun engine, and therefore libkrun, so it is - # built exactly like the CLI: with ${{ matrix.flags }}, which is --impure - # on darwin so the flake can reach Homebrew's libkrun. It used to be built - # without those flags as an assertion that it had no Homebrew dependency; - # that assertion stopped being true when it stopped driving the CLI. + # The daemon takes the engine without its `boot` feature, so it still + # links no libkrun and builds purely everywhere. Deliberately built + # WITHOUT ${{ matrix.flags }}: on darwin that omits --impure, which keeps + # this step an assertion that the daemon has no Homebrew dependency. - name: nix build (bsdkrund) + run: nix build -L --out-link result-bsdkrund .#bsdkrund + + # The supervisor is the half that does link libkrun, so it is built like + # the CLI — impurely on darwin. + - name: nix build (bsdkrun-supervisor) env: BSDKRUN_IMPURE: "1" - run: nix build -L ${{ matrix.flags }} --out-link result-bsdkrund .#bsdkrund + run: nix build -L ${{ matrix.flags }} --out-link result-supervisor .#bsdkrun-supervisor - name: Smoke-test bsdkrund run: ./result-bsdkrund/bin/bsdkrund --version + - name: Smoke-test bsdkrun-supervisor + run: ./result-supervisor/bin/bsdkrun-supervisor --version + - name: nix flake check env: BSDKRUN_IMPURE: "1" diff --git a/.github/workflows/release-daemon.yml b/.github/workflows/release-daemon.yml index 2ec1b1c..58095a1 100644 --- a/.github/workflows/release-daemon.yml +++ b/.github/workflows/release-daemon.yml @@ -2,10 +2,12 @@ name: release (bsdkrund) # Builds the gRPC daemon for macOS and Linux on both architectures. # -# Unlike the CLI, the daemon links no libkrun: it drives the `bsdkrun` binary -# already installed on the host as a subprocess. That makes this a plain Rust -# build with no hypervisor dependency, so no libkrunfw kernel compile and no -# per-arch native library juggling. +# Unlike the CLI, the daemon links no libkrun: it takes `bsdkrun-core` without +# its `boot` feature, so it reads and manages machines itself but hands anything +# that has to *start* one to `bsdkrun-supervisor`, which ships with the CLI. +# That keeps this a plain Rust build with no hypervisor dependency — no libkrunfw +# kernel compile, no per-arch native library juggling, and a genuinely static +# artifact. # # Linux targets are musl, so the artifacts are statically linked and run on any # distro a user might have on a VPS or bare-metal box — glibc builds from a @@ -64,8 +66,7 @@ jobs: ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} # Fail here, with a reason, rather than several steps later as an opaque - # "cannot start /bin/bash: No such file or directory" from the first step - # that sets working-directory: daemon. + # cargo error about an unknown package. - name: Check out contains the daemon run: | set -eu @@ -81,7 +82,6 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - workspaces: daemon key: ${{ matrix.target }} # tonic-prost-build shells out to protoc to compile the .proto. @@ -93,15 +93,17 @@ jobs: if: matrix.os == 'darwin' run: brew install protobuf + # `-p`, not `--workspace`: cargo unifies features across one build, so + # building the supervisor here too would compile bsdkrun-core with `boot` + # on and link libkrun into the daemon — exactly what the static check + # below exists to catch. - name: Build - working-directory: daemon - run: cargo build --release --bin bsdkrund --target ${{ matrix.target }} + run: cargo build --release -p bsdkrun-daemon --target ${{ matrix.target }} # A statically linked binary must not have an interpreter or NEEDED # entries; catching that here beats discovering it on someone's server. - name: Verify the Linux binary is static if: matrix.os == 'linux' - working-directory: daemon run: | set -eux bin=target/${{ matrix.target }}/release/bsdkrund @@ -128,11 +130,9 @@ jobs: # Every target is built on a runner of its own architecture, so the # artifact can simply be run. - name: Smoke-test the binary - working-directory: daemon run: ./target/${{ matrix.target }}/release/bsdkrund --version - name: Package - working-directory: daemon run: | set -eux mkdir -p dist @@ -149,13 +149,13 @@ jobs: uses: actions/upload-artifact@v4 with: name: bsdkrund-${{ matrix.target }} - path: daemon/dist/* + path: dist/* - name: Publish release assets if: ${{ startsWith(github.ref, 'refs/tags/') || inputs.tag != '' }} uses: softprops/action-gh-release@v2 with: tag_name: ${{ inputs.tag != '' && inputs.tag || github.ref_name }} - files: daemon/dist/* + files: dist/* fail_on_unmatched_files: true generate_release_notes: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd86d4d..4e3aed4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,16 @@ jobs: - name: Build + sign bsdkrun (release) run: make release + # `bsdkrun-supervisor` is what bsdkrund uses to boot a machine. It has the + # CLI's dependency profile — it links libkrun — so it ships here rather + # than with the daemon, whose whole point is being static and portable. + - name: Build + sign bsdkrun-supervisor + run: | + set -eux + cargo build --release -p bsdkrun-supervisor + codesign --entitlements bsdkrun.entitlements --force -s - \ + target/release/bsdkrun-supervisor + - name: Package artifacts run: | set -eux @@ -91,6 +101,11 @@ jobs: # Per-tarball checksum sidecar (the @bsdkrun/cli postinstall verifies it). ( cd dist && shasum -a 256 bsdkrun-aarch64-apple-darwin.tar.gz \ > bsdkrun-aarch64-apple-darwin.tar.gz.sha256 ) + # The supervisor, packaged the same way, for a host running bsdkrund. + tar -C target/release \ + -czf dist/bsdkrun-supervisor-aarch64-apple-darwin.tar.gz bsdkrun-supervisor + ( cd dist && shasum -a 256 bsdkrun-supervisor-aarch64-apple-darwin.tar.gz \ + > bsdkrun-supervisor-aarch64-apple-darwin.tar.gz.sha256 ) # All cross-built guest agents (linux/freebsd × aarch64/x86_64). BSD # ones are for manual injection into a running VM. cp core/src/agent-bin/bsdkrun-agent.* dist/ @@ -212,6 +227,11 @@ jobs: - name: Build bsdkrun (release) run: cargo build --release + # Same libkrun dependency as the CLI, so it is bundled the same way below + # rather than shipped with the (deliberately static) daemon. + - name: Build bsdkrun-supervisor + run: cargo build --release -p bsdkrun-supervisor + # Bundle libkrun so the npm package is self-contained: ship bsdkrun next to # the libkrun/libkrunfw shared objects it links, and rewrite every object's # rpath to $ORIGIN so the loader finds its siblings in the same dir. The @@ -222,6 +242,7 @@ jobs: set -eux rm -rf dist pkg && mkdir -p dist pkg cp target/release/bsdkrun pkg/bsdkrun + cp target/release/bsdkrun-supervisor pkg/bsdkrun-supervisor # Copy every non-system shared lib bsdkrun (transitively) needs — the # ones we built into /usr/local — into pkg/ under their SONAME. ldd @@ -238,6 +259,7 @@ jobs: done } copy_local_deps pkg/bsdkrun + copy_local_deps pkg/bsdkrun-supervisor # Point bsdkrun and each bundled lib at their siblings ($ORIGIN = the # dir the object is loaded from). Single quotes: the linker, not the @@ -246,8 +268,10 @@ jobs: echo "----- bundle contents -----"; ls -l pkg echo "----- bsdkrun deps after rpath rewrite -----"; ldd pkg/bsdkrun || true + echo "----- supervisor deps -----"; ldd pkg/bsdkrun-supervisor || true - # Pack the whole bundle (bsdkrun + *.so.*). Postinstall extracts it all. + # Pack the whole bundle (bsdkrun + bsdkrun-supervisor + *.so.*). + # Postinstall extracts it all. tar -C pkg -czf "dist/bsdkrun-${{ matrix.triple }}.tar.gz" . ( cd dist && shasum -a 256 "bsdkrun-${{ matrix.triple }}.tar.gz" \ > "bsdkrun-${{ matrix.triple }}.tar.gz.sha256" ) diff --git a/Cargo.lock b/Cargo.lock index 07a4ed6..f993511 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,6 +635,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "bsdkrun-supervisor" +version = "0.6.0" +dependencies = [ + "anyhow", + "bsdkrun-core", + "clap", + "serde_json", + "tracing-subscriber", +] + [[package]] name = "bytes" version = "1.12.1" diff --git a/Cargo.toml b/Cargo.toml index 78dd967..a622a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ # `default-members` keeps `cargo build --release` meaning what it always has — -# just the CLI. The daemon is still built on demand (`cargo build -p -# bsdkrun-daemon`), so the CLI's release flow never pays for tonic, axum and a -# GraphQL engine; being workspace members only buys them a shared lockfile and -# one `target/` dir, which matters now that they share `bsdkrun-core`. +# just the CLI. The daemon and its supervisor are built on demand (`cargo build +# -p bsdkrun-daemon`), so the CLI's release flow never pays for tonic, axum and +# a GraphQL engine; being workspace members only buys them a shared lockfile and +# one `target/` dir, which matters now that they all share `bsdkrun-core`. # # `agent/` stays out: it is cross-compiled for the guest, not the host. [workspace] # The root package is a member implicitly. Listing it as `"."` as well makes # cargo stop honouring `exclude` for nested manifests, which quietly adopts the # Tauri app and the examples below. -members = ["core", "daemon"] +members = ["core", "daemon", "supervisor"] default-members = ["."] # Everything else in the tree that carries a Cargo.toml but is built on its own # terms: the guest agent (cross-compiled), the Tauri app, and the unikraft diff --git a/Makefile b/Makefile index fa55b2c..d26832a 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,10 @@ BIN_RELEASE := target/release/bsdkrun # so it needs the hypervisor entitlement exactly as the CLI does. DAEMON_DEBUG := target/debug/bsdkrund DAEMON_RELEASE := target/release/bsdkrund +# The supervisor is the half that links libkrun, so it is the one that actually +# needs the entitlement; the daemon is signed alongside it for consistency. +SUPERVISOR_DEBUG := target/debug/bsdkrun-supervisor +SUPERVISOR_RELEASE := target/release/bsdkrun-supervisor ENTITLEMENTS := bsdkrun.entitlements UNAME_S := $(shell uname -s) @@ -36,18 +40,21 @@ sign: ifeq ($(UNAME_S),Darwin) codesign --entitlements $(ENTITLEMENTS) --force -s - $(BIN_DEBUG) @[ -f $(DAEMON_DEBUG) ] && codesign --entitlements $(ENTITLEMENTS) --force -s - $(DAEMON_DEBUG) || true + @[ -f $(SUPERVISOR_DEBUG) ] && codesign --entitlements $(ENTITLEMENTS) --force -s - $(SUPERVISOR_DEBUG) || true endif sign-release: ifeq ($(UNAME_S),Darwin) codesign --entitlements $(ENTITLEMENTS) --force -s - $(BIN_RELEASE) @[ -f $(DAEMON_RELEASE) ] && codesign --entitlements $(ENTITLEMENTS) --force -s - $(DAEMON_RELEASE) || true + @[ -f $(SUPERVISOR_RELEASE) ] && codesign --entitlements $(ENTITLEMENTS) --force -s - $(SUPERVISOR_RELEASE) || true endif # Sign just the daemon (macOS only; a no-op elsewhere). sign-daemon: ifeq ($(UNAME_S),Darwin) codesign --entitlements $(ENTITLEMENTS) --force -s - $(DAEMON_RELEASE) + codesign --entitlements $(ENTITLEMENTS) --force -s - $(SUPERVISOR_RELEASE) endif # --- web UI ---------------------------------------------------------------- @@ -63,8 +70,12 @@ web: # --- daemon ---------------------------------------------------------------- # # Standalone crate (own workspace): the gRPC + GraphQL server. +# `-p` per package, deliberately: cargo unifies features across a single build, +# so `--workspace` would compile bsdkrun-core once with `boot` on and link +# libkrun into the daemon — the one thing this split exists to avoid. daemon: cargo build --release -p bsdkrun-daemon + cargo build --release -p bsdkrun-supervisor @$(MAKE) sign-daemon # --- guest agents (release assets) ----------------------------------------- diff --git a/core/Cargo.toml b/core/Cargo.toml index a1f5618..0cb6637 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -11,11 +11,21 @@ description = "The bsdkrun engine: microVM boot, images, networking, volumes and # # `links` claims libkrun for the whole build graph, and lets `build.rs` publish # the library directory to dependents as `DEP_KRUN_LIBDIR` — see build.rs for why -# the binary crates need it. +# the binary crates need it. With `boot` off, build.rs emits nothing and there is +# no libkrun in the link at all. links = "krun" [features] -default = [] +default = ["boot"] +# Actually starting machines: the libkrun FFI and every boot path over it. +# +# Off, this crate links no hypervisor and builds anywhere — which is what lets +# `bsdkrund` stay a static, dependency-free binary while still being bsdkrun +# rather than a wrapper around it. It keeps the whole read and management +# surface (`api`, `ps`, `commit`, volumes, networks, flavors) and the command +# definitions it needs to *describe* a boot; `bsdkrun-supervisor` is the crate +# that turns one into a running machine. +boot = [] # `bsdkrun ui` — the embedded web SPA. Off by default so a consumer that only # wants the engine (the daemon) does not pull in a web server. ui = ["dep:actix-web", "dep:rust-embed", "dep:mime_guess"] diff --git a/core/build.rs b/core/build.rs index 2f9370f..4fa279b 100644 --- a/core/build.rs +++ b/core/build.rs @@ -16,10 +16,18 @@ fn main() { ensure_web_assets(); } - println!("cargo:rustc-link-lib=dylib=krun"); println!("cargo:rerun-if-env-changed=LIBKRUN_PREFIX"); println!("cargo:rerun-if-changed=build.rs"); + // Without `boot` there is no FFI compiled, so there is nothing to link and + // nothing to tell dependents about: the crate builds on a host that has + // never heard of libkrun. + if std::env::var_os("CARGO_FEATURE_BOOT").is_none() { + return; + } + + println!("cargo:rustc-link-lib=dylib=krun"); + // Explicit override wins on any OS. if let Ok(prefix) = std::env::var("LIBKRUN_PREFIX") { if !prefix.is_empty() { diff --git a/core/src/cli.rs b/core/src/cli.rs index 587e8df..d863cab 100644 --- a/core/src/cli.rs +++ b/core/src/cli.rs @@ -17,6 +17,7 @@ use clap::builder::styling::{Color, RgbColor, Style, Styles}; use clap::{Args, Parser, Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "boot")] use crate::krun; use crate::net::PortForward; #[cfg(target_os = "macos")] @@ -732,6 +733,7 @@ pub struct LinuxArgs { impl LinuxArgs { /// virtio-fs is the default; `--initramfs` opts out of it. + #[cfg(feature = "boot")] pub(crate) fn virtiofs(&self) -> bool { !self.initramfs } @@ -782,6 +784,7 @@ pub enum KernelFormat { } impl KernelFormat { + #[cfg(feature = "boot")] pub(crate) fn to_krun(self) -> u32 { match self { KernelFormat::Raw => krun::KRUN_KERNEL_FORMAT_RAW, diff --git a/core/src/commands/boot.rs b/core/src/commands/boot.rs index 6cf0768..6795184 100644 --- a/core/src/commands/boot.rs +++ b/core/src/commands/boot.rs @@ -10,14 +10,19 @@ use tracing::{info, warn}; use crate::cli::*; use crate::krun::Ctx; use crate::net::{Gvproxy, PortForward}; -#[cfg(target_os = "macos")] -use crate::store; use crate::{ - agent, console, db, fetch, host, id, krun, linux, names, nanos, net, network, oci, osv, tty, - unikraft, watchdog, + agent, console, db, fetch, flavors, host, id, krun, linux, names, nanos, net, network, oci, + osv, tty, unikraft, watchdog, }; -use super::guest::{agent_error, agent_target, interactive_shell_argv, interactive_shell_env}; +use super::flavor::{ + flavor_build_key, flavor_build_volume, flavor_provision_argv, resolve_linux_flavor, + LinuxFlavorSpec, +}; +use super::guest::{ + agent_error, agent_target, guest_os_kind, interactive_shell_argv, interactive_shell_env, +}; +use super::{basename, machine_dir_or_tmp, machine_rootfs_dir, volume_dir}; /// Attach any `--attach-disk` images after the root disk. Block ids are /// `data0`, `data1`, … — libkrun only requires them to be unique. @@ -1164,35 +1169,6 @@ pub(crate) fn find_krunkit_firmware() -> Result { ) } -/// Per-machine state dir (`/machines/`), falling back to a temp dir. -pub(crate) fn machine_dir_or_tmp(id: &str) -> std::path::PathBuf { - let dir = db::machine_dir(id).unwrap_or_else(|_| std::env::temp_dir().join(id)); - std::fs::create_dir_all(&dir).ok(); - dir -} - -/// Directory that will hold a machine's writable rootfs clone. On macOS with a -/// case-sensitive store set up this lives on the store (nix guests need that, -/// and the clone stays CoW because source and destination share a volume); -/// everywhere else it is the machine's own state dir, as before. -pub(crate) fn machine_rootfs_dir(id: &str, vdir: &std::path::Path) -> std::path::PathBuf { - #[cfg(target_os = "macos")] - { - if let Some(d) = store::machine_rootfs_dir(id) { - return d; - } - } - let _ = id; - vdir.to_path_buf() -} - -/// The last path component, for display. -pub(crate) fn basename(p: &std::path::Path) -> String { - p.file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| p.to_string_lossy().into_owned()) -} - /// Prepare a BSD machine's root disk. With `volume`, the disk lives at a stable /// path under `/volumes` and is reused across runs (changes persist); /// with `persist`, the base disk is booted in place; otherwise it's cloned into @@ -1268,21 +1244,6 @@ pub(crate) fn parse_mount(spec: &str) -> Result { }) } -/// Resolve a `--volume NAME` to its directory under `/volumes`, rejecting -/// names that could escape it. -pub(crate) fn volume_dir(name: &str) -> Result { - let ok = !name.is_empty() - && name != "." - && name != ".." - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); - if !ok { - anyhow::bail!("invalid volume name {name:?} — use letters, digits, '-', '_' or '.'"); - } - Ok(db::volumes_dir()?.join(name)) -} - /// Copy `src` to `dst` as a copy-on-write clone where the filesystem supports it /// (APFS on macOS, reflink on Linux), falling back to a plain copy. pub(crate) fn clone_cow_file(src: &std::path::Path, dst: &std::path::Path) -> Result<()> { @@ -2102,3 +2063,509 @@ pub(crate) fn run_guest_command( } std::process::exit(code); } + +// --------------------------------------------------------------------------- +// restarting a machine +// --------------------------------------------------------------------------- + +/// Restart a stopped machine *in place*: re-boot the recorded image / resources +/// / volume under the SAME id (like `docker start`), rather than minting a new +/// machine. Detached. The guest OS is inferred from the recorded image ref. +pub(crate) fn cmd_start(id: &str) -> Result<()> { + let db = db::Db::open()?; + let vm = db.find_machine(id)?; + + // Already up? Nothing to do. + if vm.status == "running" && vm.pid.map(db::pid_alive).unwrap_or(false) { + println!("{}", vm.id); + return Ok(()); + } + + let cpus = vm.cpus.clamp(1, 255) as u8; + let mem = vm.mem.max(64) as u32; + let volume = vm.volume.clone(); + + // Resume a BSD machine from ITS OWN root disk, not a re-fetched base. The + // per-machine working disk (a CoW clone of the original base — a fetched + // image OR a committed snapshot) lives in the state dir as `root.`. + // Re-cloning a base here would silently replace the disk: for a snapshot + // flavor that means booting the DEFAULT image over the user's snapshot data + // (data loss). So when that disk exists, boot it IN PLACE (persist) — which + // also preserves runtime changes across stop/start, like `docker start`. + // Volume-backed machines already resume their volume disk, so skip those. + let existing_disk = if vm.volume.is_none() { + let vdir = machine_dir_or_tmp(&vm.id); + ["root.raw", "root.img", "root.qcow2"] + .iter() + .map(|n| vdir.join(n)) + .find(|p| p.exists()) + } else { + None + }; + + // The DB row is left in place (the boot re-records it via INSERT OR REPLACE), + // so it flips exited→running rather than vanishing from `ps`. NB: don't wipe + // the whole state dir here — that means an `rm -rf` of the old read-only nix + // rootfs, which is slow enough to make Play "spin forever". The Linux boot + // path (prepare_linux_root) renames the stale rootfs aside and GC's it in the + // background, so the restart returns promptly. Old sockets/logs/port files + // are simply overwritten on the new boot. + + // The next boot picks up this id + name instead of generating fresh ones. + id::set_override(&vm.id); + if let Some(name) = &vm.name { + names::set_override(name); + } + + // Re-join the recorded global network on restart (its membership is stored + // in the DB and edited via `network connect/disconnect`). Reuse the member + // name so the derived MAC — and thus the BSD DHCP lease — stays stable; hint + // the previously-assigned IP so a plain restart keeps its address. + if let Some(ip) = vm.net_ip.as_deref().filter(|s| !s.is_empty()) { + std::env::set_var("BSDKRUN_NET_PREF_IP", ip); + } + let net = NetConfig { + no_net: false, + ports: vec![], + mac: None, + network: vm.network.clone(), + name: vm.name.clone(), + }; + let vmcfg = VmConfig { cpus, mem }; + + // Detect the guest from the recorded kind first (the image ref is unreliable + // for a snapshot machine — its image is `disk.img`/`disk.raw`, not a + // `netbsd-*`/`freebsd-*` name). FreeBSD records `firmware` (macOS EFI) or + // `freebsd` (Linux PVH); NetBSD records `netbsd` (or legacy `kernel`). + let reference = vm.image.to_lowercase(); + let is_freebsd = + matches!(vm.kind.as_str(), "firmware" | "freebsd") || reference.starts_with("freebsd"); + let is_netbsd = + matches!(vm.kind.as_str(), "kernel" | "netbsd") || reference.starts_with("netbsd"); + + if vm.kind == "linux" { + let largs = LinuxArgs { + image: vm.image.clone(), + kernel: None, + kernel_version: linux::DEFAULT_KERNEL_VERSION.to_string(), + detach: true, + initramfs: false, + volume: volume.clone(), + mounts: vec![], + entrypoint: None, + env: vec![], + console: "hvc0".to_string(), + net, + vm: vmcfg, + repo: None, + command: vec![], // persistent restart — keep a console shell alive + }; + // Resume the machine's OWN rootfs (which holds its snapshot + runtime + // changes) by passing it as the boot source, so restart never re-clones + // the base OCI image and loses data. Reuse any intact, non-empty rootfs — + // NOT gated on /bin|/nix, so images without those top-level dirs still + // resume. Volume machines resume their volume rootfs already; a missing or + // broken (nested) dir falls back to the base image. + let own_rootfs = machine_dir_or_tmp(&vm.id).join("rootfs"); + let intact = own_rootfs.symlink_metadata().is_ok() + && !own_rootfs.join("rootfs").exists() + && std::fs::read_dir(&own_rootfs) + .map(|mut d| d.next().is_some()) + .unwrap_or(false); + if volume.is_none() && intact { + boot_linux_from(largs, Some(own_rootfs), &[]) + } else { + boot_linux(largs) + } + } else if vm.kind == "nanos" { + // Re-boot the machine's own cloned disk in place when it survives (so + // TFS state persists across stop/start, like the BSDs); fall back to + // a fresh clone of the original image. + let spec = nanos::BootSpec::load(std::path::Path::new(&vm.state_dir))?; + let reuse = existing_disk.is_some(); + boot_nanos_image( + &spec.image, + spec.kernel.as_deref(), + &spec.cmdline, + None, + true, + reuse, + net, + vmcfg, + existing_disk, + ) + } else if vm.kind == "osv" { + // Re-boot the machine's own cloned disk in place when it survives, so + // filesystem writes persist across stop/start like the BSDs; fall back + // to a fresh clone of the original image. + let spec = osv::BootSpec::load(std::path::Path::new(&vm.state_dir))?; + let reuse = existing_disk.is_some(); + boot_osv_image( + &spec.image, + &spec.cmdline, + spec.gic, + None, + false, + reuse, + volume.as_deref(), + &[], + true, + net, + vmcfg, + existing_disk, + ) + } else if vm.kind == "unikraft" { + // Nothing to resume — a unikernel has no disk — so this just boots the + // same image again, from the spec saved beside its console log. + let spec = unikraft::BootSpec::load(std::path::Path::new(&vm.state_dir))?; + boot_unikraft_image( + &spec.kernel, + &spec.cmdline, + spec.initramfs.as_deref(), + true, + net, + vmcfg, + &spec.volumes, + ) + } else if is_freebsd || is_netbsd { + // Boot the machine's own disk in place when it exists (see above), so a + // snapshot machine keeps its data; otherwise fall back to the base image. + let reuse = existing_disk.is_some(); + let args = BsdArgs { + version: None, // bundled image (as originally booted) + firmware: None, + force: false, + attach_disk: vec![], + disk_size: None, + run: RunConfig { + detach: true, + persist: reuse, // in-place boot of the existing disk (no re-clone) + volume, + }, + net, + vm: vmcfg, + verbose: false, + repo: None, + command: vec![], + }; + let result = match (is_freebsd, existing_disk) { + (true, Some(d)) => boot_freebsd_disk(args, Some(d)), + (true, None) => boot_freebsd(args), + (false, Some(d)) => boot_netbsd_disk(args, Some(d)), + (false, None) => boot_netbsd(args), + }; + // Booting the in-place disk relabels the row's image to `root.`; + // restore the original label so `ps` still shows what it was booted from. + if reuse && result.is_ok() { + db.set_machine_image(&vm.id, &vm.image).ok(); + } + result + } else { + // Put the id back for any future attempt and report clearly. + anyhow::bail!( + "don't know how to restart a {:?} machine ({}); start it with `run` instead", + vm.kind, + vm.image + ); + } +} + +// --------------------------------------------------------------------------- +// booting a flavor +// --------------------------------------------------------------------------- +// +// These live here rather than beside the rest of the flavor commands because +// they end in a boot: the listing and editing half of `flavor` has to stay +// available to a build that links no hypervisor. + +pub(crate) fn flavor_linux_args( + image: String, + detach: bool, + cpus: u8, + mem: u32, + volume: Option, + ports: Vec, + env: Vec, +) -> LinuxArgs { + LinuxArgs { + image, + kernel: None, + kernel_version: linux::DEFAULT_KERNEL_VERSION.to_string(), + detach, + initramfs: false, + volume, + mounts: vec![], + entrypoint: None, + env, + console: "hvc0".to_string(), + net: NetConfig { + no_net: false, + ports, + mac: None, + network: None, + name: None, + }, + vm: VmConfig { cpus, mem }, + repo: None, + command: vec![], + } +} + +/// Ensure a flavor's provisioned rootfs is built and cached, returning its path. +/// Cache hit → returns immediately (instant, no re-provisioning). Miss → runs +/// the provisioning build in a child `bsdkrun` process (streaming its progress) +/// and records the result so every later launch just clones it. +pub(crate) fn ensure_flavor_built( + spec: &LinuxFlavorSpec, + name: &str, + cpus: u8, + mem: u32, +) -> Result { + let key = flavor_build_key(&spec.image, &spec.nix, &spec.provision); + let vol = flavor_build_volume(&key); + let voldir = volume_dir(&vol)?; + let rootfs = voldir.join("rootfs"); + let marker = voldir.join(".provisioned"); + if marker.exists() && rootfs.exists() { + info!(flavor = name, key = %key, "using cached flavor build"); + return Ok(rootfs); + } + // Cache miss: build in a CHILD process. Provisioning ends in `process::exit` + // (see `run_guest_command`), so it must not run in this process — we need to + // survive it to clone + boot the real machine afterwards. + info!(flavor = name, key = %key, "building flavor (first launch)…"); + host::force_remove_dir_all(&voldir); // clear any half-built remnant + let exe = std::env::current_exe().context("locating bsdkrun for the flavor build")?; + let status = std::process::Command::new(exe) + .args([ + "flavor", + "__build", + name, + "--key", + &key, + "--cpus", + &cpus.to_string(), + "--mem", + &mem.to_string(), + ]) + .status() + .context("spawning the flavor build")?; + if !status.success() { + host::force_remove_dir_all(&voldir); + anyhow::bail!("provisioning {name} failed (see the output above)"); + } + if !rootfs.exists() { + host::force_remove_dir_all(&voldir); + anyhow::bail!("the flavor build produced no rootfs for {name}"); + } + std::fs::write(&marker, key.as_bytes()).ok(); + Ok(rootfs) +} + +/// Hidden `bsdkrun flavor __build` — the child that provisions a flavor into its +/// build volume, then powers the builder off. Not for direct use. +pub(crate) fn cmd_flavor_build(name: &str, key: &str, cpus: u8, mem: u32) -> Result<()> { + let spec = resolve_linux_flavor(name) + .ok_or_else(|| anyhow::anyhow!("no such Linux flavor to build: {name}"))?; + let argv = flavor_provision_argv(name, &spec.nix, &spec.provision) + .ok_or_else(|| anyhow::anyhow!("{name} has nothing to provision"))?; + let vol = flavor_build_volume(key); + + // Boot a builder whose root is the persistent build volume. A trivial + // keep-alive is the main process so the VM (and its agent) stay up on any + // base image while provisioning runs; `run_machine` powers it off when the + // provisioning command finishes (detach=false ⇒ keep_running=false). + let largs = LinuxArgs { + image: spec.image.clone(), + kernel: None, + kernel_version: linux::DEFAULT_KERNEL_VERSION.to_string(), + detach: false, + initramfs: false, + volume: Some(vol), + mounts: vec![], + entrypoint: None, + env: spec.env.clone(), + console: "hvc0".to_string(), + net: NetConfig { + no_net: false, + ports: vec![], + mac: None, + network: None, + name: None, + }, + vm: VmConfig { cpus, mem }, + repo: None, + command: vec![ + "sh".into(), + "-c".into(), + "while :; do sleep 86400; done".into(), + ], + }; + boot_linux_from(largs, None, &argv) +} + +/// `bsdkrun flavor build ` — pre-build a flavor's provisioned rootfs into +/// the cache so a later `run` is instant. Streams provisioning output. +pub(crate) fn cmd_flavor_prebuild(name: &str, cpus: u8, mem: u32, force: bool) -> Result<()> { + let Some(spec) = resolve_linux_flavor(name) else { + anyhow::bail!("no such Linux flavor to build: {name} (see `bsdkrun flavors`)"); + }; + if spec.nix.is_empty() && spec.provision.is_empty() { + println!("{name}: nothing to build (no provisioning steps)"); + return Ok(()); + } + if force { + // Drop the cached build so it's rebuilt from scratch. + let key = flavor_build_key(&spec.image, &spec.nix, &spec.provision); + if let Ok(dir) = volume_dir(&flavor_build_volume(&key)) { + host::force_remove_dir_all(&dir); + } + } + let built = ensure_flavor_built(&spec, name, cpus, mem)?; + info!(flavor = name, rootfs = %built.display(), "flavor built"); + println!("{name}"); + Ok(()) +} + +/// `bsdkrun flavor run ` — boot a machine from a catalog/user flavor or a +/// saved snapshot. Provisioned flavors are built once (cached) then cloned. +pub(crate) fn cmd_flavor_run(args: FlavorRunArgs) -> Result<()> { + let db = db::Db::open()?; + + // Optional `--repo` clones a repo into the machine after boot (cd on shell). + let repo_argv = args + .repo + .as_deref() + .and_then(repo_clone_argv) + .unwrap_or_default(); + + // A saved snapshot (from `commit`) wins over any catalog/user name. + if let Some(f) = db.find_flavor(&args.name)? { + // Normalize (old snapshots stored the boot mode: firmware/kernel). + let osk = guest_os_kind(&f.kind, &f.base); + if osk == "linux" { + let rootfs = std::path::PathBuf::from(&f.path).join("rootfs"); + if !rootfs.exists() { + anyhow::bail!("snapshot {:?} is missing its rootfs data", f.name); + } + let largs = flavor_linux_args( + f.base.clone(), + args.detach, + args.vm.cpus, + args.vm.mem, + args.volume, + args.ports, + vec![], + ); + return boot_linux_from(largs, Some(rootfs), &repo_argv); + } + + // BSD snapshot: boot from its saved root disk (`disk.raw` / `disk.img`). + let disk = ["disk.raw", "disk.img"] + .iter() + .map(|n| std::path::PathBuf::from(&f.path).join(n)) + .find(|p| p.exists()) + .ok_or_else(|| anyhow::anyhow!("snapshot {:?} is missing its disk data", f.name))?; + let bargs = BsdArgs { + version: None, + firmware: None, + force: false, + attach_disk: vec![], + disk_size: None, + run: RunConfig { + detach: args.detach, + persist: false, + volume: args.volume, + }, + net: NetConfig { + no_net: false, + ports: args.ports, + mac: None, + network: None, + name: None, + }, + vm: VmConfig { + cpus: args.vm.cpus, + mem: args.vm.mem, + }, + verbose: false, + repo: None, + command: repo_argv, + }; + return if osk == "netbsd" { + boot_netbsd_disk(bargs, Some(disk)) + } else { + boot_freebsd_disk(bargs, Some(disk)) + }; + } + + // A Linux flavor (catalog or user): build-once-then-clone. + if let Some(spec) = resolve_linux_flavor(&args.name) { + let mut ports: Vec = args.ports; + for p in &spec.ports { + if let Ok(pf) = p.parse::() { + ports.push(pf); + } + } + let largs = flavor_linux_args( + spec.image.clone(), + args.detach, + args.vm.cpus, + args.vm.mem, + args.volume, + ports, + spec.env.clone(), + ); + // Provisioned flavors boot from a cached, pre-provisioned rootfs; plain + // ones boot the base image directly. + let has_provisioning = !spec.nix.is_empty() || !spec.provision.is_empty(); + if has_provisioning { + let built = ensure_flavor_built(&spec, &args.name, args.vm.cpus, args.vm.mem)?; + return boot_linux_from(largs, Some(built), &repo_argv); + } + return boot_linux_from(largs, None, &repo_argv); + } + + // A BSD catalog flavor (no provisioning/cache — boots the bundled image). + let Some(c) = flavors::find(&args.name) else { + anyhow::bail!("no such flavor: {} (see `bsdkrun flavors`)", args.name); + }; + let mut ports: Vec = args.ports; + for p in c.ports { + if let Ok(pf) = p.parse::() { + ports.push(pf); + } + } + let bargs = BsdArgs { + version: None, + firmware: None, + force: false, + attach_disk: vec![], + disk_size: None, + run: RunConfig { + detach: args.detach, + persist: false, + volume: args.volume, + }, + net: NetConfig { + no_net: false, + ports, + mac: None, + network: None, + name: None, + }, + vm: VmConfig { + cpus: args.vm.cpus, + mem: args.vm.mem, + }, + verbose: false, + repo: None, + // On BSD the post-boot command IS the repo clone (if any). + command: repo_argv, + }; + match c.base { + flavors::Base::Freebsd => boot_freebsd(bargs), + flavors::Base::Netbsd => boot_netbsd(bargs), + flavors::Base::Oci(_) => unreachable!("OCI flavors handled above"), + } +} diff --git a/core/src/commands/flavor.rs b/core/src/commands/flavor.rs index 642f146..c54a8f0 100644 --- a/core/src/commands/flavor.rs +++ b/core/src/commands/flavor.rs @@ -1,53 +1,11 @@ //! Flavors: the built-in catalog, user definitions in `flavors.toml`, saved //! snapshots, and the build that turns a definition into a bootable rootfs. -use std::path::PathBuf; - -use anyhow::{Context, Result}; +use anyhow::Result; use tracing::info; -use crate::cli::*; -use crate::net::PortForward; -use crate::{api, db, flavors, host, linux}; - -use super::boot::{ - boot_freebsd, boot_freebsd_disk, boot_linux_from, boot_netbsd, boot_netbsd_disk, - repo_clone_argv, volume_dir, -}; -use super::guest::guest_os_kind; - -pub(crate) fn flavor_linux_args( - image: String, - detach: bool, - cpus: u8, - mem: u32, - volume: Option, - ports: Vec, - env: Vec, -) -> LinuxArgs { - LinuxArgs { - image, - kernel: None, - kernel_version: linux::DEFAULT_KERNEL_VERSION.to_string(), - detach, - initramfs: false, - volume, - mounts: vec![], - entrypoint: None, - env, - console: "hvc0".to_string(), - net: NetConfig { - no_net: false, - ports, - mac: None, - network: None, - name: None, - }, - vm: VmConfig { cpus, mem }, - repo: None, - command: vec![], - } -} +use crate::cli::FlavorAddArgs; +use crate::{api, db, flavors, host}; /// Validate a flavor/snapshot name (used as a directory + DB key). pub(crate) fn valid_flavor_name(name: &str) -> Result<()> { @@ -201,11 +159,11 @@ pub fn add_flavor(a: FlavorAddArgs) -> Result { /// A resolved Linux flavor: the base image plus its defaults and provisioning /// steps, from either the built-in catalog or a user `flavors.toml`. pub(crate) struct LinuxFlavorSpec { - image: String, - env: Vec, - ports: Vec, - nix: Vec, - provision: Vec, + pub(crate) image: String, + pub(crate) env: Vec, + pub(crate) ports: Vec, + pub(crate) nix: Vec, + pub(crate) provision: Vec, } /// Resolve a Linux flavor (catalog or user) by name. Returns `None` for a BSD @@ -309,262 +267,3 @@ pub(crate) fn flavor_provision_argv( lines.push(format!("echo '==> {label} ready'")); Some(vec!["sh".into(), "-lc".into(), lines.join("\n")]) } - -/// Ensure a flavor's provisioned rootfs is built and cached, returning its path. -/// Cache hit → returns immediately (instant, no re-provisioning). Miss → runs -/// the provisioning build in a child `bsdkrun` process (streaming its progress) -/// and records the result so every later launch just clones it. -pub(crate) fn ensure_flavor_built( - spec: &LinuxFlavorSpec, - name: &str, - cpus: u8, - mem: u32, -) -> Result { - let key = flavor_build_key(&spec.image, &spec.nix, &spec.provision); - let vol = flavor_build_volume(&key); - let voldir = volume_dir(&vol)?; - let rootfs = voldir.join("rootfs"); - let marker = voldir.join(".provisioned"); - if marker.exists() && rootfs.exists() { - info!(flavor = name, key = %key, "using cached flavor build"); - return Ok(rootfs); - } - // Cache miss: build in a CHILD process. Provisioning ends in `process::exit` - // (see `run_guest_command`), so it must not run in this process — we need to - // survive it to clone + boot the real machine afterwards. - info!(flavor = name, key = %key, "building flavor (first launch)…"); - host::force_remove_dir_all(&voldir); // clear any half-built remnant - let exe = std::env::current_exe().context("locating bsdkrun for the flavor build")?; - let status = std::process::Command::new(exe) - .args([ - "flavor", - "__build", - name, - "--key", - &key, - "--cpus", - &cpus.to_string(), - "--mem", - &mem.to_string(), - ]) - .status() - .context("spawning the flavor build")?; - if !status.success() { - host::force_remove_dir_all(&voldir); - anyhow::bail!("provisioning {name} failed (see the output above)"); - } - if !rootfs.exists() { - host::force_remove_dir_all(&voldir); - anyhow::bail!("the flavor build produced no rootfs for {name}"); - } - std::fs::write(&marker, key.as_bytes()).ok(); - Ok(rootfs) -} - -/// Hidden `bsdkrun flavor __build` — the child that provisions a flavor into its -/// build volume, then powers the builder off. Not for direct use. -pub(crate) fn cmd_flavor_build(name: &str, key: &str, cpus: u8, mem: u32) -> Result<()> { - let spec = resolve_linux_flavor(name) - .ok_or_else(|| anyhow::anyhow!("no such Linux flavor to build: {name}"))?; - let argv = flavor_provision_argv(name, &spec.nix, &spec.provision) - .ok_or_else(|| anyhow::anyhow!("{name} has nothing to provision"))?; - let vol = flavor_build_volume(key); - - // Boot a builder whose root is the persistent build volume. A trivial - // keep-alive is the main process so the VM (and its agent) stay up on any - // base image while provisioning runs; `run_machine` powers it off when the - // provisioning command finishes (detach=false ⇒ keep_running=false). - let largs = LinuxArgs { - image: spec.image.clone(), - kernel: None, - kernel_version: linux::DEFAULT_KERNEL_VERSION.to_string(), - detach: false, - initramfs: false, - volume: Some(vol), - mounts: vec![], - entrypoint: None, - env: spec.env.clone(), - console: "hvc0".to_string(), - net: NetConfig { - no_net: false, - ports: vec![], - mac: None, - network: None, - name: None, - }, - vm: VmConfig { cpus, mem }, - repo: None, - command: vec![ - "sh".into(), - "-c".into(), - "while :; do sleep 86400; done".into(), - ], - }; - boot_linux_from(largs, None, &argv) -} - -/// `bsdkrun flavor build ` — pre-build a flavor's provisioned rootfs into -/// the cache so a later `run` is instant. Streams provisioning output. -pub(crate) fn cmd_flavor_prebuild(name: &str, cpus: u8, mem: u32, force: bool) -> Result<()> { - let Some(spec) = resolve_linux_flavor(name) else { - anyhow::bail!("no such Linux flavor to build: {name} (see `bsdkrun flavors`)"); - }; - if spec.nix.is_empty() && spec.provision.is_empty() { - println!("{name}: nothing to build (no provisioning steps)"); - return Ok(()); - } - if force { - // Drop the cached build so it's rebuilt from scratch. - let key = flavor_build_key(&spec.image, &spec.nix, &spec.provision); - if let Ok(dir) = volume_dir(&flavor_build_volume(&key)) { - host::force_remove_dir_all(&dir); - } - } - let built = ensure_flavor_built(&spec, name, cpus, mem)?; - info!(flavor = name, rootfs = %built.display(), "flavor built"); - println!("{name}"); - Ok(()) -} - -/// `bsdkrun flavor run ` — boot a machine from a catalog/user flavor or a -/// saved snapshot. Provisioned flavors are built once (cached) then cloned. -pub(crate) fn cmd_flavor_run(args: FlavorRunArgs) -> Result<()> { - let db = db::Db::open()?; - - // Optional `--repo` clones a repo into the machine after boot (cd on shell). - let repo_argv = args - .repo - .as_deref() - .and_then(repo_clone_argv) - .unwrap_or_default(); - - // A saved snapshot (from `commit`) wins over any catalog/user name. - if let Some(f) = db.find_flavor(&args.name)? { - // Normalize (old snapshots stored the boot mode: firmware/kernel). - let osk = guest_os_kind(&f.kind, &f.base); - if osk == "linux" { - let rootfs = std::path::PathBuf::from(&f.path).join("rootfs"); - if !rootfs.exists() { - anyhow::bail!("snapshot {:?} is missing its rootfs data", f.name); - } - let largs = flavor_linux_args( - f.base.clone(), - args.detach, - args.vm.cpus, - args.vm.mem, - args.volume, - args.ports, - vec![], - ); - return boot_linux_from(largs, Some(rootfs), &repo_argv); - } - - // BSD snapshot: boot from its saved root disk (`disk.raw` / `disk.img`). - let disk = ["disk.raw", "disk.img"] - .iter() - .map(|n| std::path::PathBuf::from(&f.path).join(n)) - .find(|p| p.exists()) - .ok_or_else(|| anyhow::anyhow!("snapshot {:?} is missing its disk data", f.name))?; - let bargs = BsdArgs { - version: None, - firmware: None, - force: false, - attach_disk: vec![], - disk_size: None, - run: RunConfig { - detach: args.detach, - persist: false, - volume: args.volume, - }, - net: NetConfig { - no_net: false, - ports: args.ports, - mac: None, - network: None, - name: None, - }, - vm: VmConfig { - cpus: args.vm.cpus, - mem: args.vm.mem, - }, - verbose: false, - repo: None, - command: repo_argv, - }; - return if osk == "netbsd" { - boot_netbsd_disk(bargs, Some(disk)) - } else { - boot_freebsd_disk(bargs, Some(disk)) - }; - } - - // A Linux flavor (catalog or user): build-once-then-clone. - if let Some(spec) = resolve_linux_flavor(&args.name) { - let mut ports: Vec = args.ports; - for p in &spec.ports { - if let Ok(pf) = p.parse::() { - ports.push(pf); - } - } - let largs = flavor_linux_args( - spec.image.clone(), - args.detach, - args.vm.cpus, - args.vm.mem, - args.volume, - ports, - spec.env.clone(), - ); - // Provisioned flavors boot from a cached, pre-provisioned rootfs; plain - // ones boot the base image directly. - let has_provisioning = !spec.nix.is_empty() || !spec.provision.is_empty(); - if has_provisioning { - let built = ensure_flavor_built(&spec, &args.name, args.vm.cpus, args.vm.mem)?; - return boot_linux_from(largs, Some(built), &repo_argv); - } - return boot_linux_from(largs, None, &repo_argv); - } - - // A BSD catalog flavor (no provisioning/cache — boots the bundled image). - let Some(c) = flavors::find(&args.name) else { - anyhow::bail!("no such flavor: {} (see `bsdkrun flavors`)", args.name); - }; - let mut ports: Vec = args.ports; - for p in c.ports { - if let Ok(pf) = p.parse::() { - ports.push(pf); - } - } - let bargs = BsdArgs { - version: None, - firmware: None, - force: false, - attach_disk: vec![], - disk_size: None, - run: RunConfig { - detach: args.detach, - persist: false, - volume: args.volume, - }, - net: NetConfig { - no_net: false, - ports, - mac: None, - network: None, - name: None, - }, - vm: VmConfig { - cpus: args.vm.cpus, - mem: args.vm.mem, - }, - verbose: false, - repo: None, - // On BSD the post-boot command IS the repo clone (if any). - command: repo_argv, - }; - match c.base { - flavors::Base::Freebsd => boot_freebsd(bargs), - flavors::Base::Netbsd => boot_netbsd(bargs), - flavors::Base::Oci(_) => unreachable!("OCI flavors handled above"), - } -} diff --git a/core/src/commands/machines.rs b/core/src/commands/machines.rs index 7d42d77..cc92cae 100644 --- a/core/src/commands/machines.rs +++ b/core/src/commands/machines.rs @@ -3,18 +3,14 @@ use anyhow::{Context, Result}; use tracing::info; -use crate::cli::*; #[cfg(target_os = "macos")] use crate::store; -use crate::{agent, api, db, host, id, linux, names, nanos, osv, unikraft}; +use crate::{agent, api, db, host}; -use super::boot::{ - boot_freebsd, boot_freebsd_disk, boot_linux, boot_linux_from, boot_nanos_image, boot_netbsd, - boot_netbsd_disk, boot_osv_image, boot_unikraft_image, machine_dir_or_tmp, volume_dir, -}; use super::flavor::valid_flavor_name; use super::guest::{guest_os_kind, reject_unikraft}; use super::truncate; +use super::volume_dir; #[allow(clippy::print_literal)] // padded tabular headers read clearer as args pub(crate) fn cmd_ps(all: bool, json: bool) -> Result<()> { @@ -126,208 +122,6 @@ pub fn update(id: &str, cpus: Option, mem: Option) -> Result { Ok(vm.id) } -/// Restart a stopped machine *in place*: re-boot the recorded image / resources -/// / volume under the SAME id (like `docker start`), rather than minting a new -/// machine. Detached. The guest OS is inferred from the recorded image ref. -pub(crate) fn cmd_start(id: &str) -> Result<()> { - let db = db::Db::open()?; - let vm = db.find_machine(id)?; - - // Already up? Nothing to do. - if vm.status == "running" && vm.pid.map(db::pid_alive).unwrap_or(false) { - println!("{}", vm.id); - return Ok(()); - } - - let cpus = vm.cpus.clamp(1, 255) as u8; - let mem = vm.mem.max(64) as u32; - let volume = vm.volume.clone(); - - // Resume a BSD machine from ITS OWN root disk, not a re-fetched base. The - // per-machine working disk (a CoW clone of the original base — a fetched - // image OR a committed snapshot) lives in the state dir as `root.`. - // Re-cloning a base here would silently replace the disk: for a snapshot - // flavor that means booting the DEFAULT image over the user's snapshot data - // (data loss). So when that disk exists, boot it IN PLACE (persist) — which - // also preserves runtime changes across stop/start, like `docker start`. - // Volume-backed machines already resume their volume disk, so skip those. - let existing_disk = if vm.volume.is_none() { - let vdir = machine_dir_or_tmp(&vm.id); - ["root.raw", "root.img", "root.qcow2"] - .iter() - .map(|n| vdir.join(n)) - .find(|p| p.exists()) - } else { - None - }; - - // The DB row is left in place (the boot re-records it via INSERT OR REPLACE), - // so it flips exited→running rather than vanishing from `ps`. NB: don't wipe - // the whole state dir here — that means an `rm -rf` of the old read-only nix - // rootfs, which is slow enough to make Play "spin forever". The Linux boot - // path (prepare_linux_root) renames the stale rootfs aside and GC's it in the - // background, so the restart returns promptly. Old sockets/logs/port files - // are simply overwritten on the new boot. - - // The next boot picks up this id + name instead of generating fresh ones. - id::set_override(&vm.id); - if let Some(name) = &vm.name { - names::set_override(name); - } - - // Re-join the recorded global network on restart (its membership is stored - // in the DB and edited via `network connect/disconnect`). Reuse the member - // name so the derived MAC — and thus the BSD DHCP lease — stays stable; hint - // the previously-assigned IP so a plain restart keeps its address. - if let Some(ip) = vm.net_ip.as_deref().filter(|s| !s.is_empty()) { - std::env::set_var("BSDKRUN_NET_PREF_IP", ip); - } - let net = NetConfig { - no_net: false, - ports: vec![], - mac: None, - network: vm.network.clone(), - name: vm.name.clone(), - }; - let vmcfg = VmConfig { cpus, mem }; - - // Detect the guest from the recorded kind first (the image ref is unreliable - // for a snapshot machine — its image is `disk.img`/`disk.raw`, not a - // `netbsd-*`/`freebsd-*` name). FreeBSD records `firmware` (macOS EFI) or - // `freebsd` (Linux PVH); NetBSD records `netbsd` (or legacy `kernel`). - let reference = vm.image.to_lowercase(); - let is_freebsd = - matches!(vm.kind.as_str(), "firmware" | "freebsd") || reference.starts_with("freebsd"); - let is_netbsd = - matches!(vm.kind.as_str(), "kernel" | "netbsd") || reference.starts_with("netbsd"); - - if vm.kind == "linux" { - let largs = LinuxArgs { - image: vm.image.clone(), - kernel: None, - kernel_version: linux::DEFAULT_KERNEL_VERSION.to_string(), - detach: true, - initramfs: false, - volume: volume.clone(), - mounts: vec![], - entrypoint: None, - env: vec![], - console: "hvc0".to_string(), - net, - vm: vmcfg, - repo: None, - command: vec![], // persistent restart — keep a console shell alive - }; - // Resume the machine's OWN rootfs (which holds its snapshot + runtime - // changes) by passing it as the boot source, so restart never re-clones - // the base OCI image and loses data. Reuse any intact, non-empty rootfs — - // NOT gated on /bin|/nix, so images without those top-level dirs still - // resume. Volume machines resume their volume rootfs already; a missing or - // broken (nested) dir falls back to the base image. - let own_rootfs = machine_dir_or_tmp(&vm.id).join("rootfs"); - let intact = own_rootfs.symlink_metadata().is_ok() - && !own_rootfs.join("rootfs").exists() - && std::fs::read_dir(&own_rootfs) - .map(|mut d| d.next().is_some()) - .unwrap_or(false); - if volume.is_none() && intact { - boot_linux_from(largs, Some(own_rootfs), &[]) - } else { - boot_linux(largs) - } - } else if vm.kind == "nanos" { - // Re-boot the machine's own cloned disk in place when it survives (so - // TFS state persists across stop/start, like the BSDs); fall back to - // a fresh clone of the original image. - let spec = nanos::BootSpec::load(std::path::Path::new(&vm.state_dir))?; - let reuse = existing_disk.is_some(); - boot_nanos_image( - &spec.image, - spec.kernel.as_deref(), - &spec.cmdline, - None, - true, - reuse, - net, - vmcfg, - existing_disk, - ) - } else if vm.kind == "osv" { - // Re-boot the machine's own cloned disk in place when it survives, so - // filesystem writes persist across stop/start like the BSDs; fall back - // to a fresh clone of the original image. - let spec = osv::BootSpec::load(std::path::Path::new(&vm.state_dir))?; - let reuse = existing_disk.is_some(); - boot_osv_image( - &spec.image, - &spec.cmdline, - spec.gic, - None, - false, - reuse, - volume.as_deref(), - &[], - true, - net, - vmcfg, - existing_disk, - ) - } else if vm.kind == "unikraft" { - // Nothing to resume — a unikernel has no disk — so this just boots the - // same image again, from the spec saved beside its console log. - let spec = unikraft::BootSpec::load(std::path::Path::new(&vm.state_dir))?; - boot_unikraft_image( - &spec.kernel, - &spec.cmdline, - spec.initramfs.as_deref(), - true, - net, - vmcfg, - &spec.volumes, - ) - } else if is_freebsd || is_netbsd { - // Boot the machine's own disk in place when it exists (see above), so a - // snapshot machine keeps its data; otherwise fall back to the base image. - let reuse = existing_disk.is_some(); - let args = BsdArgs { - version: None, // bundled image (as originally booted) - firmware: None, - force: false, - attach_disk: vec![], - disk_size: None, - run: RunConfig { - detach: true, - persist: reuse, // in-place boot of the existing disk (no re-clone) - volume, - }, - net, - vm: vmcfg, - verbose: false, - repo: None, - command: vec![], - }; - let result = match (is_freebsd, existing_disk) { - (true, Some(d)) => boot_freebsd_disk(args, Some(d)), - (true, None) => boot_freebsd(args), - (false, Some(d)) => boot_netbsd_disk(args, Some(d)), - (false, None) => boot_netbsd(args), - }; - // Booting the in-place disk relabels the row's image to `root.`; - // restore the original label so `ps` still shows what it was booted from. - if reuse && result.is_ok() { - db.set_machine_image(&vm.id, &vm.image).ok(); - } - result - } else { - // Put the id back for any future attempt and report clearly. - anyhow::bail!( - "don't know how to restart a {:?} machine ({}); start it with `run` instead", - vm.kind, - vm.image - ); - } -} - pub(crate) fn cmd_rm(ids: &[String], force: bool) -> Result<()> { for id in ids { println!("{}", remove_machine(id, force)?); diff --git a/core/src/commands/mod.rs b/core/src/commands/mod.rs index b9442fe..9240104 100644 --- a/core/src/commands/mod.rs +++ b/core/src/commands/mod.rs @@ -4,17 +4,30 @@ //! These still print — a `ps` here writes the same table it always did — so the //! CLI is a thin pass-through. The daemon does not call them; it goes through //! [`crate::api`], which returns the same information as data. +//! +//! Which is why a build without `boot` leaves most of them unused: the printing +//! layer exists for the CLI, and a build that cannot start a machine is not the +//! CLI. `api` and the helpers underneath are what such a build is for. +#![cfg_attr(not(feature = "boot"), allow(dead_code))] +#[cfg(feature = "boot")] pub mod boot; pub mod flavor; pub mod guest; pub mod images; pub mod machines; +#[cfg(feature = "boot")] pub mod probe; #[cfg(target_os = "macos")] pub mod store; pub mod volumes; +use std::path::PathBuf; + +use anyhow::Result; + +use crate::db; + /// Truncate a string to `n` display chars, adding an ellipsis if cut. /// /// Shared by every table-printing subcommand, which is why it lives here rather @@ -27,3 +40,55 @@ pub(crate) fn truncate(s: &str, n: usize) -> String { format!("{head}…") } } + +// --------------------------------------------------------------------------- +// paths +// --------------------------------------------------------------------------- +// +// Where a machine and a volume keep their files. These sit here rather than +// beside the boot code that mostly uses them because `commit` and the volume +// listing need them too, and neither should pull in a hypervisor. + +/// Per-machine state dir (`/machines/`), falling back to a temp dir. +pub(crate) fn machine_dir_or_tmp(id: &str) -> std::path::PathBuf { + let dir = db::machine_dir(id).unwrap_or_else(|_| std::env::temp_dir().join(id)); + std::fs::create_dir_all(&dir).ok(); + dir +} + +/// Directory that will hold a machine's writable rootfs clone. On macOS with a +/// case-sensitive store set up this lives on the store (nix guests need that, +/// and the clone stays CoW because source and destination share a volume); +/// everywhere else it is the machine's own state dir, as before. +pub(crate) fn machine_rootfs_dir(id: &str, vdir: &std::path::Path) -> std::path::PathBuf { + #[cfg(target_os = "macos")] + { + if let Some(d) = crate::store::machine_rootfs_dir(id) { + return d; + } + } + let _ = id; + vdir.to_path_buf() +} + +/// Resolve a `--volume NAME` to its directory under `/volumes`, rejecting +/// names that could escape it. +pub(crate) fn volume_dir(name: &str) -> Result { + let ok = !name.is_empty() + && name != "." + && name != ".." + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + if !ok { + anyhow::bail!("invalid volume name {name:?} — use letters, digits, '-', '_' or '.'"); + } + Ok(db::volumes_dir()?.join(name)) +} + +/// The last path component, for display. +pub(crate) fn basename(p: &std::path::Path) -> String { + p.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.to_string_lossy().into_owned()) +} diff --git a/core/src/krun.rs b/core/src/krun.rs index 2de76d8..6bbe573 100644 --- a/core/src/krun.rs +++ b/core/src/krun.rs @@ -3,9 +3,16 @@ //! libkrun is itself written in Rust but exposes a stable C API. On macOS it //! drives Hypervisor.framework. We only bind the subset needed to launch a //! microVM from an external kernel or firmware plus virtio-blk disks. +//! +//! Only the kernel-format constants survive without the `boot` feature: other +//! modules pick a format when they *describe* a machine, which a build that +//! cannot start one still does. +#[cfg(feature = "boot")] use std::ffi::CString; +#[cfg(feature = "boot")] use std::os::raw::c_char; +#[cfg(feature = "boot")] use std::path::Path; // Kernel image formats accepted by krun_set_kernel. @@ -23,12 +30,19 @@ pub const KRUN_KERNEL_FORMAT_IMAGE_ZSTD: u32 = 5; // virtio-net feature bits (see libkrun.h). `COMPAT_NET_FEATURES` is the set // libkrun's own passt/gvproxy helpers enable — the safe baseline (checksum // offload + TSO/UFO) that a userspace proxy like gvproxy negotiates. +#[cfg(feature = "boot")] const NET_FEATURE_CSUM: u32 = 1 << 0; +#[cfg(feature = "boot")] const NET_FEATURE_GUEST_CSUM: u32 = 1 << 1; +#[cfg(feature = "boot")] const NET_FEATURE_GUEST_TSO4: u32 = 1 << 7; +#[cfg(feature = "boot")] const NET_FEATURE_GUEST_UFO: u32 = 1 << 10; +#[cfg(feature = "boot")] const NET_FEATURE_HOST_TSO4: u32 = 1 << 11; +#[cfg(feature = "boot")] const NET_FEATURE_HOST_UFO: u32 = 1 << 14; +#[cfg(feature = "boot")] const COMPAT_NET_FEATURES: u32 = NET_FEATURE_CSUM | NET_FEATURE_GUEST_CSUM | NET_FEATURE_GUEST_TSO4 @@ -38,9 +52,11 @@ const COMPAT_NET_FEATURES: u32 = NET_FEATURE_CSUM // Per-interface flags. `NET_FLAG_VFKIT` tells libkrun the unixgram peer speaks // gvproxy's "vfkit" framing (the mode gvproxy's `-listen-vfkit` socket uses). +#[cfg(feature = "boot")] const NET_FLAG_VFKIT: u32 = 1 << 0; #[link(name = "krun")] +#[cfg(feature = "boot")] extern "C" { fn krun_set_log_level(level: u32) -> i32; fn krun_create_ctx() -> i32; @@ -83,6 +99,7 @@ extern "C" { } /// Turn a negative libkrun return (a `-errno`) into a readable error. +#[cfg(feature = "boot")] fn check(ret: i32, what: &str) -> anyhow::Result { if ret < 0 { let errno = -ret; @@ -92,10 +109,12 @@ fn check(ret: i32, what: &str) -> anyhow::Result { Ok(ret) } +#[cfg(feature = "boot")] fn cstr(p: &str) -> anyhow::Result { Ok(CString::new(p)?) } +#[cfg(feature = "boot")] fn path_cstr(p: &Path) -> anyhow::Result { let s = p .to_str() @@ -106,6 +125,7 @@ fn path_cstr(p: &Path) -> anyhow::Result { /// Verify a libkrun symbol is actually resolvable in the loaded dylib before we /// call it. Guards against DYLD loading an old libkrun that lacks newer symbols /// (calling a missing symbol jumps through a NULL stub → SIGSEGV). +#[cfg(feature = "boot")] fn require_symbol(name: &str) -> anyhow::Result<()> { let c = CString::new(name)?; // RTLD_DEFAULT searches all loaded images; NULL => symbol not present. @@ -127,6 +147,7 @@ fn require_symbol(name: &str) -> anyhow::Result<()> { /// guest. Otherwise we return the read end of a fresh pipe: kqueue can poll it, /// but it never yields data (nobody holds the write end open to send any). This /// keeps non-interactive/captured runs from aborting inside libkrun. +#[cfg(feature = "boot")] fn console_input_fd() -> anyhow::Result { // STDIN_FILENO == 0. if unsafe { libc::isatty(0) } == 1 { @@ -150,11 +171,13 @@ fn console_input_fd() -> anyhow::Result { } /// A libkrun configuration context. Freed on drop unless consumed by `start_enter`. +#[cfg(feature = "boot")] pub struct Ctx { id: u32, entered: bool, } +#[cfg(feature = "boot")] impl Ctx { pub fn new() -> anyhow::Result { // Every boot path funnels through here, so this is where a host without @@ -389,6 +412,7 @@ impl Ctx { } } +#[cfg(feature = "boot")] impl Drop for Ctx { fn drop(&mut self) { if !self.entered { diff --git a/core/src/lib.rs b/core/src/lib.rs index 61c64c0..20331c9 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -30,6 +30,9 @@ pub mod fetch; pub mod flavors; pub mod host; pub mod id; +/// The libkrun FFI. Its constants are always available — other modules pick a +/// kernel format with them — but the parts that call into the library are only +/// compiled when this crate can actually start a machine. pub mod krun; pub mod linux; pub mod names; @@ -49,8 +52,9 @@ pub mod ui; pub mod unikraft; pub mod watchdog; +#[cfg(feature = "boot")] use anyhow::Result; - +#[cfg(feature = "boot")] use cli::Command; /// This engine's version, which is also the version the CLI and the daemon @@ -79,9 +83,10 @@ pub fn init_host() { /// Run one subcommand, exactly as `bsdkrun` would. /// -/// The daemon uses this for the handful of operations that are genuinely a -/// command line — the passthrough RPC, and the boot supervisor it re-execs — -/// while everything else goes through [`api`] instead. +/// Only compiled with `boot`, since most of what it dispatches to starts a +/// machine. A caller that links this crate without it — the daemon — drives +/// [`api`] directly and hands anything boot-shaped to `bsdkrun-supervisor`. +#[cfg(feature = "boot")] pub fn dispatch(cmd: Command) -> Result<()> { use cli::*; @@ -105,7 +110,7 @@ pub fn dispatch(cmd: Command) -> Result<()> { Command::Ps(args) => commands::machines::cmd_ps(args.all, args.json), Command::Images(args) => commands::images::cmd_images(args.json), Command::Stop(args) => commands::machines::cmd_stop(&args.id), - Command::Start(args) => commands::machines::cmd_start(&args.id), + Command::Start(args) => commands::boot::cmd_start(&args.id), Command::Update(args) => commands::machines::cmd_update(&args.id, args.cpus, args.mem), Command::Rm(args) => commands::machines::cmd_rm(&args.ids, args.force), Command::Agent(args) => match args.cmd { @@ -138,14 +143,14 @@ pub fn dispatch(cmd: Command) -> Result<()> { } Command::Flavors(args) => commands::flavor::cmd_flavors(args.json), Command::Flavor(args) => match args.cmd { - FlavorCmd::Run(a) => commands::flavor::cmd_flavor_run(a), + FlavorCmd::Run(a) => commands::boot::cmd_flavor_run(a), FlavorCmd::Add(a) => commands::flavor::cmd_flavor_add(a), FlavorCmd::Rm(a) => commands::flavor::cmd_flavor_rm(&a.names, a.force), FlavorCmd::Build(a) => { - commands::flavor::cmd_flavor_prebuild(&a.name, a.vm.cpus, a.vm.mem, a.force) + commands::boot::cmd_flavor_prebuild(&a.name, a.vm.cpus, a.vm.mem, a.force) } FlavorCmd::BuildInternal(a) => { - commands::flavor::cmd_flavor_build(&a.name, &a.key, a.vm.cpus, a.vm.mem) + commands::boot::cmd_flavor_build(&a.name, &a.key, a.vm.cpus, a.vm.mem) } }, Command::Ui(args) => serve_ui(args), @@ -162,14 +167,17 @@ pub fn dispatch(cmd: Command) -> Result<()> { /// `bsdkrun ui`, when this build has the SPA compiled in. /// +/// Reachable only from [`dispatch`], hence the `boot` gate alongside the `ui` +/// one: a build that cannot start a machine has no dispatch to reach it from. +/// /// The bundle is behind a feature so the daemon — which serves its own API and /// has no use for a second web server — does not link one. -#[cfg(feature = "ui")] +#[cfg(all(feature = "boot", feature = "ui"))] fn serve_ui(args: cli::UiArgs) -> Result<()> { ui::serve_ui(args.bind, !args.no_open) } -#[cfg(not(feature = "ui"))] +#[cfg(all(feature = "boot", not(feature = "ui")))] fn serve_ui(_args: cli::UiArgs) -> Result<()> { anyhow::bail!("this build has no web UI compiled in") } diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 4a23011..cd9b471 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -45,9 +45,14 @@ server = [ tonic-prost-build = "0.14" [dependencies] -# The engine. Optional so the desktop app, which builds this crate with -# `default-features = false` for its client half alone, never links libkrun. -bsdkrun-core = { path = "../core", optional = true } +# The engine, WITHOUT `boot`. That is what keeps this binary free of libkrun — +# and therefore statically linkable and runnable on any distro — while still +# being bsdkrun rather than a wrapper around it: it reads and manages machines +# in-process, and hands anything that has to start one to `bsdkrun-supervisor`. +# +# Optional as well, so the desktop app's client-only build (`default-features = +# false`) pulls in none of it. +bsdkrun-core = { path = "../core", default-features = false, optional = true } tonic = { version = "0.14", features = ["router", "transport", "tls-ring", "tls-native-roots", "gzip"] } tonic-prost = "0.14" tonic-health = { version = "0.14", optional = true } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index af9cee8..07dda43 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -22,7 +22,7 @@ use bsdkrun_daemon::ops::Ops; use bsdkrun_daemon::pb::bsdkrun_server::BsdkrunServer; use bsdkrun_daemon::service::BsdkrunService; use bsdkrun_daemon::shell::ShellRegistry; -use bsdkrun_daemon::supervisor::{Supervisor, CLI_SUBCOMMAND, RUN_SUBCOMMAND}; +use bsdkrun_daemon::supervisor::Supervisor; use clap::Parser; use tonic::transport::{Identity, Server, ServerTlsConfig}; use tracing::{info, warn}; @@ -57,6 +57,12 @@ struct Args { #[arg(long, env = "BSDKRUN_TOKEN", hide_env_values = true)] token: Option, + /// Path to `bsdkrun-supervisor` (default: beside this binary, else PATH). + /// It is what actually boots machines; see the supervisor module for why it + /// is a separate process. + #[arg(long, env = "BSDKRUN_SUPERVISOR")] + supervisor: Option, + /// PEM certificate chain, to serve over TLS. #[arg(long, requires = "tls_key")] tls_cert: Option, @@ -70,63 +76,8 @@ struct Args { log_level: String, } -fn main() -> Result<()> { - // Before clap: the two hidden subcommands are this binary re-entering - // itself as a machine supervisor, and they are not a daemon at all — they - // must not start a runtime, bind a port or mint a token. See - // [`bsdkrun_daemon::supervisor`] for why they exist. - let argv: Vec = std::env::args().collect(); - match argv.get(1).map(String::as_str) { - Some(RUN_SUBCOMMAND) => return run_supervised(&argv[2..]), - Some(CLI_SUBCOMMAND) => return run_command_line(&argv[2..]), - _ => {} - } - serve() -} - -/// `bsdkrund __run ` — run one JSON-encoded command against the engine. -fn run_supervised(args: &[String]) -> Result<()> { - let spec = args - .first() - .context("__run takes one JSON-encoded command")?; - let cmd: bsdkrun_core::cli::Command = - serde_json::from_str(spec).context("decoding the command")?; - supervisor_setup(); - bsdkrun_core::dispatch(cmd) -} - -/// `bsdkrund __cli -- ` — run a bsdkrun command line, parsed by the -/// engine's own clap definition. Backs the passthrough RPC. -fn run_command_line(args: &[String]) -> Result<()> { - let args = args - .strip_prefix(std::slice::from_ref(&"--".to_string())) - .unwrap_or(args); - let cli = bsdkrun_core::cli::Cli::try_parse_from( - std::iter::once("bsdkrun".to_string()).chain(args.iter().cloned()), - )?; - supervisor_setup(); - bsdkrun_core::krun::Ctx::set_log_level(cli.log_level).ok(); - bsdkrun_core::dispatch(cli.cmd) -} - -/// Logging and host setup for a supervisor process. -/// -/// Its stdout belongs to the machine (an id, a console, a command's output), -/// so diagnostics go to stderr — which is what the daemon reads back as the -/// progress half of a launch stream. -fn supervisor_setup() { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .with_target(false) - .with_writer(std::io::stderr) - .init(); - bsdkrun_core::init_host(); -} - #[tokio::main] -async fn serve() -> Result<()> { +async fn main() -> Result<()> { let args = Args::parse(); tracing_subscriber::fmt() @@ -139,7 +90,7 @@ async fn serve() -> Result<()> { // Resolve this binary up front: it is what a booted machine is supervised // by, and failing at startup beats failing on the first boot. - let supervisor = Supervisor::resolve()?; + let supervisor = Supervisor::resolve(args.supervisor)?; info!( engine = bsdkrun_core::VERSION, supervisor = %supervisor.exe().display(), diff --git a/daemon/src/supervisor.rs b/daemon/src/supervisor.rs index d218260..0b6d38a 100644 --- a/daemon/src/supervisor.rs +++ b/daemon/src/supervisor.rs @@ -1,21 +1,26 @@ -//! Running a command in a *separate* process — this daemon's own binary. +//! Running a command in a *separate* process — `bsdkrun-supervisor`. //! -//! Almost everything the daemon does now happens in-process, against -//! `bsdkrun-core`. Two things cannot: +//! Almost everything the daemon does happens in-process, against +//! `bsdkrun-core`. Two things cannot, and both go through here: //! -//! * **Booting.** `core`'s detached boot `fork()`s and the child *becomes* the +//! * **Booting.** The detached boot `fork()`s and the child *becomes* the //! machine. Forking a multithreaded tokio process and then doing libkrun //! init, sqlite and tracing in the child risks deadlocking on a lock some //! other thread held at fork time — and it would tie every VM's life to the -//! daemon's. A fresh single-threaded process has neither problem, and a -//! machine booted this way survives `systemctl restart bsdkrund`. +//! daemon's. The supervisor has one thread and no server in it, and a +//! machine it boots survives `systemctl restart bsdkrund`. //! * **Long jobs that report progress on stdout** — `fetch`, `flavor build` — //! and the passthrough RPC, whose whole point is to run a command line. //! -//! The process it starts is `current_exe()`: this daemon, re-entered through the -//! hidden `__run` subcommand, which deserializes a [`core::cli::Command`] and -//! hands it to the same engine the CLI uses. It never looks for a `bsdkrun` -//! binary — there need not be one on the host at all. +//! It is a separate binary rather than this one re-exec'd, because it is where +//! libkrun is linked: keeping it out of `bsdkrund` is what lets the daemon stay +//! a static binary that runs on any distro. It is emphatically *not* the +//! `bsdkrun` CLI — the daemon looks for no such thing, and a host need not have +//! one at all. +//! +//! What crosses the process boundary is a typed +//! [`bsdkrun_core::cli::Command`], JSON-encoded, so there is still no argv +//! between the daemon and the engine to get wrong. use std::path::PathBuf; use std::process::Stdio; @@ -30,12 +35,14 @@ use tonic::Status; use crate::pb::{output_chunk, CommandResult, OutputChunk}; -/// The hidden subcommand this daemon re-enters itself through, carrying one -/// JSON-encoded [`CoreCommand`]. -pub const RUN_SUBCOMMAND: &str = "__run"; +/// The binary this daemon hands boot-shaped work to. Shipped beside it. +pub const SUPERVISOR_BIN: &str = "bsdkrun-supervisor"; + +/// Its subcommand for a typed [`CoreCommand`], carried as JSON. +pub const RUN_SUBCOMMAND: &str = "run"; -/// The same, for a raw command line rather than a typed command. -pub const CLI_SUBCOMMAND: &str = "__cli"; +/// Its subcommand for a raw command line, used only by the passthrough RPC. +pub const CLI_SUBCOMMAND: &str = "cli"; /// Input from the client for an interactive RPC, shared by the pty and piped /// session types so [`crate::service`] can drive either through one enum. @@ -45,9 +52,10 @@ pub enum SessionInput { Eof, } -/// Directories where the runtime tools a machine needs (gvproxy, curl, tar) -/// commonly live. A daemon started by systemd/launchd inherits a minimal PATH, -/// so we prepend these the same way the desktop app does for GUI launches. +/// Directories where the supervisor and the runtime tools a machine needs +/// (gvproxy, curl, tar) commonly live. A daemon started by systemd/launchd +/// inherits a minimal PATH, so we append these the same way the desktop app +/// does for GUI launches. const EXTRA_PATHS: &[&str] = &[ "/opt/homebrew/bin", "/opt/homebrew/sbin", @@ -77,15 +85,39 @@ pub struct Supervisor { } impl Supervisor { - /// Resolve this daemon's own binary. Fails fast at startup: a daemon that - /// cannot re-exec itself can boot nothing. - pub fn resolve() -> Result { - let exe = std::env::current_exe() - .context("locating this daemon's own binary (needed to supervise machines it boots)")?; - Ok(Self { - exe, - path: augmented_path(), - }) + /// Find `bsdkrun-supervisor`: beside this binary first, then on PATH. + /// + /// Resolved at startup rather than per-request, because a daemon that + /// cannot find it can boot nothing, and saying so once at startup beats + /// failing on the first boot someone attempts. + pub fn resolve(override_path: Option) -> Result { + let path = augmented_path(); + if let Some(p) = override_path { + if !p.exists() { + anyhow::bail!("no supervisor at {}", p.display()); + } + return Ok(Self { exe: p, path }); + } + // Beside the daemon is where a package puts it, and where a `cargo + // build` leaves it, so it is worth preferring over whatever PATH says. + let beside = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|d| d.join(SUPERVISOR_BIN))) + .filter(|p| p.exists()); + let exe = beside + .or_else(|| { + path.split(':') + .map(|d| std::path::Path::new(d).join(SUPERVISOR_BIN)) + .find(|p| p.exists()) + }) + .with_context(|| { + format!( + "{SUPERVISOR_BIN} not found beside this binary or on PATH. It ships with \ + bsdkrund and is what actually boots machines; install it, or point the \ + daemon at it with --supervisor /path/to/{SUPERVISOR_BIN}" + ) + })?; + Ok(Self { exe, path }) } /// Point the supervisor at a specific binary instead of this process. @@ -108,8 +140,8 @@ impl Supervisor { &self.path } - /// `bsdkrund __cli -- `: a bsdkrun command line, parsed by the same - /// clap definition the CLI uses. Only the passthrough RPC needs this — + /// `bsdkrun-supervisor cli -- `: a bsdkrun command line, parsed by + /// the engine's own clap definition. Only the passthrough RPC needs this — /// everything else hands over a typed command instead. pub fn argv_raw(&self, args: &[String]) -> Vec { let mut argv = vec![CLI_SUBCOMMAND.to_string(), "--".to_string()]; @@ -117,7 +149,7 @@ impl Supervisor { argv } - /// `bsdkrund __run ` for a parsed command. + /// `bsdkrun-supervisor run ` for a parsed command. pub fn argv(&self, cmd: &CoreCommand) -> Result, Status> { let spec = serde_json::to_string(cmd) .map_err(|e| Status::internal(format!("encoding the command: {e}")))?; @@ -358,9 +390,9 @@ mod tests { use super::*; #[test] - fn the_argv_is_the_hidden_subcommand_plus_one_json_argument() { + fn the_argv_is_the_run_subcommand_plus_one_json_argument() { let sup = Supervisor { - exe: PathBuf::from("/usr/bin/bsdkrund"), + exe: PathBuf::from("/usr/bin/bsdkrun-supervisor"), path: String::new(), }; let cmd = CoreCommand::Ps(bsdkrun_core::cli::PsArgs { diff --git a/daemon/tests/common/mod.rs b/daemon/tests/common/mod.rs index c279dee..e6500c3 100644 --- a/daemon/tests/common/mod.rs +++ b/daemon/tests/common/mod.rs @@ -49,8 +49,8 @@ case "$SPEC" in echo "boom" >&2; exit 3 ;; esac -# `__cli -- `: the passthrough RPC. -if [ "$1" = "__cli" ]; then +# `cli -- `: the passthrough RPC. +if [ "$1" = "cli" ]; then shift [ "$1" = "--" ] && shift case "$1" in @@ -256,8 +256,8 @@ pub fn invocations(log: &Path) -> Vec> { pub fn decode(argv: &[String]) -> serde_json::Value { assert_eq!( argv.first().map(String::as_str), - Some("__run"), - "not a __run invocation: {argv:?}" + Some("run"), + "not a `run` invocation: {argv:?}" ); serde_json::from_str(&argv[1]).expect("the supervisor was handed valid JSON") } diff --git a/daemon/tests/e2e.rs b/daemon/tests/e2e.rs index 37dbedf..e43192d 100644 --- a/daemon/tests/e2e.rs +++ b/daemon/tests/e2e.rs @@ -529,9 +529,9 @@ async fn a_half_closed_request_stream_does_not_cancel_the_command() { let (out, _, code) = drain(out_stream).await; assert!(out.contains("ran: probe"), "{out}"); assert_eq!(code, Some(0)); - // The passthrough goes through the `__cli` entry point, which parses the - // command line with the engine's own clap definition. - assert_eq!(h.last_argv(), ["__cli", "--", "probe"]); + // The passthrough goes through the supervisor's `cli` entry point, which + // parses the command line with the engine's own clap definition. + assert_eq!(h.last_argv(), ["cli", "--", "probe"]); } // --------------------------------------------------------------------------- @@ -731,36 +731,50 @@ fn the_binary_generates_and_prints_a_token() { } /// Locate the `bsdkrund` binary next to the test executable. -fn daemon_binary() -> PathBuf { +fn built_binary(name: &str) -> Option { let mut dir = std::env::current_exe().expect("test exe"); dir.pop(); // deps/ if dir.ends_with("deps") { dir.pop(); } - let exe = dir.join("bsdkrund"); - assert!( - Path::new(&exe).exists(), - "bsdkrund not built at {}; run `cargo build --bins` first", - exe.display() - ); - exe + let exe = dir.join(name); + Path::new(&exe).exists().then_some(exe) +} + +fn daemon_binary() -> PathBuf { + built_binary("bsdkrund") + .expect("bsdkrund not built; run `cargo build --release -p bsdkrun-daemon` first") +} + +/// The real supervisor, if this checkout could build one. +/// +/// It links libkrun, so a host without one cannot have built it — and the +/// daemon's own test suite deliberately runs on such hosts. The two tests that +/// need the real binary skip rather than fail there; every other test uses the +/// stub, which is the point of having one. +fn supervisor_binary() -> Option { + built_binary("bsdkrun-supervisor") } -/// The supervisor entry point is real: `bsdkrund __run ` runs the engine -/// in this binary, with no `bsdkrun` anywhere on PATH. +/// The supervisor is real: `bsdkrun-supervisor run ` runs the engine with +/// no `bsdkrun` anywhere on PATH. /// /// This is the property the whole change exists for, so it is asserted against -/// the actual daemon binary rather than a stub. +/// the actual shipped binary rather than a stub. #[test] -fn the_daemon_binary_runs_engine_commands_itself() { +fn the_supervisor_binary_runs_engine_commands() { let state = fixture_state().to_path_buf(); let spec = serde_json::to_string(&serde_json::json!({ "Ps": { "all": true, "json": true } })) .unwrap(); - let out = std::process::Command::new(daemon_binary()) - .arg("__run") + let Some(exe) = supervisor_binary() else { + eprintln!("skipping: bsdkrun-supervisor is not built (no libkrun on this host)"); + return; + }; + let out = std::process::Command::new(exe) + .arg("run") .arg(&spec) .env("BSDKRUN_STATE", &state) // Nothing on PATH at all: there is no CLI to fall back to. @@ -788,8 +802,12 @@ fn the_daemon_binary_runs_engine_commands_itself() { /// An unparseable spec fails loudly rather than booting something unintended. #[test] fn the_supervisor_rejects_a_spec_it_cannot_decode() { - let out = std::process::Command::new(daemon_binary()) - .arg("__run") + let Some(exe) = supervisor_binary() else { + eprintln!("skipping: bsdkrun-supervisor is not built (no libkrun on this host)"); + return; + }; + let out = std::process::Command::new(exe) + .arg("run") .arg("{\"NoSuchCommand\":{}}") .output() .expect("running the supervisor"); diff --git a/daemon/tests/graphql.rs b/daemon/tests/graphql.rs index ede85d8..85c84a6 100644 --- a/daemon/tests/graphql.rs +++ b/daemon/tests/graphql.rs @@ -79,7 +79,7 @@ impl Harness { if let Some(cmd) = self .invocations() .into_iter() - .filter(|a| a.first().map(|s| s == "__run").unwrap_or(false)) + .filter(|a| a.first().map(|s| s == "run").unwrap_or(false)) .map(|a| decode(&a)) .rfind(|c| c.get(variant).is_some()) { diff --git a/flake.nix b/flake.nix index 08a6a45..84ec0d8 100644 --- a/flake.nix +++ b/flake.nix @@ -213,10 +213,10 @@ # (`../core` has to exist) and `-p bsdkrun-daemon` is what narrows the # build back down to it. # - # It links the engine, and therefore libkrun, so it is no longer the - # pure everywhere-buildable package it was while it merely spawned the - # `bsdkrun` binary: on darwin it needs Homebrew's libkrun and - # `--impure`, exactly like bsdkrun itself. + # It links the engine WITHOUT its `boot` feature, so it stays the pure, + # hypervisor-free package it has always been: booting lives in + # `bsdkrun-supervisor`, which ships beside it and is the only half that + # needs libkrun. daemonArgs = { # NOT `cleanCargoSource`: that keeps only Rust and Cargo files, which # drops proto/bsdkrun.proto and leaves the build script failing with @@ -235,9 +235,10 @@ # daemon build would build bsdkrun instead — and want the web bundle. cargoExtraArgs = "-p bsdkrun-daemon"; - # Same libkrun wiring as bsdkrun: `bsdkrun-core` links it, and - # LIBKRUN_PREFIX short-circuits its build script's brew/pkg-config - # search. + # libkrun is wired in for `bsdkrun-supervisor`, which overrides these + # args below. `bsdkrund` itself takes `bsdkrun-core` without its + # `boot` feature, so it links no hypervisor at all — but the two share + # this attrset, and an unused LIBKRUN_PREFIX costs nothing. nativeBuildInputs = [ pkgs.pkg-config pkgs.llvmPackages.llvm pkgs.protobuf ]; buildInputs = lib.optionals (!isDarwin) [ libkrun ]; LIBKRUN_PREFIX = libkrunPrefix; @@ -253,6 +254,37 @@ daemonArtifacts = craneLib.buildDepsOnly daemonArgs; + # ---- bsdkrun-supervisor ------------------------------------------- + # The half of the daemon that links libkrun, split out so `bsdkrund` + # itself does not have to. Same source tree, different package. + supervisorArgs = daemonArgs // { + pname = "bsdkrun-supervisor"; + version = "0.6.0"; + cargoExtraArgs = "-p bsdkrun-supervisor"; + }; + + supervisorArtifacts = craneLib.buildDepsOnly supervisorArgs; + + bsdkrun-supervisor = craneLib.buildPackage (supervisorArgs // { + cargoArtifacts = supervisorArtifacts; + + nativeBuildInputs = supervisorArgs.nativeBuildInputs + ++ lib.optionals (!isDarwin) [ pkgs.makeWrapper ]; + postInstall = lib.optionalString (!isDarwin) '' + wrapProgram $out/bin/bsdkrun-supervisor \ + --prefix PATH : ${lib.makeBinPath runtimeDeps} + ''; + + meta = with lib; { + description = + "Runs one bsdkrun command in its own process, for bsdkrund (not a user-facing tool)"; + homepage = "https://github.com/tsirysndr/bsdkrun"; + license = licenses.mit; + mainProgram = "bsdkrun-supervisor"; + platforms = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ]; + }; + }); + bsdkrund = craneLib.buildPackage (daemonArgs // { cargoArtifacts = daemonArtifacts; @@ -306,7 +338,7 @@ in { checks = { - inherit bsdkrun bsdkrund; + inherit bsdkrun bsdkrund bsdkrun-supervisor; bsdkrun-clippy = craneLib.cargoClippy (commonArgs // { inherit cargoArtifacts; @@ -325,6 +357,7 @@ packages.default = bsdkrun; packages.bsdkrun = bsdkrun; packages.bsdkrund = bsdkrund; + packages.bsdkrun-supervisor = bsdkrun-supervisor; # The SPA on its own, for serving from something other than `bsdkrun ui`. packages.web = webUi; diff --git a/supervisor/Cargo.toml b/supervisor/Cargo.toml new file mode 100644 index 0000000..c6ad38d --- /dev/null +++ b/supervisor/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bsdkrun-supervisor" +version = "0.6.0" +edition = "2021" +description = "Runs one bsdkrun command in its own process, and hosts the machine it boots" + +[[bin]] +name = "bsdkrun-supervisor" +path = "src/main.rs" + +[dependencies] +# The engine, with `boot`: this binary exists precisely to be the thing that +# links libkrun, so that `bsdkrund` does not have to. +bsdkrun-core = { path = "../core" } +anyhow = "1" +clap = { version = "4", features = ["derive"] } +serde_json = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/supervisor/build.rs b/supervisor/build.rs new file mode 100644 index 0000000..0a0228d --- /dev/null +++ b/supervisor/build.rs @@ -0,0 +1,8 @@ +/// Re-emit libkrun's rpath for this binary. See `bsdkrun`'s build.rs — the +/// search path propagates from `bsdkrun-core`, the rpath cannot. +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + if let Some(dir) = std::env::var_os("DEP_KRUN_LIBDIR") { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.to_string_lossy()); + } +} diff --git a/supervisor/src/main.rs b/supervisor/src/main.rs new file mode 100644 index 0000000..797a2fc --- /dev/null +++ b/supervisor/src/main.rs @@ -0,0 +1,88 @@ +//! `bsdkrun-supervisor` — runs one bsdkrun command in a process of its own. +//! +//! It exists so `bsdkrund` does not have to link a hypervisor. Two things the +//! daemon cannot do in-process end up here: +//! +//! * **Booting.** The detached boot `fork()`s and the child *becomes* the +//! machine. Forking a multithreaded tokio process and then doing libkrun +//! init, sqlite and tracing in the child risks deadlocking on a lock some +//! other thread held at fork time — and it would tie every VM's life to the +//! daemon's. This process has one thread and no server in it, and the +//! machine it forks outlives both it and the daemon. +//! * **Long jobs that report progress on stdout** — `fetch`, `flavor build` — +//! and the daemon's passthrough RPC, whose point is to run a command line. +//! +//! Two shapes, because the daemon has two kinds of caller: +//! +//! ```text +//! bsdkrun-supervisor run '{"Linux":{…}}' a typed command, as the daemon built it +//! bsdkrun-supervisor cli -- linux -d alpine a command line, parsed by the engine +//! ``` +//! +//! `run` is what almost everything uses: the daemon hands over a +//! [`bsdkrun_core::cli::Command`] it constructed as a *value*, so there is no +//! argv to get wrong in between. `cli` exists only for the passthrough RPC, +//! which is handed a command line by its caller. +//! +//! Not a user-facing tool. It ships beside `bsdkrund`, which finds it there. + +use anyhow::{Context, Result}; +use bsdkrun_core::cli::{Cli, Command as CoreCommand}; +use clap::Parser; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command( + name = "bsdkrun-supervisor", + version, + about = "Runs one bsdkrun command in its own process (used by bsdkrund; not a user-facing tool)" +)] +struct Args { + #[command(subcommand)] + cmd: Mode, +} + +#[derive(clap::Subcommand)] +enum Mode { + /// Run a JSON-encoded `Command`, as the daemon constructed it. + Run { + /// The command, as JSON. + spec: String, + }, + /// Run a bsdkrun command line, parsed by the engine's own definition. + Cli { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // stdout belongs to the machine — an id, a console, a command's output — so + // diagnostics go to stderr, which is the progress half of a launch stream + // as the daemon reads it back. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_target(false) + .with_writer(std::io::stderr) + .init(); + + bsdkrun_core::init_host(); + + match args.cmd { + Mode::Run { spec } => { + let cmd: CoreCommand = serde_json::from_str(&spec).context("decoding the command")?; + bsdkrun_core::dispatch(cmd) + } + Mode::Cli { args } => { + let cli = Cli::try_parse_from( + std::iter::once("bsdkrun".to_string()).chain(args.into_iter()), + )?; + bsdkrun_core::krun::Ctx::set_log_level(cli.log_level).ok(); + bsdkrun_core::dispatch(cli.cmd) + } + } +}