From 9610b7bf95ff96b681d95ef6c6948c0c632cd7ca Mon Sep 17 00:00:00 2001 From: Norrie Taylor <91171431+norrietaylor@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:08:45 -0700 Subject: [PATCH 1/4] add microvm-rootfs and virtio-kernel-raw packages (#229) * virtio-kernel-raw: decompress the virtio-linux kernel to a raw Image Gunzip the virtio-linux Image.gz to an uncompressed aarch64 Image so consumers (minvmd) can load it with KRUN_KERNEL_FORMAT_RAW, skipping libkrun's in-VMM gzip decompress (~77 ms, over half of microVM boot-to-READY). Co-Authored-By: Claude Opus 4.8 (1M context) * minvmd-rootfs: package the minvmd guest rootfs as an ext4 image Assemble the minvmd microVM guest rootfs (socat + bash + coreutils closure, bring-up init, /etc/minvmd/manifest boot contract) into a read-only ext4 image via mke2fs. minvmd loads it as a block device (krun_add_disk2 -> /dev/vda). Co-Authored-By: Claude Opus 4.8 (1M context) * genericize: rename minvmd-rootfs -> microvm-rootfs, drop product references Make the packages product-agnostic: microvm-rootfs with /sbin/microvm-init and /etc/microvm/manifest, and scrub internal references from comments. Co-Authored-By: Claude Opus 4.8 (1M context) * virtio-kernel-raw: handle x86_64 bzImage (not just aarch64 Image.gz) The kernel is only gzip-compressed on aarch64 (Image.gz); on x86_64 virtio-linux ships a bzImage, so the unconditional gunzip failed the build there. gzip-test first: decompress when gzip, else pass through. Co-Authored-By: Claude Opus 4.8 (1M context) * microvm-rootfs: ship runtime closure only; harden init + prune Address review feedback: - Declare base + socat as runtime_deps and e2fsprogs as a build-only dep (mke2fs packs the image), instead of lumping everything in build_deps. - Remove the build-only e2fsprogs files from the staged tree before packing, so the runtime image carries only the runtime closure (mke2fs runs from the build sandbox PATH, not from $STAGE). Verified absent from rootfs.img. - Fail the bring-up init if the READY handshake never succeeds, rather than starting the listener and turning a boot failure into a downstream timeout. - Scope `|| true` to the find invocation so a cd/rm failure still fails the build. * virtio-kernel-raw: add version plumbing; depend on base Add the version binding, attrs.upstream_version, and build_args forwarding the repo's build.ncl contract expects. Depend on base (provides gzip + sh at build) instead of listing gzip/bash/coreutils individually. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/microvm-rootfs/build.ncl | 28 ++++++ packages/microvm-rootfs/build.sh | 122 +++++++++++++++++++++++++++ packages/virtio-kernel-raw/build.ncl | 34 ++++++++ packages/virtio-kernel-raw/build.sh | 16 ++++ 4 files changed, 200 insertions(+) create mode 100644 packages/microvm-rootfs/build.ncl create mode 100755 packages/microvm-rootfs/build.sh create mode 100644 packages/virtio-kernel-raw/build.ncl create mode 100755 packages/virtio-kernel-raw/build.sh diff --git a/packages/microvm-rootfs/build.ncl b/packages/microvm-rootfs/build.ncl new file mode 100644 index 0000000..131e395 --- /dev/null +++ b/packages/microvm-rootfs/build.ncl @@ -0,0 +1,28 @@ +let { BuildSpec, Local, OutputData, .. } = import "minimal.ncl" in +let base = import "../base/build.ncl" in +let e2fsprogs = import "../e2fsprogs/build.ncl" in +let socat = import "../socat/build.ncl" in +{ + name = "microvm-rootfs", + + # A read-only ext4 guest rootfs for a libkrun microVM. build.sh snapshots the + # runtime userland (base + socat), drops in a small bring-up init, prunes + # build-only bulk, and packs an ext4 image to load as a virtio-blk block + # device. e2fsprogs is build-only — it provides mke2fs to pack the image — and + # its files are removed from the tree before packing, so the runtime image + # ships only the runtime closure. + runtime_deps = [ + base, + socat, + ], + build_deps = [ + { file = "build.sh" } | Local, + e2fsprogs, + ], + + cmd = "./build.sh", + + outputs = { + rootfs = { glob = "usr/share/microvm-rootfs/rootfs.img" } | OutputData, + }, +} | BuildSpec diff --git a/packages/microvm-rootfs/build.sh b/packages/microvm-rootfs/build.sh new file mode 100755 index 0000000..9981d43 --- /dev/null +++ b/packages/microvm-rootfs/build.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Assemble a libkrun microVM guest rootfs as a read-only ext4 image. +# +# The build sandbox hardlinks this package's runtime closure (base + socat + +# their libs) and its build-only deps (e2fsprogs, for mke2fs) into the sandbox +# root at standard paths. We snapshot the userland into a staging tree, drop the +# build-only e2fsprogs files, add a small bring-up init, prune bulk, and pack an +# ext4 image with mke2fs. The image is loaded as a virtio-blk block device (e.g. +# root=/dev/vda); a block root has no overlaid init, so the kernel runs the init +# below directly. devtmpfs auto-mounts /dev, giving the init /dev/vsock. +set -euo pipefail + +STAGE="$(pwd)/stage" +rm -rf "$STAGE" +mkdir -p "$STAGE" + +# Snapshot the runtime userland composed into this build sandbox. +for d in usr bin sbin lib lib64 etc; do + if [ -e "/$d" ]; then + cp -a "/$d" "$STAGE/" + fi +done + +# e2fsprogs is a build-only dependency: it provides mke2fs to pack the image +# below (invoked from the build sandbox PATH, not from $STAGE), but the guest +# never needs it at runtime. Drop its staged files so the runtime image carries +# only the runtime closure. Done before the init is created, so nothing we ship +# is at risk. The symlink guard skips a usr-merged /usr/sbin. +if [ -d "$STAGE/usr/sbin" ] && [ ! -L "$STAGE/usr/sbin" ]; then + rm -rf "$STAGE/usr/sbin" +fi +rm -f "$STAGE"/usr/bin/chattr "$STAGE"/usr/bin/lsattr "$STAGE"/usr/bin/uuidgen \ + "$STAGE"/usr/bin/compile_et "$STAGE"/usr/bin/mk_cmds +rm -f "$STAGE"/usr/lib/libext2fs*.so* "$STAGE"/usr/lib/libe2p*.so* "$STAGE"/usr/lib/libss*.so* + +mkdir -p "$STAGE/bin" "$STAGE/sbin" "$STAGE/etc/microvm" + +# Kernel mountpoints. devtmpfs auto-mounts on /dev at boot (CONFIG_DEVTMPFS_MOUNT) +# — without the directory it fails with "devtmpfs: error mounting -2" and the +# guest has no /dev/vsock node. /proc and /sys are conventional mountpoints. +mkdir -p "$STAGE/dev" "$STAGE/proc" "$STAGE/sys" "$STAGE/run" "$STAGE/tmp" +chmod 1777 "$STAGE/tmp" + +# Guarantee /bin/sh for the init script's shebang. +if [ ! -e "$STAGE/bin/sh" ]; then + if [ -e "$STAGE/bin/bash" ]; then + ln -sf bash "$STAGE/bin/sh" + elif [ -e "$STAGE/usr/bin/bash" ]; then + ln -sf ../usr/bin/bash "$STAGE/bin/sh" + fi +fi + +# Bring-up init: signal readiness by connecting out to the host (vsock CID 2) +# port 7350 and writing "READY\n", then serve an echo on vsock port 2222 for +# host<->guest connectivity checks. Retry the marker briefly in case the vsock +# device is not live the instant init starts. +cat > "$STAGE/sbin/microvm-init" <<'INIT' +#!/bin/sh +i=0 +while [ "$i" -lt 50 ]; do + printf 'READY\n' | socat -t2 - VSOCK-CONNECT:2:7350 && break + i=$((i + 1)) + sleep 0.1 +done +# Fail loudly if the READY handshake never succeeded, rather than starting the +# listener anyway and turning a boot failure into a downstream timeout. +[ "$i" -lt 50 ] || { + echo "microvm-init: failed to publish READY on vsock 7350" >&2 + exit 1 +} +exec socat VSOCK-LISTEN:2222,fork EXEC:cat +INIT +chmod +x "$STAGE/sbin/microvm-init" + +# Machine-readable record of the bring-up contract. +cat > "$STAGE/etc/microvm/manifest" <<'MANIFEST' +# microvm guest rootfs contract +# format=ext4-block-image +# init=/sbin/microvm-init +# vsock_port_ready=7350 guest CONNECTs out (host listen=false); writes "READY\n" once +# vsock_port_echo=2222 guest LISTENs (host listen=true); echoes per connection +# net=none +MANIFEST + +# Prune build-time-only bulk the guest never needs: headers, static libs, +# docs/man, and especially glibc's locale archive (the bulk of the closure). +# The bring-up workload is sh + socat; the C locale fallback is sufficient. +# `|| true` is scoped to `find` only — a failure in `cd` or `rm -rf` must still +# fail the build (set -euo pipefail), while `find`'s noncritical errors are ok. +( cd "$STAGE" && \ + rm -rf usr/include usr/share/man usr/share/doc usr/share/info \ + usr/share/locale usr/share/i18n usr/lib/locale usr/lib/pkgconfig \ + usr/share/aclocal usr/share/gtk-doc usr/share/bash-completion \ + usr/share/gdb && \ + { find . \( -name '*.a' -o -name '*.la' -o -name '*.o' \) -delete 2>/dev/null || true; } ) + +# Fail loudly (not silently with an empty output) if the image tool is absent. +command -v mke2fs >/dev/null || { + echo "ERROR: mke2fs not found on PATH ($PATH) — is e2fsprogs in build_deps?" >&2 + exit 1 +} + +# Pack the staging tree into a raw ext4 image (mke2fs -d copies the tree). +# Size = tree + 10% + 8 MiB headroom, in 1 KiB blocks. The headroom must cover +# ext4 metadata (inode tables) that `du` does not account for, or `mke2fs -d` +# can fail to fit the tree. +OUT="$OUTPUT_DIR/usr/share/microvm-rootfs" +mkdir -p "$OUT" +KB="$(du -sk "$STAGE" | cut -f1)" +BLOCKS=$(( KB + KB / 10 + 8192 )) +# No journal (`-O ^has_journal`): the root mounts read-only, so the journal is +# pure overhead and its ~4 MiB+ reservation can overflow a tight image. +mke2fs -q -t ext4 -O ^has_journal -d "$STAGE" -b 1024 -F "$OUT/rootfs.img" "$BLOCKS" + +# Assert the output exists so a silent mke2fs failure surfaces here rather than +# downstream as a missing materialize output. +[ -s "$OUT/rootfs.img" ] || { + echo "ERROR: mke2fs did not produce $OUT/rootfs.img" >&2 + ls -la "$OUT" >&2 || true + exit 1 +} +echo "built rootfs.img: $(wc -c < "$OUT/rootfs.img") bytes" diff --git a/packages/virtio-kernel-raw/build.ncl b/packages/virtio-kernel-raw/build.ncl new file mode 100644 index 0000000..e507f27 --- /dev/null +++ b/packages/virtio-kernel-raw/build.ncl @@ -0,0 +1,34 @@ +let { Attrs, BuildSpec, Local, OutputData, .. } = import "minimal.ncl" in +let base = import "../base/build.ncl" in +let virtio-linux = import "../virtio-linux/build.ncl" in + +# Tracks the virtio-linux kernel version; this package only decompresses it. +let version = "6.12.43" in +{ + name = "virtio-kernel-raw", + + # Produce a raw, directly-loadable kernel Image from the virtio-linux kernel. + # On aarch64 that is a gzip-compressed Image (Image.gz); decompress it so + # libkrun can load it via KRUN_KERNEL_FORMAT_RAW, skipping its in-VMM gzip + # decompress for a faster microVM boot. On x86_64 it is already a bzImage and + # passes through unchanged. base provides gzip + sh at build time. + build_deps = [ + { file = "build.sh" } | Local, + base, + virtio-linux, + ], + + cmd = "./build.sh", + build_args = { + include version, + }, + + outputs = { + image = { glob = "usr/share/virtio-linux/Image" } | OutputData, + }, + + attrs = + { + upstream_version = version, + } | Attrs, +} | BuildSpec diff --git a/packages/virtio-kernel-raw/build.sh b/packages/virtio-kernel-raw/build.sh new file mode 100755 index 0000000..fbacd8a --- /dev/null +++ b/packages/virtio-kernel-raw/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Produce a raw, directly-loadable kernel Image from virtio-linux. virtio-linux +# is a build_dep, so its vmlinuz output is present in the sandbox at +# /usr/share/virtio-linux/vmlinuz. On aarch64 that is a gzip-compressed Image +# (Image.gz); decompress it so libkrun can load it via KRUN_KERNEL_FORMAT_RAW +# without an in-VMM gzip step. On x86_64 it is already a bzImage (not gzip), so +# pass it through unchanged. +set -euo pipefail + +OUT="$OUTPUT_DIR/usr/share/virtio-linux" +mkdir -p "$OUT" +if gzip -t /usr/share/virtio-linux/vmlinuz 2>/dev/null; then + gunzip -c /usr/share/virtio-linux/vmlinuz > "$OUT/Image" +else + cp /usr/share/virtio-linux/vmlinuz "$OUT/Image" +fi From 141ae2c2fd7f444b44afb6df1c8f6d0039592e64 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 7 Jun 2026 17:58:17 -0700 Subject: [PATCH 2/4] feat(capy): add capy secrets-manager package Installs the Capy CLI (@capysc/cli@0.6.1) into a Minimal environment and pinholes the developer's ~/.capy session into the box, so in-box `capy run -- ` injects branch-scoped secrets without transporting any key material (shared-session model). - build.ncl: @capysc/cli install, runtime deps (node-lts, git, ca-certificates, coreutils, glibc, base), node_modules output, and a ~/.capy Credential pinhole (read-write; trusted dev shells only). - build.sh: npm global install into the output prefix. - self-test: capy --version runs in a clean room and matches the pin. Verified: minimal package capy + minimal check capy pass; capy runs in a box. --- packages/capy/build.ncl | 60 +++++++++++++++++++++++++++++++++++++++++ packages/capy/build.sh | 16 +++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/capy/build.ncl create mode 100755 packages/capy/build.sh diff --git a/packages/capy/build.ncl b/packages/capy/build.ncl new file mode 100644 index 0000000..77b21af --- /dev/null +++ b/packages/capy/build.ncl @@ -0,0 +1,60 @@ +let { Attrs, BuildSpec, Local, OutputBin, OutputData, Test, .. } = import "minimal.ncl" in +let base = import "../base/build.ncl" in +let coreutils = import "../coreutils/build.ncl" in +let glibc = import "../glibc/build.ncl" in +let ca-certificates = import "../ca-certificates/build.ncl" in +let git = import "../git/build.ncl" in +let node-lts = import "../node-lts/build.ncl" in + +let version = "0.6.1" in +{ + name = "capy", + build_deps = [ + { file = "build.sh" } | Local, + node-lts, # node + npm to install the package + ], + runtime_deps = [ + base, + glibc, + coreutils, # `capy` shebang resolves node via /usr/bin/env + ca-certificates, # HTTPS to the Capy API (sync / co-decrypt) + git, # capy shells out to git for repo/branch detection + node-lts, # capy is a Node CLI (#!/usr/bin/env node) + ], + + cmd = "./build.sh", + build_args = { + include version, + }, + + # npm fetches @capysc/cli + its deps from the registry during the build. + # Follow-up: vendor the tarball + transitive deps as hash-pinned Sources for a + # fully hermetic / SLSA build. + needs = { dns = {}, internet = {} }, + + outputs = { + capy = { glob = "usr/bin/capy" } | OutputBin, + node_modules = { glob = "usr/lib/node_modules/**" } | OutputData, + }, + attrs = + { + upstream_version = version, + # 1a shared-session model: pinhole the host ~/.capy into the box so in-box + # capy reuses the developer's existing login (no key transport). rw because + # capy refreshes its session and writes caches. Exposes the full host + # session to the box: trusted dev shells only, never untrusted agent tasks. + env_dir_mappings = [{ read_only = false, path = "~/.capy", class = 'Credential }], + } | Attrs, + + tests = { + runs = + { + class = 'Standalone, + test_deps = [base, node-lts], + cmds = [ + # capy must run and report its version with no auth/network/config. + ["/bin/bash", "-c", "capy --version | grep -q '%{version}'"], + ], + } | Test, + }, +} | BuildSpec diff --git a/packages/capy/build.sh b/packages/capy/build.sh new file mode 100755 index 0000000..8f9d625 --- /dev/null +++ b/packages/capy/build.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Install @capysc/cli (the `capy` CLI) into the package output prefix. +# Outputs are harvested from $OUTPUT_DIR (see build.ncl `outputs`). +PREFIX="$OUTPUT_DIR/usr" +mkdir -p "$PREFIX" + +# Keep npm's cache inside the build sandbox (no writable $HOME). +export npm_config_cache="$(pwd)/.npm-cache" + +npm install \ + --global \ + --prefix "$PREFIX" \ + --no-audit --no-fund \ + "@capysc/cli@${MINIMAL_ARG_VERSION}" From e4d361063fb450af3eae45381975e1004ff71da9 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 7 Jun 2026 18:22:20 -0700 Subject: [PATCH 3/4] review: drop hand-rolled npm cache, rely on toolchain env_state_wiring The previous `export npm_config_cache="$(pwd)/.npm-cache"` was redundant and non-deterministic: node/node-lts already wire NPM_CONFIG_CACHE to a Minimal- managed state dir via env_state_wiring. Simplify build.sh to match the other npm-CLI packages (pyright, typescript-language-server, mermaid-cli). Verified: minimal package capy + minimal check capy still pass (incl. self-test). --- packages/capy/build.sh | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/capy/build.sh b/packages/capy/build.sh index 8f9d625..ac2d134 100755 --- a/packages/capy/build.sh +++ b/packages/capy/build.sh @@ -1,16 +1,4 @@ #!/bin/sh -set -e +set -ex -# Install @capysc/cli (the `capy` CLI) into the package output prefix. -# Outputs are harvested from $OUTPUT_DIR (see build.ncl `outputs`). -PREFIX="$OUTPUT_DIR/usr" -mkdir -p "$PREFIX" - -# Keep npm's cache inside the build sandbox (no writable $HOME). -export npm_config_cache="$(pwd)/.npm-cache" - -npm install \ - --global \ - --prefix "$PREFIX" \ - --no-audit --no-fund \ - "@capysc/cli@${MINIMAL_ARG_VERSION}" +npm install -g --prefix=$OUTPUT_DIR/usr @capysc/cli@$MINIMAL_ARG_VERSION From 264c05c523aafb0e52dd44dfef7506a60d2961fe Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 7 Jun 2026 20:22:14 -0700 Subject: [PATCH 4/4] feat(capy): add source_provenance for the canonical upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares capysc/capy-cli as the source of record per the new-package checklist. (Building from source vs the npm prebuilt is deferred — still npm-install for now.) --- packages/capy/build.ncl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/capy/build.ncl b/packages/capy/build.ncl index 77b21af..f84bb73 100644 --- a/packages/capy/build.ncl +++ b/packages/capy/build.ncl @@ -39,6 +39,11 @@ let version = "0.6.1" in attrs = { upstream_version = version, + source_provenance = { + category = 'GithubRepo, + owner = "capysc", + repo = "capy-cli", + }, # 1a shared-session model: pinhole the host ~/.capy into the box so in-box # capy reuses the developer's existing login (no key transport). rw because # capy refreshes its session and writes caches. Exposes the full host