diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31d217f4c..85b45d7cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,31 @@ For the local case, check out [cstor-dist](https://github.com/cgwalters/cstor-di Another alternative is mounting via virtiofs (see e.g. [this PR to bcvk](https://github.com/bootc-dev/bcvk/pull/16)). If you're using libvirt, see [this document](https://libvirt.org/kbase/virtiofs.html). +#### Using sysext for fast iteration + +For the fastest development cycle when working on the bootc client +(e.g. `bootc upgrade`, `bootc switch`), you can use the sysext-based +workflow. This builds the bootc binary via a container, shares it into +a persistent VM via virtiofs, and overlays it onto `/usr` using +systemd-sysext (~30s rebuild cycle): + +```bash +# Build sysext and launch a persistent dev VM +just bcvk up + +# After editing code, rebuild and refresh the overlay (~30s) +just bcvk sync + +# SSH into the VM — bootc is your dev build +just bcvk ssh bootc status + +# When done +just bcvk down +``` + +The sysext overlay means `bootc` on the VM's PATH is your dev build. +Run `just bcvk` to list all available commands. + #### Running bootc against a live environment If your development environment host is also a bootc system (e.g. a diff --git a/Dockerfile b/Dockerfile index c050877a6..b228de578 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,6 +99,23 @@ ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} # Build RPM directly from source, using cached target directory RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome RPM_VERSION="${pkgversion}" /src/contrib/packaging/build-rpm +# Build a systemd-sysext containing just the bootc binary. +# Skips RPM machinery entirely for fast incremental rebuilds. +FROM buildroot as sysext +RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome < /out/bootc/usr/lib/extension-release.d/extension-release.bootc < cargo xtask # -------------------------------------------------------------------- +mod bcvk 'bcvk.just' + # Configuration variables (override via environment or command line) # Example: BOOTC_base=quay.io/fedora/fedora-bootc:42 just build @@ -336,6 +338,16 @@ build-units: eval $(just _git-build-vars) podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} --target units -t localhost/bootc-units . +# ============================================================================ +# Development VM workflow (sysext-based) +# ============================================================================ + +# Build a systemd-sysext via the container build (binary only, for fast iteration) +[group('dev')] +sysext: + contrib/packaging/build-container-stage sysext target/sysext \ + {{base_buildargs}} $(just _local-deps-args) + # ============================================================================ # Internal helpers (prefixed with _) # ============================================================================ @@ -359,6 +371,13 @@ _git-build-vars: echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" echo "VERSION=${VERSION}" +_local-deps-args: + #!/bin/bash + set -euo pipefail + if [[ -z "{{no_auto_local_deps}}" ]]; then + cargo xtask local-rust-deps + fi + _keygen: ./hack/generate-secureboot-keys diff --git a/bcvk.just b/bcvk.just new file mode 100644 index 000000000..ca20603d4 --- /dev/null +++ b/bcvk.just @@ -0,0 +1,84 @@ +# bcvk development VM management +# +# The dev binary is overlaid onto /usr via systemd-sysext. After +# rebuilding with `just sysext`, run `just bcvk sync` to +# refresh the overlay (~30s total cycle). +# +# Usage: +# just bcvk up # Build sysext + launch persistent VM +# just bcvk sync # Rebuild sysext + refresh overlay (~30s) +# just bcvk ssh # SSH into the VM +# just bcvk ephemeral # Ephemeral VM (full image, destroyed on exit) + +base_img := env("BOOTC_base_img", "localhost/bootc") + +# List available recipes +[private] +default: + @just --list bcvk + +# Run an ephemeral VM from the latest build and SSH in (destroyed on exit) +[group('ephemeral')] +ephemeral: + just build + bcvk ephemeral run-ssh {{base_img}} + +# Launch persistent development VM with sysext +[group('vm')] +up: + just sysext + cargo xtask bcvk vm + +# Rebuild sysext and verify the new binary in the running VM +[group('vm')] +sync: + just sysext + cargo xtask bcvk sync + +# SSH into development VM (interactive shell if no args given) +[group('vm')] +ssh *ARGS: + cargo xtask bcvk ssh {{ARGS}} + +# Stop and remove development VM +[group('vm')] +down: + cargo xtask bcvk down + +# Show development VM status +[group('vm')] +status: + cargo xtask bcvk status + +# Watch development VM logs +[group('vm')] +logs: + cargo xtask bcvk logs + +# Show sysext status in development VM +[group('vm')] +sysext-status: + cargo xtask bcvk ssh systemd-sysext status + +# Restart development VM +[group('vm')] +restart: + #!/bin/bash + set -euo pipefail + echo "Restarting development VM..." + cargo xtask bcvk ssh -- sudo systemctl reboot || true + sleep 5 + echo "Waiting for VM to come back up..." + for i in {1..30}; do + if cargo xtask bcvk ssh -- echo "VM is up" 2>/dev/null; then + echo "VM is back online!" + break + fi + echo "Waiting... (attempt $i/30)" + sleep 2 + done + +# Clean up all development resources (VM + sysext) +[group('vm')] +clean: + cargo xtask bcvk clean diff --git a/contrib/packaging/build-container-stage b/contrib/packaging/build-container-stage new file mode 100755 index 000000000..750eb6e13 --- /dev/null +++ b/contrib/packaging/build-container-stage @@ -0,0 +1,40 @@ +#!/bin/bash +# Build a Dockerfile stage and extract /out to a versioned subdirectory. +# Usage: build-container-stage [podman-build-args...] +# +# Each build creates a new timestamped directory inside +# (e.g. output-dir/bootc-1234567890/) and prints the version name to +# stdout on the last line. A "current" symlink is updated to point at +# the new version. Old versions are pruned (keeping the 2 most recent) +# so the previous version remains valid for any active overlay. +set -euo pipefail + +stage="${1:?Usage: build-container-stage [podman-build-args...]}" +output_dir="${2:?Usage: build-container-stage [podman-build-args...]}" +shift 2 + +image_tag="localhost/bootc-${stage}" + +podman build -t "${image_tag}" --target="${stage}" "$@" . + +mkdir -p "${output_dir}" + +# Extract into a versioned subdirectory, using the image build timestamp +# so the version name reflects when the binary was actually built. +version="bootc-$(podman inspect --format '{{.Created.Unix}}' "${image_tag}")" +version_dir="${output_dir}/${version}" +mkdir -p "${version_dir}" +podman run --rm "${image_tag}" tar -C /out -cf - . | tar -C "${version_dir}" -xvf - +chmod -R a+rX "${version_dir}" + +# Update the "current" symlink atomically +ln -sfn "${version}" "${output_dir}/current.tmp" +mv -Tf "${output_dir}/current.tmp" "${output_dir}/current" + +# Prune old versions, keeping the 2 most recent +ls -1dt "${output_dir}"/bootc-[0-9]* 2>/dev/null | tail -n +3 | while read -r old; do + rm -rf "${old}" +done + +# Print the version name so callers (xtask) can use it +echo "sysext-version=${version}" diff --git a/crates/xtask/src/sysext.rs b/crates/xtask/src/sysext.rs new file mode 100644 index 000000000..940dfa748 --- /dev/null +++ b/crates/xtask/src/sysext.rs @@ -0,0 +1,350 @@ +//! Development VM management with systemd-sysext overlay +//! +//! This module manages a persistent bcvk development VM where the bootc +//! binary is overlaid onto /usr via systemd-sysext. +//! +//! The `target/sysext/` directory is shared with the VM via virtiofs. +//! Inside it, each build creates a versioned subdirectory (e.g. +//! `bootc-1712345678/`) with a `current` symlink pointing at the latest. +//! Inside the VM, `/run/extensions/bootc` is a symlink into the virtiofs +//! mount that follows `current`. +//! +//! On sync, the host builds a new version, then the VM swaps its symlink +//! and runs `systemd-sysext refresh`. The old version's inodes stay +//! valid until the overlay is torn down during refresh. +//! +//! The development cycle is: +//! 1. `just bcvk up` — build sysext, launch VM, set up overlay +//! 2. Edit code +//! 3. `just bcvk sync` — rebuild + refresh overlay (~30s) +//! 4. Repeat from 2 + +use std::fs; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use camino::Utf8Path; +use fn_error_context::context; +use xshell::{Shell, cmd}; + +const SYSEXT_DIR: &str = "target/sysext"; +const DEV_VM_NAME: &str = "bootc-dev"; +const DEV_VM_LABEL: &str = "bootc.dev=1"; +/// Virtiofs mount point inside the VM. We avoid /run/extensions to +/// prevent systemd-sysext from auto-merging during early boot. +const VM_SYSEXT_MNT: &str = "/run/virtiofs-bootc-sysext"; +/// Symlink in the VM that points to the current sysext version. +const VM_EXTENSION_LINK: &str = "/run/extensions/bootc"; + +/// Read the current sysext version from the `current` symlink. +fn current_version() -> Result { + let link = Utf8Path::new(SYSEXT_DIR).join("current"); + let target = fs::read_link(&link) + .with_context(|| format!("No current sysext version (missing {})", link))?; + let version = target + .to_str() + .context("current symlink target is not UTF-8")? + .to_string(); + Ok(version) +} + +/// Launch or sync development VM +#[context("Managing bcvk VM")] +pub(crate) fn bcvk_vm(sh: &Shell) -> Result<()> { + check_vm_deps()?; + // Verify sysext exists + current_version().context("Run 'just sysext' first")?; + + if vm_exists()? { + println!("Development VM '{}' exists, syncing...", DEV_VM_NAME); + bcvk_vm_sync(sh) + } else { + println!("Creating development VM '{}'...", DEV_VM_NAME); + create_vm(sh) + } +} + +/// Rebuild the sysext and refresh the overlay in the VM. +#[context("Syncing to VM")] +pub(crate) fn bcvk_vm_sync(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + if !vm_is_running()? { + bail!( + "Development VM '{}' is not running. Use 'just bcvk vm' to start it.", + DEV_VM_NAME + ); + } + + let version = current_version()?; + let target = format!("{}/{}/bootc", VM_SYSEXT_MNT, version); + + // Swap the extension symlink to the new version, then refresh. + // The old overlay still references valid inodes (the old versioned + // directory hasn't been deleted). systemd-sysext refresh will + // unmerge (dropping the old overlay) then re-merge (following the + // new symlink). + // + // We use systemd-run --no-block so that the SSH session returns + // immediately while systemd handles the unmerge→merge cycle + // asynchronously. + println!("Switching to sysext version: {}", version); + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- ln -sfn {target} {VM_EXTENSION_LINK}" + ) + .run() + .context("Failed to update extension symlink")?; + + println!("Refreshing sysext overlay..."); + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- systemd-run --no-block systemd-sysext refresh" + ) + .run() + .context("Failed to trigger sysext refresh")?; + + // Wait for the overlay merge to complete so the new bootc is in place. + poll( + "bootc available after sysext refresh", + std::time::Duration::from_secs(5), + || Ok(cmd!(sh, "bcvk libvirt ssh {DEV_VM_NAME} -- bootc --version").run()?), + )?; + + Ok(()) +} + +/// Stop and remove development VM +#[context("Stopping development VM")] +pub(crate) fn bcvk_vm_down(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + if vm_exists()? { + cmd!(sh, "bcvk libvirt rm --stop --force {DEV_VM_NAME}") + .run() + .context("Failed to stop VM")?; + println!("Development VM '{}' stopped and removed", DEV_VM_NAME); + } else { + println!("No development VM '{}' found, nothing to do", DEV_VM_NAME); + } + Ok(()) +} + +/// SSH into development VM. +/// +/// Uses `std::process::Command` with inherited stdio so that interactive +/// sessions get a proper TTY. When args are given after `--`, they are +/// passed as a remote command; otherwise an interactive shell is opened. +#[context("SSH to development VM")] +pub(crate) fn bcvk_vm_ssh(_sh: &Shell, args: &[String]) -> Result<()> { + check_vm_deps()?; + + let mut cmd = Command::new("bcvk"); + cmd.args(["libvirt", "ssh", DEV_VM_NAME]); + if !args.is_empty() { + cmd.arg("--"); + cmd.args(args); + } + let status = cmd + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .context("Failed to run bcvk ssh")?; + if !status.success() { + bail!("ssh command failed with status {status}"); + } + Ok(()) +} + +/// Show VM status +#[context("Getting VM status")] +pub(crate) fn bcvk_vm_status(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + if vm_exists()? { + cmd!(sh, "bcvk libvirt list {DEV_VM_NAME}") + .run() + .context("Failed to get VM status")?; + } else { + println!( + "No development VM '{}' found. Use 'just bcvk vm' to create one.", + DEV_VM_NAME + ); + } + + Ok(()) +} + +/// Watch VM logs +#[context("Watching VM logs")] +pub(crate) fn bcvk_vm_logs(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + cmd!(sh, "bcvk libvirt ssh {DEV_VM_NAME} -- journalctl -f") + .run() + .context("Failed to watch logs")?; + + Ok(()) +} + +/// Clean all development resources +#[context("Cleaning development resources")] +pub(crate) fn bcvk_vm_clean(sh: &Shell) -> Result<()> { + bcvk_vm_down(sh).unwrap_or_else(|e| eprintln!("Warning: {}", e)); + + let sysext_dir = Utf8Path::new(SYSEXT_DIR); + if sysext_dir.exists() { + sh.remove_path(sysext_dir)?; + } + + println!("Cleaned up development VM and sysext files"); + Ok(()) +} + +// Helper functions + +#[context("Checking VM dependencies")] +fn check_vm_deps() -> Result<()> { + if Command::new("bcvk").arg("--version").output().is_err() { + bail!( + "bcvk is required for VM operations.\n\ + Install it from: https://github.com/bootc-dev/bcvk" + ); + } + + Ok(()) +} + +/// Query bcvk for a VM by name. +fn query_vm() -> Result> { + let output = Command::new("bcvk") + .args(["libvirt", "list", DEV_VM_NAME, "--format=json"]) + .output() + .context("Failed to run bcvk list")?; + + if !output.status.success() { + return Ok(None); + } + + let stdout = + String::from_utf8(output.stdout).context("Failed to parse bcvk output as UTF-8")?; + let val: serde_json::Value = + serde_json::from_str(&stdout).context("Failed to parse bcvk JSON output")?; + + match &val { + serde_json::Value::Object(_) => Ok(Some(val)), + serde_json::Value::Array(arr) => Ok(arr.first().cloned()), + _ => Ok(None), + } +} + +#[context("Checking if VM exists")] +fn vm_exists() -> Result { + Ok(query_vm()?.is_some()) +} + +#[context("Checking if VM is running")] +fn vm_is_running() -> Result { + Ok(query_vm()? + .as_ref() + .and_then(|v| v.get("state")) + .and_then(|s| s.as_str()) + == Some("running")) +} + +#[context("Creating development VM")] +fn create_vm(sh: &Shell) -> Result<()> { + let sysext_path = + fs::canonicalize(SYSEXT_DIR).context("Failed to get absolute path for sysext directory")?; + let sysext_path = sysext_path.to_string_lossy(); + + let version = current_version()?; + + let base_img = std::env::var("BOOTC_BASE_IMAGE") + .or_else(|_| std::env::var("BOOTC_base")) + .unwrap_or_else(|_| "quay.io/centos-bootc/centos-bootc:stream10".to_string()); + let bind_mount = format!("{}:{}", sysext_path, VM_SYSEXT_MNT); + + let variant = std::env::var("BOOTC_variant").unwrap_or_else(|_| "ostree".to_string()); + let mut bcvk_cmd = cmd!( + sh, + "bcvk libvirt run --name={DEV_VM_NAME} --replace --label={DEV_VM_LABEL} --bind={bind_mount}" + ); + + let seal_state = std::env::var("BOOTC_seal_state").unwrap_or_else(|_| "unsealed".to_string()); + if variant == "composefs" && seal_state == "sealed" { + let secureboot_dir = Utf8Path::new("target/test-secureboot"); + if !secureboot_dir.exists() { + println!("Generating secure boot keys for sealed variant..."); + cmd!(sh, "./hack/generate-secureboot-keys") + .run() + .context("Failed to generate secure boot keys")?; + } + bcvk_cmd = bcvk_cmd.arg("--secure-boot-keys=target/test-secureboot"); + } + + bcvk_cmd = bcvk_cmd.args(["--ssh-wait", &base_img]); + bcvk_cmd.run().context("Failed to create VM")?; + + // Set up the sysext: create a symlink from /run/extensions/bootc + // into the virtiofs-mounted versioned directory. + let target = format!("{}/{}/bootc", VM_SYSEXT_MNT, version); + println!("Setting up sysext overlay (version: {})...", version); + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- mkdir -p /run/extensions" + ) + .run() + .context("Failed to create /run/extensions")?; + + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- ln -sfn {target} {VM_EXTENSION_LINK}" + ) + .run() + .context("Failed to create extension symlink")?; + + cmd!(sh, "bcvk libvirt ssh {DEV_VM_NAME} -- systemd-sysext merge") + .run() + .context("Failed to merge sysext")?; + + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- systemd-sysext status" + ) + .run() + .context("Failed to get sysext status")?; + + println!(); + println!("Development VM is ready! bootc is overlaid on /usr via sysext."); + println!(" Rebuild+sync: just bcvk sync"); + println!(" SSH: just bcvk ssh"); + println!(" Test: just bcvk ssh bootc status"); + println!(" Stop: just bcvk down"); + + Ok(()) +} + +/// Poll a closure until it succeeds or the timeout elapses. +/// +/// Calls `f` repeatedly with a 1-second interval. Returns the first +/// `Ok` value, or the last error if the timeout is reached. +fn poll( + condition: &str, + timeout: std::time::Duration, + mut f: impl FnMut() -> Result, +) -> Result { + let start = std::time::Instant::now(); + let mut last_err = None; + while start.elapsed() < timeout { + match f() { + Ok(v) => return Ok(v), + Err(e) => { + last_err = Some(e); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("timed out waiting for: {condition}"))) +} diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 4bb584c82..0332dba5d 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -18,6 +18,7 @@ use xshell::{Shell, cmd}; mod buildsys; mod man; +mod sysext; mod tmt; const NAME: &str = "bootc"; @@ -62,6 +63,34 @@ enum Commands { ValidateComposefsDigest(ValidateComposefsDigestArgs), /// Print podman bind mount arguments for local path dependencies LocalRustDeps(LocalRustDepsArgs), + /// Development VM management via bcvk + systemd-sysext + Bcvk { + #[command(subcommand)] + command: BcvkCommands, + }, +} + +/// Subcommands for development VM management +#[derive(Debug, Subcommand)] +enum BcvkCommands { + /// Launch or sync persistent development VM with sysext + Vm, + /// Sync sysext to running development VM + Sync, + /// Stop and remove development VM + Down, + /// SSH into development VM (interactive shell if no command given) + Ssh { + /// Command to run in the VM (omit for interactive shell) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show development VM status + Status, + /// Watch development VM logs + Logs, + /// Clean all development resources + Clean, } /// Arguments for validate-composefs-digest command @@ -250,6 +279,15 @@ fn try_main() -> Result<()> { Commands::CheckBuildsys => buildsys::check_buildsys(&sh, "Dockerfile".into()), Commands::ValidateComposefsDigest(args) => validate_composefs_digest(&sh, &args), Commands::LocalRustDeps(args) => local_rust_deps(&sh, &args), + Commands::Bcvk { command } => match command { + BcvkCommands::Vm => sysext::bcvk_vm(&sh), + BcvkCommands::Sync => sysext::bcvk_vm_sync(&sh), + BcvkCommands::Down => sysext::bcvk_vm_down(&sh), + BcvkCommands::Ssh { args } => sysext::bcvk_vm_ssh(&sh, &args), + BcvkCommands::Status => sysext::bcvk_vm_status(&sh), + BcvkCommands::Logs => sysext::bcvk_vm_logs(&sh), + BcvkCommands::Clean => sysext::bcvk_vm_clean(&sh), + }, } }