Skip to content

Commit 70ea40a

Browse files
committed
Add integration test suite and CI job
1 parent c67e540 commit 70ea40a

4 files changed

Lines changed: 345 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ jobs:
3030
- name: Build
3131
run: bun run build
3232

33+
integration-tests:
34+
runs-on: ubuntu-latest
35+
needs: build-and-test
36+
timeout-minutes: 40
37+
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@v4
41+
42+
- name: Setup Bun
43+
uses: oven-sh/setup-bun@v2
44+
with:
45+
bun-version: 1.3.6
46+
47+
- name: Install dependencies
48+
run: bun install --frozen-lockfile
49+
50+
- name: Install system dependencies
51+
run: |
52+
sudo apt-get update
53+
sudo apt-get install -y qemu-system-x86 e2fsprogs
54+
55+
- name: Run integration tests
56+
env:
57+
INTEGRATION_PLATFORM: linux/amd64
58+
INTEGRATION_IMAGE: busybox:latest
59+
run: bun run test:integration
60+
3361
e2e-smoke:
3462
runs-on: ubuntu-latest
3563
needs: build-and-test

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ bun run typecheck
6969
bun run build
7070
```
7171

72+
### 1b) Run integration tests
73+
74+
```bash
75+
bun run test:integration
76+
```
77+
7278
### 2) Convert image -> assets
7379

7480
```bash

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"scripts": {
1313
"build": "rm -rf dist && tsc -p tsconfig.json",
1414
"typecheck": "tsc -p tsconfig.json --noEmit",
15-
"test": "bun test",
15+
"test": "bun test test/*.test.ts",
16+
"test:integration": "bun test test/integration --timeout 300000 --max-concurrency 1",
1617
"e2e:smoke": "bash ./scripts/e2e-smoke.sh",
1718
"oci2gondolin": "bun run src/bin/oci2gondolin.ts",
1819
"dockerfile2gondolin": "bun run src/bin/dockerfile2gondolin.ts"
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import { afterAll, describe, expect, it } from "bun:test";
2+
import { spawn, spawnSync } from "node:child_process";
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
7+
import { VM } from "@earendil-works/gondolin";
8+
9+
type CommandResult = {
10+
code: number;
11+
signal: NodeJS.Signals | null;
12+
stdout: string;
13+
stderr: string;
14+
};
15+
16+
type CommandOptions = {
17+
cwd?: string;
18+
env?: NodeJS.ProcessEnv;
19+
timeoutMs?: number;
20+
};
21+
22+
const REPO_ROOT = process.cwd();
23+
const IMAGE = process.env.INTEGRATION_IMAGE ?? "busybox:latest";
24+
const PLATFORM = resolveIntegrationPlatform(process.env.INTEGRATION_PLATFORM ?? process.arch);
25+
26+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "docker2vm-integration-"));
27+
const rootfsOutDir = path.join(tempRoot, "busybox-rootfs");
28+
const assetsOutDir = path.join(tempRoot, "busybox-assets");
29+
30+
afterAll(() => {
31+
fs.rmSync(tempRoot, { recursive: true, force: true });
32+
});
33+
34+
describe("oci2gondolin integration", () => {
35+
it("materializes a busybox rootfs image", async () => {
36+
const debugfsBinary = requireBinary("debugfs", [
37+
"/opt/homebrew/opt/e2fsprogs/sbin/debugfs",
38+
"/usr/local/opt/e2fsprogs/sbin/debugfs",
39+
]);
40+
41+
const result = await runCommand(
42+
"bun",
43+
[
44+
"run",
45+
"src/bin/oci2gondolin.ts",
46+
"--image",
47+
IMAGE,
48+
"--platform",
49+
PLATFORM,
50+
"--mode",
51+
"rootfs",
52+
"--out",
53+
rootfsOutDir,
54+
],
55+
{ cwd: REPO_ROOT, timeoutMs: 180_000 },
56+
);
57+
58+
assertSuccess(result, "oci2gondolin rootfs conversion");
59+
60+
const rootfsPath = path.join(rootfsOutDir, "rootfs.ext4");
61+
const metadataPath = path.join(rootfsOutDir, "meta.json");
62+
63+
expect(fs.existsSync(rootfsPath)).toBe(true);
64+
expect(fs.existsSync(metadataPath)).toBe(true);
65+
expect(fs.existsSync(path.join(rootfsOutDir, "manifest.json"))).toBe(false);
66+
67+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
68+
mode: string;
69+
platform: string;
70+
source?: { kind?: string; ref?: string };
71+
};
72+
73+
expect(metadata.mode).toBe("rootfs");
74+
expect(metadata.platform).toBe(PLATFORM);
75+
expect(metadata.source?.kind).toBe("image");
76+
77+
const debugfsResult = await runCommand(
78+
debugfsBinary,
79+
["-R", "stat /bin/busybox", rootfsPath],
80+
{ timeoutMs: 30_000 },
81+
);
82+
83+
assertSuccess(debugfsResult, "debugfs stat /bin/busybox");
84+
const debugText = `${debugfsResult.stdout}\n${debugfsResult.stderr}`.toLowerCase();
85+
expect(debugText).not.toContain("file not found by ext2_lookup");
86+
}, 240_000);
87+
88+
it("materializes busybox assets and executes inside a VM", async () => {
89+
requireBinary(process.arch === "arm64" ? "qemu-system-aarch64" : "qemu-system-x86_64");
90+
91+
const result = await runCommand(
92+
"bun",
93+
[
94+
"run",
95+
"src/bin/oci2gondolin.ts",
96+
"--image",
97+
IMAGE,
98+
"--platform",
99+
PLATFORM,
100+
"--mode",
101+
"assets",
102+
"--out",
103+
assetsOutDir,
104+
],
105+
{ cwd: REPO_ROOT, timeoutMs: 180_000 },
106+
);
107+
108+
assertSuccess(result, "oci2gondolin assets conversion");
109+
110+
const rootfsPath = path.join(assetsOutDir, "rootfs.ext4");
111+
const metadataPath = path.join(assetsOutDir, "meta.json");
112+
const kernelPath = path.join(assetsOutDir, "vmlinuz-virt");
113+
const initramfsPath = path.join(assetsOutDir, "initramfs.cpio.lz4");
114+
const manifestPath = path.join(assetsOutDir, "manifest.json");
115+
116+
expect(fs.existsSync(rootfsPath)).toBe(true);
117+
expect(fs.existsSync(metadataPath)).toBe(true);
118+
expect(fs.existsSync(kernelPath)).toBe(true);
119+
expect(fs.existsSync(initramfsPath)).toBe(true);
120+
expect(fs.existsSync(manifestPath)).toBe(true);
121+
122+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as { mode: string; platform: string };
123+
expect(metadata.mode).toBe("assets");
124+
expect(metadata.platform).toBe(PLATFORM);
125+
126+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
127+
assets: { rootfs: string; kernel: string; initramfs: string };
128+
source: { platform: string };
129+
};
130+
131+
expect(manifest.assets.rootfs).toBe("rootfs.ext4");
132+
expect(manifest.assets.kernel).toBe("vmlinuz-virt");
133+
expect(manifest.assets.initramfs).toBe("initramfs.cpio.lz4");
134+
expect(manifest.source.platform).toBe(PLATFORM);
135+
136+
const originalGuestDir = process.env.GONDOLIN_GUEST_DIR;
137+
const vmSandbox = resolveVmSandboxOptions();
138+
139+
let vm: VM | null = null;
140+
try {
141+
process.env.GONDOLIN_GUEST_DIR = assetsOutDir;
142+
vm = await VM.create({ sandbox: vmSandbox });
143+
144+
const execResult = await vm.exec(["/bin/busybox", "echo", "integration-vm-ok"]);
145+
expect(execResult.exitCode).toBe(0);
146+
expect(execResult.stdout).toContain("integration-vm-ok");
147+
} finally {
148+
await vm?.close().catch(() => {
149+
// ignore close errors in test teardown
150+
});
151+
152+
if (originalGuestDir === undefined) {
153+
delete process.env.GONDOLIN_GUEST_DIR;
154+
} else {
155+
process.env.GONDOLIN_GUEST_DIR = originalGuestDir;
156+
}
157+
}
158+
}, 300_000);
159+
});
160+
161+
function resolveIntegrationPlatform(raw: string): "linux/amd64" | "linux/arm64" {
162+
const value = raw.trim().toLowerCase();
163+
164+
if (value === "linux/amd64" || value === "amd64" || value === "x64" || value === "x86_64") {
165+
return "linux/amd64";
166+
}
167+
168+
if (value === "linux/arm64" || value === "arm64" || value === "aarch64") {
169+
return "linux/arm64";
170+
}
171+
172+
throw new Error(
173+
`Unsupported INTEGRATION_PLATFORM/arch '${raw}'. Expected linux/amd64 or linux/arm64 (or host arch alias).`,
174+
);
175+
}
176+
177+
function resolveVmSandboxOptions(): { accel?: "tcg"; cpu?: "max" } | undefined {
178+
if (process.platform !== "linux") {
179+
return undefined;
180+
}
181+
182+
try {
183+
fs.accessSync("/dev/kvm", fs.constants.R_OK | fs.constants.W_OK);
184+
return undefined;
185+
} catch {
186+
return {
187+
accel: "tcg",
188+
cpu: "max",
189+
};
190+
}
191+
}
192+
193+
function requireBinary(binary: string, fallbacks: string[] = []): string {
194+
const candidates = [binary, ...fallbacks];
195+
196+
for (const candidate of candidates) {
197+
const result = spawnSync(candidate, ["--help"], { stdio: "ignore" });
198+
const error = result.error as NodeJS.ErrnoException | undefined;
199+
200+
if (!error) {
201+
return candidate;
202+
}
203+
204+
if (error.code !== "ENOENT") {
205+
throw error;
206+
}
207+
}
208+
209+
throw new Error(
210+
`Missing required binary for integration tests: ${binary}. Checked: ${candidates.join(", ")}`,
211+
);
212+
}
213+
214+
async function runCommand(
215+
command: string,
216+
args: string[],
217+
options: CommandOptions = {},
218+
): Promise<CommandResult> {
219+
const timeoutMs = options.timeoutMs ?? 60_000;
220+
221+
return await new Promise<CommandResult>((resolve, reject) => {
222+
const env = options.env ? { ...process.env, ...options.env } : process.env;
223+
const child = spawn(command, args, {
224+
cwd: options.cwd,
225+
env,
226+
stdio: ["ignore", "pipe", "pipe"],
227+
});
228+
229+
let stdout = "";
230+
let stderr = "";
231+
let settled = false;
232+
233+
child.stdout.setEncoding("utf8");
234+
child.stderr.setEncoding("utf8");
235+
236+
child.stdout.on("data", (chunk: string) => {
237+
stdout += chunk;
238+
});
239+
240+
child.stderr.on("data", (chunk: string) => {
241+
stderr += chunk;
242+
});
243+
244+
const timeout = setTimeout(() => {
245+
if (settled) {
246+
return;
247+
}
248+
249+
settled = true;
250+
child.kill("SIGTERM");
251+
setTimeout(() => {
252+
child.kill("SIGKILL");
253+
}, 2000).unref();
254+
255+
reject(
256+
new Error(
257+
[
258+
`Command timed out after ${timeoutMs}ms: ${command} ${args.join(" ")}`,
259+
"--- stdout ---",
260+
stdout,
261+
"--- stderr ---",
262+
stderr,
263+
].join("\n"),
264+
),
265+
);
266+
}, timeoutMs);
267+
268+
child.on("error", (error) => {
269+
if (settled) {
270+
return;
271+
}
272+
273+
settled = true;
274+
clearTimeout(timeout);
275+
reject(error);
276+
});
277+
278+
child.on("close", (code, signal) => {
279+
if (settled) {
280+
return;
281+
}
282+
283+
settled = true;
284+
clearTimeout(timeout);
285+
resolve({
286+
code: code ?? 1,
287+
signal,
288+
stdout,
289+
stderr,
290+
});
291+
});
292+
});
293+
}
294+
295+
function assertSuccess(result: CommandResult, label: string): void {
296+
if (result.code === 0) {
297+
return;
298+
}
299+
300+
throw new Error(
301+
[
302+
`${label} failed (exit code ${result.code}${result.signal ? `, signal ${result.signal}` : ""})`,
303+
"--- stdout ---",
304+
result.stdout,
305+
"--- stderr ---",
306+
result.stderr,
307+
].join("\n"),
308+
);
309+
}

0 commit comments

Comments
 (0)