diff --git a/CLAUDE.md b/CLAUDE.md index c6a9b9ec9..3e008b1c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - **[docs/releasing.md](docs/releasing.md)** - Release process and versioning instructions - **[docs/INTEGRATION-TESTS.md](docs/INTEGRATION-TESTS.md)** - Integration test coverage guide with gap analysis - **[docs/enclaves-architecture.md](docs/enclaves-architecture.md)** - Unified enclave architecture, MCP gateway handoff, migration, and coverage notes +- **[docs/cloud-hypervisor-foundation.md](docs/cloud-hypervisor-foundation.md)** - Cloud Hypervisor microVM foundation (config/artifacts/preflight only; not yet a runnable backend) ## Development Workflow diff --git a/README.md b/README.md index 50c1d11b5..4fb7e31e0 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su - [Auth Doctor Updater workflow](.github/workflows/auth-doctor-updater.md) — daily/manual audit that opens bounded PRs with evidence-backed authentication and API-proxy documentation corrections - [Image verification](docs/image-verification.md) — cosign signature verification - [Firecracker integration (preview)](docs/firecracker-integration.md) — Firecracker v1.16.1 microVM backend: explicit opt-in, Linux/KVM only, macOS/Windows unsupported, operator-managed artifacts with mandatory SHA-256 digests, fail-closed egress, mandatory API proxy credential isolation +- [Cloud Hypervisor foundation](docs/cloud-hypervisor-foundation.md) — config/artifact plumbing, preflight validation, and guest artifact pipeline for a future Cloud Hypervisor microVM backend; **not yet a runnable runtime** ## Development diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 7cf1c6b06..f789b6fe8 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -76,6 +76,7 @@ following top-level properties. All are OPTIONAL: | `security` | object | Security and isolation settings | | `container` | object | Container and Docker settings | | `firecracker` | object | Firecracker v1.16.1 control-plane preview settings | +| `cloudHypervisor` | object | Cloud Hypervisor v53.0 foundation settings (artifacts/digests only; no lifecycle backend yet — see §4.1) | | `chroot` | object | Chroot execution overrides for split-filesystem ARC/DinD runners | | `dind` | object | Bootstrap helpers for ARC/DinD split runner/daemon filesystems | | `runner` | object | Runner topology declaration (standard vs. ARC/DinD) | @@ -88,6 +89,21 @@ following top-level properties. All are OPTIONAL: Property-level constraints, types, and descriptions are defined normatively by `docs/awf-config.schema.json`. +### 4.1 Cloud Hypervisor foundation (not yet a runnable backend) + +The `cloudHypervisor` surface pins Cloud Hypervisor v53.0 artifacts and +digests (binary, PCI-capable guest kernel, rootfs, and the shared AWF guest +supervisor) so a config document can be prepared and round-tripped ahead of +time. **It has no lifecycle backend in this release**: `cloud-hypervisor` is +not a valid `container.containerRuntime` value, and supplying +`cloudHypervisor` options does not execute any workload. Supported host +target is GitHub-hosted Ubuntu `x86_64` runners with KVM only; self-hosted +and non-Ubuntu/non-x86_64 hosts are out of scope. See +[`src/cloud-hypervisor/preflight.ts`](../src/cloud-hypervisor/preflight.ts) +for the artifact/host trust-check module and +[`guest/cloud-hypervisor/`](../guest/cloud-hypervisor/) for the guest +artifact build/verification pipeline. + ## 5. CLI Mapping *This section is normative.* @@ -204,6 +220,18 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `firecracker.sha256.kernel` → `--firecracker-kernel-sha256` - `firecracker.sha256.rootfs` → `--firecracker-rootfs-sha256` - `firecracker.sha256.supervisor` → `--firecracker-supervisor-sha256` +- `cloudHypervisor.previewEnabled` → `--cloud-hypervisor-preview` *(foundation only; does not enable workload execution)* +- `cloudHypervisor.cloudHypervisorBinary` → `--cloud-hypervisor-binary` +- `cloudHypervisor.kernelPath` → `--cloud-hypervisor-kernel` +- `cloudHypervisor.rootfsPath` → `--cloud-hypervisor-rootfs` +- `cloudHypervisor.supervisorPath` → `--cloud-hypervisor-supervisor` +- `cloudHypervisor.vcpuCount` → `--cloud-hypervisor-vcpus` +- `cloudHypervisor.memoryMib` → `--cloud-hypervisor-memory-mib` +- `cloudHypervisor.apiTimeoutMs` → `--cloud-hypervisor-api-timeout-ms` +- `cloudHypervisor.sha256.cloudHypervisor` → `--cloud-hypervisor-binary-sha256` +- `cloudHypervisor.sha256.kernel` → `--cloud-hypervisor-kernel-sha256` +- `cloudHypervisor.sha256.rootfs` → `--cloud-hypervisor-rootfs-sha256` +- `cloudHypervisor.sha256.supervisor` → `--cloud-hypervisor-supervisor-sha256` - `chroot.binariesSourcePath` → *(config-only; mounts a runner-side binaries directory at `/tmp/awf-runner-bin` inside chroot mode and prepends it to `PATH`)* - `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* - `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index c72630fd1..ff1f1f99d 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -728,6 +728,71 @@ } } }, + "cloudHypervisor": { + "type": "object", + "description": "Cloud Hypervisor v53.0 foundation configuration (artifacts and digests only). There is no lifecycle backend yet: this cannot be selected via container.containerRuntime and cannot execute workloads.", + "additionalProperties": false, + "properties": { + "previewEnabled": { + "type": "boolean", + "default": false, + "description": "Reserve Cloud Hypervisor configuration for a future preview. Has no effect on workload execution in this release." + }, + "cloudHypervisorBinary": { + "type": "string", + "description": "Absolute path to the Cloud Hypervisor v53.0 binary. Defaults to /usr/local/bin/cloud-hypervisor." + }, + "kernelPath": { + "type": "string", + "description": "Absolute path to the trusted PCI-capable guest Linux kernel image." + }, + "rootfsPath": { + "type": "string", + "description": "Absolute path to the trusted guest root filesystem image." + }, + "supervisorPath": { + "type": "string", + "description": "Absolute path to the built AWF guest supervisor (shared with Firecracker)." + }, + "vcpuCount": { + "type": "integer", + "minimum": 1, + "default": 2, + "description": "Number of guest virtual CPUs." + }, + "memoryMib": { + "type": "integer", + "minimum": 1, + "default": 512, + "description": "Guest memory in MiB." + }, + "apiTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "Bounded timeout in milliseconds for Cloud Hypervisor API socket readiness and requests." + }, + "sha256": { + "type": "object", + "description": "Pinned SHA-256 digests for trusted Cloud Hypervisor artifacts.", + "additionalProperties": false, + "properties": { + "cloudHypervisor": { + "$ref": "#/$defs/sha256Digest" + }, + "kernel": { + "$ref": "#/$defs/sha256Digest" + }, + "rootfs": { + "$ref": "#/$defs/sha256Digest" + }, + "supervisor": { + "$ref": "#/$defs/sha256Digest" + } + } + } + } + }, "chroot": { "type": "object", "description": "Chroot execution overrides for split-filesystem ARC/DinD runners.", diff --git a/docs/cloud-hypervisor-foundation.md b/docs/cloud-hypervisor-foundation.md new file mode 100644 index 000000000..3b7d67655 --- /dev/null +++ b/docs/cloud-hypervisor-foundation.md @@ -0,0 +1,100 @@ +--- +title: Cloud Hypervisor foundation (not yet a runnable backend) +description: Pinned versions/digests, configuration surface, preflight/artifact validation module, and guest artifact pipeline for the Cloud Hypervisor microVM foundation. There is no lifecycle backend yet. +--- + +:::caution[Foundation only — no lifecycle backend] +This document describes **preparatory** work for a future Cloud Hypervisor +microVM backend: configuration/artifact plumbing, fail-closed preflight +validation, and a guest artifact build pipeline. **`cloud-hypervisor` is not a +valid `--container-runtime` value in this release** and no workload can be +executed with it. Firecracker (see +[Firecracker microVM integration (preview)](./firecracker-integration.md)) +continues to work unchanged and is unaffected by this foundation. +::: + +## What this adds + +- A `cloudHypervisor` config-file block and matching `--cloud-hypervisor-*` + CLI flags (see [`docs/awf-config-spec.md`](./awf-config-spec.md) §4.1 for + the normative property list and CLI mapping) so artifact paths and SHA-256 + digests can be pinned and round-tripped through config today. +- [`src/cloud-hypervisor/preflight.ts`](../src/cloud-hypervisor/preflight.ts): + fail-closed host and artifact validation mirroring + [`src/firecracker/preflight.ts`](../src/firecracker/preflight.ts) — pinned + version parsing, trusted-owner/non-writable regular-file checks for the + binary/kernel/rootfs/supervisor, digest verification, `/dev/kvm` access, + and required trusted host tools (`ip`, `nft`, `sysctl`, `mke2fs`, + `debugfs`, `e2fsck`, `rsync`). +- [`src/cloud-hypervisor/host-eligibility.ts`](../src/cloud-hypervisor/host-eligibility.ts): + a narrow, independently-tested helper distinguishing GitHub-hosted Ubuntu + x86_64 KVM runners from self-hosted or non-Ubuntu hosts. This is a + necessary-but-not-sufficient check — it does not open `/dev/kvm` or verify + artifacts; `runCloudHypervisorPreflight` does that. +- [`guest/cloud-hypervisor/build-test-artifacts.sh`](../guest/cloud-hypervisor/build-test-artifacts.sh) + and + [`verify-test-artifacts.sh`](../guest/cloud-hypervisor/verify-test-artifacts.sh): + a reproducible guest artifact pipeline that produces a pinned Cloud + Hypervisor binary, a PCI-capable guest Linux kernel, a deterministic raw + ext4 rootfs (BusyBox + CA bundle + the shared AWF guest supervisor), + `SHA256SUMS`, `manifest.json`, and an SPDX SBOM. + +## Pinned versions and digests + +| Artifact | Version | SHA-256 | +|---|---|---| +| `cloud-hypervisor` (x86_64 static) | v53.0 | `448af3d4e59b22c2987f7df94c213ad40fb53a10d437e42b5ee6c4fce7c29ecc` | +| Linux kernel source | 6.1.141 | `bc3c45faf6f5f0450666c75fa9dad9bc7c0cf7c7cba0dbd94e5cfdc58229c116` | +| Kernel config | firecracker v1.16.1 `microvm-kernel-ci-x86_64-6.1.config` | `adbc70ab5e89213ba00594b12d25e09bdf8bb1ed3c252d7449326bb14c22963b` | +| BusyBox source | 1.36.1 | `b8cc24c9574d809e7279c3be349795c5d5ceb6fdf19ca709f80cde50e47de314` | +| CA bundle | 2025-02-25 | `50a6277ec69113f00c5fd45f09e8b97a4b3e32daa35d3a95ab30137a55386cef` | + +Cloud Hypervisor v53.0 was the current upstream release as of this writing +([cloud-hypervisor/cloud-hypervisor releases](https://github.com/cloud-hypervisor/cloud-hypervisor/releases)). +Both the binary digest and the source-tarball digest were independently +verified against the GitHub release assets before pinning. + +### Why the guest kernel reuses the Firecracker config + +`guest/cloud-hypervisor/build-test-artifacts.sh` intentionally builds from the +**same Linux kernel source and the same pinned Firecracker +`microvm-kernel-ci-x86_64-6.1.config`** used by +[`guest/firecracker/build-test-artifacts.sh`](../guest/firecracker/build-test-artifacts.sh). +That config already enables everything Cloud Hypervisor's direct-kernel-boot, +virtio-pci transport needs — `CONFIG_PCI`, `CONFIG_VIRTIO_PCI`, +`CONFIG_PCI_MMCONFIG` (ACPI MCFG/PCIe ECAM), `CONFIG_VIRTIO_BLK`, +`CONFIG_VIRTIO_NET`, `CONFIG_VIRTIO_CONSOLE`, `CONFIG_VSOCKETS` / +`CONFIG_VIRTIO_VSOCKETS`, `CONFIG_EXT4_FS`, and `CONFIG_PVH` for +firmware-less direct boot — while leaving virtio-fs, VFIO, vhost-user, vDPA, +snapshot/restore, hotplug, and confidential-computing options off. Reusing +one reviewed, pinned kernel config keeps both VMM backends' guest kernels +identical instead of maintaining a second hand-curated config. + +### Why the guest supervisor is shared, unmodified + +[`guest/firecracker-supervisor/`](../guest/firecracker-supervisor/) documents +itself as VMM-neutral: its length-prefixed JSON framing protocol +(`protocol.go`) mirrors `src/microvm/guest-protocol.ts` on the host side and +does not depend on any Firecracker-specific transport. The Cloud Hypervisor +guest pipeline invokes +`guest/firecracker-supervisor/build.sh` as-is to produce the same +`awf-supervisor` binary used in both guest rootfs images. + +## Explicit scope limits (this layer) + +- **Direct kernel boot only.** No UEFI/firmware layer. +- **Raw ext4 disks only.** No virtio-fs, snapshot/restore, hotplug, VFIO, + vhost-user, vDPA, or confidential computing. +- **virtio-pci transport only** for block, net, and vsock devices. +- **GitHub-hosted Ubuntu x86_64 KVM runners only.** Self-hosted runners and + non-Ubuntu/non-x86_64 hosts are explicitly out of scope; see + `evaluateGithubHostedRunnerEligibility()` in + [`src/cloud-hypervisor/host-eligibility.ts`](../src/cloud-hypervisor/host-eligibility.ts). +- **No REST control-plane client, launcher, or manager.** No + `--container-runtime cloud-hypervisor` registration. No live KVM + integration test. These arrive in a later layer once the lifecycle backend + exists. + +Passing `--container-runtime cloud-hypervisor` fails immediately with an +explicit error (`assertCloudHypervisorNotYetAvailable`) instead of silently +falling through to the generic unknown-runtime passthrough. diff --git a/guest/cloud-hypervisor/build-test-artifacts.sh b/guest/cloud-hypervisor/build-test-artifacts.sh new file mode 100755 index 000000000..f11a7654d --- /dev/null +++ b/guest/cloud-hypervisor/build-test-artifacts.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 + +# Cloud Hypervisor v53.0 foundation guest artifacts. +# +# This mirrors guest/firecracker/build-test-artifacts.sh's conventions and +# intentionally reuses the *exact same* pinned Linux kernel source and +# Firecracker microvm-kernel-ci config as the Firecracker pipeline: that +# config already builds a PCI-capable kernel (CONFIG_PCI, CONFIG_VIRTIO_PCI, +# CONFIG_PCI_MMCONFIG for ACPI MCFG/PCIe ECAM, CONFIG_VIRTIO_BLK, +# CONFIG_VIRTIO_NET, CONFIG_VIRTIO_CONSOLE, CONFIG_VSOCKETS, +# CONFIG_VIRTIO_VSOCKETS, CONFIG_EXT4_FS, CONFIG_PVH for firmware-less direct +# boot) with virtio-fs, hotplug-only, and confidential-computing options left +# off. Reusing it keeps both VMM backends' guest kernels identical and +# reviewed against a single trusted source instead of maintaining a second, +# hand-curated kernel config. +# +# guest/firecracker-supervisor/build.sh is reused unmodified: it documents +# itself as VMM-neutral (length-prefixed JSON framing over vsock/UDS), so no +# Cloud Hypervisor-specific supervisor is needed. +# +# NOTE: this produces artifacts for the Cloud Hypervisor *foundation* only. +# There is no lifecycle backend yet (see src/cloud-hypervisor/), so these +# artifacts are not wired into any runnable AWF workload in this release. + +CLOUD_HYPERVISOR_VERSION=53.0 +CLOUD_HYPERVISOR_BINARY_SHA256=448af3d4e59b22c2987f7df94c213ad40fb53a10d437e42b5ee6c4fce7c29ecc +LINUX_VERSION=6.1.141 +LINUX_SHA256=bc3c45faf6f5f0450666c75fa9dad9bc7c0cf7c7cba0dbd94e5cfdc58229c116 +KERNEL_CONFIG_SHA256=adbc70ab5e89213ba00594b12d25e09bdf8bb1ed3c252d7449326bb14c22963b +BUSYBOX_VERSION=1.36.1 +BUSYBOX_SHA256=b8cc24c9574d809e7279c3be349795c5d5ceb6fdf19ca709f80cde50e47de314 +CA_BUNDLE_DATE=2025-02-25 +CA_BUNDLE_SHA256=50a6277ec69113f00c5fd45f09e8b97a4b3e32daa35d3a95ab30137a55386cef +SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-1767225600} + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +OUTPUT=${OUTPUT:-"$ROOT/release/cloud-hypervisor-test-x86_64"} +BUILD=${BUILD:-"$ROOT/.build/cloud-hypervisor-test-x86_64"} +JOBS=${JOBS:-$(getconf _NPROCESSORS_ONLN)} + +if [ "$(uname -s)" != Linux ] || [ "$(uname -m)" != x86_64 ]; then + echo "Cloud Hypervisor test artifacts must be built on Linux x86_64 (GitHub-hosted Ubuntu runners only)" >&2 + exit 1 +fi + +for tool in curl sha256sum tar make gcc ld mke2fs e2fsck go; do + command -v "$tool" >/dev/null || { + echo "required build tool not found: $tool" >&2 + exit 1 + } +done + +rm -rf "$BUILD" "$OUTPUT" +mkdir -p "$BUILD/downloads" "$OUTPUT" + +download_verified() { + local url=$1 + local expected=$2 + local destination=$3 + curl --fail --location --proto '=https' --tlsv1.2 "$url" --output "$destination" + printf '%s %s\n' "$expected" "$destination" | sha256sum --check --status +} + +# Cloud Hypervisor ships a single statically-linked release binary — no +# jailer-equivalent process and no archive/SHA256SUMS bundle to unpack. +binary="$OUTPUT/cloud-hypervisor" +download_verified \ + "https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/v${CLOUD_HYPERVISOR_VERSION}/cloud-hypervisor-static" \ + "$CLOUD_HYPERVISOR_BINARY_SHA256" \ + "$binary" +chmod 0755 "$binary" + +linux_tar="$BUILD/downloads/linux-${LINUX_VERSION}.tar.xz" +kernel_config="$BUILD/downloads/cloud-hypervisor-kernel.config" +download_verified \ + "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_VERSION}.tar.xz" \ + "$LINUX_SHA256" \ + "$linux_tar" +# Reuses Firecracker's pinned, PCI-capable microvm-kernel-ci config (see +# header comment): same kernel source + same config as +# guest/firecracker/build-test-artifacts.sh, pinned to the Firecracker +# v1.16.1 release tag for stable provenance. +download_verified \ + "https://raw.githubusercontent.com/firecracker-microvm/firecracker/v1.16.1/resources/guest_configs/microvm-kernel-ci-x86_64-6.1.config" \ + "$KERNEL_CONFIG_SHA256" \ + "$kernel_config" +tar --extract --xz --file "$linux_tar" --directory "$BUILD" +cp "$kernel_config" "$BUILD/linux-${LINUX_VERSION}/.config" +make -C "$BUILD/linux-${LINUX_VERSION}" \ + ARCH=x86_64 \ + KBUILD_BUILD_TIMESTAMP="@${SOURCE_DATE_EPOCH}" \ + KBUILD_BUILD_USER=awf \ + KBUILD_BUILD_HOST=github \ + LOCALVERSION=-awf-cloud-hypervisor \ + olddefconfig +make -C "$BUILD/linux-${LINUX_VERSION}" \ + -j"$JOBS" \ + ARCH=x86_64 \ + KBUILD_BUILD_TIMESTAMP="@${SOURCE_DATE_EPOCH}" \ + KBUILD_BUILD_USER=awf \ + KBUILD_BUILD_HOST=github \ + LOCALVERSION=-awf-cloud-hypervisor \ + bzImage +install -m 0644 \ + "$BUILD/linux-${LINUX_VERSION}/arch/x86/boot/bzImage" \ + "$OUTPUT/vmlinux.bin" + +busybox_tar="$BUILD/downloads/busybox-${BUSYBOX_VERSION}.tar.bz2" +download_verified \ + "https://busybox.net/downloads/busybox-${BUSYBOX_VERSION}.tar.bz2" \ + "$BUSYBOX_SHA256" \ + "$busybox_tar" +tar --extract --bzip2 --file "$busybox_tar" --directory "$BUILD" +busybox_dir="$BUILD/busybox-${BUSYBOX_VERSION}" +make -C "$busybox_dir" defconfig +enable_busybox_option() { + local option=$1 + if grep -q "^CONFIG_${option}=" "$busybox_dir/.config"; then + sed -i "s/^CONFIG_${option}=.*/CONFIG_${option}=y/" "$busybox_dir/.config" + elif grep -q "^# CONFIG_${option} is not set$" "$busybox_dir/.config"; then + sed -i "s/^# CONFIG_${option} is not set$/CONFIG_${option}=y/" "$busybox_dir/.config" + else + printf 'CONFIG_%s=y\n' "$option" >>"$busybox_dir/.config" + fi +} +disable_busybox_option() { + local option=$1 + if grep -q "^CONFIG_${option}=" "$busybox_dir/.config"; then + sed -i "s/^CONFIG_${option}=.*/# CONFIG_${option} is not set/" "$busybox_dir/.config" + elif ! grep -q "^# CONFIG_${option} is not set$" "$busybox_dir/.config"; then + printf '# CONFIG_%s is not set\n' "$option" >>"$busybox_dir/.config" + fi +} +for option in \ + STATIC \ + WGET \ + FEATURE_WGET_HTTPS \ + TLS \ + IP \ + IPADDR \ + IPLINK \ + IPROUTE \ + NC \ + NSLOOKUP \ + TIMEOUT; do + enable_busybox_option "$option" +done +# BusyBox 1.36.1 tc depends on CBQ UAPI definitions removed from newer build hosts. +# The minimal guest never uses traffic control; AWF enforces policy in the host netns. +disable_busybox_option TC +make -C "$busybox_dir" -j"$JOBS" + +# The AWF guest supervisor is intentionally VMM-neutral (see +# guest/firecracker-supervisor/protocol.go) and is shared as-is between the +# Firecracker and Cloud Hypervisor guest pipelines. +supervisor="$OUTPUT/awf-supervisor" +VERSION="v${CLOUD_HYPERVISOR_VERSION}" \ + OUTPUT="$supervisor" \ + "$ROOT/guest/firecracker-supervisor/build.sh" + +rootfs_tree="$BUILD/rootfs" +mkdir -p \ + "$rootfs_tree/bin" \ + "$rootfs_tree/dev" \ + "$rootfs_tree/etc/ssl/certs" \ + "$rootfs_tree/proc" \ + "$rootfs_tree/root" \ + "$rootfs_tree/sbin" \ + "$rootfs_tree/sys" \ + "$rootfs_tree/tmp" \ + "$rootfs_tree/usr/bin" \ + "$rootfs_tree/usr/sbin" \ + "$rootfs_tree/workspace" +make -C "$busybox_dir" CONFIG_PREFIX="$rootfs_tree" install +install -m 0755 "$supervisor" "$rootfs_tree/sbin/awf-supervisor" +cat >"$rootfs_tree/etc/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/sh +awf:x:1000:1000:AWF guest:/workspace:/bin/sh +nobody:x:65534:65534:nobody:/:/bin/false +EOF +cat >"$rootfs_tree/etc/group" <<'EOF' +root:x:0: +awf:x:1000: +nogroup:x:65534: +EOF +cat >"$rootfs_tree/etc/resolv.conf" <<'EOF' +# Direct DNS is intentionally unavailable in the Cloud Hypervisor foundation guest. +EOF +ca_bundle="$BUILD/downloads/cacert-${CA_BUNDLE_DATE}.pem" +download_verified \ + "https://curl.se/ca/cacert-${CA_BUNDLE_DATE}.pem" \ + "$CA_BUNDLE_SHA256" \ + "$ca_bundle" +install -m 0644 "$ca_bundle" "$rootfs_tree/etc/ssl/certs/ca-certificates.crt" +chmod 01777 "$rootfs_tree/tmp" +find "$rootfs_tree" -print0 | xargs -0 touch --no-dereference --date="@${SOURCE_DATE_EPOCH}" + +rootfs="$OUTPUT/rootfs.ext4" +E2FSPROGS_FAKE_TIME="$SOURCE_DATE_EPOCH" mke2fs \ + -t ext4 \ + -F \ + -q \ + -b 4096 \ + -d "$rootfs_tree" \ + -U 2f6f6e8f-2f2a-4b6a-9b9a-7d6a4a1c5c3a \ + -E lazy_itable_init=0,lazy_journal_init=0 \ + "$rootfs" \ + 32768 +E2FSPROGS_FAKE_TIME="$SOURCE_DATE_EPOCH" e2fsck -f -y "$rootfs" >/dev/null + +( + cd "$OUTPUT" + sha256sum \ + cloud-hypervisor \ + vmlinux.bin \ + rootfs.ext4 \ + awf-supervisor \ + > SHA256SUMS +) + +cat >"$OUTPUT/manifest.json" <"$OUTPUT/sbom.spdx.json" <&2 + exit 1 + } +done + +( + cd "$ARTIFACT_DIR" + sha256sum --check SHA256SUMS +) + +"$ARTIFACT_DIR/cloud-hypervisor" --version | grep -F '53.0' +file "$ARTIFACT_DIR/vmlinux.bin" | grep -E 'Linux kernel|boot executable' +e2fsck -f -n "$ARTIFACT_DIR/rootfs.ext4" +debugfs -R 'stat /sbin/awf-supervisor' "$ARTIFACT_DIR/rootfs.ext4" 2>&1 \ + | grep -F 'Type: regular' +grep -F '"purpose": "AWF Cloud Hypervisor foundation test artifacts; not production defaults; no lifecycle backend yet"' \ + "$ARTIFACT_DIR/manifest.json" +grep -F '"spdxVersion": "SPDX-2.3"' "$ARTIFACT_DIR/sbom.spdx.json" diff --git a/guest/firecracker-supervisor/protocol.go b/guest/firecracker-supervisor/protocol.go index e49bf1fcd..5b85b7e5c 100644 --- a/guest/firecracker-supervisor/protocol.go +++ b/guest/firecracker-supervisor/protocol.go @@ -1,5 +1,14 @@ package main +// This file implements the guest side of the AWF framed guest-supervisor +// protocol. It is intentionally VMM-neutral: the length-prefixed JSON +// framing and frame types here mirror src/microvm/guest-protocol.ts on the +// host side, and this binary (despite its package's historical +// "firecracker-supervisor" name/path) does not depend on any +// Firecracker-specific transport. A future VMM backend can reuse this +// supervisor as-is, addressed through the same vsock/UDS compatibility +// boundary, without protocol changes. + import ( "bytes" "encoding/base64" diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index c72630fd1..ff1f1f99d 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -728,6 +728,71 @@ } } }, + "cloudHypervisor": { + "type": "object", + "description": "Cloud Hypervisor v53.0 foundation configuration (artifacts and digests only). There is no lifecycle backend yet: this cannot be selected via container.containerRuntime and cannot execute workloads.", + "additionalProperties": false, + "properties": { + "previewEnabled": { + "type": "boolean", + "default": false, + "description": "Reserve Cloud Hypervisor configuration for a future preview. Has no effect on workload execution in this release." + }, + "cloudHypervisorBinary": { + "type": "string", + "description": "Absolute path to the Cloud Hypervisor v53.0 binary. Defaults to /usr/local/bin/cloud-hypervisor." + }, + "kernelPath": { + "type": "string", + "description": "Absolute path to the trusted PCI-capable guest Linux kernel image." + }, + "rootfsPath": { + "type": "string", + "description": "Absolute path to the trusted guest root filesystem image." + }, + "supervisorPath": { + "type": "string", + "description": "Absolute path to the built AWF guest supervisor (shared with Firecracker)." + }, + "vcpuCount": { + "type": "integer", + "minimum": 1, + "default": 2, + "description": "Number of guest virtual CPUs." + }, + "memoryMib": { + "type": "integer", + "minimum": 1, + "default": 512, + "description": "Guest memory in MiB." + }, + "apiTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "Bounded timeout in milliseconds for Cloud Hypervisor API socket readiness and requests." + }, + "sha256": { + "type": "object", + "description": "Pinned SHA-256 digests for trusted Cloud Hypervisor artifacts.", + "additionalProperties": false, + "properties": { + "cloudHypervisor": { + "$ref": "#/$defs/sha256Digest" + }, + "kernel": { + "$ref": "#/$defs/sha256Digest" + }, + "rootfs": { + "$ref": "#/$defs/sha256Digest" + }, + "supervisor": { + "$ref": "#/$defs/sha256Digest" + } + } + } + } + }, "chroot": { "type": "object", "description": "Chroot execution overrides for split-filesystem ARC/DinD runners.", diff --git a/src/cli-options.ts b/src/cli-options.ts index afc6cb7eb..c2394aeb0 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as os from 'os'; import { version } from '../package.json'; import { collectRulesetFile, collectStringArray, formatItem } from './option-parsers'; +import { CLOUD_HYPERVISOR_RELEASE_VERSION } from './types/runtime-options'; // Option group markers used by the custom help formatter to insert section headers. // Each key is the long flag name of the first option in a group. @@ -11,6 +12,7 @@ const optionGroupHeaders: Record = { 'allow-domains': 'Domain Filtering:', 'build-local': 'Image Management:', 'firecracker-preview': 'Firecracker Preview:', + 'cloud-hypervisor-preview': 'Cloud Hypervisor Preview (foundation only, not yet runnable):', 'env': 'Container Configuration:', 'dns-servers': 'Network & Security:', 'upstream-proxy': 'Network & Security:', @@ -198,6 +200,29 @@ program .option('--firecracker-rootfs-sha256 ', 'Expected SHA-256 digest of the guest rootfs.') .option('--firecracker-supervisor-sha256 ', 'Expected SHA-256 digest of the AWF guest supervisor.') + // -- Cloud Hypervisor Preview (foundation only) -- + // + // NOTE: these flags only accept and validate configuration/artifact inputs. + // There is no lifecycle backend yet, so "cloud-hypervisor" is NOT a valid + // --container-runtime value and no workload can be executed with it. + .option( + '--cloud-hypervisor-preview', + `Reserve Cloud Hypervisor v${CLOUD_HYPERVISOR_RELEASE_VERSION} configuration for a future preview.\n` + + ' Foundation only: no runtime is registered yet and this cannot execute workloads.', + false + ) + .option('--cloud-hypervisor-binary ', `Path to the Cloud Hypervisor v${CLOUD_HYPERVISOR_RELEASE_VERSION} binary.`) + .option('--cloud-hypervisor-kernel ', 'Path to the PCI-capable guest Linux kernel image.') + .option('--cloud-hypervisor-rootfs ', 'Path to the guest root filesystem image.') + .option('--cloud-hypervisor-supervisor ', 'Path to the built AWF guest supervisor (shared with Firecracker).') + .option('--cloud-hypervisor-vcpus ', 'Guest virtual CPU count (default: 2).') + .option('--cloud-hypervisor-memory-mib ', 'Guest memory in MiB (default: 512).') + .option('--cloud-hypervisor-api-timeout-ms ', 'Bounded API socket readiness timeout in milliseconds (default: 5000).') + .option('--cloud-hypervisor-binary-sha256 ', 'Expected SHA-256 digest of the Cloud Hypervisor binary.') + .option('--cloud-hypervisor-kernel-sha256 ', 'Expected SHA-256 digest of the guest kernel.') + .option('--cloud-hypervisor-rootfs-sha256 ', 'Expected SHA-256 digest of the guest rootfs.') + .option('--cloud-hypervisor-supervisor-sha256 ', 'Expected SHA-256 digest of the AWF guest supervisor.') + // -- Container Configuration -- .option( '-e, --env ', diff --git a/src/cloud-hypervisor/config.test.ts b/src/cloud-hypervisor/config.test.ts new file mode 100644 index 000000000..4ad23909f --- /dev/null +++ b/src/cloud-hypervisor/config.test.ts @@ -0,0 +1,128 @@ +import { buildConfig } from '../commands/build-config'; +import { mapAwfFileConfigToCliOptions } from '../config-mapper'; +import { validateAwfFileConfig } from '../config-file'; +import { + CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, + CLOUD_HYPERVISOR_DEFAULT_BINARY, + CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, + CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, +} from '../types/runtime-options'; + +function buildCloudHypervisorConfig(options: Record) { + return buildConfig({ + options: { + keepContainers: false, + buildLocal: false, + skipPull: false, + imageRegistry: 'registry', + imageTag: 'latest', + envAll: false, + sslBump: false, + enableDind: false, + enableDlp: false, + ...options, + }, + agentCommand: 'echo test', + logLevel: 'info', + allowedDomains: [], + blockedDomains: [], + localhostDetected: false, + additionalEnv: {}, + volumeMounts: undefined, + upstreamProxy: undefined, + dnsServers: [], + dnsOverHttps: undefined, + allowedUrls: undefined, + memoryLimit: undefined, + pidsLimit: undefined, + agentImage: undefined, + modelAliases: undefined, + allowedModels: undefined, + disallowedModels: undefined, + maxEffectiveTokens: undefined, + maxAiCredits: undefined, + effectiveTokenModelMultipliers: undefined, + effectiveTokenDefaultModelMultiplier: undefined, + maxRuns: undefined, + maxPermissionDenied: undefined, + maxCacheMisses: undefined, + resolvedCopilotApiTarget: undefined, + resolvedCopilotApiBasePath: undefined, + dockerHostPathPrefix: undefined, + }).cloudHypervisor; +} + +describe('Cloud Hypervisor configuration (foundation only)', () => { + it('maps the cohesive config-file surface to CLI option semantics', () => { + const digest = 'a'.repeat(64); + const mapped = mapAwfFileConfigToCliOptions({ + cloudHypervisor: { + previewEnabled: true, + cloudHypervisorBinary: '/opt/cloud-hypervisor', + kernelPath: '/opt/vmlinux', + rootfsPath: '/opt/rootfs.ext4', + supervisorPath: '/opt/awf-supervisor', + vcpuCount: 4, + memoryMib: 1024, + apiTimeoutMs: 8000, + sha256: { kernel: digest, supervisor: digest }, + }, + }); + + expect(mapped).toEqual(expect.objectContaining({ + cloudHypervisorPreview: true, + cloudHypervisorBinary: '/opt/cloud-hypervisor', + cloudHypervisorKernel: '/opt/vmlinux', + cloudHypervisorRootfs: '/opt/rootfs.ext4', + cloudHypervisorSupervisor: '/opt/awf-supervisor', + cloudHypervisorVcpus: 4, + cloudHypervisorMemoryMib: 1024, + cloudHypervisorApiTimeoutMs: 8000, + cloudHypervisorKernelSha256: digest, + cloudHypervisorSupervisorSha256: digest, + })); + }); + + it('applies explicit safe defaults when Cloud Hypervisor is configured', () => { + expect(buildCloudHypervisorConfig({ cloudHypervisorPreview: true })).toEqual({ + previewEnabled: true, + cloudHypervisorBinary: CLOUD_HYPERVISOR_DEFAULT_BINARY, + kernelPath: undefined, + rootfsPath: undefined, + supervisorPath: undefined, + vcpuCount: CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, + memoryMib: CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, + apiTimeoutMs: CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, + sha256: undefined, + }); + }); + + it('does not populate Cloud Hypervisor defaults for unrelated runtimes', () => { + expect(buildCloudHypervisorConfig({ + containerRuntime: 'gvisor', + cloudHypervisorPreview: false, + })).toBeUndefined(); + }); + + it('validates positive resources, digests, and unknown keys', () => { + expect(validateAwfFileConfig({ + cloudHypervisor: { + vcpuCount: 2, + memoryMib: 512, + sha256: { rootfs: '0'.repeat(64) }, + }, + })).toEqual([]); + expect(validateAwfFileConfig({ cloudHypervisor: { vcpuCount: 0 } })) + .toContain('config.cloudHypervisor.vcpuCount must be a positive integer'); + expect(validateAwfFileConfig({ cloudHypervisor: { sha256: { kernel: 'bad' } } })) + .toContain('config.cloudHypervisor.sha256.kernel must match pattern "^[A-Fa-f0-9]{64}$"'); + expect(validateAwfFileConfig({ cloudHypervisor: { unsupported: true } })) + .toContain('config.cloudHypervisor.unsupported is not supported'); + }); + + it('rejects "cloud-hypervisor" as a --container-runtime value (not yet an available runtime)', () => { + expect(validateAwfFileConfig({ + container: { containerRuntime: 'cloud-hypervisor' }, + }).some((error) => error.includes('container.containerRuntime'))).toBe(true); + }); +}); diff --git a/src/cloud-hypervisor/host-eligibility.test.ts b/src/cloud-hypervisor/host-eligibility.test.ts new file mode 100644 index 000000000..cd0de3979 --- /dev/null +++ b/src/cloud-hypervisor/host-eligibility.test.ts @@ -0,0 +1,73 @@ +import { + assertGithubHostedRunnerEligibility, + evaluateGithubHostedRunnerEligibility, + type GithubHostedRunnerEnv, +} from './host-eligibility'; + +function env(overrides: Partial = {}): GithubHostedRunnerEnv { + return { + platform: 'linux', + arch: 'x64', + githubActions: 'true', + runnerEnvironment: 'github-hosted', + imageOs: 'ubuntu24', + ...overrides, + }; +} + +describe('GitHub-hosted Ubuntu KVM runner eligibility', () => { + it('accepts a GitHub-hosted Ubuntu x86_64 runner', () => { + expect(evaluateGithubHostedRunnerEligibility(env())).toEqual({ eligible: true }); + expect(() => assertGithubHostedRunnerEligibility(env())).not.toThrow(); + }); + + it('rejects non-Linux hosts', () => { + expect(evaluateGithubHostedRunnerEligibility(env({ platform: 'darwin' }))) + .toEqual({ eligible: false, reason: expect.stringMatching(/requires Linux/) }); + }); + + it('rejects non-x86_64 architectures', () => { + expect(evaluateGithubHostedRunnerEligibility(env({ arch: 'arm64' }))) + .toEqual({ eligible: false, reason: expect.stringMatching(/x86_64 runners/) }); + }); + + it('rejects hosts outside GitHub Actions', () => { + expect(evaluateGithubHostedRunnerEligibility(env({ githubActions: undefined }))) + .toEqual({ eligible: false, reason: expect.stringMatching(/GitHub Actions runs/) }); + }); + + it('rejects self-hosted runners', () => { + expect(evaluateGithubHostedRunnerEligibility(env({ runnerEnvironment: 'self-hosted' }))) + .toEqual({ eligible: false, reason: expect.stringMatching(/not self-hosted/) }); + }); + + it('rejects non-Ubuntu runner images', () => { + expect(evaluateGithubHostedRunnerEligibility(env({ imageOs: 'windows2022' }))) + .toEqual({ eligible: false, reason: expect.stringMatching(/Ubuntu runner image/) }); + expect(evaluateGithubHostedRunnerEligibility(env({ imageOs: undefined }))) + .toEqual({ eligible: false, reason: expect.stringMatching(/Ubuntu runner image/) }); + }); + + it('throws the evaluated reason via the assertion helper', () => { + expect(() => assertGithubHostedRunnerEligibility(env({ platform: 'darwin' }))) + .toThrow(/requires Linux/); + }); + + it('defaults to reading the live process environment', () => { + const originalEnv = { ...process.env }; + const originalPlatform = process.platform; + const originalArch = process.arch; + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + process.env.GITHUB_ACTIONS = 'true'; + process.env.RUNNER_ENVIRONMENT = 'github-hosted'; + process.env.ImageOS = 'ubuntu24'; + try { + expect(evaluateGithubHostedRunnerEligibility().eligible).toBe(true); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'arch', { value: originalArch }); + process.env = originalEnv; + } + }); +}); diff --git a/src/cloud-hypervisor/host-eligibility.ts b/src/cloud-hypervisor/host-eligibility.ts new file mode 100644 index 000000000..a19554886 --- /dev/null +++ b/src/cloud-hypervisor/host-eligibility.ts @@ -0,0 +1,94 @@ +/** + * GitHub-hosted Ubuntu x86_64 KVM runner eligibility. + * + * Cloud Hypervisor support targets only GitHub-hosted Ubuntu runners with + * KVM; self-hosted and non-Ubuntu/non-x86_64 hosts are explicitly out of + * scope. This is a separate, narrowly-scoped helper from + * `runCloudHypervisorPreflight` so eligibility (host identity) and + * fail-closed artifact/host-policy validation (trust/digest checks) can be + * tested and reasoned about independently. The full live check (actually + * opening `/dev/kvm`, resolving cgroups, etc.) remains part of preflight; + * this only decides "is this the kind of host we support at all". + */ + +export interface GithubHostedRunnerEnv { + platform: NodeJS.Platform; + arch: string; + /** `GITHUB_ACTIONS` — `"true"` when running inside a GitHub Actions job. */ + githubActions?: string; + /** `RUNNER_ENVIRONMENT` — `"github-hosted"` or `"self-hosted"`. */ + runnerEnvironment?: string; + /** `ImageOS` — e.g. `"ubuntu24"`, set on GitHub-hosted runner images. */ + imageOs?: string; +} + +export interface GithubHostedRunnerEligibility { + eligible: boolean; + /** Present only when `eligible` is `false`; explains which check failed. */ + reason?: string; +} + +function currentEnv(): GithubHostedRunnerEnv { + return { + platform: process.platform, + arch: process.arch, + githubActions: process.env.GITHUB_ACTIONS, + runnerEnvironment: process.env.RUNNER_ENVIRONMENT, + imageOs: process.env.ImageOS, + }; +} + +/** + * Returns whether the current host is eligible to run Cloud Hypervisor: + * a GitHub-hosted (not self-hosted) Ubuntu x86_64 Linux runner. + * + * This is a necessary-but-not-sufficient check — it does not verify KVM + * device access, artifact trust, or pinned versions/digests. Those remain + * `runCloudHypervisorPreflight`'s responsibility. + */ +export function evaluateGithubHostedRunnerEligibility( + env: GithubHostedRunnerEnv = currentEnv(), +): GithubHostedRunnerEligibility { + if (env.platform !== 'linux') { + return { eligible: false, reason: `Cloud Hypervisor requires Linux; found ${env.platform}` }; + } + if (env.arch !== 'x64') { + return { + eligible: false, + reason: `Cloud Hypervisor supports only GitHub-hosted x86_64 runners; found Node architecture ${env.arch}`, + }; + } + if (env.githubActions !== 'true') { + return { + eligible: false, + reason: 'Cloud Hypervisor is supported only inside GitHub Actions runs (GITHUB_ACTIONS != "true")', + }; + } + if (env.runnerEnvironment !== 'github-hosted') { + return { + eligible: false, + reason: 'Cloud Hypervisor is supported only on GitHub-hosted runners, not self-hosted ' + + `(RUNNER_ENVIRONMENT=${env.runnerEnvironment ?? 'unset'})`, + }; + } + if (!env.imageOs || !/^ubuntu/i.test(env.imageOs)) { + return { + eligible: false, + reason: `Cloud Hypervisor requires a GitHub-hosted Ubuntu runner image (ImageOS=${env.imageOs ?? 'unset'})`, + }; + } + return { eligible: true }; +} + +/** + * Throws with `evaluateGithubHostedRunnerEligibility`'s reason when the + * current host is not an eligible GitHub-hosted Ubuntu x86_64 KVM runner. + */ +export function assertGithubHostedRunnerEligibility( + env: GithubHostedRunnerEnv = currentEnv(), +): void { + const result = evaluateGithubHostedRunnerEligibility(env); + if (!result.eligible) { + throw new Error(result.reason); + } +} diff --git a/src/cloud-hypervisor/preflight.test.ts b/src/cloud-hypervisor/preflight.test.ts new file mode 100644 index 000000000..5714afc59 --- /dev/null +++ b/src/cloud-hypervisor/preflight.test.ts @@ -0,0 +1,379 @@ +import { constants } from 'fs'; +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; +import execa from 'execa'; +import * as os from 'os'; +import * as path from 'path'; +import type { CloudHypervisorOptions } from '../types/runtime-options'; +import { + calculateSha256, + cloudHypervisorPreflightTestHelpers, + parseCloudHypervisorVersion, + runCloudHypervisorPreflight, + type CloudHypervisorPreflightDependencies, +} from './preflight'; + +jest.mock('execa'); + +const digest = 'a'.repeat(64); +const mockedExeca = execa as jest.MockedFunction; + +function config(overrides: Partial = {}): CloudHypervisorOptions { + return { + previewEnabled: true, + cloudHypervisorBinary: '/opt/cloud-hypervisor', + kernelPath: '/opt/vmlinux', + rootfsPath: '/opt/rootfs.ext4', + supervisorPath: '/opt/awf-supervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + ...overrides, + }; +} + +function dependencies( + overrides: Partial = {}, +): Partial { + return { + platform: 'linux', + arch: 'x64', + uid: 1000, + access: jest.fn().mockResolvedValue(undefined), + lstat: jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 0, + }), + runVersion: jest.fn().mockResolvedValue('cloud-hypervisor v53.0'), + sha256: jest.fn().mockResolvedValue(digest), + assertToolAvailable: jest.fn(async (tool: string) => `/usr/bin/${tool}`), + assertHostPolicy: jest.fn().mockResolvedValue(2), + assertDockerInfrastructure: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('Cloud Hypervisor preflight (foundation only)', () => { + let originalPath: string | undefined; + + beforeEach(() => { + originalPath = process.env.PATH; + mockedExeca.mockReset(); + }); + + afterEach(() => { + delete process.env.SUDO_UID; + jest.restoreAllMocks(); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + }); + + it('runs default version, tool, and digest host probes', async () => { + const defaults = cloudHypervisorPreflightTestHelpers.defaultDependencies; + mockedExeca + .mockResolvedValueOnce({ + exitCode: 0, + stdout: `node ${process.version.slice(1)}`, + stderr: '', + } as never) + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'unsupported flag', + } as never); + await expect(defaults.runVersion(process.execPath)).resolves.toContain( + process.version.slice(1), + ); + await expect(defaults.runVersion('/bin/false')).rejects.toThrow( + /--version" exited with code/, + ); + + process.env.PATH = `${path.delimiter}/usr/bin`; + await expect(defaults.assertToolAvailable('false')) + .resolves.toBe('/usr/bin/false'); + await expect(defaults.assertToolAvailable('definitely-not-an-awf-tool')) + .rejects.toThrow(/was not found on PATH/); + + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-ch-preflight-digest-')); + const target = path.join(directory, 'artifact'); + try { + await fs.writeFile(target, 'verified artifact'); + await expect(calculateSha256(target)).resolves.toBe( + createHash('sha256').update('verified artifact').digest('hex'), + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } + }); + + it('runs host policy and Docker probes through the default helper', async () => { + const defaults = cloudHypervisorPreflightTestHelpers.defaultDependencies; + jest.spyOn(process, 'getuid').mockReturnValue(0); + const access = jest.spyOn(fs, 'access').mockResolvedValue(undefined); + await expect(defaults.assertHostPolicy()).resolves.toBe(2); + expect(access).toHaveBeenCalledWith( + '/proc/sys/kernel/seccomp/actions_avail', + constants.R_OK, + ); + + mockedExeca.mockResolvedValue({ + exitCode: 0, + stdout: 'available', + stderr: '', + } as never); + await expect(defaults.assertDockerInfrastructure('/usr/bin/docker')).resolves.toBeUndefined(); + expect(mockedExeca).toHaveBeenCalledWith( + '/usr/bin/docker', + ['compose', 'version'], + expect.objectContaining({ timeout: 10_000, reject: false }), + ); + }); + + it('reports host policy and Docker probe failures', async () => { + const defaults = cloudHypervisorPreflightTestHelpers.defaultDependencies; + jest.spyOn(process, 'getuid').mockReturnValue(1000); + await expect(defaults.assertHostPolicy()).rejects.toThrow(/requires root/); + + mockedExeca.mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: 'daemon unavailable', + } as never); + await expect(defaults.assertDockerInfrastructure('/usr/bin/docker')) + .rejects.toThrow(/\/usr\/bin\/docker info failed.*daemon unavailable/); + }); + + it('parses Cloud Hypervisor release output', () => { + expect(parseCloudHypervisorVersion('cloud-hypervisor v53.0')).toBe('53.0'); + expect(parseCloudHypervisorVersion('53.0')).toBe('53.0'); + expect(() => parseCloudHypervisorVersion('unknown')).toThrow(/Could not parse/); + }); + + it('pins Cloud Hypervisor v53.0 and verifies configured digests', async () => { + const deps = dependencies(); + const result = await runCloudHypervisorPreflight(config({ + sha256: { + cloudHypervisor: digest, + kernel: digest, + rootfs: digest, + supervisor: digest, + }, + }), deps); + + expect(result.version).toBe('53.0'); + expect(result.cgroupVersion).toBe(2); + expect(deps.access).toHaveBeenCalledWith( + '/dev/kvm', + constants.R_OK | constants.W_OK, + ); + expect(deps.sha256).toHaveBeenCalledTimes(4); + expect(deps.assertToolAvailable).toHaveBeenCalledTimes(8); + expect(deps.assertDockerInfrastructure).toHaveBeenCalledWith('/usr/bin/docker'); + expect(result.tools).toEqual({ + ip: '/usr/bin/ip', + nft: '/usr/bin/nft', + sysctl: '/usr/bin/sysctl', + mke2fs: '/usr/bin/mke2fs', + debugfs: '/usr/bin/debugfs', + e2fsck: '/usr/bin/e2fsck', + rsync: '/usr/bin/rsync', + }); + }); + + it('rejects inaccessible KVM without checking artifacts', async () => { + const access = jest.fn().mockRejectedValue(new Error('EACCES')); + const lstat = jest.fn(); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ access, lstat }), + )).rejects.toThrow(/readable and writable \/dev\/kvm.*EACCES/); + expect(lstat).not.toHaveBeenCalled(); + }); + + it('rejects mismatched versions, unsafe permissions, and digest mismatches', async () => { + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ runVersion: jest.fn().mockResolvedValue('cloud-hypervisor v52.0') }), + )).rejects.toThrow(/pinned to v53\.0/); + + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ + lstat: jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100777, + uid: 1000, + }), + }), + )).rejects.toThrow(/must not be group- or world-writable/); + + await expect(runCloudHypervisorPreflight( + config({ sha256: { kernel: digest } }), + dependencies({ sha256: jest.fn().mockResolvedValue('b'.repeat(64)) }), + )).rejects.toThrow(/SHA-256 mismatch/); + + await expect(runCloudHypervisorPreflight( + config({ sha256: { kernel: 'bad' } }), + dependencies(), + )).rejects.toThrow(/must contain exactly 64 hexadecimal/); + }); + + it('verifies Cloud Hypervisor digest before invoking the binary', async () => { + const runVersion = jest.fn().mockResolvedValue('cloud-hypervisor v53.0'); + await expect(runCloudHypervisorPreflight( + config({ sha256: { cloudHypervisor: digest } }), + dependencies({ + runVersion, + sha256: jest.fn(async (filePath: string) => ( + filePath === '/opt/cloud-hypervisor' ? 'b'.repeat(64) : digest + )), + }), + )).rejects.toThrow(/Cloud Hypervisor binary SHA-256 mismatch/); + expect(runVersion).not.toHaveBeenCalled(); + }); + + it('rejects missing artifacts, unsupported hosts, and unavailable tools', async () => { + await expect(runCloudHypervisorPreflight( + config({ supervisorPath: undefined }), + dependencies(), + )).rejects.toThrow(/requires guest kernel, rootfs, and supervisor/); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ platform: 'darwin' }), + )).rejects.toThrow(/requires Linux with KVM/); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ arch: 'arm64' }), + )).rejects.toThrow(/supported only on x86_64 GitHub-hosted runners/); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ + assertToolAvailable: jest.fn(async (tool: string) => { + if (tool === 'ip') throw new Error('missing'); + return `/usr/bin/${tool}`; + }), + }), + )).rejects.toThrow(/requires host tool "ip": missing/); + }); + + it('rejects untrusted artifact files and inaccessible paths', async () => { + await expect(runCloudHypervisorPreflight( + config({ cloudHypervisorBinary: 'relative/cloud-hypervisor' }), + dependencies(), + )).rejects.toThrow(/path must be absolute/); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ + lstat: jest.fn(async (filePath: string) => ( + filePath === '/opt/cloud-hypervisor' + ? { + isFile: () => false, + isSymbolicLink: () => true, + mode: 0o120777, + uid: 0, + } + : { + isFile: () => false, + isSymbolicLink: () => false, + mode: 0o040755, + uid: 0, + } + )), + }), + )).rejects.toThrow(/regular file and not a symbolic link/); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ + lstat: jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 4000, + }), + }), + )).rejects.toThrow(/must be owned by root or uid/); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ + access: jest.fn(async (filePath: string) => { + if (filePath !== '/dev/kvm') throw new Error('EACCES'); + }), + }), + )).rejects.toThrow(/does not have the required host access/); + }); + + it('uses SUDO_UID as trusted owner when running under sudo', async () => { + process.env.SUDO_UID = '2001'; + const lstat = jest.fn().mockResolvedValue({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 2001, + }); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ uid: undefined, lstat }), + )).resolves.toMatchObject({ version: '53.0' }); + }); + + it('rejects writable or symlinked parent directories', async () => { + const lstat = jest.fn(async (filePath: string) => { + if (filePath === '/opt') { + return { + isFile: () => false, + isSymbolicLink: () => false, + mode: 0o040777, + uid: 0, + }; + } + return { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 0, + }; + }); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ lstat }), + )).rejects.toThrow(/parent directory must not be group- or world-writable/); + + const symlinkParent = jest.fn(async (filePath: string) => { + if (filePath === '/opt') { + return { + isFile: () => false, + isSymbolicLink: () => true, + mode: 0o040755, + uid: 0, + }; + } + return { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o100755, + uid: 0, + }; + }); + await expect(runCloudHypervisorPreflight( + config(), + dependencies({ lstat: symlinkParent }), + )).rejects.toThrow(/parent directory must not be a symbolic link/); + }); + + it('rejects user-controlled PATH tools', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-ch-preflight-tool-')); + const tool = path.join(directory, 'ip'); + await fs.writeFile(tool, '#!/bin/sh\n'); + await fs.chmod(tool, 0o755); + process.env.PATH = directory; + try { + await expect(cloudHypervisorPreflightTestHelpers.defaultDependencies.assertToolAvailable('ip')) + .rejects.toThrow(/trusted host tool "ip"/); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cloud-hypervisor/preflight.ts b/src/cloud-hypervisor/preflight.ts new file mode 100644 index 000000000..5bfea4daf --- /dev/null +++ b/src/cloud-hypervisor/preflight.ts @@ -0,0 +1,415 @@ +import { createHash } from 'crypto'; +import { createReadStream, constants, promises as fs } from 'fs'; +import * as path from 'path'; +import execa from 'execa'; +import { + CLOUD_HYPERVISOR_RELEASE_VERSION, + type CloudHypervisorOptions, +} from '../types/runtime-options'; + +/** + * Fail-closed host and artifact validation for the Cloud Hypervisor v53.0 + * foundation. This module intentionally mirrors + * `src/firecracker/preflight.ts`'s trust-check patterns (absolute paths, + * root/operator-owned non-writable regular files, trusted ancestor + * directories, digest pinning, PATH-resolved but ownership-verified host + * tools) so both VMM backends share the same fail-closed posture. + * + * There is no lifecycle backend yet — this is exercised only by unit tests + * and by a future layer that wires up Cloud Hypervisor workload execution. + * Cloud Hypervisor has no jailer-equivalent process, so there is no paired + * binary version cross-check the way Firecracker cross-checks jailer. + */ + +export interface CloudHypervisorPreflightDependencies { + platform: NodeJS.Platform; + arch: string; + uid: number; + access(filePath: string, mode: number): Promise; + lstat(filePath: string): Promise<{ + isFile(): boolean; + isSymbolicLink(): boolean; + mode: number; + uid: number; + }>; + runVersion(binaryPath: string): Promise; + sha256(filePath: string): Promise; + assertToolAvailable(tool: string): Promise; + assertHostPolicy(): Promise<1 | 2>; + assertDockerInfrastructure(dockerBinaryPath: string): Promise; +} + +export type CloudHypervisorHostToolPaths = Readonly<{ + ip: string; + nft: string; + sysctl: string; + mke2fs: string; + debugfs: string; + e2fsck: string; + rsync: string; +}>; +const CLOUD_HYPERVISOR_HOST_TOOLS: (keyof CloudHypervisorHostToolPaths)[] = [ + 'ip', 'nft', 'sysctl', 'mke2fs', 'debugfs', 'e2fsck', 'rsync', +]; + +const defaultDependencies: CloudHypervisorPreflightDependencies = { + platform: process.platform, + arch: process.arch, + uid: -1, + access: fs.access, + lstat: fs.lstat, + runVersion: async (binaryPath) => { + const result = await execa(binaryPath, ['--version'], { + reject: false, + timeout: 5_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.exitCode !== 0) { + throw new Error( + `"${binaryPath} --version" exited with code ${result.exitCode}: ${result.stderr.trim()}`, + ); + } + return `${result.stdout}\n${result.stderr}`.trim(); + }, + sha256: calculateSha256, + assertToolAvailable: async (tool) => { + const searchPath = process.env.PATH ?? ''; + for (const directory of searchPath.split(path.delimiter)) { + if (!directory) continue; + try { + const candidate = path.join(directory, tool); + await assertTrustedHostTool(tool, candidate); + return candidate; + } catch { + // Continue searching the bounded host PATH. + } + } + throw new Error(`required trusted host tool "${tool}" was not found on PATH`); + }, + assertHostPolicy: async () => { + if (process.getuid?.() !== 0) { + throw new Error( + 'Cloud Hypervisor network setup requires root; invoke awf through sudo from a non-root account', + ); + } + try { + await fs.access('/proc/sys/net/ipv4/ip_forward', constants.R_OK); + await fs.access('/proc/sys/net/ipv6/conf/all/disable_ipv6', constants.R_OK); + await fs.access('/proc/sys/kernel/seccomp/actions_avail', constants.R_OK); + } catch (error) { + throw new Error( + 'host kernel policy does not expose required network namespace and seccomp controls: ' + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + try { + await fs.access('/sys/fs/cgroup/cgroup.controllers', constants.R_OK); + return 2; + } catch { + try { + await fs.access('/sys/fs/cgroup', constants.R_OK | constants.W_OK); + return 1; + } catch (error) { + throw new Error( + 'Cloud Hypervisor requires a writable cgroup v1 hierarchy or cgroup v2 controllers: ' + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } + }, + assertDockerInfrastructure: async (dockerBinaryPath) => { + for (const args of [['info'], ['compose', 'version']] as const) { + const result = await execa(dockerBinaryPath, [...args], { + reject: false, + timeout: 10_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.exitCode !== 0) { + throw new Error( + `${dockerBinaryPath} ${args.join(' ')} failed with code ${result.exitCode}: ${result.stderr.trim()}`, + ); + } + } + }, +}; + +/** @internal Exposed only for focused host-probe tests. */ +export const cloudHypervisorPreflightTestHelpers = { defaultDependencies }; + +export interface CloudHypervisorPreflightResult { + version: string; + cloudHypervisorBinary: string; + kernelPath: string; + rootfsPath: string; + supervisorPath: string; + tools: CloudHypervisorHostToolPaths; + cgroupVersion: 1 | 2; +} + +async function assertTrustedHostTool(label: string, filePath: string): Promise { + if (!path.isAbsolute(filePath)) { + throw new Error(`host tool "${label}" path must be absolute: ${filePath}`); + } + const { root } = path.parse(filePath); + const segments = filePath.slice(root.length).split('/').filter(Boolean); + let ancestor = root; + for (const segment of segments.slice(0, -1)) { + ancestor = path.join(ancestor, segment); + const stat = await fs.lstat(ancestor); + if (stat.isSymbolicLink() || (stat.mode & 0o022) !== 0 || stat.uid !== 0) { + throw new Error(`host tool "${label}" has an untrusted parent directory: ${ancestor}`); + } + } + const stat = await fs.lstat(filePath); + if ( + stat.isSymbolicLink() || + !stat.isFile() || + (stat.mode & 0o022) !== 0 || + stat.uid !== 0 + ) { + throw new Error(`host tool "${label}" must be a root-owned non-writable regular file: ${filePath}`); + } + await fs.access(filePath, constants.X_OK); +} + +/** + * Parses a `cloud-hypervisor --version` output like `cloud-hypervisor v53.0` + * (also accepts the plain `v53.0`/`53.0` forms some builds emit). + */ +export function parseCloudHypervisorVersion(output: string): string { + const match = output.match(/\bv?(\d+\.\d+(?:\.\d+)?)\b/); + if (!match) { + throw new Error(`Could not parse Cloud Hypervisor version from: ${JSON.stringify(output)}`); + } + return match[1]; +} + +export async function calculateSha256(filePath: string): Promise { + const hash = createHash('sha256'); + const stream = createReadStream(filePath); + for await (const chunk of stream) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} + +async function assertTrustedRegularFile( + label: string, + filePath: string, + accessMode: number, + dependencies: CloudHypervisorPreflightDependencies, +): Promise { + if (!path.isAbsolute(filePath)) { + throw new Error(`${label} path must be absolute: ${filePath}`); + } + await assertTrustedAncestorChain(label, filePath, dependencies); + const stat = await dependencies.lstat(filePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`${label} must be a regular file and not a symbolic link: ${filePath}`); + } + if ((stat.mode & 0o022) !== 0) { + throw new Error(`${label} must not be group- or world-writable: ${filePath}`); + } + if (stat.uid !== 0 && stat.uid !== dependencies.uid) { + throw new Error( + `${label} must be owned by root or uid ${dependencies.uid}; found uid ${stat.uid}: ${filePath}`, + ); + } + try { + await dependencies.access(filePath, accessMode); + } catch (error) { + throw new Error( + `${label} does not have the required host access: ${filePath}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function parsePositiveUid(value: string | undefined): number | undefined { + if (!value || !/^[1-9]\d*$/.test(value)) return undefined; + return Number(value); +} + +function resolveTrustedOperatorUid(): number { + return parsePositiveUid(process.env.SUDO_UID) ?? (process.getuid?.() ?? -1); +} + +async function assertTrustedAncestorChain( + label: string, + filePath: string, + dependencies: CloudHypervisorPreflightDependencies, +): Promise { + const { root } = path.parse(filePath); + const segments = filePath.slice(root.length).split('/').filter((segment) => segment.length > 0); + let ancestor = root; + for (const segment of segments.slice(0, -1)) { + ancestor = path.join(ancestor, segment); + const stat = await dependencies.lstat(ancestor); + if (stat.isSymbolicLink()) { + throw new Error( + `${label} parent directory must not be a symbolic link: ${ancestor}`, + ); + } + if ((stat.mode & 0o022) !== 0) { + throw new Error( + `${label} parent directory must not be group- or world-writable: ${ancestor}`, + ); + } + if (stat.uid !== 0 && stat.uid !== dependencies.uid) { + throw new Error( + `${label} parent directory must be owned by root or uid ${dependencies.uid}; ` + + `found uid ${stat.uid}: ${ancestor}`, + ); + } + } +} + +async function assertDigest( + label: string, + filePath: string, + expected: string | undefined, + dependencies: CloudHypervisorPreflightDependencies, +): Promise { + if (!expected) return; + if (!/^[a-fA-F0-9]{64}$/.test(expected)) { + throw new Error(`${label} SHA-256 must contain exactly 64 hexadecimal characters`); + } + const actual = await dependencies.sha256(filePath); + if (actual.toLowerCase() !== expected.toLowerCase()) { + throw new Error( + `${label} SHA-256 mismatch: expected ${expected.toLowerCase()}, got ${actual.toLowerCase()}`, + ); + } +} + +/** + * Fail-closed host and artifact validation for Cloud Hypervisor v53.0. + * + * This performs the same categories of checks as + * `runFirecrackerPreflight` — Linux/KVM host requirements, trusted + * artifact ownership/permissions, pinned version, and pinned digests — + * adapted for Cloud Hypervisor's single-binary VMM (no jailer). + */ +export async function runCloudHypervisorPreflight( + config: CloudHypervisorOptions, + overrides: Partial = {}, +): Promise { + const dependencies = { + ...defaultDependencies, + ...overrides, + uid: overrides.uid ?? resolveTrustedOperatorUid(), + }; + if (dependencies.platform !== 'linux') { + throw new Error(`Cloud Hypervisor requires Linux with KVM; found ${dependencies.platform}`); + } + if (dependencies.arch !== 'x64') { + throw new Error( + `Cloud Hypervisor is supported only on x86_64 GitHub-hosted runners; found Node architecture ${dependencies.arch}`, + ); + } + if (!config.kernelPath || !config.rootfsPath || !config.supervisorPath) { + throw new Error( + 'Cloud Hypervisor requires guest kernel, rootfs, and supervisor artifact paths', + ); + } + + try { + await dependencies.access('/dev/kvm', constants.R_OK | constants.W_OK); + } catch (error) { + throw new Error( + 'Cloud Hypervisor requires readable and writable /dev/kvm: ' + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + const cgroupVersion = await dependencies.assertHostPolicy(); + let dockerBinaryPath: string; + try { + dockerBinaryPath = await dependencies.assertToolAvailable('docker'); + } catch (error) { + throw new Error( + 'Cloud Hypervisor requires host tool "docker": ' + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + await dependencies.assertDockerInfrastructure(dockerBinaryPath); + await assertTrustedRegularFile( + 'Cloud Hypervisor binary', + config.cloudHypervisorBinary, + constants.R_OK | constants.X_OK, + dependencies, + ); + + const tools = {} as Record; + for (const tool of CLOUD_HYPERVISOR_HOST_TOOLS) { + try { + tools[tool] = await dependencies.assertToolAvailable(tool); + } catch (error) { + throw new Error( + `Cloud Hypervisor requires host tool "${tool}": ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } + await assertTrustedRegularFile( + 'Cloud Hypervisor guest kernel', + config.kernelPath, + constants.R_OK, + dependencies, + ); + await assertTrustedRegularFile( + 'Cloud Hypervisor rootfs', + config.rootfsPath, + constants.R_OK, + dependencies, + ); + await assertTrustedRegularFile( + 'Cloud Hypervisor guest supervisor', + config.supervisorPath, + constants.R_OK, + dependencies, + ); + await assertDigest( + 'Cloud Hypervisor binary', + config.cloudHypervisorBinary, + config.sha256?.cloudHypervisor, + dependencies, + ); + + const version = parseCloudHypervisorVersion( + await dependencies.runVersion(config.cloudHypervisorBinary), + ); + if (version !== CLOUD_HYPERVISOR_RELEASE_VERSION) { + throw new Error( + `Cloud Hypervisor is pinned to v${CLOUD_HYPERVISOR_RELEASE_VERSION}; found v${version}`, + ); + } + + await assertDigest( + 'Cloud Hypervisor guest kernel', + config.kernelPath, + config.sha256?.kernel, + dependencies, + ); + await assertDigest( + 'Cloud Hypervisor rootfs', + config.rootfsPath, + config.sha256?.rootfs, + dependencies, + ); + await assertDigest( + 'Cloud Hypervisor guest supervisor', + config.supervisorPath, + config.sha256?.supervisor, + dependencies, + ); + + return { + version, + cloudHypervisorBinary: config.cloudHypervisorBinary, + kernelPath: config.kernelPath, + rootfsPath: config.rootfsPath, + supervisorPath: config.supervisorPath, + tools, + cgroupVersion, + }; +} diff --git a/src/cloud-hypervisor/runtime-validation.test.ts b/src/cloud-hypervisor/runtime-validation.test.ts new file mode 100644 index 000000000..668cf4fb1 --- /dev/null +++ b/src/cloud-hypervisor/runtime-validation.test.ts @@ -0,0 +1,85 @@ +import { assertCloudHypervisorNotYetAvailable, assertCloudHypervisorSelection, requireCloudHypervisorConfig } from './runtime-validation'; +import type { WrapperConfig } from '../types'; + +function baseConfig(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-test', + buildLocal: false, + skipPull: false, + imageRegistry: 'registry', + imageTag: 'latest', + envAll: false, + dnsServers: [], + enableHostAccess: false, + sslBump: false, + enableDind: false, + enableDlp: false, + legacySecurity: undefined, + ...overrides, + } as WrapperConfig; +} + +describe('Cloud Hypervisor runtime-selection guard (foundation only)', () => { + it('rejects --container-runtime cloud-hypervisor with an explicit not-yet-available error', () => { + expect(() => assertCloudHypervisorNotYetAvailable( + baseConfig({ containerRuntime: 'cloud-hypervisor' }), + )).toThrow(/not yet an available --container-runtime/); + }); + + it('allows any other runtime, including undefined', () => { + expect(() => assertCloudHypervisorNotYetAvailable(baseConfig())).not.toThrow(); + expect(() => assertCloudHypervisorNotYetAvailable( + baseConfig({ containerRuntime: 'gvisor' }), + )).not.toThrow(); + }); + + it('requires --container-runtime cloud-hypervisor when cloudHypervisor options are set', () => { + const config = baseConfig({ + containerRuntime: 'gvisor', + cloudHypervisor: { + previewEnabled: true, + cloudHypervisorBinary: '/opt/cloud-hypervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + }, + }); + expect(() => assertCloudHypervisorSelection(config)).toThrow( + /Cloud Hypervisor options require --container-runtime cloud-hypervisor/, + ); + }); + + it('accepts cloudHypervisor options when no other runtime is specified', () => { + const config = baseConfig({ + cloudHypervisor: { + previewEnabled: true, + cloudHypervisorBinary: '/opt/cloud-hypervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + }, + }); + expect(() => assertCloudHypervisorSelection(config)).not.toThrow(); + }); + + it('returns the Cloud Hypervisor config when present', () => { + const cloudHypervisor = { + previewEnabled: true, + cloudHypervisorBinary: '/opt/cloud-hypervisor', + vcpuCount: 2, + memoryMib: 512, + apiTimeoutMs: 5000, + }; + expect(requireCloudHypervisorConfig(baseConfig({ cloudHypervisor }))).toBe(cloudHypervisor); + }); + + it('throws when Cloud Hypervisor config is missing', () => { + expect(() => requireCloudHypervisorConfig(baseConfig())).toThrow( + /resolved without Cloud Hypervisor runtime configuration/, + ); + }); +}); diff --git a/src/cloud-hypervisor/runtime-validation.ts b/src/cloud-hypervisor/runtime-validation.ts new file mode 100644 index 000000000..da93f4e7a --- /dev/null +++ b/src/cloud-hypervisor/runtime-validation.ts @@ -0,0 +1,42 @@ +import type { CloudHypervisorOptions, WrapperConfig } from '../types'; + +/** + * Cloud Hypervisor is a foundation-only runtime in this release: its config + * surface, preflight/artifact validation, and guest artifact pipeline exist + * so a later layer can add a lifecycle backend, but no backend is registered + * yet (see `src/container-runtime.ts` and + * `src/external-runtime-backend-resolver.ts`). + * + * This guard produces a clear, actionable error instead of letting + * `--container-runtime cloud-hypervisor` fall through to the generic + * unknown-runtime passthrough (which would surface as an opaque Docker + * runtime error). + */ +export function assertCloudHypervisorNotYetAvailable(config: WrapperConfig): void { + if (config.containerRuntime === 'cloud-hypervisor') { + throw new Error( + 'Cloud Hypervisor is not yet an available --container-runtime. ' + + 'Its configuration surface is foundation-only in this release: no lifecycle backend is registered.', + ); + } +} + +/** + * Cloud Hypervisor options may only be supplied alongside their own + * (currently non-selectable) runtime name, mirroring + * `assertFirecrackerSelection`'s intent for config-shape consistency. + */ +export function assertCloudHypervisorSelection(config: WrapperConfig): void { + if (config.cloudHypervisor && config.containerRuntime !== undefined && config.containerRuntime !== 'cloud-hypervisor') { + throw new Error( + 'Cloud Hypervisor options require --container-runtime cloud-hypervisor (not yet an available runtime; foundation-only configuration).', + ); + } +} + +export function requireCloudHypervisorConfig(config: WrapperConfig): CloudHypervisorOptions { + if (!config.cloudHypervisor) { + throw new Error('Cloud Hypervisor backend resolved without Cloud Hypervisor runtime configuration'); + } + return config.cloudHypervisor; +} diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 7a5578b42..f9b453c30 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -9,6 +9,10 @@ import { FIRECRACKER_DEFAULT_JAILER_BINARY, FIRECRACKER_DEFAULT_MEMORY_MIB, FIRECRACKER_DEFAULT_VCPU_COUNT, + CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, + CLOUD_HYPERVISOR_DEFAULT_BINARY, + CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, + CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, } from '../types/runtime-options'; /** @@ -125,6 +129,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { const chrootIdentity = buildChrootIdentity(options); const dind = buildDindConfig(options); const firecracker = buildFirecrackerConfig(options); + const cloudHypervisor = buildCloudHypervisorConfig(options); const apiCredentials = resolveApiCredentials(options, { resolvedCopilotApiTarget, resolvedCopilotApiBasePath, @@ -227,6 +232,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { chrootIdentity, dind, firecracker, + cloudHypervisor, enclaves: normalizeEnclavesConfig( options.enclaves as AwfFileConfig['enclaves'] | undefined, ), @@ -273,17 +279,17 @@ function buildFirecrackerConfig( kernelPath: options.firecrackerKernel as string | undefined, rootfsPath: options.firecrackerRootfs as string | undefined, supervisorPath: options.firecrackerSupervisor as string | undefined, - vcpuCount: parseFirecrackerPositiveInteger( + vcpuCount: parsePositiveIntegerOption( options.firecrackerVcpus, '--firecracker-vcpus', FIRECRACKER_DEFAULT_VCPU_COUNT, ), - memoryMib: parseFirecrackerPositiveInteger( + memoryMib: parsePositiveIntegerOption( options.firecrackerMemoryMib, '--firecracker-memory-mib', FIRECRACKER_DEFAULT_MEMORY_MIB, ), - apiTimeoutMs: parseFirecrackerPositiveInteger( + apiTimeoutMs: parsePositiveIntegerOption( options.firecrackerApiTimeoutMs, '--firecracker-api-timeout-ms', FIRECRACKER_DEFAULT_API_TIMEOUT_MS, @@ -294,7 +300,7 @@ function buildFirecrackerConfig( }; } -function parseFirecrackerPositiveInteger( +function parsePositiveIntegerOption( value: unknown, optionName: string, defaultValue: number, @@ -307,6 +313,70 @@ function parseFirecrackerPositiveInteger( return parsed; } +/** + * Builds the Cloud Hypervisor foundation config (artifacts/digests only). + * + * There is no lifecycle backend for this runtime yet, so `selected` only + * mirrors the Firecracker pattern for config round-trip/test symmetry — + * `--container-runtime cloud-hypervisor` is rejected explicitly elsewhere + * (see `assertCloudHypervisorNotYetAvailable`) before this config could ever + * drive workload execution. + */ +function buildCloudHypervisorConfig( + options: Record, +): WrapperConfig['cloudHypervisor'] { + const selected = options.containerRuntime === 'cloud-hypervisor'; + const configured = options.cloudHypervisorPreview === true + || [ + 'cloudHypervisorBinary', + 'cloudHypervisorKernel', + 'cloudHypervisorRootfs', + 'cloudHypervisorSupervisor', + 'cloudHypervisorVcpus', + 'cloudHypervisorMemoryMib', + 'cloudHypervisorApiTimeoutMs', + 'cloudHypervisorBinarySha256', + 'cloudHypervisorKernelSha256', + 'cloudHypervisorRootfsSha256', + 'cloudHypervisorSupervisorSha256', + ].some((key) => options[key] !== undefined); + if (!selected && !configured) return undefined; + + const sha256 = { + cloudHypervisor: options.cloudHypervisorBinarySha256 as string | undefined, + kernel: options.cloudHypervisorKernelSha256 as string | undefined, + rootfs: options.cloudHypervisorRootfsSha256 as string | undefined, + supervisor: options.cloudHypervisorSupervisorSha256 as string | undefined, + }; + + return { + previewEnabled: options.cloudHypervisorPreview === true, + cloudHypervisorBinary: + (options.cloudHypervisorBinary as string | undefined) ?? CLOUD_HYPERVISOR_DEFAULT_BINARY, + kernelPath: options.cloudHypervisorKernel as string | undefined, + rootfsPath: options.cloudHypervisorRootfs as string | undefined, + supervisorPath: options.cloudHypervisorSupervisor as string | undefined, + vcpuCount: parsePositiveIntegerOption( + options.cloudHypervisorVcpus, + '--cloud-hypervisor-vcpus', + CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, + ), + memoryMib: parsePositiveIntegerOption( + options.cloudHypervisorMemoryMib, + '--cloud-hypervisor-memory-mib', + CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, + ), + apiTimeoutMs: parsePositiveIntegerOption( + options.cloudHypervisorApiTimeoutMs, + '--cloud-hypervisor-api-timeout-ms', + CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, + ), + sha256: Object.values(sha256).some((value) => value !== undefined) + ? sha256 + : undefined, + }; +} + function buildChrootIdentity( options: Record ): WrapperConfig['chrootIdentity'] { diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 01a1d6f6d..a4faf5245 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -14,6 +14,10 @@ import { assertFirecrackerRuntimeCompatibility, assertFirecrackerSelection, } from '../../firecracker/runtime-validation'; +import { + assertCloudHypervisorNotYetAvailable, + assertCloudHypervisorSelection, +} from '../../cloud-hypervisor/runtime-validation'; // --------------------------------------------------------------------------- // Public API @@ -77,6 +81,8 @@ export function assembleAndValidateConfig( validateInfrastructureOptions(config); try { assertFirecrackerSelection(config); + assertCloudHypervisorNotYetAvailable(config); + assertCloudHypervisorSelection(config); } catch (error) { logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); process.exit(1); diff --git a/src/config-file.ts b/src/config-file.ts index 936d6c83d..94419dd9c 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { validateWithSchema } from './schema-validator'; import type { RawEnclavesConfig } from './types/enclave-options'; -import type { FirecrackerArtifactDigests } from './types/runtime-options'; +import type { FirecrackerArtifactDigests, CloudHypervisorArtifactDigests } from './types/runtime-options'; /** @internal Used only by config-file helpers — not part of public API */ // ts-prune-ignore-next @@ -135,6 +135,22 @@ export interface AwfFileConfig { apiTimeoutMs?: number; sha256?: FirecrackerArtifactDigests; }; + /** + * Cloud Hypervisor microVM foundation (config/artifacts only). + * There is no lifecycle backend yet: this cannot be selected via + * `container.containerRuntime`. + */ + cloudHypervisor?: { + previewEnabled?: boolean; + cloudHypervisorBinary?: string; + kernelPath?: string; + rootfsPath?: string; + supervisorPath?: string; + vcpuCount?: number; + memoryMib?: number; + apiTimeoutMs?: number; + sha256?: CloudHypervisorArtifactDigests; + }; chroot?: { binariesSourcePath?: string; identity?: { diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 5bcbaeabf..55b99cebd 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -128,6 +128,18 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record = {}): WrapperConfig { } as WrapperConfig; } -function infrastructure(): FirecrackerInfrastructureSnapshot { +function infrastructure(): MicrovmInfrastructureSnapshot { return { networkId: 'a'.repeat(64), bridgeName: 'br-aaaaaaaaaaaa', diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts index 9dc3a6738..0065755d8 100644 --- a/src/firecracker-runtime-backend.ts +++ b/src/firecracker-runtime-backend.ts @@ -7,16 +7,16 @@ import { SQUID_IP, } from './config/network-policy'; import { - resolveFirecrackerInfrastructure, - type FirecrackerInfrastructureSnapshot, -} from './firecracker/infrastructure'; + resolveMicrovmInfrastructure, + type MicrovmInfrastructureSnapshot, +} from './microvm/infrastructure'; +import type { + GuestExecutionRequest, + GuestExecutionResult, +} from './microvm/vsock-client'; import type { FirecrackerPreflightResult } from './firecracker/preflight'; import { FirecrackerManager } from './firecracker/manager'; import { runFirecrackerPreflight } from './firecracker/preflight'; -import type { - FirecrackerGuestExecutionRequest, - FirecrackerGuestExecutionResult, -} from './firecracker/vsock-client'; import { getRealUserHome, getSafeHostGid, getSafeHostUid } from './host-identity'; import { logger } from './logger'; import { buildAgentEnvironment } from './services/agent-service'; @@ -49,7 +49,7 @@ interface FirecrackerManagerAdapter { readonly networkNamespace?: string; start(): Promise; startInstance(): Promise; - execute(request: FirecrackerGuestExecutionRequest): Promise; + execute(request: GuestExecutionRequest): Promise; cancel(reason?: string, requestId?: string): Promise; writeStdin(data: Buffer, requestId?: string): Promise; endStdin(requestId?: string): Promise; @@ -60,11 +60,11 @@ interface FirecrackerManagerAdapter { export interface FirecrackerRuntimeBackendDependencies { startInfrastructure: WorkflowDependencies['startContainers']; preflight(config: FirecrackerOptions): Promise; - resolveInfrastructure(enableApiProxy: boolean, ipPath?: string): Promise; + resolveInfrastructure(enableApiProxy: boolean, ipPath?: string): Promise; createManager( config: FirecrackerOptions, workDir: string, - infrastructure: FirecrackerInfrastructureSnapshot, + infrastructure: MicrovmInfrastructureSnapshot, workspacePath: string, homePath: string, identity: { uid: number; gid: number }, @@ -85,7 +85,7 @@ function defaultDependencies( startInfrastructure, preflight: runFirecrackerPreflight, resolveInfrastructure: (enableApiProxy, ipPath) => - resolveFirecrackerInfrastructure(enableApiProxy, undefined, ipPath), + resolveMicrovmInfrastructure(enableApiProxy, undefined, ipPath), createManager: (config, workDir, infrastructure, workspacePath, homePath, identity) => new FirecrackerManager( config, @@ -127,7 +127,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { private manager: FirecrackerManagerAdapter | undefined; private environment: Record | undefined; private activeExecution: - | { requestId: string; promise: Promise } + | { requestId: string; promise: Promise } | undefined; private stopped = false; private stopping: Promise | undefined; @@ -400,7 +400,7 @@ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { export function buildFirecrackerGuestEnvironment( config: WrapperConfig, - infrastructure: Pick, + infrastructure: Pick, guestIp = '100.64.0.2', ): Record { const networkConfig = { diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index 639b427d2..9bb27e51d 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -1,5 +1,11 @@ import type { ExecaChildProcess } from 'execa'; import { PassThrough } from 'stream'; +import type { + MicrovmNetworkLifecycle, + MicrovmNetworkPlan, +} from '../microvm/network'; +import type { MicrovmVsockClient } from '../microvm/vsock-client'; +import type { MicrovmWorkspaceImage } from '../microvm/workspace'; import type { FirecrackerOptions } from '../types/runtime-options'; import type { FirecrackerApiClient } from './api-client'; import { @@ -10,12 +16,6 @@ import { type FirecrackerManagerDependencies, type FirecrackerManagerNetworkConfig, } from './manager'; -import type { - FirecrackerNetworkLifecycle, - FirecrackerNetworkPlan, -} from './network'; -import type { FirecrackerVsockClient } from './vsock-client'; -import type { FirecrackerWorkspaceImage } from './workspace-image'; import type { FirecrackerHostToolPaths } from './preflight'; const hostTools: FirecrackerHostToolPaths = { @@ -67,7 +67,7 @@ function networkConfig( }; } -function networkLifecycle(plan: FirecrackerNetworkPlan): FirecrackerNetworkLifecycle { +function networkLifecycle(plan: MicrovmNetworkPlan): MicrovmNetworkLifecycle { return { plan, setup: jest.fn().mockResolvedValue(plan), @@ -130,7 +130,7 @@ describe('FirecrackerManager', () => { await expect(child).resolves.toMatchObject({ exitCode: 0 }); await expect(defaults.sleep(0)).resolves.toBeUndefined(); expect(defaults.createClient('/tmp/firecracker.socket', 100)).toBeDefined(); - expect(defaults.createNetwork({} as FirecrackerNetworkPlan, hostTools)).toBeDefined(); + expect(defaults.createNetwork({} as MicrovmNetworkPlan, hostTools)).toBeDefined(); expect(defaults.createWorkspaceImage({ runId: 'adapter-test', workDir: '/tmp/awf', @@ -179,17 +179,17 @@ describe('FirecrackerManager', () => { '/tmp/awf', '/opt/firecracker', '../escape', - )).toThrow(/Unsafe Firecracker run id/); + )).toThrow(/Unsafe microVM run id/); expect(() => createFirecrackerRunPaths( '/tmp/awf', '/opt/firecracker', 'run_1', - )).toThrow(/Unsafe Firecracker run id/); + )).toThrow(/Unsafe microVM run id/); expect(() => createFirecrackerRunPaths( '/tmp/awf', '/opt/firecracker', `run-${'a'.repeat(61)}`, - )).toThrow(/Unsafe Firecracker run id/); + )).toThrow(/Unsafe microVM run id/); }); it('launches jailer and configures machine, kernel, and root drive', async () => { @@ -237,13 +237,13 @@ describe('FirecrackerManager', () => { expect(deps.createNetwork).toHaveBeenCalledWith( expect.objectContaining({ infrastructureBridge: 'awfbr0', - jailerUid: 1000, - jailerGid: 1000, + tapOwnerUid: 1000, + tapOwnerGid: 1000, }), hostTools, ); const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] - .value as FirecrackerNetworkLifecycle; + .value as MicrovmNetworkLifecycle; expect(lifecycle.setup).toHaveBeenCalledTimes(1); }); @@ -273,7 +273,7 @@ describe('FirecrackerManager', () => { { recursive: true, force: true }, ); const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] - .value as FirecrackerNetworkLifecycle; + .value as MicrovmNetworkLifecycle; expect(lifecycle.cleanup).toHaveBeenCalledTimes(1); }); @@ -355,7 +355,7 @@ describe('FirecrackerManager', () => { expect(child.exitCode).toBe(0); }), cleanup: jest.fn().mockResolvedValue(undefined), - } as unknown as FirecrackerWorkspaceImage; + } as unknown as MicrovmWorkspaceImage; const guestClient = { connect: jest.fn().mockResolvedValue({ version: 1, @@ -371,7 +371,7 @@ describe('FirecrackerManager', () => { }), shutdown: jest.fn().mockResolvedValue(undefined), destroy: jest.fn(), - } as unknown as FirecrackerVsockClient; + } as unknown as MicrovmVsockClient; const deps = dependencies({ launch: jest.fn().mockReturnValue(child), createWorkspaceImage: jest.fn().mockReturnValue(workspace), @@ -451,7 +451,7 @@ describe('FirecrackerManager', () => { resize: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn().mockResolvedValue(undefined), destroy: jest.fn(), - } as unknown as FirecrackerVsockClient; + } as unknown as MicrovmVsockClient; const workspace = { prepare: jest.fn().mockResolvedValue({ workspaceImagePath: '/tmp/workspace.ext4', @@ -461,7 +461,7 @@ describe('FirecrackerManager', () => { }), extractAfterStop: jest.fn().mockResolvedValue(undefined), cleanup: jest.fn().mockResolvedValue(undefined), - } as unknown as FirecrackerWorkspaceImage; + } as unknown as MicrovmWorkspaceImage; const deps = dependencies({ createVsockClient: jest.fn().mockReturnValue(guestClient), createWorkspaceImage: jest.fn().mockReturnValue(workspace), @@ -504,12 +504,12 @@ describe('FirecrackerManager', () => { }), extractAfterStop: jest.fn().mockResolvedValue(undefined), cleanup: jest.fn().mockResolvedValue(undefined), - } as unknown as FirecrackerWorkspaceImage; + } as unknown as MicrovmWorkspaceImage; const guestClient = { connect: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn().mockResolvedValue(undefined), destroy: jest.fn(), - } as unknown as FirecrackerVsockClient; + } as unknown as MicrovmVsockClient; const deps = dependencies({ launch: jest.fn().mockReturnValue(child), createWorkspaceImage: jest.fn().mockReturnValue(workspace), @@ -534,7 +534,7 @@ describe('FirecrackerManager', () => { await manager.stop({ preserve: true }); const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] - .value as FirecrackerNetworkLifecycle; + .value as MicrovmNetworkLifecycle; expect(workspace.extractAfterStop).toHaveBeenCalledTimes(1); expect(lifecycle.cleanup).not.toHaveBeenCalled(); expect(workspace.cleanup).not.toHaveBeenCalled(); @@ -559,8 +559,8 @@ describe('FirecrackerManager', () => { guestGatewayIp: '100.64.0.1', guestPrefixLength: 30, guestMac: '02:00:00:00:00:01', - jailerUid: 1000, - jailerGid: 1000, + tapOwnerUid: 1000, + tapOwnerGid: 1000, allowedEndpoints: [], networkInterface: { iface_id: 'eth0', host_dev_name: 'tap' }, }, { @@ -595,12 +595,12 @@ describe('FirecrackerManager', () => { }), extractAfterStop: jest.fn().mockResolvedValue(undefined), cleanup: jest.fn().mockResolvedValue(undefined), - } as unknown as FirecrackerWorkspaceImage; + } as unknown as MicrovmWorkspaceImage; const guestClient = { connect: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn().mockResolvedValue(undefined), destroy: jest.fn(), - } as unknown as FirecrackerVsockClient; + } as unknown as MicrovmVsockClient; const deps = dependencies({ launch: jest.fn().mockReturnValue(child), createWorkspaceImage: jest.fn().mockReturnValue(workspace), @@ -624,7 +624,7 @@ describe('FirecrackerManager', () => { await expect(manager.stop()).rejects.toThrow(/stopped before workspace\/network removal/); const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] - .value as FirecrackerNetworkLifecycle; + .value as MicrovmNetworkLifecycle; expect(lifecycle.cleanup).not.toHaveBeenCalled(); expect(workspace.extractAfterStop).not.toHaveBeenCalled(); expect(deps.rm).not.toHaveBeenCalled(); @@ -646,12 +646,12 @@ describe('FirecrackerManager', () => { }), extractAfterStop: jest.fn().mockResolvedValue(undefined), cleanup: jest.fn().mockResolvedValue(undefined), - } as unknown as FirecrackerWorkspaceImage; + } as unknown as MicrovmWorkspaceImage; const guestClient = { connect: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn().mockResolvedValue(undefined), destroy: jest.fn(), - } as unknown as FirecrackerVsockClient; + } as unknown as MicrovmVsockClient; let sleepCalls = 0; const deps = dependencies({ launch: jest.fn().mockReturnValue(child), @@ -706,7 +706,7 @@ describe('FirecrackerManager', () => { await expect(manager.start()).rejects.toThrow('invalid NIC'); const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] - .value as FirecrackerNetworkLifecycle; + .value as MicrovmNetworkLifecycle; expect(lifecycle.cleanup).toHaveBeenCalledTimes(1); expect(deps.rm).toHaveBeenCalled(); }); diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts index 3fa2ebb77..cc45230ce 100644 --- a/src/firecracker/manager.ts +++ b/src/firecracker/manager.ts @@ -7,27 +7,27 @@ import { type FirecrackerOptions, } from '../types/runtime-options'; import { getSafeHostGid, getSafeHostUid } from '../host-identity'; -import { FirecrackerApiClient } from './api-client'; import { - FirecrackerLinuxNetworkCommands, - FirecrackerNetworkManager, - assertSafeFirecrackerRunId, - createFirecrackerNetworkPlan, - type FirecrackerControlPeer, - type FirecrackerNetworkLifecycle, - type FirecrackerNetworkPlan, -} from './network'; -import { runFirecrackerPreflight } from './preflight'; -import type { FirecrackerHostToolPaths } from './preflight'; + LinuxNetworkCommands, + MicrovmNetworkManager, + assertSafeMicrovmRunId, + createMicrovmNetworkPlan, + type MicrovmControlPeer, + type MicrovmNetworkLifecycle, + type MicrovmNetworkPlan, +} from '../microvm/network'; import { - FirecrackerVsockClient, - type FirecrackerGuestExecutionRequest, - type FirecrackerGuestExecutionResult, -} from './vsock-client'; + MicrovmVsockClient, + type GuestExecutionRequest, + type GuestExecutionResult, +} from '../microvm/vsock-client'; import { - FirecrackerWorkspaceImage, - type FirecrackerWorkspaceImageConfig, -} from './workspace-image'; + MicrovmWorkspaceImage, + type MicrovmWorkspaceImageConfig, +} from '../microvm/workspace'; +import { FirecrackerApiClient } from './api-client'; +import { runFirecrackerPreflight } from './preflight'; +import type { FirecrackerHostToolPaths } from './preflight'; const API_SOCKET_NAME = 'firecracker.socket'; const VSOCK_SOCKET_NAME = 'awf-vsock.socket'; @@ -76,16 +76,16 @@ export interface FirecrackerManagerDependencies { rm(directory: string, options: { recursive: true; force: true }): Promise; sleep(milliseconds: number): Promise; createClient(socketPath: string, timeoutMs: number): FirecrackerApiClient; - createNetwork(plan: FirecrackerNetworkPlan, tools: FirecrackerHostToolPaths): FirecrackerNetworkLifecycle; - createWorkspaceImage(config: FirecrackerWorkspaceImageConfig, tools: FirecrackerHostToolPaths): FirecrackerWorkspaceImage; - createVsockClient(socketPath: string, guestPort: number, timeoutMs: number): FirecrackerVsockClient; + createNetwork(plan: MicrovmNetworkPlan, tools: FirecrackerHostToolPaths): MicrovmNetworkLifecycle; + createWorkspaceImage(config: MicrovmWorkspaceImageConfig, tools: FirecrackerHostToolPaths): MicrovmWorkspaceImage; + createVsockClient(socketPath: string, guestPort: number, timeoutMs: number): MicrovmVsockClient; resolveIdentity(): { uid: number; gid: number }; } export interface FirecrackerManagerNetworkConfig { infrastructureBridge: string; enableApiProxy: boolean; - controlPeer?: FirecrackerControlPeer; + controlPeer?: MicrovmControlPeer; } export interface FirecrackerManagerGuestConfig { @@ -126,12 +126,12 @@ const defaultDependencies: FirecrackerManagerDependencies = { rm: fs.rm, sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), createClient: (socketPath, timeoutMs) => new FirecrackerApiClient({ socketPath, timeoutMs }), - createNetwork: (plan, tools) => new FirecrackerNetworkManager( + createNetwork: (plan, tools) => new MicrovmNetworkManager( plan, - new FirecrackerLinuxNetworkCommands(undefined, tools), + new LinuxNetworkCommands(undefined, tools), ), - createWorkspaceImage: (config, tools) => new FirecrackerWorkspaceImage(config, undefined, tools), - createVsockClient: (socketPath, guestPort, timeoutMs) => new FirecrackerVsockClient({ + createWorkspaceImage: (config, tools) => new MicrovmWorkspaceImage(config, undefined, tools), + createVsockClient: (socketPath, guestPort, timeoutMs) => new MicrovmVsockClient({ socketPath, guestPort, connectTimeoutMs: timeoutMs, @@ -180,7 +180,7 @@ export function createFirecrackerRunPaths( firecrackerBinary: string, runId = `awf-${process.pid}-${randomBytes(6).toString('hex')}`, ): FirecrackerRunPaths { - assertSafeFirecrackerRunId(runId); + assertSafeMicrovmRunId(runId); const chrootBaseDir = path.join(workDir, 'firecracker-jailer'); const jailRoot = path.join( chrootBaseDir, @@ -209,10 +209,10 @@ export class FirecrackerManager { readonly paths: FirecrackerRunPaths; private process: ExecaChildProcess | undefined; private client: FirecrackerApiClient | undefined; - private network: FirecrackerNetworkLifecycle | undefined; - private workspace: FirecrackerWorkspaceImage | undefined; - private guestClient: FirecrackerVsockClient | undefined; - private networkPlan: FirecrackerNetworkPlan | undefined; + private network: MicrovmNetworkLifecycle | undefined; + private workspace: MicrovmWorkspaceImage | undefined; + private guestClient: MicrovmVsockClient | undefined; + private networkPlan: MicrovmNetworkPlan | undefined; private instanceStarted = false; private readonly stdoutCapture = new BoundedOutputCapture(FIRECRACKER_CAPTURE_LIMIT_BYTES); private readonly stderrCapture = new BoundedOutputCapture(FIRECRACKER_CAPTURE_LIMIT_BYTES); @@ -247,10 +247,10 @@ export class FirecrackerManager { try { const artifacts = await this.dependencies.preflight(this.config); const identity = this.guestConfig?.identity ?? this.dependencies.resolveIdentity(); - const networkPlan = createFirecrackerNetworkPlan(this.paths.runId, { + const networkPlan = createMicrovmNetworkPlan(this.paths.runId, { ...this.networkConfig, - jailerUid: identity.uid, - jailerGid: identity.gid, + tapOwnerUid: identity.uid, + tapOwnerGid: identity.gid, }); this.networkPlan = networkPlan; this.network = this.dependencies.createNetwork(networkPlan, artifacts.tools); @@ -389,8 +389,8 @@ export class FirecrackerManager { } async execute( - request: FirecrackerGuestExecutionRequest, - ): Promise { + request: GuestExecutionRequest, + ): Promise { if (!this.guestClient) { throw new Error('Firecracker guest supervisor is not ready'); } @@ -653,7 +653,7 @@ export class FirecrackerManager { } export function buildSupervisorBootArgs( - networkPlan: FirecrackerNetworkPlan, + networkPlan: MicrovmNetworkPlan, guestConfig: FirecrackerManagerGuestConfig, ): string { const port = guestConfig.vsockPort ?? FIRECRACKER_GUEST_VSOCK_PORT; diff --git a/src/firecracker/vsock-protocol.test.ts b/src/microvm/guest-protocol.test.ts similarity index 76% rename from src/firecracker/vsock-protocol.test.ts rename to src/microvm/guest-protocol.test.ts index 9819bd5bd..b07616ef1 100644 --- a/src/firecracker/vsock-protocol.test.ts +++ b/src/microvm/guest-protocol.test.ts @@ -1,25 +1,25 @@ import { - FIRECRACKER_GUEST_PROTOCOL_VERSION, - FIRECRACKER_MAX_FRAME_BYTES, - FIRECRACKER_MAX_STREAM_CHUNK_BYTES, - FirecrackerFrameDecoder, - FirecrackerProtocolError, - encodeFirecrackerFrame, - validateFirecrackerFrame, - type FirecrackerGuestFrame, -} from './vsock-protocol'; + GUEST_PROTOCOL_VERSION, + GUEST_MAX_FRAME_BYTES, + GUEST_MAX_STREAM_CHUNK_BYTES, + GuestFrameDecoder, + GuestProtocolError, + encodeGuestFrame, + validateGuestFrame, + type GuestProtocolFrame, +} from './guest-protocol'; -const ready: FirecrackerGuestFrame = { - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, +const ready: GuestProtocolFrame = { + version: GUEST_PROTOCOL_VERSION, type: 'ready', requestId: 'control', capabilities: { stdin: true, tty: false, resize: false }, }; -describe('Firecracker guest vsock protocol', () => { +describe('AWF guest protocol framing', () => { it('frames and incrementally decodes typed messages', () => { - const encoded = encodeFirecrackerFrame(ready); - const decoder = new FirecrackerFrameDecoder(); + const encoded = encodeGuestFrame(ready); + const decoder = new GuestFrameDecoder(); expect(decoder.push(encoded.subarray(0, 2))).toEqual([]); expect(decoder.push(encoded.subarray(2, 7))).toEqual([]); expect(decoder.push(encoded.subarray(7))).toEqual([ready]); @@ -27,36 +27,36 @@ describe('Firecracker guest vsock protocol', () => { }); it('decodes multiple frames and rejects incomplete terminal data', () => { - const decoder = new FirecrackerFrameDecoder(); + const decoder = new GuestFrameDecoder(); expect(decoder.push(Buffer.concat([ - encodeFirecrackerFrame(ready), - encodeFirecrackerFrame({ ...ready, requestId: 'second' }), + encodeGuestFrame(ready), + encodeGuestFrame({ ...ready, requestId: 'second' }), ]))).toHaveLength(2); decoder.push(Buffer.from([0, 0])); expect(() => decoder.finish()).toThrow(/incomplete frame/); }); it('rejects oversized, empty, malformed, and unknown frames', () => { - const decoder = new FirecrackerFrameDecoder(); + const decoder = new GuestFrameDecoder(); const oversized = Buffer.alloc(4); - oversized.writeUInt32BE(FIRECRACKER_MAX_FRAME_BYTES + 1); + oversized.writeUInt32BE(GUEST_MAX_FRAME_BYTES + 1); expect(() => decoder.push(oversized)).toThrow(/Invalid.*length/); - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ ...ready, version: 2, - })).toThrow(new FirecrackerProtocolError( + })).toThrow(new GuestProtocolError( 'protocol_version_mismatch', - 'Unsupported Firecracker guest protocol version 2; expected 1', + 'Unsupported guest protocol version 2; expected 1', )); - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ ...ready, unexpected: true, })).toThrow(/Unexpected frame property/); }); it('validates execute schemas, identifiers, and bounded environment data', () => { - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ version: 1, type: 'execute', requestId: '../escape', @@ -67,7 +67,7 @@ describe('Firecracker guest vsock protocol', () => { gid: 1000, tty: false, })).toThrow(/requestId/); - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ version: 1, type: 'execute', requestId: 'run', @@ -78,7 +78,7 @@ describe('Firecracker guest vsock protocol', () => { gid: 1000, tty: false, })).toThrow(/argv/); - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ version: 1, type: 'execute', requestId: 'run', @@ -92,13 +92,13 @@ describe('Firecracker guest vsock protocol', () => { }); it('enforces decoded stream chunk limits and exact result semantics', () => { - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ version: 1, type: 'stdout', requestId: 'run', - data: Buffer.alloc(FIRECRACKER_MAX_STREAM_CHUNK_BYTES + 1).toString('base64'), + data: Buffer.alloc(GUEST_MAX_STREAM_CHUNK_BYTES + 1).toString('base64'), })).toThrow(/decoded stream data exceeds/); - expect(() => validateFirecrackerFrame({ + expect(() => validateGuestFrame({ version: 1, type: 'result', requestId: 'run', @@ -109,7 +109,7 @@ describe('Firecracker guest vsock protocol', () => { }); it('validates every host and guest frame schema boundary', () => { - const validFrames: FirecrackerGuestFrame[] = [ + const validFrames: GuestProtocolFrame[] = [ { version: 1, type: 'execute', @@ -151,7 +151,7 @@ describe('Firecracker guest vsock protocol', () => { { version: 1, type: 'shutting_down', requestId: 'shutdown' }, ]; for (const frame of validFrames) { - expect(() => validateFirecrackerFrame(frame)).not.toThrow(); + expect(() => validateGuestFrame(frame)).not.toThrow(); } const invalidFrames: unknown[] = [ @@ -206,7 +206,7 @@ describe('Firecracker guest vsock protocol', () => { { version: 1, type: 'unknown', requestId: 'run' }, ]; for (const frame of invalidFrames) { - expect(() => validateFirecrackerFrame(frame)).toThrow(FirecrackerProtocolError); + expect(() => validateGuestFrame(frame)).toThrow(GuestProtocolError); } }); @@ -215,7 +215,7 @@ describe('Firecracker guest vsock protocol', () => { const malformedWire = Buffer.alloc(4 + malformed.length); malformedWire.writeUInt32BE(malformed.length, 0); malformed.copy(malformedWire, 4); - expect(() => new FirecrackerFrameDecoder().push(malformedWire)) + expect(() => new GuestFrameDecoder().push(malformedWire)) .toThrow(/invalid JSON/); const oversizedFrame = { @@ -233,7 +233,7 @@ describe('Firecracker guest vsock protocol', () => { uid: 1000, gid: 1000, tty: false, - } as FirecrackerGuestFrame; - expect(() => encodeFirecrackerFrame(oversizedFrame)).toThrow(/exceeds/); + } as GuestProtocolFrame; + expect(() => encodeGuestFrame(oversizedFrame)).toThrow(/exceeds/); }); }); diff --git a/src/firecracker/vsock-protocol.ts b/src/microvm/guest-protocol.ts similarity index 70% rename from src/firecracker/vsock-protocol.ts rename to src/microvm/guest-protocol.ts index ec4f8d907..479e117ce 100644 --- a/src/firecracker/vsock-protocol.ts +++ b/src/microvm/guest-protocol.ts @@ -1,11 +1,16 @@ -export const FIRECRACKER_GUEST_PROTOCOL_VERSION = 1 as const; -export const FIRECRACKER_MAX_FRAME_BYTES = 1024 * 1024; -export const FIRECRACKER_MAX_STREAM_CHUNK_BYTES = 64 * 1024; -export const FIRECRACKER_MAX_ENV_ENTRIES = 512; -export const FIRECRACKER_MAX_ARGV_ENTRIES = 4096; -export const FIRECRACKER_MAX_STRING_BYTES = 256 * 1024; +/** + * AWF framed guest-supervisor protocol. Transport-independent: the same + * length-prefixed JSON framing is used regardless of which VMM backend + * (Firecracker today, others later) carries the bytes over vsock/UDS. + */ +export const GUEST_PROTOCOL_VERSION = 1 as const; +export const GUEST_MAX_FRAME_BYTES = 1024 * 1024; +export const GUEST_MAX_STREAM_CHUNK_BYTES = 64 * 1024; +export const GUEST_MAX_ENV_ENTRIES = 512; +export const GUEST_MAX_ARGV_ENTRIES = 4096; +export const GUEST_MAX_STRING_BYTES = 256 * 1024; -export type FirecrackerGuestErrorCode = +export type GuestProtocolErrorCode = | 'invalid_frame' | 'protocol_version_mismatch' | 'invalid_request' @@ -15,12 +20,12 @@ export type FirecrackerGuestErrorCode = | 'internal_error'; interface ProtocolFrame { - readonly version: typeof FIRECRACKER_GUEST_PROTOCOL_VERSION; + readonly version: typeof GUEST_PROTOCOL_VERSION; readonly type: string; readonly requestId: string; } -export interface FirecrackerReadyFrame extends ProtocolFrame { +export interface GuestReadyFrame extends ProtocolFrame { readonly type: 'ready'; readonly capabilities: { readonly stdin: boolean; @@ -29,7 +34,7 @@ export interface FirecrackerReadyFrame extends ProtocolFrame { }; } -export interface FirecrackerExecuteFrame extends ProtocolFrame { +export interface GuestExecuteFrame extends ProtocolFrame { readonly type: 'execute'; readonly argv: readonly string[]; readonly env: Readonly>; @@ -40,74 +45,74 @@ export interface FirecrackerExecuteFrame extends ProtocolFrame { readonly timeoutMs?: number; } -export interface FirecrackerStreamFrame extends ProtocolFrame { +export interface GuestStreamFrame extends ProtocolFrame { readonly type: 'stdout' | 'stderr'; readonly data: string; } -export interface FirecrackerStdinFrame extends ProtocolFrame { +export interface GuestStdinFrame extends ProtocolFrame { readonly type: 'stdin'; readonly data?: string; readonly eof?: boolean; } -export interface FirecrackerResizeFrame extends ProtocolFrame { +export interface GuestResizeFrame extends ProtocolFrame { readonly type: 'resize'; readonly columns: number; readonly rows: number; } -export interface FirecrackerCancelFrame extends ProtocolFrame { +export interface GuestCancelFrame extends ProtocolFrame { readonly type: 'cancel'; readonly reason: string; } -export interface FirecrackerResultFrame extends ProtocolFrame { +export interface GuestResultFrame extends ProtocolFrame { readonly type: 'result'; readonly exitCode: number | null; readonly signal: string | null; readonly timedOut: boolean; } -export interface FirecrackerErrorFrame extends ProtocolFrame { +export interface GuestErrorFrame extends ProtocolFrame { readonly type: 'error'; - readonly code: FirecrackerGuestErrorCode; + readonly code: GuestProtocolErrorCode; readonly message: string; readonly expectedVersion?: number; } -export interface FirecrackerShutdownFrame extends ProtocolFrame { +export interface GuestShutdownFrame extends ProtocolFrame { readonly type: 'shutdown' | 'shutting_down'; } -export type FirecrackerGuestFrame = - | FirecrackerReadyFrame - | FirecrackerExecuteFrame - | FirecrackerStreamFrame - | FirecrackerStdinFrame - | FirecrackerResizeFrame - | FirecrackerCancelFrame - | FirecrackerResultFrame - | FirecrackerErrorFrame - | FirecrackerShutdownFrame; +export type GuestProtocolFrame = + | GuestReadyFrame + | GuestExecuteFrame + | GuestStreamFrame + | GuestStdinFrame + | GuestResizeFrame + | GuestCancelFrame + | GuestResultFrame + | GuestErrorFrame + | GuestShutdownFrame; -export class FirecrackerProtocolError extends Error { +export class GuestProtocolError extends Error { constructor( - readonly code: FirecrackerGuestErrorCode, + readonly code: GuestProtocolErrorCode, message: string, ) { super(message); - this.name = 'FirecrackerProtocolError'; + this.name = 'GuestProtocolError'; } } -export function encodeFirecrackerFrame(frame: FirecrackerGuestFrame): Buffer { - validateFirecrackerFrame(frame); +export function encodeGuestFrame(frame: GuestProtocolFrame): Buffer { + validateGuestFrame(frame); const payload = Buffer.from(JSON.stringify(frame), 'utf8'); - if (payload.length > FIRECRACKER_MAX_FRAME_BYTES) { - throw new FirecrackerProtocolError( + if (payload.length > GUEST_MAX_FRAME_BYTES) { + throw new GuestProtocolError( 'invalid_frame', - `Firecracker guest frame exceeds ${FIRECRACKER_MAX_FRAME_BYTES} bytes`, + `guest frame exceeds ${GUEST_MAX_FRAME_BYTES} bytes`, ); } const header = Buffer.allocUnsafe(4); @@ -115,25 +120,25 @@ export function encodeFirecrackerFrame(frame: FirecrackerGuestFrame): Buffer { return Buffer.concat([header, payload]); } -export class FirecrackerFrameDecoder { +export class GuestFrameDecoder { private buffered: Buffer = Buffer.alloc(0); get pendingBytes(): number { return this.buffered.length; } - push(chunk: Buffer): FirecrackerGuestFrame[] { + push(chunk: Buffer): GuestProtocolFrame[] { if (chunk.length === 0) return []; this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]); - const frames: FirecrackerGuestFrame[] = []; + const frames: GuestProtocolFrame[] = []; while (this.buffered.length >= 4) { const payloadLength = this.buffered.readUInt32BE(0); - if (payloadLength === 0 || payloadLength > FIRECRACKER_MAX_FRAME_BYTES) { - throw new FirecrackerProtocolError( + if (payloadLength === 0 || payloadLength > GUEST_MAX_FRAME_BYTES) { + throw new GuestProtocolError( 'invalid_frame', - `Invalid Firecracker guest frame length: ${payloadLength}`, + `Invalid guest frame length: ${payloadLength}`, ); } if (this.buffered.length < payloadLength + 4) break; @@ -143,12 +148,12 @@ export class FirecrackerFrameDecoder { try { decoded = JSON.parse(payload.toString('utf8')); } catch (error) { - throw new FirecrackerProtocolError( + throw new GuestProtocolError( 'invalid_frame', - `Firecracker guest frame contains invalid JSON: ${formatError(error)}`, + `guest frame contains invalid JSON: ${formatError(error)}`, ); } - validateFirecrackerFrame(decoded); + validateGuestFrame(decoded); frames.push(decoded); } return frames; @@ -156,22 +161,22 @@ export class FirecrackerFrameDecoder { finish(): void { if (this.buffered.length !== 0) { - throw new FirecrackerProtocolError( + throw new GuestProtocolError( 'invalid_frame', - `Firecracker guest connection ended with ${this.buffered.length} incomplete frame bytes`, + `guest connection ended with ${this.buffered.length} incomplete frame bytes`, ); } } } -export function validateFirecrackerFrame(value: unknown): asserts value is FirecrackerGuestFrame { +export function validateGuestFrame(value: unknown): asserts value is GuestProtocolFrame { const frame = asRecord(value, 'frame'); const version = frame.version; - if (version !== FIRECRACKER_GUEST_PROTOCOL_VERSION) { - throw new FirecrackerProtocolError( + if (version !== GUEST_PROTOCOL_VERSION) { + throw new GuestProtocolError( 'protocol_version_mismatch', - `Unsupported Firecracker guest protocol version ${String(version)}; ` + - `expected ${FIRECRACKER_GUEST_PROTOCOL_VERSION}`, + `Unsupported guest protocol version ${String(version)}; ` + + `expected ${GUEST_PROTOCOL_VERSION}`, ); } const type = requiredString(frame.type, 'type', 64); @@ -193,23 +198,23 @@ export function validateFirecrackerFrame(value: unknown): asserts value is Firec if ( !Array.isArray(frame.argv) || frame.argv.length === 0 || - frame.argv.length > FIRECRACKER_MAX_ARGV_ENTRIES + frame.argv.length > GUEST_MAX_ARGV_ENTRIES ) { - invalid(`argv must contain 1-${FIRECRACKER_MAX_ARGV_ENTRIES} strings`); + invalid(`argv must contain 1-${GUEST_MAX_ARGV_ENTRIES} strings`); } for (const [index, arg] of frame.argv.entries()) { - requiredString(arg, `argv[${index}]`, FIRECRACKER_MAX_STRING_BYTES); + requiredString(arg, `argv[${index}]`, GUEST_MAX_STRING_BYTES); } const env = asRecord(frame.env, 'env'); const entries = Object.entries(env); - if (entries.length > FIRECRACKER_MAX_ENV_ENTRIES) { - invalid(`env exceeds ${FIRECRACKER_MAX_ENV_ENTRIES} entries`); + if (entries.length > GUEST_MAX_ENV_ENTRIES) { + invalid(`env exceeds ${GUEST_MAX_ENV_ENTRIES} entries`); } for (const [name, envValue] of entries) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.length > 256) { invalid(`Invalid environment variable name: ${name}`); } - requiredString(envValue, `env.${name}`, FIRECRACKER_MAX_STRING_BYTES, true); + requiredString(envValue, `env.${name}`, GUEST_MAX_STRING_BYTES, true); } const cwd = requiredString(frame.cwd, 'cwd', 4096); if (!cwd.startsWith('/') || cwd.includes('\0')) invalid('cwd must be an absolute path'); @@ -225,7 +230,7 @@ export function validateFirecrackerFrame(value: unknown): asserts value is Firec const data = requiredString( frame.data, 'data', - Math.ceil(FIRECRACKER_MAX_STREAM_CHUNK_BYTES * 4 / 3) + 4, + Math.ceil(GUEST_MAX_STREAM_CHUNK_BYTES * 4 / 3) + 4, true, ); validateBase64Chunk(data); @@ -240,7 +245,7 @@ export function validateFirecrackerFrame(value: unknown): asserts value is Firec validateBase64Chunk(requiredString( frame.data, 'data', - Math.ceil(FIRECRACKER_MAX_STREAM_CHUNK_BYTES * 4 / 3) + 4, + Math.ceil(GUEST_MAX_STREAM_CHUNK_BYTES * 4 / 3) + 4, true, )); } @@ -292,7 +297,7 @@ export function validateFirecrackerFrame(value: unknown): asserts value is Firec assertKeys(frame, ['version', 'type', 'requestId']); return; default: - invalid(`Unknown Firecracker guest frame type: ${type}`); + invalid(`Unknown guest frame type: ${type}`); } } @@ -300,8 +305,8 @@ function validateBase64Chunk(value: string): void { if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { invalid('stream data must be canonical base64'); } - if (Buffer.byteLength(value, 'base64') > FIRECRACKER_MAX_STREAM_CHUNK_BYTES) { - invalid(`decoded stream data exceeds ${FIRECRACKER_MAX_STREAM_CHUNK_BYTES} bytes`); + if (Buffer.byteLength(value, 'base64') > GUEST_MAX_STREAM_CHUNK_BYTES) { + invalid(`decoded stream data exceeds ${GUEST_MAX_STREAM_CHUNK_BYTES} bytes`); } } @@ -359,7 +364,7 @@ function boundedInteger(value: unknown, label: string, minimum: number, maximum: } function invalid(message: string): never { - throw new FirecrackerProtocolError('invalid_request', message); + throw new GuestProtocolError('invalid_request', message); } function formatError(error: unknown): string { diff --git a/src/firecracker/infrastructure.test.ts b/src/microvm/infrastructure.test.ts similarity index 81% rename from src/firecracker/infrastructure.test.ts rename to src/microvm/infrastructure.test.ts index 7f2552a04..cfa091a79 100644 --- a/src/firecracker/infrastructure.test.ts +++ b/src/microvm/infrastructure.test.ts @@ -1,6 +1,6 @@ import { - resolveFirecrackerInfrastructure, - type FirecrackerInfrastructureDependencies, + resolveMicrovmInfrastructure, + type MicrovmInfrastructureDependencies, } from './infrastructure'; import execa from 'execa'; @@ -31,7 +31,7 @@ function networkInspection( function dependencies( inspection: unknown = networkInspection(), -): jest.Mocked { +): jest.Mocked { return { inspectNetwork: jest.fn().mockResolvedValue(inspection), inspectLink: jest.fn(async (bridgeName: string) => [{ @@ -41,7 +41,7 @@ function dependencies( }; } -describe('Firecracker infrastructure discovery', () => { +describe('microVM infrastructure discovery', () => { beforeEach(() => { mockedExeca.mockReset(); }); @@ -62,7 +62,7 @@ describe('Firecracker infrastructure discovery', () => { stderr: '', } as never); - await expect(resolveFirecrackerInfrastructure(true)).resolves.toEqual( + await expect(resolveMicrovmInfrastructure(true)).resolves.toEqual( expect.objectContaining({ squidIp: '172.30.0.10', apiProxyIp: '172.30.0.30' }), ); expect(mockedExeca).toHaveBeenNthCalledWith( @@ -85,7 +85,7 @@ describe('Firecracker infrastructure discovery', () => { stdout: '', stderr: 'network unavailable', } as never); - await expect(resolveFirecrackerInfrastructure(true)) + await expect(resolveMicrovmInfrastructure(true)) .rejects.toThrow(/Could not inspect.*network unavailable/); mockedExeca @@ -99,13 +99,13 @@ describe('Firecracker infrastructure discovery', () => { stdout: '', stderr: 'link unavailable', } as never); - await expect(resolveFirecrackerInfrastructure(true)) + await expect(resolveMicrovmInfrastructure(true)) .rejects.toThrow(/Could not inspect.*bridge.*link unavailable/); }); it('derives the Docker bridge from the live network ID and revalidates targets', async () => { const deps = dependencies(); - const resolved = await resolveFirecrackerInfrastructure(true, deps); + const resolved = await resolveMicrovmInfrastructure(true, deps); expect(resolved).toEqual(expect.objectContaining({ networkId: 'a'.repeat(64), @@ -121,17 +121,17 @@ describe('Firecracker infrastructure discovery', () => { }); it('rejects ambiguous, non-internal, or address-shifted topology', async () => { - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies([networkInspection()[0], networkInspection()[0]]), )).rejects.toThrow(/exactly one Docker network inspection/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ Internal: false })), - )).rejects.toThrow(/Unexpected Firecracker infrastructure topology/); + )).rejects.toThrow(/Unexpected microVM infrastructure topology/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ Containers: { @@ -141,12 +141,12 @@ describe('Firecracker infrastructure discovery', () => { })), )).rejects.toThrow(/Unexpected "awf-squid" address/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ Id: 'invalid' })), )).rejects.toThrow(/invalid network ID/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ IPAM: { Config: [] } })), )).rejects.toThrow(/must have exactly 172\.30\.0\.0\/24/); @@ -155,7 +155,7 @@ describe('Firecracker infrastructure discovery', () => { it('validates the bridge and required service endpoint shape', async () => { const badLink = dependencies(); badLink.inspectLink.mockResolvedValue([]); - await expect(resolveFirecrackerInfrastructure(true, badLink)) + await expect(resolveMicrovmInfrastructure(true, badLink)) .rejects.toThrow(/exactly one host bridge/); const nonBridge = dependencies(); @@ -163,29 +163,29 @@ describe('Firecracker infrastructure discovery', () => { ifname: `br-${'a'.repeat(12)}`, linkinfo: { info_kind: 'veth' }, }]); - await expect(resolveFirecrackerInfrastructure(true, nonBridge)) + await expect(resolveMicrovmInfrastructure(true, nonBridge)) .rejects.toThrow(/is not the Docker bridge/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ Containers: {} })), )).rejects.toThrow(/Expected exactly one "awf-squid" endpoint/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ Options: { 'com.docker.network.bridge.name': 'unsafe bridge' }, })), - )).rejects.toThrow(/Unsafe Firecracker infrastructure bridge name/); + )).rejects.toThrow(/Unsafe microVM infrastructure bridge name/); - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies([null]), )).rejects.toThrow(/Docker network inspection is not an object/); }); it('supports Squid-only infrastructure without an API proxy', async () => { - const resolved = await resolveFirecrackerInfrastructure( + const resolved = await resolveMicrovmInfrastructure( false, dependencies(networkInspection({ Containers: { @@ -197,7 +197,7 @@ describe('Firecracker infrastructure discovery', () => { }); it('rejects an accidentally composed primary agent', async () => { - await expect(resolveFirecrackerInfrastructure( + await expect(resolveMicrovmInfrastructure( true, dependencies(networkInspection({ Containers: { @@ -214,7 +214,7 @@ describe('Firecracker infrastructure discovery', () => { deps.inspectNetwork .mockResolvedValueOnce(networkInspection()) .mockResolvedValueOnce(networkInspection({ Id: 'b'.repeat(64) })); - const resolved = await resolveFirecrackerInfrastructure(true, deps); + const resolved = await resolveMicrovmInfrastructure(true, deps); await expect(resolved.revalidate()).rejects.toThrow(/topology changed/); }); diff --git a/src/firecracker/infrastructure.ts b/src/microvm/infrastructure.ts similarity index 86% rename from src/firecracker/infrastructure.ts rename to src/microvm/infrastructure.ts index 1a97f4cc8..a5f3714d6 100644 --- a/src/firecracker/infrastructure.ts +++ b/src/microvm/infrastructure.ts @@ -41,7 +41,7 @@ interface IpLinkInspection { }; } -export interface FirecrackerInfrastructureSnapshot { +export interface MicrovmInfrastructureSnapshot { readonly networkId: string; readonly bridgeName: string; readonly subnet: string; @@ -51,12 +51,12 @@ export interface FirecrackerInfrastructureSnapshot { revalidate(): Promise; } -export interface FirecrackerInfrastructureDependencies { +export interface MicrovmInfrastructureDependencies { inspectNetwork(): Promise; inspectLink(bridgeName: string, ipPath?: string): Promise; } -const defaultDependencies: FirecrackerInfrastructureDependencies = { +const defaultDependencies: MicrovmInfrastructureDependencies = { inspectNetwork: async () => { const result = await execa('docker', ['network', 'inspect', NETWORK_NAME], { env: getLocalDockerEnv(), @@ -65,7 +65,7 @@ const defaultDependencies: FirecrackerInfrastructureDependencies = { }); if (result.exitCode !== 0) { throw new Error( - `Could not inspect Firecracker infrastructure network "${NETWORK_NAME}": ` + + `Could not inspect microVM infrastructure network "${NETWORK_NAME}": ` + result.stderr.trim(), ); } @@ -78,7 +78,7 @@ const defaultDependencies: FirecrackerInfrastructureDependencies = { }); if (result.exitCode !== 0) { throw new Error( - `Could not inspect Firecracker infrastructure bridge "${bridgeName}": ` + + `Could not inspect microVM infrastructure bridge "${bridgeName}": ` + result.stderr.trim(), ); } @@ -88,14 +88,15 @@ const defaultDependencies: FirecrackerInfrastructureDependencies = { /** * Resolves and proves the exact host bridge and service addresses used by the - * Compose infrastructure. No default bridge name or daemon-local assumption is + * Compose infrastructure that any microVM backend attaches its network + * namespace to. No default bridge name or daemon-local assumption is * accepted. */ -export async function resolveFirecrackerInfrastructure( +export async function resolveMicrovmInfrastructure( enableApiProxy: boolean, - dependencies: FirecrackerInfrastructureDependencies = defaultDependencies, + dependencies: MicrovmInfrastructureDependencies = defaultDependencies, ipPath?: string, -): Promise { +): Promise { const resolved = await inspectInfrastructure(enableApiProxy, dependencies, ipPath); return { ...resolved, @@ -110,7 +111,7 @@ export async function resolveFirecrackerInfrastructure( live.apiProxyIp !== resolved.apiProxyIp ) { throw new Error( - `Firecracker infrastructure topology changed after discovery; ` + + `microVM infrastructure topology changed after discovery; ` + `refusing to attach the microVM`, ); } @@ -120,9 +121,9 @@ export async function resolveFirecrackerInfrastructure( async function inspectInfrastructure( enableApiProxy: boolean, - dependencies: FirecrackerInfrastructureDependencies, + dependencies: MicrovmInfrastructureDependencies, ipPath?: string, -): Promise> { +): Promise> { const raw = await dependencies.inspectNetwork(); if (!Array.isArray(raw) || raw.length !== 1) { throw new Error( @@ -137,7 +138,7 @@ async function inspectInfrastructure( network.Internal !== true ) { throw new Error( - `Unexpected Firecracker infrastructure topology for "${NETWORK_NAME}": ` + + `Unexpected microVM infrastructure topology for "${NETWORK_NAME}": ` + `name=${String(network.Name)} driver=${String(network.Driver)} ` + `scope=${String(network.Scope)} internal=${String(network.Internal)}`, ); @@ -216,14 +217,14 @@ function assertContainerAbsent( ): void { if (containers.some((container) => container.Name === name)) { throw new Error( - `Unexpected Compose agent "${name}" is attached during Firecracker execution`, + `Unexpected Compose agent "${name}" is attached during microVM execution`, ); } } function assertInterfaceName(name: string): void { if (name.length < 1 || name.length > 15 || !/^[A-Za-z0-9_.-]+$/.test(name)) { - throw new Error(`Unsafe Firecracker infrastructure bridge name: ${name}`); + throw new Error(`Unsafe microVM infrastructure bridge name: ${name}`); } } diff --git a/src/firecracker/network.test.ts b/src/microvm/network.test.ts similarity index 88% rename from src/firecracker/network.test.ts rename to src/microvm/network.test.ts index 49f666707..fb6f40653 100644 --- a/src/firecracker/network.test.ts +++ b/src/microvm/network.test.ts @@ -1,39 +1,39 @@ import { - FirecrackerLinuxNetworkCommands, - FirecrackerNetworkManager, - createFirecrackerNetworkPlan, - generateFirecrackerNftRuleset, - type FirecrackerConnectivityProbe, - type FirecrackerNetworkCommandOptions, - type FirecrackerNetworkPlan, + LinuxNetworkCommands, + MicrovmNetworkManager, + createMicrovmNetworkPlan, + generateMicrovmNftRuleset, + type MicrovmConnectivityProbe, + type MicrovmNetworkCommandOptions, + type MicrovmNetworkPlan, } from './network'; interface CommandCall { command: string; args: readonly string[]; - options: FirecrackerNetworkCommandOptions; + options: MicrovmNetworkCommandOptions; } function createPlan( runId = 'run-123', - overrides: Partial[1]> = {}, -): FirecrackerNetworkPlan { - return createFirecrackerNetworkPlan(runId, { + overrides: Partial[1]> = {}, +): MicrovmNetworkPlan { + return createMicrovmNetworkPlan(runId, { infrastructureBridge: 'awfbr0', enableApiProxy: true, - jailerUid: 1000, - jailerGid: 1000, + tapOwnerUid: 1000, + tapOwnerGid: 1000, ...overrides, }); } function commandHarness(failAt?: number): { calls: CommandCall[]; - commands: FirecrackerLinuxNetworkCommands; + commands: LinuxNetworkCommands; } { const calls: CommandCall[] = []; let rejectingCall = 0; - const commands = new FirecrackerLinuxNetworkCommands( + const commands = new LinuxNetworkCommands( jest.fn(async (command, args, options) => { calls.push({ command, args, options }); if (options.reject && ++rejectingCall === failAt) { @@ -44,7 +44,7 @@ function commandHarness(failAt?: number): { return { calls, commands }; } -describe('Firecracker network planning', () => { +describe('microVM network planning', () => { it('allocates deterministic, disjoint per-run guest addressing and bounded names', () => { const first = createPlan('run-123'); const same = createPlan('run-123'); @@ -105,7 +105,7 @@ describe('Firecracker network planning', () => { expect(() => createPlan('bad-bridge', { infrastructureBridge: 'bridge-name-is-too-long', })).toThrow(/IFNAMSIZ/); - expect(() => createPlan('root-owner', { jailerUid: 0 })).toThrow(/uid/); + expect(() => createPlan('root-owner', { tapOwnerUid: 0 })).toThrow(/uid/); expect(() => createPlan('public-peer', { controlPeer: { ip: '8.8.8.8', ports: [443] }, })).toThrow(/RFC1918/); @@ -123,7 +123,7 @@ describe('Firecracker network planning', () => { it('rejects a future centralized infrastructure policy that overlaps the guest link', () => { const plan = createPlan('overlap-defense'); - expect(() => generateFirecrackerNftRuleset({ + expect(() => generateMicrovmNftRuleset({ ...plan, infrastructureCidr: plan.guestSubnet, infrastructureIp: plan.guestIp, @@ -131,10 +131,10 @@ describe('Firecracker network planning', () => { }); }); -describe('Firecracker nftables policy', () => { +describe('microVM nftables policy', () => { it('installs default-drop policy with exact endpoint, identity, and return rules', () => { const plan = createPlan(); - const ruleset = generateFirecrackerNftRuleset(plan); + const ruleset = generateMicrovmNftRuleset(plan); expect(ruleset).toContain(`table inet ${plan.nftTableName}`); expect(ruleset.match(/policy drop;/g)).toHaveLength(3); @@ -161,7 +161,7 @@ describe('Firecracker nftables policy', () => { it('emits SNAT only for the same exact allowed destination pairs', () => { const plan = createPlan('narrow-snat', { enableApiProxy: false }); - const ruleset = generateFirecrackerNftRuleset(plan); + const ruleset = generateMicrovmNftRuleset(plan); const snatLines = ruleset.split('\n').filter((line) => line.includes('snat to')); expect(snatLines).toEqual([ @@ -172,14 +172,14 @@ describe('Firecracker nftables policy', () => { }); }); -describe('Firecracker network lifecycle', () => { +describe('microVM network lifecycle', () => { it('creates the namespace, veth, TAP, forwarding, and atomic policy in order', async () => { const plan = createPlan(); const { calls, commands } = commandHarness(); - const probe: FirecrackerConnectivityProbe = { + const probe: MicrovmConnectivityProbe = { verify: jest.fn().mockResolvedValue(undefined), }; - const manager = new FirecrackerNetworkManager(plan, commands, probe); + const manager = new MicrovmNetworkManager(plan, commands, probe); await expect(manager.setup()).resolves.toBe(plan); @@ -217,7 +217,7 @@ describe('Firecracker network lifecycle', () => { args: ['netns', 'exec', plan.namespaceName, 'nft', '-f', '-'], options: { reject: true, - input: generateFirecrackerNftRuleset(plan), + input: generateMicrovmNftRuleset(plan), }, }); expect(probe.verify).toHaveBeenCalledWith(plan); @@ -229,7 +229,7 @@ describe('Firecracker network lifecycle', () => { for (let failAt = 1; failAt <= setupStageCount; failAt += 1) { const { calls, commands } = commandHarness(failAt); - const manager = new FirecrackerNetworkManager(plan, commands); + const manager = new MicrovmNetworkManager(plan, commands); await expect(manager.setup()).rejects.toThrow(`stage ${failAt} failed`); const cleanupCalls = calls.filter((call) => call.args.includes('delete')); @@ -261,10 +261,10 @@ describe('Firecracker network lifecycle', () => { it('treats a supplied connectivity probe failure as setup failure', async () => { const plan = createPlan('probe-failure'); const { calls, commands } = commandHarness(); - const probe: FirecrackerConnectivityProbe = { + const probe: MicrovmConnectivityProbe = { verify: jest.fn().mockRejectedValue(new Error('proxy unreachable')), }; - const manager = new FirecrackerNetworkManager(plan, commands, probe); + const manager = new MicrovmNetworkManager(plan, commands, probe); await expect(manager.setup()).rejects.toThrow('proxy unreachable'); expect(calls.slice(-1)[0].args).toEqual([ @@ -275,7 +275,7 @@ describe('Firecracker network lifecycle', () => { it('disconnects the host veth before deleting the namespace and its nft policy', async () => { const plan = createPlan('cleanup-twice'); const { calls, commands } = commandHarness(); - const manager = new FirecrackerNetworkManager(plan, commands); + const manager = new MicrovmNetworkManager(plan, commands); await manager.setup(); await manager.cleanup(); @@ -314,7 +314,7 @@ describe('Firecracker network lifecycle', () => { } return originalIp(args, reject); }); - const manager = new FirecrackerNetworkManager(plan, commands); + const manager = new MicrovmNetworkManager(plan, commands); await manager.setup(); await expect(manager.cleanup()).rejects.toThrow('host veth deletion failed'); @@ -349,7 +349,7 @@ describe('Firecracker network lifecycle', () => { } return originalIp(args, reject); }); - const manager = new FirecrackerNetworkManager(plan, commands); + const manager = new MicrovmNetworkManager(plan, commands); await manager.setup(); await expect(manager.cleanup()).rejects.toThrow('namespace deletion failed'); diff --git a/src/firecracker/network.ts b/src/microvm/network.ts similarity index 76% rename from src/firecracker/network.ts rename to src/microvm/network.ts index 9c55ceeef..c8f3d5136 100644 --- a/src/firecracker/network.ts +++ b/src/microvm/network.ts @@ -1,6 +1,5 @@ import { createHash } from 'crypto'; import execa from 'execa'; -import type { FirecrackerHostToolPaths } from './preflight'; import { AGENT_IP, API_PROXY_IP, @@ -10,36 +9,56 @@ import { SQUID_PORT, apiProxyPorts, } from '../config/network-policy'; -import type { FirecrackerNetworkInterface } from './api-client'; const LINUX_INTERFACE_NAME_MAX_LENGTH = 15; -const FIRECRACKER_GUEST_NETWORK_BASE = ipv4ToInteger('100.64.0.0'); -const FIRECRACKER_GUEST_SUBNET_COUNT = 1 << 20; -const FIRECRACKER_GUEST_PREFIX_LENGTH = 30; +const GUEST_NETWORK_BASE = ipv4ToInteger('100.64.0.0'); +const GUEST_SUBNET_COUNT = 1 << 20; +const GUEST_PREFIX_LENGTH = 30; const NETNS_DIRECTORY = '/var/run/netns'; const BLOCKED_LINK_LOCAL_CIDR = '169.254.0.0/16'; const BLOCKED_MULTICAST_CIDR = '224.0.0.0/4'; -export interface FirecrackerAllowedEndpoint { +/** Minimal host tool paths this module needs; a structural subset so callers + * (e.g. Firecracker's preflight-derived tool paths) can pass their own + * richer tool-path record without this module depending on it. */ +export interface MicrovmNetworkHostTools { + readonly ip: string; + readonly nft: string; + readonly sysctl: string; +} + +/** + * Generic tap-device descriptor a VMM's network-interface configuration API + * needs. Field names intentionally match the wire shape already used by + * Firecracker's `PUT /network-interfaces`; a future backend with a + * differently-shaped API translates from this structural descriptor. + */ +export interface MicrovmTapInterface { + readonly iface_id: string; + readonly host_dev_name: string; + readonly guest_mac?: string; +} + +export interface MicrovmAllowedEndpoint { readonly name: string; readonly ip: string; readonly port: number; } -export interface FirecrackerControlPeer { +export interface MicrovmControlPeer { readonly ip: string; readonly ports: readonly number[]; } -export interface FirecrackerNetworkPlanOptions { +export interface MicrovmNetworkPlanOptions { readonly infrastructureBridge: string; readonly enableApiProxy: boolean; - readonly jailerUid: number; - readonly jailerGid: number; - readonly controlPeer?: FirecrackerControlPeer; + readonly tapOwnerUid: number; + readonly tapOwnerGid: number; + readonly controlPeer?: MicrovmControlPeer; } -export interface FirecrackerNetworkPlan { +export interface MicrovmNetworkPlan { readonly runId: string; readonly namespaceName: string; readonly netnsPath: string; @@ -56,28 +75,28 @@ export interface FirecrackerNetworkPlan { readonly guestGatewayIp: string; readonly guestPrefixLength: number; readonly guestMac: string; - readonly jailerUid: number; - readonly jailerGid: number; - readonly allowedEndpoints: readonly FirecrackerAllowedEndpoint[]; - readonly networkInterface: FirecrackerNetworkInterface; + readonly tapOwnerUid: number; + readonly tapOwnerGid: number; + readonly allowedEndpoints: readonly MicrovmAllowedEndpoint[]; + readonly networkInterface: MicrovmTapInterface; } -export interface FirecrackerConnectivityProbe { - verify(plan: FirecrackerNetworkPlan): Promise; +export interface MicrovmConnectivityProbe { + verify(plan: MicrovmNetworkPlan): Promise; } -export interface FirecrackerNetworkCommandOptions { +export interface MicrovmNetworkCommandOptions { readonly reject: boolean; readonly input?: string; } -export type FirecrackerNetworkCommandExecutor = ( +export type MicrovmNetworkCommandExecutor = ( command: string, args: readonly string[], - options: FirecrackerNetworkCommandOptions, + options: MicrovmNetworkCommandOptions, ) => Promise; -const defaultCommandExecutor: FirecrackerNetworkCommandExecutor = async ( +const defaultCommandExecutor: MicrovmNetworkCommandExecutor = async ( command, args, options, @@ -88,10 +107,10 @@ const defaultCommandExecutor: FirecrackerNetworkCommandExecutor = async ( /** * Dependency-injected argv-only Linux networking operations. */ -export class FirecrackerLinuxNetworkCommands { +export class LinuxNetworkCommands { constructor( - private readonly execute: FirecrackerNetworkCommandExecutor = defaultCommandExecutor, - private readonly tools: Pick = { + private readonly execute: MicrovmNetworkCommandExecutor = defaultCommandExecutor, + private readonly tools: MicrovmNetworkHostTools = { ip: 'ip', nft: 'nft', sysctl: 'sysctl', @@ -136,27 +155,27 @@ export class FirecrackerLinuxNetworkCommands { } } -export interface FirecrackerNetworkLifecycle { - readonly plan: FirecrackerNetworkPlan; - setup(): Promise; +export interface MicrovmNetworkLifecycle { + readonly plan: MicrovmNetworkPlan; + setup(): Promise; cleanup(): Promise; } /** - * Owns the host-side network resources for exactly one Firecracker run. + * Owns the host-side network resources for exactly one microVM run. */ -export class FirecrackerNetworkManager implements FirecrackerNetworkLifecycle { +export class MicrovmNetworkManager implements MicrovmNetworkLifecycle { private setupComplete = false; private namespaceCreated = false; private hostVethCreated = false; constructor( - readonly plan: FirecrackerNetworkPlan, - private readonly commands = new FirecrackerLinuxNetworkCommands(), - private readonly probe?: FirecrackerConnectivityProbe, + readonly plan: MicrovmNetworkPlan, + private readonly commands = new LinuxNetworkCommands(), + private readonly probe?: MicrovmConnectivityProbe, ) {} - async setup(): Promise { + async setup(): Promise { if (this.setupComplete) return this.plan; try { @@ -182,8 +201,8 @@ export class FirecrackerNetworkManager implements FirecrackerNetworkLifecycle { 'tuntap', 'add', 'dev', this.plan.tapName, 'mode', 'tap', - 'user', String(this.plan.jailerUid), - 'group', String(this.plan.jailerGid), + 'user', String(this.plan.tapOwnerUid), + 'group', String(this.plan.tapOwnerGid), ]); await this.commands.ipInNamespace(this.plan.namespaceName, [ 'addr', 'add', @@ -222,7 +241,7 @@ export class FirecrackerNetworkManager implements FirecrackerNetworkLifecycle { await this.commands.nftInNamespace( this.plan.namespaceName, ['-f', '-'], - generateFirecrackerNftRuleset(this.plan), + generateMicrovmNftRuleset(this.plan), ); await this.probe?.verify(this.plan); this.setupComplete = true; @@ -232,7 +251,7 @@ export class FirecrackerNetworkManager implements FirecrackerNetworkLifecycle { await this.cleanup(); } catch (cleanupError) { throw new Error( - `Firecracker network setup failed: ${formatError(error)}; ` + + `microVM network setup failed: ${formatError(error)}; ` + `rollback also failed: ${formatError(cleanupError)}`, ); } @@ -266,25 +285,25 @@ export class FirecrackerNetworkManager implements FirecrackerNetworkLifecycle { if (errors.length > 0) { throw new Error( - `Failed to clean up Firecracker network: ${errors.map(formatError).join('; ')}`, + `Failed to clean up microVM network: ${errors.map(formatError).join('; ')}`, ); } } } -export function createFirecrackerNetworkPlan( +export function createMicrovmNetworkPlan( runId: string, - options: FirecrackerNetworkPlanOptions, -): FirecrackerNetworkPlan { - assertSafeFirecrackerRunId(runId); + options: MicrovmNetworkPlanOptions, +): MicrovmNetworkPlan { + assertSafeMicrovmRunId(runId); assertInterfaceName(options.infrastructureBridge, 'infrastructure bridge'); - assertPositiveIdentity(options.jailerUid, 'jailer uid'); - assertPositiveIdentity(options.jailerGid, 'jailer gid'); + assertPositiveIdentity(options.tapOwnerUid, 'tap owner uid'); + assertPositiveIdentity(options.tapOwnerGid, 'tap owner gid'); const digest = createHash('sha256').update(runId).digest(); const token = digest.toString('hex').slice(0, 12); - const subnetIndex = digest.readUInt32BE(0) & (FIRECRACKER_GUEST_SUBNET_COUNT - 1); - const subnetBase = FIRECRACKER_GUEST_NETWORK_BASE + subnetIndex * 4; + const subnetIndex = digest.readUInt32BE(0) & (GUEST_SUBNET_COUNT - 1); + const subnetBase = GUEST_NETWORK_BASE + subnetIndex * 4; const guestGatewayIp = integerToIpv4(subnetBase + 1); const guestIp = integerToIpv4(subnetBase + 2); const guestMac = [ @@ -313,7 +332,7 @@ export function createFirecrackerNetworkPlan( options.enableApiProxy, options.controlPeer, ); - const plan: FirecrackerNetworkPlan = { + const plan: MicrovmNetworkPlan = { runId, namespaceName, netnsPath: `${NETNS_DIRECTORY}/${namespaceName}`, @@ -325,13 +344,13 @@ export function createFirecrackerNetworkPlan( infrastructureIp: AGENT_IP, infrastructureCidr: NETWORK_SUBNET, hostGatewayIp: HOST_GATEWAY, - guestSubnet: `${integerToIpv4(subnetBase)}/${FIRECRACKER_GUEST_PREFIX_LENGTH}`, + guestSubnet: `${integerToIpv4(subnetBase)}/${GUEST_PREFIX_LENGTH}`, guestIp, guestGatewayIp, - guestPrefixLength: FIRECRACKER_GUEST_PREFIX_LENGTH, + guestPrefixLength: GUEST_PREFIX_LENGTH, guestMac, - jailerUid: options.jailerUid, - jailerGid: options.jailerGid, + tapOwnerUid: options.tapOwnerUid, + tapOwnerGid: options.tapOwnerGid, allowedEndpoints, networkInterface: { iface_id: 'eth0', @@ -343,7 +362,7 @@ export function createFirecrackerNetworkPlan( return plan; } -export function generateFirecrackerNftRuleset(plan: FirecrackerNetworkPlan): string { +export function generateMicrovmNftRuleset(plan: MicrovmNetworkPlan): string { validatePlan(plan); const allowRules = plan.allowedEndpoints.flatMap((endpoint) => [ ` iifname "${plan.tapName}" oifname "${plan.namespaceVethName}" ` + @@ -396,9 +415,9 @@ export function generateFirecrackerNftRuleset(plan: FirecrackerNetworkPlan): str function createAllowedEndpoints( enableApiProxy: boolean, - controlPeer?: FirecrackerControlPeer, -): readonly FirecrackerAllowedEndpoint[] { - const endpoints: FirecrackerAllowedEndpoint[] = [{ + controlPeer?: MicrovmControlPeer, +): readonly MicrovmAllowedEndpoint[] { + const endpoints: MicrovmAllowedEndpoint[] = [{ name: 'squid', ip: SQUID_IP, port: SQUID_PORT, @@ -422,16 +441,16 @@ function createAllowedEndpoints( isInCidr(controlPeer.ip, BLOCKED_MULTICAST_CIDR) ) { throw new Error( - `Unsafe Firecracker control peer IP outside ${NETWORK_SUBNET}: ${controlPeer.ip}`, + `Unsafe microVM control peer IP outside ${NETWORK_SUBNET}: ${controlPeer.ip}`, ); } if (controlPeer.ports.length === 0) { - throw new Error('Firecracker control peer must specify at least one TCP port'); + throw new Error('microVM control peer must specify at least one TCP port'); } for (const port of controlPeer.ports) { assertPort(port, 'control peer port'); if (port === 53) { - throw new Error('Firecracker control peer cannot enable direct DNS'); + throw new Error('microVM control peer cannot enable direct DNS'); } endpoints.push({ name: 'control-peer', ip: controlPeer.ip, port }); } @@ -446,8 +465,8 @@ function createAllowedEndpoints( }); } -function validatePlan(plan: FirecrackerNetworkPlan): void { - assertSafeFirecrackerRunId(plan.runId); +function validatePlan(plan: MicrovmNetworkPlan): void { + assertSafeMicrovmRunId(plan.runId); assertSafeObjectName(plan.namespaceName, 'network namespace'); assertSafeObjectName(plan.nftTableName, 'nftables table'); assertInterfaceName(plan.infrastructureBridge, 'infrastructure bridge'); @@ -467,7 +486,7 @@ function validatePlan(plan: FirecrackerNetworkPlan): void { isInCidr(infrastructureNetworkIp, plan.guestSubnet) ) { throw new Error( - `Firecracker guest subnet overlaps infrastructure: ` + + `microVM guest subnet overlaps infrastructure: ` + `${plan.guestSubnet} and ${plan.infrastructureCidr}`, ); } @@ -482,7 +501,7 @@ function validatePlan(plan: FirecrackerNetworkPlan): void { )) )) ) { - throw new Error(`Unsafe Firecracker guest MAC: ${plan.guestMac}`); + throw new Error(`Unsafe microVM guest MAC: ${plan.guestMac}`); } for (const endpoint of plan.allowedEndpoints) { assertSafeObjectName(endpoint.name, 'endpoint name'); @@ -490,21 +509,21 @@ function validatePlan(plan: FirecrackerNetworkPlan): void { assertPort(endpoint.port, 'endpoint port'); if (isInCidr(endpoint.ip, plan.guestSubnet)) { throw new Error( - `Firecracker endpoint ${endpoint.ip}:${endpoint.port} overlaps the guest subnet`, + `microVM endpoint ${endpoint.ip}:${endpoint.port} overlaps the guest subnet`, ); } } } -export function assertSafeFirecrackerRunId(runId: string): void { +export function assertSafeMicrovmRunId(runId: string): void { if (runId.length < 1 || runId.length > 64 || !/^[A-Za-z0-9-]+$/.test(runId)) { - throw new Error(`Unsafe Firecracker run id: ${runId}`); + throw new Error(`Unsafe microVM run id: ${runId}`); } } function assertSafeObjectName(value: string, label: string): void { if (!/^[A-Za-z0-9_.-]+$/.test(value)) { - throw new Error(`Unsafe Firecracker ${label}: ${value}`); + throw new Error(`Unsafe microVM ${label}: ${value}`); } } @@ -512,20 +531,20 @@ function assertInterfaceName(value: string, label: string): void { assertSafeObjectName(value, label); if (value.length > LINUX_INTERFACE_NAME_MAX_LENGTH) { throw new Error( - `Firecracker ${label} exceeds Linux IFNAMSIZ: ${value}`, + `microVM ${label} exceeds Linux IFNAMSIZ: ${value}`, ); } } function assertPositiveIdentity(value: number, label: string): void { if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`Firecracker ${label} must be a positive integer`); + throw new Error(`microVM ${label} must be a positive integer`); } } function assertPort(value: number, label: string): void { if (!Number.isInteger(value) || value < 1 || value > 65_535) { - throw new Error(`Firecracker ${label} must be an integer in 1-65535`); + throw new Error(`microVM ${label} must be an integer in 1-65535`); } } @@ -536,7 +555,7 @@ function assertPrivateIpv4(value: string, label: string): void { !isInCidr(value, '172.16.0.0/12') && !isInCidr(value, '192.168.0.0/16') ) { - throw new Error(`Firecracker ${label} must be an RFC1918 address: ${value}`); + throw new Error(`microVM ${label} must be an RFC1918 address: ${value}`); } } @@ -553,7 +572,7 @@ function assertIpv4(value: string, label: string): void { Number(octet) > 255 )) ) { - throw new Error(`Invalid Firecracker ${label}: ${value}`); + throw new Error(`Invalid microVM ${label}: ${value}`); } } @@ -562,7 +581,7 @@ function assertCidr(value: string, label: string): void { assertIpv4(address, label); const prefix = Number(rawPrefix); if (extra !== undefined || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { - throw new Error(`Invalid Firecracker ${label}: ${value}`); + throw new Error(`Invalid microVM ${label}: ${value}`); } } diff --git a/src/firecracker/vsock-client.test.ts b/src/microvm/vsock-client.test.ts similarity index 87% rename from src/firecracker/vsock-client.test.ts rename to src/microvm/vsock-client.test.ts index 0f447096f..d14880d2e 100644 --- a/src/firecracker/vsock-client.test.ts +++ b/src/microvm/vsock-client.test.ts @@ -4,16 +4,16 @@ import * as os from 'os'; import * as path from 'path'; import { PassThrough, Writable } from 'stream'; import { - FIRECRACKER_GUEST_PROTOCOL_VERSION, - FIRECRACKER_MAX_STREAM_CHUNK_BYTES, - FirecrackerFrameDecoder, - encodeFirecrackerFrame, - type FirecrackerGuestFrame, -} from './vsock-protocol'; -import { FirecrackerGuestError, FirecrackerVsockClient } from './vsock-client'; + GUEST_PROTOCOL_VERSION, + GUEST_MAX_STREAM_CHUNK_BYTES, + GuestFrameDecoder, + encodeGuestFrame, + type GuestProtocolFrame, +} from './guest-protocol'; +import { GuestExecutionError, MicrovmVsockClient } from './vsock-client'; async function createServer( - handler: (frame: FirecrackerGuestFrame, socket: net.Socket) => void, + handler: (frame: GuestProtocolFrame, socket: net.Socket) => void, capabilities = { stdin: true, tty: false, resize: false }, ): Promise<{ socketPath: string; close(): Promise }> { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-vsock-')); @@ -21,7 +21,7 @@ async function createServer( const server = net.createServer((socket) => { let handshaken = false; let handshake = Buffer.alloc(0); - const decoder = new FirecrackerFrameDecoder(); + const decoder = new GuestFrameDecoder(); socket.on('data', (chunk: Buffer) => { if (!handshaken) { handshake = Buffer.concat([handshake, chunk]); @@ -30,8 +30,8 @@ async function createServer( expect(handshake.subarray(0, newline).toString()).toBe('CONNECT 52'); handshaken = true; socket.write('OK 1234\n'); - socket.write(encodeFirecrackerFrame({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + socket.write(encodeGuestFrame({ + version: GUEST_PROTOCOL_VERSION, type: 'ready', requestId: 'control', capabilities, @@ -79,20 +79,20 @@ async function createRawServer( }; } -describe('FirecrackerVsockClient', () => { +describe('MicrovmVsockClient', () => { it('streams output, stdin, and exact terminal status', async () => { - const received: FirecrackerGuestFrame[] = []; + const received: GuestProtocolFrame[] = []; const server = await createServer((frame, socket) => { received.push(frame); if (frame.type === 'execute') { socket.write(Buffer.concat([ - encodeFirecrackerFrame({ + encodeGuestFrame({ version: 1, type: 'stdout', requestId: frame.requestId, data: Buffer.from('hello').toString('base64'), }), - encodeFirecrackerFrame({ + encodeGuestFrame({ version: 1, type: 'stderr', requestId: frame.requestId, @@ -101,7 +101,7 @@ describe('FirecrackerVsockClient', () => { ])); } if (frame.type === 'stdin' && frame.eof) { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'result', requestId: frame.requestId, @@ -111,7 +111,7 @@ describe('FirecrackerVsockClient', () => { })); } }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -155,7 +155,7 @@ describe('FirecrackerVsockClient', () => { it('cancels at the host deadline and deterministically returns 124', async () => { const server = await createServer((frame, socket) => { if (frame.type === 'cancel') { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'result', requestId: frame.requestId, @@ -165,7 +165,7 @@ describe('FirecrackerVsockClient', () => { })); } }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, cancellationGraceMs: 100, @@ -191,7 +191,7 @@ describe('FirecrackerVsockClient', () => { const server = await createServer((frame, socket) => { if (frame.type === 'execute') socket.destroy(); }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -209,7 +209,7 @@ describe('FirecrackerVsockClient', () => { it('preserves numeric fallback signal exit status from the guest', async () => { const server = await createServer((frame, socket) => { if (frame.type === 'execute') { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'result', requestId: frame.requestId, @@ -219,7 +219,7 @@ describe('FirecrackerVsockClient', () => { })); } }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -241,7 +241,7 @@ describe('FirecrackerVsockClient', () => { it('requires advertised TTY capability', async () => { const server = await createServer(() => undefined); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -262,14 +262,14 @@ describe('FirecrackerVsockClient', () => { it('uses an acknowledged shutdown frame before closing the transport', async () => { const server = await createServer((frame, socket) => { if (frame.type === 'shutdown') { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'shutting_down', requestId: frame.requestId, })); } }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -283,7 +283,7 @@ describe('FirecrackerVsockClient', () => { const server = await createServer((frame, socket) => { if (frame.type !== 'execute') return; execution += 1; - const result = encodeFirecrackerFrame({ + const result = encodeGuestFrame({ version: 1, type: 'result', requestId: frame.requestId, @@ -297,7 +297,7 @@ describe('FirecrackerVsockClient', () => { socket.write(result.subarray(0, 2)); } }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, readTimeoutMs: 10, @@ -323,11 +323,11 @@ describe('FirecrackerVsockClient', () => { }); it('guards disconnected control methods and invalid ports', async () => { - expect(() => new FirecrackerVsockClient({ + expect(() => new MicrovmVsockClient({ socketPath: '/tmp/unused', guestPort: 0, })).toThrow(/1-65535/); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: '/tmp/unused', guestPort: 52, }); @@ -348,11 +348,11 @@ describe('FirecrackerVsockClient', () => { }); it('supports chunked stdin, cancellation, resize, and pending request guards', async () => { - const received: FirecrackerGuestFrame[] = []; + const received: GuestProtocolFrame[] = []; const server = await createServer((frame, socket) => { received.push(frame); if (frame.type === 'stdin' && frame.eof) { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'result', requestId: frame.requestId, @@ -362,14 +362,14 @@ describe('FirecrackerVsockClient', () => { })); } if (frame.type === 'shutdown') { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'shutting_down', requestId: frame.requestId, })); } }, { stdin: true, tty: false, resize: true }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -392,7 +392,7 @@ describe('FirecrackerVsockClient', () => { await expect(client.shutdown()).rejects.toThrow(/while a request is running/); await client.resize(100, 40); await client.cancel('manual cancellation'); - await client.writeStdin(Buffer.alloc(FIRECRACKER_MAX_STREAM_CHUNK_BYTES + 1, 1)); + await client.writeStdin(Buffer.alloc(GUEST_MAX_STREAM_CHUNK_BYTES + 1, 1)); await client.endStdin(); await expect(execution).resolves.toEqual(expect.objectContaining({ exitCode: 0 })); @@ -409,7 +409,7 @@ describe('FirecrackerVsockClient', () => { it('returns 124 when cancellation grace expires without a guest result', async () => { const server = await createServer(() => undefined); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, cancellationGraceMs: 5, @@ -437,7 +437,7 @@ describe('FirecrackerVsockClient', () => { const server = await createServer((frame, socket) => { if (frame.type !== 'execute') return; request += 1; - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'error', requestId: request === 1 ? frame.requestId : 'different', @@ -445,7 +445,7 @@ describe('FirecrackerVsockClient', () => { message: 'rejected', })); }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -457,7 +457,7 @@ describe('FirecrackerVsockClient', () => { cwd: '/workspace', uid: 1000, gid: 1000, - })).rejects.toBeInstanceOf(FirecrackerGuestError); + })).rejects.toBeInstanceOf(GuestExecutionError); await expect(client.execute({ requestId: 'unexpected', argv: ['false'], @@ -473,7 +473,7 @@ describe('FirecrackerVsockClient', () => { const invalid = await createRawServer((socket) => { socket.once('data', () => setTimeout(() => socket.write('DENIED\n'), 5)); }); - const invalidClient = new FirecrackerVsockClient({ + const invalidClient = new MicrovmVsockClient({ socketPath: invalid.socketPath, guestPort: 52, connectTimeoutMs: 100, @@ -485,7 +485,7 @@ describe('FirecrackerVsockClient', () => { const oversized = await createRawServer((socket) => { socket.once('data', () => setTimeout(() => socket.write('x'.repeat(129)), 5)); }); - const oversizedClient = new FirecrackerVsockClient({ + const oversizedClient = new MicrovmVsockClient({ socketPath: oversized.socketPath, guestPort: 52, connectTimeoutMs: 100, @@ -495,7 +495,7 @@ describe('FirecrackerVsockClient', () => { await oversized.close(); const silent = await createRawServer(() => undefined); - const silentClient = new FirecrackerVsockClient({ + const silentClient = new MicrovmVsockClient({ socketPath: silent.socketPath, guestPort: 52, connectTimeoutMs: 5, @@ -507,7 +507,7 @@ describe('FirecrackerVsockClient', () => { const disconnected = await createRawServer((socket) => { socket.once('data', () => setTimeout(() => socket.destroy(), 5)); }); - const disconnectedClient = new FirecrackerVsockClient({ + const disconnectedClient = new MicrovmVsockClient({ socketPath: disconnected.socketPath, guestPort: 52, connectTimeoutMs: 100, @@ -519,14 +519,14 @@ describe('FirecrackerVsockClient', () => { it('rejects unexpected guest frames and request identifiers', async () => { const unexpected = await createServer((frame, socket) => { if (frame.type === 'execute') { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'shutdown', requestId: frame.requestId, })); } }); - const unexpectedClient = new FirecrackerVsockClient({ + const unexpectedClient = new MicrovmVsockClient({ socketPath: unexpected.socketPath, guestPort: 52, }); @@ -543,7 +543,7 @@ describe('FirecrackerVsockClient', () => { const mismatched = await createServer((frame, socket) => { if (frame.type === 'execute') { - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'stdout', requestId: 'different', @@ -551,7 +551,7 @@ describe('FirecrackerVsockClient', () => { })); } }); - const mismatchedClient = new FirecrackerVsockClient({ + const mismatchedClient = new MicrovmVsockClient({ socketPath: mismatched.socketPath, guestPort: 52, }); @@ -563,20 +563,20 @@ describe('FirecrackerVsockClient', () => { cwd: '/workspace', uid: 1000, gid: 1000, - })).rejects.toThrow(/Unexpected Firecracker guest request id/); + })).rejects.toThrow(/Unexpected guest request id/); await mismatched.close(); }); it('honors output backpressure and unknown signal fallback status', async () => { const server = await createServer((frame, socket) => { if (frame.type !== 'execute') return; - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'stdout', requestId: frame.requestId, data: Buffer.alloc(1024, 1).toString('base64'), })); - socket.write(encodeFirecrackerFrame({ + socket.write(encodeGuestFrame({ version: 1, type: 'result', requestId: frame.requestId, @@ -591,7 +591,7 @@ describe('FirecrackerVsockClient', () => { setImmediate(callback); }, }); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); @@ -616,7 +616,7 @@ describe('FirecrackerVsockClient', () => { it('rejects writes after a connected transport is destroyed', async () => { const server = await createServer(() => undefined); - const client = new FirecrackerVsockClient({ + const client = new MicrovmVsockClient({ socketPath: server.socketPath, guestPort: 52, }); diff --git a/src/firecracker/vsock-client.ts b/src/microvm/vsock-client.ts similarity index 69% rename from src/firecracker/vsock-client.ts rename to src/microvm/vsock-client.ts index 9a6655756..f75d97aae 100644 --- a/src/firecracker/vsock-client.ts +++ b/src/microvm/vsock-client.ts @@ -3,21 +3,21 @@ import * as net from 'net'; import { constants as osConstants } from 'os'; import type { Writable } from 'stream'; import { - FIRECRACKER_GUEST_PROTOCOL_VERSION, - FIRECRACKER_MAX_STREAM_CHUNK_BYTES, - FirecrackerFrameDecoder, - FirecrackerProtocolError, - encodeFirecrackerFrame, - type FirecrackerErrorFrame, - type FirecrackerExecuteFrame, - type FirecrackerGuestFrame, - type FirecrackerReadyFrame, - type FirecrackerResultFrame, -} from './vsock-protocol'; + GUEST_PROTOCOL_VERSION, + GUEST_MAX_STREAM_CHUNK_BYTES, + GuestFrameDecoder, + GuestProtocolError, + encodeGuestFrame, + type GuestErrorFrame, + type GuestExecuteFrame, + type GuestProtocolFrame, + type GuestReadyFrame, + type GuestResultFrame, +} from './guest-protocol'; -const FIRECRACKER_VSOCK_HANDSHAKE_LIMIT = 128; +const GUEST_VSOCK_HANDSHAKE_LIMIT = 128; -export interface FirecrackerVsockClientOptions { +export interface MicrovmVsockClientOptions { readonly socketPath: string; readonly guestPort: number; readonly connectTimeoutMs?: number; @@ -26,7 +26,7 @@ export interface FirecrackerVsockClientOptions { readonly cancellationGraceMs?: number; } -export interface FirecrackerGuestExecutionRequest { +export interface GuestExecutionRequest { readonly argv: readonly string[]; readonly env: Readonly>; readonly cwd: string; @@ -39,7 +39,7 @@ export interface FirecrackerGuestExecutionRequest { readonly stderr?: Writable; } -export interface FirecrackerGuestExecutionResult { +export interface GuestExecutionResult { readonly requestId: string; readonly exitCode: number; readonly signal: string | null; @@ -50,37 +50,40 @@ interface PendingExecution { readonly requestId: string; readonly stdout?: Writable; readonly stderr?: Writable; - readonly resolve: (result: FirecrackerGuestExecutionResult) => void; + readonly resolve: (result: GuestExecutionResult) => void; readonly reject: (error: Error) => void; hostTimedOut: boolean; timeout?: NodeJS.Timeout; cancellation?: NodeJS.Timeout; } -export class FirecrackerGuestError extends Error { - constructor(readonly frame: FirecrackerErrorFrame) { - super(`Firecracker guest ${frame.code}: ${frame.message}`); - this.name = 'FirecrackerGuestError'; +export class GuestExecutionError extends Error { + constructor(readonly frame: GuestErrorFrame) { + super(`guest ${frame.code}: ${frame.message}`); + this.name = 'GuestExecutionError'; } } /** - * Host endpoint for Firecracker's CONNECT-over-UDS vsock mapping. + * Host endpoint for a VMM's CONNECT-over-UDS vsock mapping (the convention + * used by Firecracker and other VMMs that expose vsock via a host UDS + * socket). Speaks the AWF framed guest protocol once the handshake + * completes; independent of which VMM backend owns the socket. */ -export class FirecrackerVsockClient { +export class MicrovmVsockClient { private readonly connectTimeoutMs: number; private readonly readTimeoutMs: number; private readonly writeTimeoutMs: number; private readonly cancellationGraceMs: number; - private readonly decoder = new FirecrackerFrameDecoder(); + private readonly decoder = new GuestFrameDecoder(); private socket: net.Socket | undefined; - private ready: FirecrackerReadyFrame | undefined; + private ready: GuestReadyFrame | undefined; private pending: PendingExecution | undefined; private handshakeComplete = false; private handshakeBuffer = Buffer.alloc(0); private processing = Promise.resolve(); private readyWaiter: { - resolve: (frame: FirecrackerReadyFrame) => void; + resolve: (frame: GuestReadyFrame) => void; reject: (error: Error) => void; } | undefined; private shutdownWaiter: { @@ -89,9 +92,9 @@ export class FirecrackerVsockClient { } | undefined; private frameReadTimeout: NodeJS.Timeout | undefined; - constructor(private readonly options: FirecrackerVsockClientOptions) { + constructor(private readonly options: MicrovmVsockClientOptions) { if (!Number.isInteger(options.guestPort) || options.guestPort < 1 || options.guestPort > 65_535) { - throw new Error(`Firecracker guest vsock port must be in 1-65535: ${options.guestPort}`); + throw new Error(`guest vsock port must be in 1-65535: ${options.guestPort}`); } this.connectTimeoutMs = options.connectTimeoutMs ?? 5_000; this.readTimeoutMs = options.readTimeoutMs ?? 30_000; @@ -99,8 +102,8 @@ export class FirecrackerVsockClient { this.cancellationGraceMs = options.cancellationGraceMs ?? 2_000; } - async connect(): Promise { - if (this.socket) throw new Error('Firecracker guest vsock client is already connected'); + async connect(): Promise { + if (this.socket) throw new Error('guest vsock client is already connected'); const socket = net.createConnection({ path: this.options.socketPath }); this.socket = socket; socket.on('data', (chunk: Buffer) => this.onData(chunk)); @@ -112,12 +115,12 @@ export class FirecrackerVsockClient { socket.once('error', reject); }), this.connectTimeoutMs, - `Firecracker guest vsock UDS connect timed out after ${this.connectTimeoutMs}ms`, + `guest vsock UDS connect timed out after ${this.connectTimeoutMs}ms`, ); await this.writeRaw(Buffer.from(`CONNECT ${this.options.guestPort}\n`, 'ascii')); return withTimeout( - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { this.readyWaiter = { resolve, reject }; if (this.ready) { this.readyWaiter = undefined; @@ -125,26 +128,26 @@ export class FirecrackerVsockClient { } }), this.connectTimeoutMs, - `Firecracker guest readiness timed out after ${this.connectTimeoutMs}ms`, + `guest readiness timed out after ${this.connectTimeoutMs}ms`, ); } - execute(request: FirecrackerGuestExecutionRequest): Promise { + execute(request: GuestExecutionRequest): Promise { if (!this.ready || !this.socket) { - return Promise.reject(new Error('Firecracker guest supervisor is not ready')); + return Promise.reject(new Error('guest supervisor is not ready')); } if (this.pending) { return Promise.reject(new Error( - `Firecracker guest request ${this.pending.requestId} is still running`, + `guest request ${this.pending.requestId} is still running`, )); } if (request.tty && !this.ready.capabilities.tty) { - return Promise.reject(new Error('Firecracker guest supervisor does not support TTY execution')); + return Promise.reject(new Error('guest supervisor does not support TTY execution')); } const requestId = request.requestId ?? `exec-${process.pid}-${randomBytes(8).toString('hex')}`; - const frame: FirecrackerExecuteFrame = { - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + const frame: GuestExecuteFrame = { + version: GUEST_PROTOCOL_VERSION, type: 'execute', requestId, argv: request.argv, @@ -156,7 +159,7 @@ export class FirecrackerVsockClient { ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }), }; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const pending: PendingExecution = { requestId, stdout: request.stdout, @@ -170,7 +173,7 @@ export class FirecrackerVsockClient { pending.timeout = setTimeout(() => { pending.hostTimedOut = true; void this.send({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'cancel', requestId, reason: `host timeout after ${request.timeoutMs}ms`, @@ -178,7 +181,7 @@ export class FirecrackerVsockClient { pending.cancellation = setTimeout(() => { if (this.pending !== pending) return; this.completePending({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'result', requestId, exitCode: 124, @@ -194,22 +197,22 @@ export class FirecrackerVsockClient { } async writeStdin(data: Buffer, requestId = this.pending?.requestId): Promise { - if (!requestId) throw new Error('No active Firecracker guest request'); - for (let offset = 0; offset < data.length; offset += FIRECRACKER_MAX_STREAM_CHUNK_BYTES) { + if (!requestId) throw new Error('No active guest request'); + for (let offset = 0; offset < data.length; offset += GUEST_MAX_STREAM_CHUNK_BYTES) { await this.send({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'stdin', requestId, - data: data.subarray(offset, offset + FIRECRACKER_MAX_STREAM_CHUNK_BYTES) + data: data.subarray(offset, offset + GUEST_MAX_STREAM_CHUNK_BYTES) .toString('base64'), }); } } endStdin(requestId = this.pending?.requestId): Promise { - if (!requestId) return Promise.reject(new Error('No active Firecracker guest request')); + if (!requestId) return Promise.reject(new Error('No active guest request')); return this.send({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'stdin', requestId, eof: true, @@ -217,9 +220,9 @@ export class FirecrackerVsockClient { } cancel(reason = 'host cancellation', requestId = this.pending?.requestId): Promise { - if (!requestId) return Promise.reject(new Error('No active Firecracker guest request')); + if (!requestId) return Promise.reject(new Error('No active guest request')); return this.send({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'cancel', requestId, reason, @@ -227,12 +230,12 @@ export class FirecrackerVsockClient { } resize(columns: number, rows: number, requestId = this.pending?.requestId): Promise { - if (!requestId) return Promise.reject(new Error('No active Firecracker guest request')); + if (!requestId) return Promise.reject(new Error('No active guest request')); if (!this.ready?.capabilities.resize) { - return Promise.reject(new Error('Firecracker guest supervisor does not support TTY resize')); + return Promise.reject(new Error('guest supervisor does not support TTY resize')); } return this.send({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'resize', requestId, columns, @@ -242,17 +245,17 @@ export class FirecrackerVsockClient { async shutdown(): Promise { if (!this.socket) return; - if (this.pending) throw new Error('Cannot shut down Firecracker guest while a request is running'); + if (this.pending) throw new Error('Cannot shut down guest while a request is running'); const requestId = 'shutdown'; const acknowledgment = withTimeout( new Promise((resolve, reject) => { this.shutdownWaiter = { resolve, reject }; }), this.connectTimeoutMs, - `Firecracker guest shutdown acknowledgment timed out after ${this.connectTimeoutMs}ms`, + `guest shutdown acknowledgment timed out after ${this.connectTimeoutMs}ms`, ); await this.send({ - version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + version: GUEST_PROTOCOL_VERSION, type: 'shutdown', requestId, }); @@ -271,14 +274,14 @@ export class FirecrackerVsockClient { let protocolData = chunk; if (!this.handshakeComplete) { this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, chunk]); - if (this.handshakeBuffer.length > FIRECRACKER_VSOCK_HANDSHAKE_LIMIT) { - throw new Error('Firecracker vsock CONNECT response exceeded 128 bytes'); + if (this.handshakeBuffer.length > GUEST_VSOCK_HANDSHAKE_LIMIT) { + throw new Error('vsock CONNECT response exceeded 128 bytes'); } const newline = this.handshakeBuffer.indexOf(0x0a); if (newline === -1) return; const response = this.handshakeBuffer.subarray(0, newline).toString('ascii'); if (!/^OK(?: \d+)?$/.test(response)) { - throw new Error(`Firecracker vsock CONNECT failed: ${response}`); + throw new Error(`vsock CONNECT failed: ${response}`); } this.handshakeComplete = true; protocolData = this.handshakeBuffer.subarray(newline + 1); @@ -292,23 +295,23 @@ export class FirecrackerVsockClient { if (this.decoder.pendingBytes > 0) { this.frameReadTimeout = setTimeout(() => { this.fail(new Error( - `Firecracker guest frame read timed out after ${this.readTimeoutMs}ms`, + `guest frame read timed out after ${this.readTimeoutMs}ms`, )); }, this.readTimeoutMs); } }).catch((error) => this.fail(toError(error))); } - private async handleFrame(frame: FirecrackerGuestFrame): Promise { + private async handleFrame(frame: GuestProtocolFrame): Promise { if (frame.type === 'ready') { - if (this.ready) throw new FirecrackerProtocolError('invalid_frame', 'Duplicate ready frame'); + if (this.ready) throw new GuestProtocolError('invalid_frame', 'Duplicate ready frame'); this.ready = frame; this.readyWaiter?.resolve(frame); this.readyWaiter = undefined; return; } if (frame.type === 'error') { - const error = new FirecrackerGuestError(frame); + const error = new GuestExecutionError(frame); if (this.pending?.requestId === frame.requestId) { this.rejectPending(error); } else { @@ -332,23 +335,23 @@ export class FirecrackerVsockClient { this.shutdownWaiter = undefined; return; } - throw new FirecrackerProtocolError( + throw new GuestProtocolError( 'invalid_frame', - `Unexpected ${frame.type} frame from Firecracker guest`, + `Unexpected ${frame.type} frame from guest`, ); } private requirePending(requestId: string): PendingExecution { if (!this.pending || this.pending.requestId !== requestId) { - throw new FirecrackerProtocolError( + throw new GuestProtocolError( 'request_not_found', - `Unexpected Firecracker guest request id: ${requestId}`, + `Unexpected guest request id: ${requestId}`, ); } return this.pending; } - private completePending(frame: FirecrackerResultFrame): void { + private completePending(frame: GuestResultFrame): void { const pending = this.requirePending(frame.requestId); clearTimeout(pending.timeout); clearTimeout(pending.cancellation); @@ -379,22 +382,22 @@ export class FirecrackerVsockClient { pending.reject(error); } - private send(frame: FirecrackerGuestFrame): Promise { + private send(frame: GuestProtocolFrame): Promise { if (!this.handshakeComplete) { - return Promise.reject(new Error('Firecracker vsock CONNECT handshake is not complete')); + return Promise.reject(new Error('vsock CONNECT handshake is not complete')); } - return this.writeRaw(encodeFirecrackerFrame(frame)); + return this.writeRaw(encodeGuestFrame(frame)); } private writeRaw(data: Buffer): Promise { const socket = this.socket; if (!socket || socket.destroyed || !socket.writable) { - return Promise.reject(new Error('Firecracker guest connection is not writable')); + return Promise.reject(new Error('guest connection is not writable')); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error( - `Firecracker guest write timed out after ${this.writeTimeoutMs}ms`, + `guest write timed out after ${this.writeTimeoutMs}ms`, )); socket.destroy(); }, this.writeTimeoutMs); @@ -420,11 +423,11 @@ export class FirecrackerVsockClient { private onClose(): void { if (this.pending) { this.rejectPending(new Error( - `Firecracker guest disconnected while request ${this.pending.requestId} was running`, + `guest disconnected while request ${this.pending.requestId} was running`, )); } if (!this.ready) { - this.readyWaiter?.reject(new Error('Firecracker guest disconnected before readiness')); + this.readyWaiter?.reject(new Error('guest disconnected before readiness')); this.readyWaiter = undefined; } } diff --git a/src/firecracker/workspace-image.test.ts b/src/microvm/workspace.test.ts similarity index 88% rename from src/firecracker/workspace-image.test.ts rename to src/microvm/workspace.test.ts index 94653d138..35266bb84 100644 --- a/src/firecracker/workspace-image.test.ts +++ b/src/microvm/workspace.test.ts @@ -3,24 +3,24 @@ import { createHash } from 'crypto'; import * as os from 'os'; import * as path from 'path'; import { - FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, - FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES, - FirecrackerWorkspaceImage, + MICROVM_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, + MICROVM_MIN_WORKSPACE_IMAGE_BYTES, + MicrovmWorkspaceImage, assertNoWorkspaceConflicts, - buildFirecrackerWorkspaceManifest, - calculateFirecrackerWorkspaceImageBytes, - type FirecrackerWorkspaceImageDependencies, -} from './workspace-image'; + buildMicrovmWorkspaceManifest, + calculateMicrovmWorkspaceImageBytes, + type MicrovmWorkspaceImageDependencies, +} from './workspace'; -describe('Firecracker workspace images', () => { +describe('microVM workspace images', () => { it('sizes images with headroom, block alignment, minimum, and cap', () => { - expect(calculateFirecrackerWorkspaceImageBytes(0)) - .toBe(FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES); - expect(calculateFirecrackerWorkspaceImageBytes(512 * 1024 * 1024) % 4096).toBe(0); - expect(() => calculateFirecrackerWorkspaceImageBytes( - FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, + expect(calculateMicrovmWorkspaceImageBytes(0)) + .toBe(MICROVM_MIN_WORKSPACE_IMAGE_BYTES); + expect(calculateMicrovmWorkspaceImageBytes(512 * 1024 * 1024) % 4096).toBe(0); + expect(() => calculateMicrovmWorkspaceImageBytes( + MICROVM_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, )).toThrow(/exceeding cap/); - expect(() => calculateFirecrackerWorkspaceImageBytes(0, 1024)).toThrow(/cap/); + expect(() => calculateMicrovmWorkspaceImageBytes(0, 1024)).toThrow(/cap/); }); it('preserves hidden files, modes, and safe symlinks while excluding credentials', async () => { @@ -40,12 +40,12 @@ describe('Firecracker workspace images', () => { await fs.writeFile(baseRootfs, 'rootfs'); await fs.writeFile(supervisor, 'binary'); const commands: Array<{ command: string; args: readonly string[] }> = []; - const dependencies: FirecrackerWorkspaceImageDependencies = { + const dependencies: MicrovmWorkspaceImageDependencies = { runTool: jest.fn(async (command, args) => { commands.push({ command, args }); }), }; - const image = new FirecrackerWorkspaceImage({ + const image = new MicrovmWorkspaceImage({ runId: 'run-1', workDir: root, workspacePath: workspace, @@ -58,7 +58,7 @@ describe('Firecracker workspace images', () => { }, dependencies); const prepared = await image.prepare(); - expect(prepared.imageBytes).toBe(FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES); + expect(prepared.imageBytes).toBe(MICROVM_MIN_WORKSPACE_IMAGE_BYTES); expect((await fs.stat(prepared.workspaceImagePath)).mode & 0o777).toBe(0o600); expect(await fs.readFile( path.join(image.stagingDirectory, 'workspace', '.hidden'), @@ -90,7 +90,7 @@ describe('Firecracker workspace images', () => { const workspace = path.join(root, 'source'); await fs.mkdir(workspace); await fs.symlink('../outside', path.join(workspace, 'escape')); - await expect(buildFirecrackerWorkspaceManifest(workspace)) + await expect(buildMicrovmWorkspaceManifest(workspace)) .rejects.toThrow(/escapes/); await fs.rm(root, { recursive: true, force: true }); }); @@ -152,14 +152,14 @@ describe('Firecracker workspace images', () => { await fs.writeFile(path.join(root, 'base.ext4'), 'rootfs'); await fs.writeFile(path.join(root, 'supervisor'), 'binary'); let e2fsckCalls = 0; - const dependencies: FirecrackerWorkspaceImageDependencies = { + const dependencies: MicrovmWorkspaceImageDependencies = { runTool: jest.fn(async (command) => { if (command === 'e2fsck' && ++e2fsckCalls > 1) { throw new Error('corrupt image'); } }), }; - const image = new FirecrackerWorkspaceImage({ + const image = new MicrovmWorkspaceImage({ runId: 'run-2', workDir: root, workspacePath: workspace, @@ -188,7 +188,7 @@ describe('Firecracker workspace images', () => { await fs.writeFile(path.join(root, 'base.ext4'), 'rootfs'); await fs.writeFile(path.join(root, 'supervisor'), 'binary'); const rsyncCalls: string[][] = []; - const image = new FirecrackerWorkspaceImage({ + const image = new MicrovmWorkspaceImage({ runId: 'run-3', workDir: root, workspacePath: workspace, diff --git a/src/firecracker/workspace-image.ts b/src/microvm/workspace.ts similarity index 80% rename from src/firecracker/workspace-image.ts rename to src/microvm/workspace.ts index 3cc24ebd0..b99b253eb 100644 --- a/src/firecracker/workspace-image.ts +++ b/src/microvm/workspace.ts @@ -2,20 +2,29 @@ import { createHash } from 'crypto'; import { createReadStream, promises as fs, type Stats } from 'fs'; import * as path from 'path'; import execa from 'execa'; -import type { FirecrackerHostToolPaths } from './preflight'; import { CREDENTIAL_ENTRIES, HOME_TOOL_SUBDIRS, } from '../config/mount-policy'; const MIB = 1024 * 1024; -export const FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES = 256 * MIB; -export const FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES = 8 * 1024 * MIB; -const FIRECRACKER_WORKSPACE_IMAGE_HEADROOM_BYTES = 128 * MIB; -const FIRECRACKER_WORKSPACE_BLOCK_BYTES = 4096; -const FIRECRACKER_E2FSCK_REPAIR_EXIT_CODE = 1; +export const MICROVM_MIN_WORKSPACE_IMAGE_BYTES = 256 * MIB; +export const MICROVM_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES = 8 * 1024 * MIB; +const WORKSPACE_IMAGE_HEADROOM_BYTES = 128 * MIB; +const WORKSPACE_BLOCK_BYTES = 4096; +const E2FSCK_REPAIR_EXIT_CODE = 1; + +/** Minimal host tool paths this module needs; a structural subset so callers + * (e.g. Firecracker's preflight-derived tool paths) can pass their own + * richer tool-path record without this module depending on it. */ +export interface MicrovmWorkspaceHostTools { + readonly mke2fs: string; + readonly debugfs: string; + readonly e2fsck: string; + readonly rsync: string; +} -export interface FirecrackerWorkspaceImageConfig { +export interface MicrovmWorkspaceImageConfig { readonly runId: string; readonly workDir: string; readonly workspacePath: string; @@ -28,7 +37,7 @@ export interface FirecrackerWorkspaceImageConfig { readonly gid: number; } -export interface FirecrackerWorkspaceManifestEntry { +export interface MicrovmWorkspaceManifestEntry { readonly type: 'file' | 'directory' | 'symlink'; readonly mode: number; readonly uid: number; @@ -38,16 +47,16 @@ export interface FirecrackerWorkspaceManifestEntry { readonly target?: string; } -export type FirecrackerWorkspaceManifest = ReadonlyMap< +export type MicrovmWorkspaceManifest = ReadonlyMap< string, - FirecrackerWorkspaceManifestEntry + MicrovmWorkspaceManifestEntry >; -export interface FirecrackerWorkspaceImageDependencies { +export interface MicrovmWorkspaceImageDependencies { runTool(command: string, args: readonly string[]): Promise; } -const defaultDependencies: FirecrackerWorkspaceImageDependencies = { +const defaultDependencies: MicrovmWorkspaceImageDependencies = { runTool: async (command, args) => { const result = await execa(command, [...args], { reject: false, @@ -57,7 +66,7 @@ const defaultDependencies: FirecrackerWorkspaceImageDependencies = { if (result.exitCode === 0) return; if ( (command === 'e2fsck' || command.endsWith('/e2fsck')) && - result.exitCode === FIRECRACKER_E2FSCK_REPAIR_EXIT_CODE + result.exitCode === E2FSCK_REPAIR_EXIT_CODE ) return; throw new Error( `${command} exited with code ${result.exitCode}: ` + @@ -66,31 +75,31 @@ const defaultDependencies: FirecrackerWorkspaceImageDependencies = { }, }; -export interface FirecrackerWorkspacePreparation { +export interface MicrovmWorkspacePreparation { readonly workspaceImagePath: string; readonly rootfsImagePath: string; readonly imageBytes: number; - readonly originalManifest: FirecrackerWorkspaceManifest; + readonly originalManifest: MicrovmWorkspaceManifest; } /** * Owns the host-only population and post-stop extraction of one writable image. */ -export class FirecrackerWorkspaceImage { +export class MicrovmWorkspaceImage { readonly runDirectory: string; readonly stagingDirectory: string; readonly workspaceImagePath: string; readonly rootfsImagePath: string; readonly recoveryImagePath: string; - private originalManifest: FirecrackerWorkspaceManifest | undefined; + private originalManifest: MicrovmWorkspaceManifest | undefined; private prepared = false; private extractionSucceeded = false; private recoveryPreserved = false; constructor( - private readonly config: FirecrackerWorkspaceImageConfig, - private readonly dependencies: FirecrackerWorkspaceImageDependencies = defaultDependencies, - private readonly tools?: Pick, + private readonly config: MicrovmWorkspaceImageConfig, + private readonly dependencies: MicrovmWorkspaceImageDependencies = defaultDependencies, + private readonly tools?: MicrovmWorkspaceHostTools, ) { assertSafeRunId(config.runId); this.runDirectory = path.join(config.workDir, 'firecracker-images', config.runId); @@ -111,8 +120,8 @@ export class FirecrackerWorkspaceImage { return this.dependencies.runTool(this.tools?.[command] ?? command, args); } - async prepare(): Promise { - if (this.prepared) throw new Error('Firecracker workspace image is already prepared'); + async prepare(): Promise { + if (this.prepared) throw new Error('microVM workspace image is already prepared'); await fs.mkdir(path.join(this.stagingDirectory, 'workspace'), { recursive: true, mode: 0o700, @@ -134,7 +143,7 @@ export class FirecrackerWorkspaceImage { try { await fs.lstat(path.join(this.config.workspacePath, '.awf-home')); throw new Error( - 'Workspace contains reserved Firecracker guest home path: .awf-home', + 'Workspace contains reserved microVM guest home path: .awf-home', ); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; @@ -147,12 +156,12 @@ export class FirecrackerWorkspaceImage { this.config.gid, ); await this.copyAllowedHomeState(); - this.originalManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); + this.originalManifest = await buildMicrovmWorkspaceManifest(this.config.workspacePath); const imageRoot = path.join(this.stagingDirectory, 'workspace'); const stagingUsage = await calculateTreeUsage(imageRoot); - const imageBytes = calculateFirecrackerWorkspaceImageBytes( - stagingUsage.bytes + stagingUsage.entries * FIRECRACKER_WORKSPACE_BLOCK_BYTES, + const imageBytes = calculateMicrovmWorkspaceImageBytes( + stagingUsage.bytes + stagingUsage.entries * WORKSPACE_BLOCK_BYTES, this.config.maxImageBytes, ); const inodeCount = Math.max(8192, Math.ceil(stagingUsage.entries * 1.25) + 1024); @@ -166,11 +175,11 @@ export class FirecrackerWorkspaceImage { '-t', 'ext4', '-F', '-q', - '-b', String(FIRECRACKER_WORKSPACE_BLOCK_BYTES), + '-b', String(WORKSPACE_BLOCK_BYTES), '-N', String(inodeCount), '-d', imageRoot, this.workspaceImagePath, - String(imageBytes / FIRECRACKER_WORKSPACE_BLOCK_BYTES), + String(imageBytes / WORKSPACE_BLOCK_BYTES), ]); await this.prepareRootfs(); @@ -184,11 +193,11 @@ export class FirecrackerWorkspaceImage { } /** - * Must only be called after the Firecracker process has terminated. + * Must only be called after the microVM process has terminated. */ async extractAfterStop(changedImagePath = this.workspaceImagePath): Promise { if (!this.prepared || !this.originalManifest) { - throw new Error('Firecracker workspace image has not been prepared'); + throw new Error('microVM workspace image has not been prepared'); } if (this.extractionSucceeded) return; const extractionDirectory = path.join(this.runDirectory, 'extracted'); @@ -210,15 +219,15 @@ export class FirecrackerWorkspaceImage { force: true, }); const guestWorkspace = extractionDirectory; - const guestManifest = await buildFirecrackerWorkspaceManifest(guestWorkspace); - const currentManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); + const guestManifest = await buildMicrovmWorkspaceManifest(guestWorkspace); + const currentManifest = await buildMicrovmWorkspaceManifest(this.config.workspacePath); assertNoWorkspaceConflicts(this.originalManifest, guestManifest, currentManifest); await this.applyWorkspaceUpdateAtomically(guestWorkspace, guestManifest); this.extractionSucceeded = true; } catch (error) { await this.preserveRecoveryImage(changedImagePath); throw new Error( - `Firecracker workspace copy-back failed; changed image preserved at ` + + `microVM workspace copy-back failed; changed image preserved at ` + `${this.recoveryImagePath}: ${formatError(error)}`, ); } @@ -273,15 +282,15 @@ export class FirecrackerWorkspaceImage { } private async prepareRootfs(): Promise { - await assertRegularFile(this.config.baseRootfsPath, 'Firecracker base rootfs'); - await assertRegularFile(this.config.supervisorBinaryPath, 'Firecracker guest supervisor'); + await assertRegularFile(this.config.baseRootfsPath, 'microVM base rootfs'); + await assertRegularFile(this.config.supervisorBinaryPath, 'guest supervisor'); if (!/^[A-Fa-f0-9]{64}$/.test(this.config.supervisorSha256)) { - throw new Error('Firecracker guest supervisor SHA-256 must be 64 hexadecimal characters'); + throw new Error('guest supervisor SHA-256 must be 64 hexadecimal characters'); } const actual = await sha256File(this.config.supervisorBinaryPath); if (actual !== this.config.supervisorSha256.toLowerCase()) { throw new Error( - `Firecracker guest supervisor SHA-256 mismatch: expected ` + + `guest supervisor SHA-256 mismatch: expected ` + `${this.config.supervisorSha256.toLowerCase()}, got ${actual}`, ); } @@ -323,7 +332,7 @@ export class FirecrackerWorkspaceImage { private async applyWorkspaceUpdateAtomically( guestWorkspace: string, - guestManifest: FirecrackerWorkspaceManifest, + guestManifest: MicrovmWorkspaceManifest, ): Promise { const workspaceParent = path.dirname(this.config.workspacePath); const workspaceName = path.basename(this.config.workspacePath); @@ -345,9 +354,9 @@ export class FirecrackerWorkspaceImage { `${guestWorkspace}${path.sep}`, `${mergeDirectory}${path.sep}`, ]); - const stagedManifest = await buildFirecrackerWorkspaceManifest(mergeDirectory); + const stagedManifest = await buildMicrovmWorkspaceManifest(mergeDirectory); assertExactWorkspaceManifest(guestManifest, stagedManifest, 'staged workspace'); - const latestManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); + const latestManifest = await buildMicrovmWorkspaceManifest(this.config.workspacePath); assertNoWorkspaceConflicts(this.originalManifest!, guestManifest, latestManifest); let backupPending = false; @@ -375,39 +384,39 @@ export class FirecrackerWorkspaceImage { } } -export function calculateFirecrackerWorkspaceImageBytes( +export function calculateMicrovmWorkspaceImageBytes( contentBytes: number, - maximumBytes = FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, + maximumBytes = MICROVM_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, ): number { if (!Number.isSafeInteger(contentBytes) || contentBytes < 0) { - throw new Error(`Invalid Firecracker workspace content size: ${contentBytes}`); + throw new Error(`Invalid microVM workspace content size: ${contentBytes}`); } if ( !Number.isSafeInteger(maximumBytes) || - maximumBytes < FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES + maximumBytes < MICROVM_MIN_WORKSPACE_IMAGE_BYTES ) { throw new Error( - `Firecracker workspace image cap must be at least ` + - `${FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES} bytes`, + `microVM workspace image cap must be at least ` + + `${MICROVM_MIN_WORKSPACE_IMAGE_BYTES} bytes`, ); } const withHeadroom = Math.ceil(contentBytes * 1.25) + - FIRECRACKER_WORKSPACE_IMAGE_HEADROOM_BYTES; - const requested = Math.max(FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES, withHeadroom); - const aligned = Math.ceil(requested / FIRECRACKER_WORKSPACE_BLOCK_BYTES) * - FIRECRACKER_WORKSPACE_BLOCK_BYTES; + WORKSPACE_IMAGE_HEADROOM_BYTES; + const requested = Math.max(MICROVM_MIN_WORKSPACE_IMAGE_BYTES, withHeadroom); + const aligned = Math.ceil(requested / WORKSPACE_BLOCK_BYTES) * + WORKSPACE_BLOCK_BYTES; if (aligned > maximumBytes) { throw new Error( - `Firecracker workspace requires ${aligned} bytes, exceeding cap ${maximumBytes}`, + `microVM workspace requires ${aligned} bytes, exceeding cap ${maximumBytes}`, ); } return aligned; } -export async function buildFirecrackerWorkspaceManifest( +export async function buildMicrovmWorkspaceManifest( root: string, -): Promise { - const manifest = new Map(); +): Promise { + const manifest = new Map(); await walkSafeTree(root, root, async (absolutePath, relativePath, stat) => { if (relativePath === '') return; const mode = stat.mode & 0o7777; @@ -443,9 +452,9 @@ export async function buildFirecrackerWorkspaceManifest( } export function assertNoWorkspaceConflicts( - original: FirecrackerWorkspaceManifest, - guest: FirecrackerWorkspaceManifest, - current: FirecrackerWorkspaceManifest, + original: MicrovmWorkspaceManifest, + guest: MicrovmWorkspaceManifest, + current: MicrovmWorkspaceManifest, ): void { const paths = new Set([...original.keys(), ...guest.keys(), ...current.keys()]); const conflicts: string[] = []; @@ -520,7 +529,7 @@ async function walkSafeTree( if (stat.isSymbolicLink()) { const target = await fs.readlink(current); if (path.isAbsolute(target)) { - throw new Error(`Absolute symlink is not safe for Firecracker workspace: ${current}`); + throw new Error(`Absolute symlink is not safe for microVM workspace: ${current}`); } assertContained( resolvedSafetyRoot, @@ -528,7 +537,7 @@ async function walkSafeTree( `symlink target for ${current}`, ); } else if (!stat.isFile() && !stat.isDirectory()) { - throw new Error(`Special filesystem entry is not safe for Firecracker workspace: ${current}`); + throw new Error(`Special filesystem entry is not safe for microVM workspace: ${current}`); } const result = await visitor(current, relativePath, stat); if (!stat.isDirectory() || result === 'skip') return; @@ -551,15 +560,15 @@ async function calculateTreeUsage(root: string): Promise<{ bytes: number; entrie } function entriesEqual( - left: FirecrackerWorkspaceManifestEntry | undefined, - right: FirecrackerWorkspaceManifestEntry | undefined, + left: MicrovmWorkspaceManifestEntry | undefined, + right: MicrovmWorkspaceManifestEntry | undefined, ): boolean { return JSON.stringify(left) === JSON.stringify(right); } function assertExactWorkspaceManifest( - expected: FirecrackerWorkspaceManifest, - actual: FirecrackerWorkspaceManifest, + expected: MicrovmWorkspaceManifest, + actual: MicrovmWorkspaceManifest, label: string, ): void { const mismatches: string[] = []; @@ -571,7 +580,7 @@ function assertExactWorkspaceManifest( } if (mismatches.length > 0) { throw new Error( - `Firecracker ${label} diverged during staging at ${mismatches.slice(0, 20).join(', ')}` + + `microVM ${label} diverged during staging at ${mismatches.slice(0, 20).join(', ')}` + (mismatches.length > 20 ? ` and ${mismatches.length - 20} more paths` : ''), ); } @@ -590,13 +599,13 @@ function assertContained(root: string, candidate: string, label: string): void { function assertSafeRunId(runId: string): void { if (!/^[A-Za-z0-9-]{1,64}$/.test(runId)) { - throw new Error(`Unsafe Firecracker workspace run id: ${runId}`); + throw new Error(`Unsafe microVM workspace run id: ${runId}`); } } function assertDebugfsOperand(value: string, label: string): void { if (/[\s"'\\;`\r\n]/.test(value)) { - throw new Error(`Firecracker ${label} is unsafe for debugfs commands: ${value}`); + throw new Error(`microVM ${label} is unsafe for debugfs commands: ${value}`); } } @@ -621,13 +630,13 @@ async function applySafeOwnership( uid > 0xffff_ffff || gid > 0xffff_ffff ) { - throw new Error(`Invalid Firecracker workspace identity: ${uid}:${gid}`); + throw new Error(`Invalid microVM workspace identity: ${uid}:${gid}`); } const currentUid = process.getuid?.(); const currentGid = process.getgid?.(); if (currentUid !== 0 && (currentUid !== uid || currentGid !== gid)) { throw new Error( - `Cannot map Firecracker workspace ownership to ${uid}:${gid} as ` + + `Cannot map microVM workspace ownership to ${uid}:${gid} as ` + `${String(currentUid)}:${String(currentGid)}`, ); } diff --git a/src/types/index.ts b/src/types/index.ts index 37e5a7fa9..932cb9cbe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,13 @@ export { FIRECRACKER_DEFAULT_VCPU_COUNT, FIRECRACKER_DEFAULT_MEMORY_MIB, FIRECRACKER_DEFAULT_API_TIMEOUT_MS, + type CloudHypervisorArtifactDigests, + type CloudHypervisorOptions, + CLOUD_HYPERVISOR_RELEASE_VERSION, + CLOUD_HYPERVISOR_DEFAULT_BINARY, + CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, + CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, + CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, } from './runtime-options'; export { type RateLimitConfig } from './rate-limit'; export { type FlagValidationResult } from './validation'; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index ad26e6a9a..4f84ea6ca 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -38,6 +38,47 @@ export interface FirecrackerOptions { sha256?: FirecrackerArtifactDigests; } +// ─── Cloud Hypervisor (foundation only — no runnable backend yet) ────────── +// +// This configuration surface exists so callers can pin trusted artifacts and +// round-trip config today. It is intentionally NOT registered as a +// selectable `--container-runtime` value and has no lifecycle backend: see +// `src/cloud-hypervisor/preflight.ts` for artifact/host validation and +// `guest/cloud-hypervisor/` for the guest artifact pipeline. GitHub-hosted +// Ubuntu x86_64 KVM runners are the only supported host target. + +export const CLOUD_HYPERVISOR_RELEASE_VERSION = '53.0'; +export const CLOUD_HYPERVISOR_DEFAULT_BINARY = '/usr/local/bin/cloud-hypervisor'; +export const CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT = 2; +export const CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB = 512; +export const CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS = 5_000; + +export interface CloudHypervisorArtifactDigests { + cloudHypervisor?: string; + kernel?: string; + rootfs?: string; + supervisor?: string; +} + +/** + * Foundational configuration for a future Cloud Hypervisor microVM runtime. + * + * There is no lifecycle backend for this runtime yet — it cannot be selected + * via `--container-runtime` and `previewEnabled` only gates config + * acceptance/validation, not workload execution. + */ +export interface CloudHypervisorOptions { + previewEnabled: boolean; + cloudHypervisorBinary: string; + kernelPath?: string; + rootfsPath?: string; + supervisorPath?: string; + vcpuCount: number; + memoryMib: number; + apiTimeoutMs: number; + sha256?: CloudHypervisorArtifactDigests; +} + export interface RuntimeOptions { /** * The command to execute inside the firewall container @@ -198,4 +239,12 @@ export interface RuntimeOptions { /** Firecracker microVM control-plane settings. */ firecracker?: FirecrackerOptions; + + /** + * Cloud Hypervisor microVM foundation settings (config/artifacts only). + * + * There is no lifecycle backend yet; this cannot be selected as a + * `--container-runtime` value. + */ + cloudHypervisor?: CloudHypervisorOptions; }