diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cecf76c..b5ae06c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,37 @@ jobs: integration-tests: runs-on: ubuntu-latest needs: build-and-test - timeout-minutes: 40 + timeout-minutes: 50 + strategy: + fail-fast: false + matrix: + include: + - distro: alpine + image: alpine:3.20 + probe_path: /etc/alpine-release + probe_command: cat /etc/alpine-release + probe_expect: "3.20" + - distro: debian + image: debian:bookworm-slim + probe_path: /etc/debian_version + probe_command: cat /etc/debian_version + probe_expect: "12" + - distro: ubuntu + image: ubuntu:24.04 + probe_path: /etc/os-release + probe_command: . /etc/os-release; echo "$ID:$VERSION_ID" + probe_expect: ubuntu:24.04 + - distro: fedora + image: fedora:41 + probe_path: /etc/fedora-release + probe_command: cat /etc/fedora-release + probe_expect: "Fedora release 41" + - distro: archlinux + image: archlinux:latest + probe_path: /etc/os-release + probe_command: . /etc/os-release; echo "$ID" + probe_expect: arch + name: integration-tests (${{ matrix.distro }}) steps: - name: Checkout @@ -52,10 +82,16 @@ jobs: sudo apt-get update sudo apt-get install -y qemu-system-x86 e2fsprogs + - name: Prime Gondolin guest assets + run: bun --eval 'import { ensureGuestAssets } from "@earendil-works/gondolin"; await ensureGuestAssets();' + - name: Run integration tests env: INTEGRATION_PLATFORM: linux/amd64 - INTEGRATION_IMAGE: busybox:latest + INTEGRATION_IMAGE: ${{ matrix.image }} + INTEGRATION_ROOTFS_CHECK_PATH: ${{ matrix.probe_path }} + INTEGRATION_VM_CHECK_COMMAND: ${{ matrix.probe_command }} + INTEGRATION_VM_CHECK_EXPECT: ${{ matrix.probe_expect }} run: bun run test:integration e2e-smoke: @@ -80,6 +116,9 @@ jobs: sudo apt-get update sudo apt-get install -y qemu-system-x86 e2fsprogs + - name: Prime Gondolin guest assets + run: bun --eval 'import { ensureGuestAssets } from "@earendil-works/gondolin"; await ensureGuestAssets();' + - name: Run end-to-end smoke test env: PLATFORM: linux/amd64 diff --git a/README.md b/README.md index 9e459d9..cb96aee 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # docker2vm -`docker2vm` converts OCI container images (or Dockerfiles via BuildKit) into VM-compatible outputs. Today, the runtime materialization target is Gondolin. +`docker2vm` converts OCI container images (or Dockerfiles via BuildKit) into VM-compatible outputs. Today, the runtime materialization target is [Gondolin](https://github.com/earendil-works/gondolin). -It follows an OCI-first flow inspired by "Docker without Docker": +It follows an OCI-first flow inspired by ["Docker without Docker"](https://fly.io/blog/docker-without-docker/): - resolve/pull an OCI image - apply layers to a root filesystem @@ -16,6 +16,7 @@ Docker containers share the host kernel. Gondolin runs workloads inside a VM, so ## Current features +- pinned Gondolin runtime dependency (`@earendil-works/gondolin@0.2.1`) for guest-asset retrieval - `oci2gondolin` core converter - input: `--image`, `--oci-layout`, `--oci-tar` (exactly one) - platform: `linux/amd64`, `linux/arm64` @@ -36,28 +37,23 @@ Docker containers share the host kernel. Gondolin runs workloads inside a VM, so ## Requirements -- Bun >= 1.2 -- `e2fsprogs` (`mke2fs`, `debugfs`) -- QEMU (for runtime smoke checks via `gondolin exec`) -- Docker (only required for `dockerfile2gondolin`) +- Bun >= 1.2 — https://bun.com/ +- `e2fsprogs` (`mke2fs`, `debugfs`) — https://e2fsprogs.sourceforge.net/ +- QEMU (for runtime smoke checks) — https://www.qemu.org/download/ +- Docker (only required for `dockerfile2gondolin`) — https://docs.docker.com/get-docker/ -macOS helpers: +`docker2vm` uses `@earendil-works/gondolin@0.2.1` as a runtime dependency and resolves/downloads guest assets automatically during conversion. -```bash -brew install e2fsprogs qemu -``` +If you also want to run generated assets with `gondolin exec`, install the Gondolin CLI: +- CLI docs: https://earendil-works.github.io/gondolin/cli/ +- Package: https://www.npmjs.com/package/@earendil-works/gondolin -Ubuntu helpers: - -```bash -sudo apt-get install -y e2fsprogs qemu-system-x86 -``` +> On macOS, `docker2vm` checks common Homebrew `e2fsprogs` locations automatically; updating `PATH` is usually optional. -## Install +## Platform setup guides -```bash -bun install -``` +- [macOS guide](./docs/macos.md) +- [Linux guide](./docs/linux.md) ## Quickstart @@ -75,6 +71,32 @@ bun run build bun run test:integration ``` +The CI integration matrix currently validates: + +- `alpine:3.20` +- `debian:bookworm-slim` +- `ubuntu:24.04` +- `fedora:41` +- `archlinux:latest` + +For each distro row, tests run a distro-specific probe command (for example `/etc/debian_version`, `/etc/fedora-release`, etc.), assert that probe does **not** match on the base Gondolin guest image, and verify `/bin/busybox` executes inside the converted image. + +### Choosing the build platform (`--platform`) + +`--platform` selects which OCI image variant to convert, and should match the architecture you plan to run in Gondolin. This applies to both `oci2gondolin` and `dockerfile2gondolin`. + +- Apple Silicon / arm64 Linux hosts: `linux/arm64` +- Intel / amd64 hosts: `linux/amd64` +- If omitted, both commands default from host arch (`x64 -> linux/amd64`, `arm64 -> linux/arm64`). +- You can always override manually with `--platform linux/amd64` or `--platform linux/arm64`. + +For helper scripts: + +- `e2e:smoke` uses `PLATFORM` +- integration tests use `INTEGRATION_PLATFORM` + +Cross-arch builds are possible at image-selection time, but for reliable runtime execution you should use a platform that matches the runtime guest architecture. + ### 2) Convert image -> assets ```bash @@ -88,7 +110,7 @@ bun run oci2gondolin -- \ ### 3) Run with Gondolin ```bash -GONDOLIN_GUEST_DIR=./out/busybox-assets bunx gondolin exec -- /bin/busybox echo hello +GONDOLIN_GUEST_DIR=./out/busybox-assets gondolin exec -- /bin/busybox echo hello ``` ## Dockerfile flow @@ -113,7 +135,7 @@ bun run dockerfile2gondolin -- \ Then run: ```bash -GONDOLIN_GUEST_DIR=./out/demo-assets bunx gondolin exec -- /usr/games/cowsay "hello" +GONDOLIN_GUEST_DIR=./out/demo-assets gondolin exec -- /usr/games/cowsay "hello" ``` ## End-to-end smoke test @@ -176,4 +198,5 @@ dockerfile2gondolin --file PATH --context PATH --out PATH [options] ## Repo notes - This repo is standalone; Gondolin core is not modified. +- Gondolin upstream repository: https://github.com/earendil-works/gondolin - `out/` is generated output and ignored by git. diff --git a/bun.lock b/bun.lock index d023471..9c0ee29 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "docker2vm", "dependencies": { - "@earendil-works/gondolin": "^0.2.1", + "@earendil-works/gondolin": "0.2.1", }, "devDependencies": { "@types/node": "^22.13.10", diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 0000000..3ee833a --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,95 @@ +# Linux setup guide + +This guide is for running `docker2vm` on Linux hosts. + +`docker2vm` includes a pinned Gondolin runtime dependency (`@earendil-works/gondolin@0.2.1`) to resolve guest assets during conversion. + +## 1) Install required tools + +Install tools using their official docs/download pages: + +- Bun: https://bun.com/ +- QEMU: https://www.qemu.org/download/ +- e2fsprogs: https://e2fsprogs.sourceforge.net/ + +If you want Dockerfile conversion (`dockerfile2gondolin`), also install Docker + Buildx: + +- Docker: https://docs.docker.com/get-docker/ +- Buildx: https://docs.docker.com/build/buildx/install/ + +## 2) Optional: install Gondolin CLI (for running generated assets) + +`docker2vm` is tested with `@earendil-works/gondolin@0.2.1` and can fetch guest assets automatically during conversion. + +Use Gondolin CLI install docs: + +- https://earendil-works.github.io/gondolin/cli/ +- Package page: https://www.npmjs.com/package/@earendil-works/gondolin + +## 3) Verify toolchain + +```bash +bun --version +qemu-system-x86_64 --version +mke2fs -V +debugfs -V +gondolin --help >/dev/null +``` + +## 4) Validate from source checkout + +```bash +bun run test +bun run typecheck +bun run build +``` + +## 5) Choose the build platform + +Use a platform that matches the architecture you will run in Gondolin. + +- `uname -m` => `x86_64` or `amd64`: use `linux/amd64` +- `uname -m` => `aarch64` or `arm64`: use `linux/arm64` + +`oci2gondolin` defaults automatically from host arch if `--platform` is omitted, but passing it explicitly is recommended. + +Example: + +```bash +bun run oci2gondolin -- \ + --image busybox:latest \ + --platform linux/amd64 \ + --mode assets \ + --out ./out/busybox-assets +``` + +## 6) Run integration + smoke checks + +amd64 host: + +```bash +INTEGRATION_PLATFORM=linux/amd64 bun run test:integration +PLATFORM=linux/amd64 bun run e2e:smoke +``` + +arm64 host: + +```bash +INTEGRATION_PLATFORM=linux/arm64 bun run test:integration +PLATFORM=linux/arm64 bun run e2e:smoke +``` + +## Notes on virtualization performance + +- If `/dev/kvm` is available, QEMU can use hardware acceleration. +- If `/dev/kvm` is unavailable (common in CI), the project falls back to TCG emulation (slower but functional). + +## Troubleshooting + +### `sandbox_stopped` / VM exits quickly + +Confirm QEMU is installed and that you are using a platform that matches your host and assets. + +### `mke2fs` or `debugfs` missing + +Reinstall `e2fsprogs` and verify the commands are available in your shell. diff --git a/docs/macos.md b/docs/macos.md new file mode 100644 index 0000000..e837011 --- /dev/null +++ b/docs/macos.md @@ -0,0 +1,103 @@ +# macOS setup guide + +This guide is for running `docker2vm` on macOS (Apple Silicon or Intel). + +`docker2vm` includes a pinned Gondolin runtime dependency (`@earendil-works/gondolin@0.2.1`) to resolve guest assets during conversion. + +## 1) Install required tools + +Install tools using their official docs/download pages: + +- Bun: https://bun.com/ +- QEMU: https://www.qemu.org/download/ +- e2fsprogs: https://e2fsprogs.sourceforge.net/ + +If you want Dockerfile conversion (`dockerfile2gondolin`), also install Docker Desktop: +- https://docs.docker.com/desktop/setup/install/mac-install/ + +## 2) Optional: add `e2fsprogs` binaries to `PATH` + +With Homebrew, `e2fsprogs` is often keg-only. `docker2vm` checks common Homebrew locations automatically, so a PATH change is usually **not required** for normal usage. + +If you want to run `mke2fs` / `debugfs` manually in your shell: + +```bash +export PATH="$(brew --prefix e2fsprogs)/sbin:$PATH" +``` + +To persist it, add that `export PATH=...` line to your shell profile (`~/.zshrc`, `~/.bashrc`, `~/.profile`, etc.). + +## 3) Optional: install Gondolin CLI (for running generated assets) + +`docker2vm` is tested with `@earendil-works/gondolin@0.2.1` and can fetch guest assets automatically during conversion. + +Gondolin CLI docs: +- https://earendil-works.github.io/gondolin/cli/ + +Package page: +- https://www.npmjs.com/package/@earendil-works/gondolin + +## 4) Verify toolchain + +```bash +bun --version +qemu-system-aarch64 --version || qemu-system-x86_64 --version +"$(brew --prefix e2fsprogs)/sbin/mke2fs" -V +"$(brew --prefix e2fsprogs)/sbin/debugfs" -V +gondolin --help >/dev/null +``` + +## 5) Validate from source checkout + +```bash +bun run test +bun run typecheck +bun run build +``` + +## 6) Choose the build platform + +Use a platform that matches the architecture you will run in Gondolin. + +- Apple Silicon (`uname -m` => `arm64`): use `linux/arm64` +- Intel Mac (`uname -m` => `x86_64`): use `linux/amd64` + +`oci2gondolin` defaults automatically from host arch if `--platform` is omitted, but passing it explicitly is recommended. + +Example: + +```bash +bun run oci2gondolin -- \ + --image busybox:latest \ + --platform linux/arm64 \ + --mode assets \ + --out ./out/busybox-assets +``` + +## 7) Run integration + smoke checks + +Apple Silicon: + +```bash +INTEGRATION_PLATFORM=linux/arm64 bun run test:integration +PLATFORM=linux/arm64 bun run e2e:smoke +``` + +Intel Mac: + +```bash +INTEGRATION_PLATFORM=linux/amd64 bun run test:integration +PLATFORM=linux/amd64 bun run e2e:smoke +``` + +## Troubleshooting + +### `mke2fs` / `debugfs` not found + +`docker2vm` should find common Homebrew locations automatically. If your install uses a custom prefix, either add it to `PATH` or point `GONDOLIN_GUEST_DIR` to prepared assets and verify `e2fsprogs` binaries are installed. + +### Case-sensitive filename conflicts during conversion + +Some images include paths that conflict on case-insensitive filesystems. + +Use a case-sensitive APFS location for temporary work/output (for example, a case-sensitive volume) and retry. diff --git a/package.json b/package.json index fa3bc54..e2e111b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,6 @@ }, "packageManager": "bun@1.3.6", "dependencies": { - "@earendil-works/gondolin": "^0.2.1" + "@earendil-works/gondolin": "0.2.1" } } diff --git a/src/oci2gondolin/materialize/index.ts b/src/oci2gondolin/materialize/index.ts index ae21e61..e76fb12 100644 --- a/src/oci2gondolin/materialize/index.ts +++ b/src/oci2gondolin/materialize/index.ts @@ -1,10 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { ensureGuestAssets } from "@earendil-works/gondolin"; - import type { AppliedRootfs, MaterializedOutput, Oci2GondolinOptions } from "../types"; import { sha256File } from "../utils/digest"; +import { resolveGondolinGuestAssets } from "../../shared/gondolin-assets"; import { ensureDirectory } from "../utils/fs"; import { createExt4FromDirectory } from "./ext4"; import { injectGondolinRuntime } from "./runtime-injection"; @@ -51,7 +50,7 @@ export async function materializeOutput( let assetManifestPath: string | undefined; if (options.mode === "assets") { - const baseAssets = await ensureGuestAssets(); + const baseAssets = await resolveGondolinGuestAssets(); const kernelPath = path.join(outDir, KERNEL_FILENAME); const initramfsPath = path.join(outDir, INITRAMFS_FILENAME); diff --git a/src/oci2gondolin/materialize/runtime-injection.ts b/src/oci2gondolin/materialize/runtime-injection.ts index e2d2271..0342e44 100644 --- a/src/oci2gondolin/materialize/runtime-injection.ts +++ b/src/oci2gondolin/materialize/runtime-injection.ts @@ -2,9 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { ensureGuestAssets } from "@earendil-works/gondolin"; - import { CliUsageError } from "../../shared/cli-errors"; +import { resolveGondolinGuestAssets } from "../../shared/gondolin-assets"; type RuntimeFileSpec = { sourcePathInRootfs: string; @@ -28,6 +27,11 @@ const REQUIRED_RUNTIME_FILES: RuntimeFileSpec[] = [ targetRelativePath: "bin/kmod", mode: 0o755, }, + { + sourcePathInRootfs: "/bin/busybox", + targetRelativePath: "bin/busybox", + mode: 0o755, + }, { sourcePathInRootfs: "/usr/lib/libcrypto.so.3", targetRelativePath: "usr/lib/libcrypto.so.3", @@ -89,13 +93,13 @@ export interface RuntimeInjectionResult { } export async function extractBaseRootfsTree(destinationDir: string): Promise { - const guestAssets = await ensureGuestAssets(); + const guestAssets = await resolveGondolinGuestAssets(); const baseRootfsPath = guestAssets.rootfsPath; if (!fs.existsSync(baseRootfsPath)) { throw new CliUsageError("Gondolin base rootfs.ext4 was not found.", [ `Expected path: ${baseRootfsPath}`, - "Run a gondolin command once to download guest assets, then retry.", + "Guest assets should be downloaded automatically via @earendil-works/gondolin; verify network access and retry.", ]); } @@ -119,13 +123,13 @@ export async function extractBaseRootfsTree(destinationDir: string): Promise { - const guestAssets = await ensureGuestAssets(); + const guestAssets = await resolveGondolinGuestAssets(); const baseRootfsPath = guestAssets.rootfsPath; if (!fs.existsSync(baseRootfsPath)) { throw new CliUsageError("Gondolin base rootfs.ext4 was not found.", [ `Expected path: ${baseRootfsPath}`, - "Run a gondolin command once to download guest assets, then retry.", + "Guest assets should be downloaded automatically via @earendil-works/gondolin; verify network access and retry.", ]); } diff --git a/src/shared/gondolin-assets.ts b/src/shared/gondolin-assets.ts new file mode 100644 index 0000000..82499fe --- /dev/null +++ b/src/shared/gondolin-assets.ts @@ -0,0 +1,35 @@ +import path from "node:path"; + +import { ensureGuestAssets } from "@earendil-works/gondolin"; + +import { CliUsageError } from "./cli-errors"; + +export const TESTED_GONDOLIN_VERSION = "0.2.1"; + +export interface GondolinGuestAssets { + assetDir: string; + kernelPath: string; + initrdPath: string; + rootfsPath: string; +} + +export async function resolveGondolinGuestAssets(): Promise { + try { + const assets = await ensureGuestAssets(); + + return { + assetDir: path.dirname(assets.rootfsPath), + kernelPath: assets.kernelPath, + initrdPath: assets.initrdPath, + rootfsPath: assets.rootfsPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + throw new CliUsageError("Failed to resolve Gondolin guest assets.", [ + message, + `Ensure @earendil-works/gondolin@${TESTED_GONDOLIN_VERSION} is installed.`, + "To use custom assets, set GONDOLIN_GUEST_DIR to a directory containing kernel/initramfs/rootfs assets.", + ]); + } +} diff --git a/test/integration/oci2gondolin.integration.test.ts b/test/integration/oci2gondolin.integration.test.ts index 86dc1d1..94af73a 100644 --- a/test/integration/oci2gondolin.integration.test.ts +++ b/test/integration/oci2gondolin.integration.test.ts @@ -20,19 +20,23 @@ type CommandOptions = { }; const REPO_ROOT = process.cwd(); -const IMAGE = process.env.INTEGRATION_IMAGE ?? "busybox:latest"; +const IMAGE = process.env.INTEGRATION_IMAGE ?? "debian:bookworm-slim"; const PLATFORM = resolveIntegrationPlatform(process.env.INTEGRATION_PLATFORM ?? process.arch); +const ROOTFS_CHECK_PATH = process.env.INTEGRATION_ROOTFS_CHECK_PATH ?? "/etc/debian_version"; +const VM_CHECK_COMMAND = process.env.INTEGRATION_VM_CHECK_COMMAND ?? "cat /etc/debian_version"; +const VM_CHECK_EXPECT = process.env.INTEGRATION_VM_CHECK_EXPECT ?? "12"; -const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "docker2vm-integration-")); -const rootfsOutDir = path.join(tempRoot, "busybox-rootfs"); -const assetsOutDir = path.join(tempRoot, "busybox-assets"); +const imageSlug = IMAGE.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `docker2vm-integration-${imageSlug}-`)); +const rootfsOutDir = path.join(tempRoot, `${imageSlug}-rootfs`); +const assetsOutDir = path.join(tempRoot, `${imageSlug}-assets`); afterAll(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); describe("oci2gondolin integration", () => { - it("materializes a busybox rootfs image", async () => { + it(`materializes a rootfs image for ${IMAGE}`, async () => { const debugfsBinary = requireBinary("debugfs", [ "/opt/homebrew/opt/e2fsprogs/sbin/debugfs", "/usr/local/opt/e2fsprogs/sbin/debugfs", @@ -52,7 +56,7 @@ describe("oci2gondolin integration", () => { "--out", rootfsOutDir, ], - { cwd: REPO_ROOT, timeoutMs: 180_000 }, + { cwd: REPO_ROOT, timeoutMs: 300_000 }, ); assertSuccess(result, "oci2gondolin rootfs conversion"); @@ -76,16 +80,16 @@ describe("oci2gondolin integration", () => { const debugfsResult = await runCommand( debugfsBinary, - ["-R", "stat /bin/busybox", rootfsPath], + ["-R", `stat ${ROOTFS_CHECK_PATH}`, rootfsPath], { timeoutMs: 30_000 }, ); - assertSuccess(debugfsResult, "debugfs stat /bin/busybox"); + assertSuccess(debugfsResult, `debugfs stat ${ROOTFS_CHECK_PATH}`); const debugText = `${debugfsResult.stdout}\n${debugfsResult.stderr}`.toLowerCase(); expect(debugText).not.toContain("file not found by ext2_lookup"); - }, 240_000); + }, 420_000); - it("materializes busybox assets and executes inside a VM", async () => { + it(`materializes assets for ${IMAGE} and executes inside a VM`, async () => { requireBinary(process.arch === "arm64" ? "qemu-system-aarch64" : "qemu-system-x86_64"); const result = await runCommand( @@ -102,7 +106,7 @@ describe("oci2gondolin integration", () => { "--out", assetsOutDir, ], - { cwd: REPO_ROOT, timeoutMs: 180_000 }, + { cwd: REPO_ROOT, timeoutMs: 300_000 }, ); assertSuccess(result, "oci2gondolin assets conversion"); @@ -136,14 +140,20 @@ describe("oci2gondolin integration", () => { const originalGuestDir = process.env.GONDOLIN_GUEST_DIR; const vmSandbox = resolveVmSandboxOptions(); + await assertProbeMissingFromBaseImage(vmSandbox, originalGuestDir); + let vm: VM | null = null; try { process.env.GONDOLIN_GUEST_DIR = assetsOutDir; vm = await VM.create({ sandbox: vmSandbox }); - const execResult = await vm.exec(["/bin/busybox", "echo", "integration-vm-ok"]); + const execResult = await vm.exec(["/bin/sh", "-lc", VM_CHECK_COMMAND]); expect(execResult.exitCode).toBe(0); - expect(execResult.stdout).toContain("integration-vm-ok"); + expect(execResult.stdout).toContain(VM_CHECK_EXPECT); + + const busyboxResult = await vm.exec(["/bin/busybox", "echo", "integration-vm-ok"]); + expect(busyboxResult.exitCode).toBe(0); + expect(busyboxResult.stdout).toContain("integration-vm-ok"); } finally { await vm?.close().catch(() => { // ignore close errors in test teardown @@ -155,7 +165,7 @@ describe("oci2gondolin integration", () => { process.env.GONDOLIN_GUEST_DIR = originalGuestDir; } } - }, 300_000); + }, 420_000); }); function resolveIntegrationPlatform(raw: string): "linux/amd64" | "linux/arm64" { @@ -190,6 +200,37 @@ function resolveVmSandboxOptions(): { accel?: "tcg"; cpu?: "max" } | undefined { } } +async function assertProbeMissingFromBaseImage( + vmSandbox: { accel?: "tcg"; cpu?: "max" } | undefined, + originalGuestDir: string | undefined, +): Promise { + let vm: VM | null = null; + + try { + if (originalGuestDir === undefined) { + delete process.env.GONDOLIN_GUEST_DIR; + } else { + process.env.GONDOLIN_GUEST_DIR = originalGuestDir; + } + + vm = await VM.create({ sandbox: vmSandbox }); + const baseResult = await vm.exec(["/bin/sh", "-lc", VM_CHECK_COMMAND]); + + expect(baseResult.stdout).not.toContain(VM_CHECK_EXPECT); + expect(baseResult.stderr).not.toContain(VM_CHECK_EXPECT); + } finally { + await vm?.close().catch(() => { + // ignore close errors in test teardown + }); + + if (originalGuestDir === undefined) { + delete process.env.GONDOLIN_GUEST_DIR; + } else { + process.env.GONDOLIN_GUEST_DIR = originalGuestDir; + } + } +} + function requireBinary(binary: string, fallbacks: string[] = []): string { const candidates = [binary, ...fallbacks];