Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 60 additions & 38 deletions packages/codev-sandbox/script/build-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
// baked in (other parts are fetched
// arch-correct during the build)
// [--no-office] base toolchain only
// [--backend bwrap] [--backend vz] default: both. bwrap alone skips the
// kernel and ext4 steps, which is what
// makes a cross-arch x64 build
// practical: WSL and bubblewrap consume
// the rootfs tarball and supply their
// own kernel, so only vz needs one.
// [--skip-kernel] reuse an existing vmlinux-<arch>
// [--out dir] default: dist-images/
import { $ } from "bun"
Expand All @@ -25,7 +31,7 @@ import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { SANDBOX_IMAGES_BASE_URL } from "../src/const"
import { parseSandboxManifest, type SandboxAsset } from "../src/manifest"
import type { SandboxArch } from "../src/provider"
import type { SandboxArch, SandboxBackend } from "../src/provider"

const imageDir = fileURLToPath(new URL("../image/", import.meta.url))
const DISK_SIZE = "8G"
Expand All @@ -36,6 +42,7 @@ if (!version) {
process.exit(1)
}
const archs = collectArchs()
const backends = collectBackends()
const officeBundle = argValue("--office-bundle")
const office = process.argv.includes("--no-office") ? "0" : "1"
const skipKernel = process.argv.includes("--skip-kernel")
Expand Down Expand Up @@ -69,50 +76,54 @@ for (const arch of archs) {
() => $`docker buildx build --platform linux/${docker} -t codev-sandbox-tools:${arch} --load ${join(imageDir, "tools")}`.nothrow(),
)

// The rootfs tarball feeds both backends: bwrap and wsl --import ship it
// as-is, and the vz ext4 disk is built from it.
console.log("exporting rootfs tarball...")
const cid = (await $`docker create --platform linux/${docker} codev-sandbox-guest:${arch}`.text()).trim()
const bwrapTar = join(outDir, `bwrap-${arch}.tar`)
await $`docker export ${cid} -o ${bwrapTar}`
await $`docker rm ${cid}`.quiet()
await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} gzip -f -6 /out/bwrap-${arch}.tar`
assets[`bwrap-${arch}`] = await describeAsset(join(outDir, `bwrap-${arch}.tar.gz`))

console.log("building ext4 disk...")
await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[
`mkdir /r`,
`tar -xzf /out/bwrap-${arch}.tar.gz -C /r`,
`truncate -s ${DISK_SIZE} /out/rootfs-${arch}.img`,
`mkfs.ext4 -F -q -d /r /out/rootfs-${arch}.img`,
].join(" && ")}`

const kernel = join(outDir, `vmlinux-${arch}`)
if (!skipKernel) {
console.log("building kernel (cached after the first run)...")
await retry(
"kernel build",
() =>
$`docker buildx build --platform linux/${docker} --target build -t codev-sandbox-kernel:${arch} --load ${join(imageDir, "kernel")}`.nothrow(),
)
const kernelCid = (await $`docker create --platform linux/${docker} codev-sandbox-kernel:${arch} true`.text()).trim()
await $`docker cp ${kernelCid}:/out/vmlinux ${kernel}`
await $`docker rm ${kernelCid}`.quiet()
}
if (!(await stat(kernel).catch(() => null))) {
console.error(`missing ${kernel}; run without --skip-kernel`)
process.exit(1)
}
if (backends.includes("bwrap")) assets[`bwrap-${arch}`] = await describeAsset(join(outDir, `bwrap-${arch}.tar.gz`))

if (backends.includes("vz")) {
console.log("building ext4 disk...")
await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[
`mkdir /r`,
`tar -xzf /out/bwrap-${arch}.tar.gz -C /r`,
`truncate -s ${DISK_SIZE} /out/rootfs-${arch}.img`,
`mkfs.ext4 -F -q -d /r /out/rootfs-${arch}.img`,
].join(" && ")}`

const kernel = join(outDir, `vmlinux-${arch}`)
if (!skipKernel) {
console.log("building kernel (cached after the first run)...")
await retry(
"kernel build",
() =>
$`docker buildx build --platform linux/${docker} --target build -t codev-sandbox-kernel:${arch} --load ${join(imageDir, "kernel")}`.nothrow(),
)
const kernelCid = (await $`docker create --platform linux/${docker} codev-sandbox-kernel:${arch} true`.text()).trim()
await $`docker cp ${kernelCid}:/out/vmlinux ${kernel}`
await $`docker rm ${kernelCid}`.quiet()
}
if (!(await stat(kernel).catch(() => null))) {
console.error(`missing ${kernel}; run without --skip-kernel`)
process.exit(1)
}

console.log("assembling vz tarball...")
const vzStage = join(outDir, `vz-${arch}`)
await rm(vzStage, { recursive: true, force: true })
await mkdir(vzStage, { recursive: true })
await cp(kernel, join(vzStage, "vmlinux"))
await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[
`mv /out/rootfs-${arch}.img /out/vz-${arch}/rootfs.img`,
`tar -Sczf /out/vz-${arch}.tar.gz -C /out/vz-${arch} vmlinux rootfs.img`,
].join(" && ")}`
await rm(vzStage, { recursive: true, force: true })
assets[`vz-${arch}`] = await describeAsset(join(outDir, `vz-${arch}.tar.gz`))
console.log("assembling vz tarball...")
const vzStage = join(outDir, `vz-${arch}`)
await rm(vzStage, { recursive: true, force: true })
await mkdir(vzStage, { recursive: true })
await cp(kernel, join(vzStage, "vmlinux"))
await $`docker run --rm -v ${outDir}:/out codev-sandbox-tools:${arch} sh -ec ${[
`mv /out/rootfs-${arch}.img /out/vz-${arch}/rootfs.img`,
`tar -Sczf /out/vz-${arch}.tar.gz -C /out/vz-${arch} vmlinux rootfs.img`,
].join(" && ")}`
await rm(vzStage, { recursive: true, force: true })
assets[`vz-${arch}`] = await describeAsset(join(outDir, `vz-${arch}.tar.gz`))
}
}

const manifest = parseSandboxManifest({
Expand Down Expand Up @@ -149,6 +160,17 @@ function argValue(flag: string) {
return index >= 0 ? process.argv[index + 1] : undefined
}

function collectBackends(): SandboxBackend[] {
const flags = process.argv.flatMap((value, index) => (value === "--backend" ? [process.argv[index + 1]] : []))
if (!flags.length) return ["bwrap", "vz"]
const selected = flags.filter((value): value is SandboxBackend => value === "bwrap" || value === "vz")
if (selected.length !== flags.length) {
console.error("--backend accepts bwrap or vz")
process.exit(1)
}
return selected
}

function collectArchs(): SandboxArch[] {
const flags = process.argv.flatMap((value, index) => (value === "--arch" ? [process.argv[index + 1]] : []))
if (flags.length) return flags.filter((value): value is SandboxArch => value === "arm64" || value === "x64")
Expand Down
109 changes: 109 additions & 0 deletions packages/codev-sandbox/script/e2e-wsl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bun
// Boots a real sandbox end-to-end on Windows: serves a locally built rootfs
// tarball over loopback HTTP, creates the distribution (download -> verify ->
// wsl --import), starts the guest server, and waits for the codev server's
// authenticated health check over WSL2's localhost forwarding. Dev utility.
//
// Run from packages/codev-sandbox on the Windows machine:
// bun script/e2e-wsl.ts --image dist-images\bwrap-x64.tar.gz ^
// --bundle ..\opencode\dist\node [--project dir] [--hold] [--keep]
//
// --hold keeps the distribution running so you can poke at it; --keep skips the
// unregister so it survives the run (clean up with `wsl --unregister codev-e2e`).
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { basename, join, resolve } from "node:path"
import { checkSandboxHealth } from "../src/boot"
import type { SandboxConfig } from "../src/provider"
import { createWslProvider } from "../src/wsl/provider"

// Both paths cross into the distribution through wslpath, which resolves
// relative paths against the guest's cwd — so they have to be absolute here.
const image = resolve(requireArg("--image"))
const bundle = resolve(requireArg("--bundle"))
const hold = process.argv.includes("--hold")
const keep = process.argv.includes("--keep")

const work = await mkdtemp(join(tmpdir(), "codev-wsl-e2e-"))
const projectDir = argValue("--project") ? resolve(argValue("--project")!) : join(work, "project")
await mkdir(projectDir, { recursive: true })
await writeFile(join(projectDir, "hello.txt"), "hello from the host project\n")

// Digest once, outside resolveImage: a hash object cannot be digested twice, so
// computing it inside the callback breaks the moment the provider asks again.
const payload = Bun.file(image)
const hash = createHash("sha256")
for await (const chunk of payload.stream()) hash.update(chunk)
const sha256 = hash.digest("hex")

const server = Bun.serve({ port: 0, fetch: () => new Response(Bun.file(image)) })

const provider = createWslProvider({
dataDir: join(work, "data"),
cacheDir: join(work, "cache"),
bundleDir: bundle,
resolveImage: async () => ({
url: `http://127.0.0.1:${server.port}/${basename(image)}`,
sha256,
bytes: payload.size,
}),
startTimeoutMs: 180_000,
})

const availability = await provider.availability()
console.log("availability:", availability)
if (!availability.supported) {
console.error(`cannot run here: ${availability.message}`)
await cleanup()
process.exit(1)
}
if (availability.warning) console.log(`warning: ${availability.warning}`)

const config: SandboxConfig = { id: "sandbox:e2e", name: "e2e", backend: "wsl", projectDir }

console.time("create")
await provider.create(config)
console.timeEnd("create")

console.time("start")
const sandbox = await provider.start(config)
console.timeEnd("start")
console.log(`server: ${sandbox.url} (user ${sandbox.username})`)

const healthy = await checkSandboxHealth(sandbox.url, { username: sandbox.username ?? "", password: sandbox.password })
console.log(`health: ${healthy ? "OK" : "FAILED"}`)

if (hold) {
console.log("holding the distribution up; Ctrl-C to stop")
await new Promise(() => {})
}

const exited = new Promise<void>((resolve) => sandbox.onExit(() => resolve()))
sandbox.stop()
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 10_000))])
if (keep) console.log(`keeping the distribution; remove it with: wsl --unregister codev-${config.name}`)
if (!keep) await provider.destroy(config)
await cleanup()
if (!healthy) process.exit(1)
console.log("e2e OK")
process.exit(0)

async function cleanup() {
server.stop(true)
await rm(work, { recursive: true, force: true })
}

function argValue(flag: string) {
const index = process.argv.indexOf(flag)
return index >= 0 ? process.argv[index + 1] : undefined
}

function requireArg(flag: string) {
const value = argValue(flag)
if (!value) {
console.error(`${flag} is required`)
process.exit(1)
}
return value
}
2 changes: 2 additions & 0 deletions packages/codev-sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ export * from "./bwrap/rootfs"
export * from "./bwrap/provider"
export * from "./vz/protocol"
export * from "./vz/provider"
export * from "./wsl/cli"
export * from "./wsl/provider"
77 changes: 77 additions & 0 deletions packages/codev-sandbox/src/wsl/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { spawn } from "node:child_process"
import { createServer } from "node:net"

export type WslResult = {
code: number | null
stdout: string
stderr: string
}

// wsl.exe writes its informational output (--version, --status, --list) as
// UTF-16LE with no BOM on most builds, while command output piped from inside a
// distro is plain UTF-8. Sniffing NUL bytes distinguishes them: a UTF-16LE run
// of ASCII always interleaves NULs, which valid UTF-8 text never contains.
export function decodeWslOutput(buffer: Buffer) {
if (!buffer.includes(0)) return buffer.toString("utf8")
return buffer.toString("utf16le").replace(/\0/g, "")
}

export function runWsl(
args: string[],
opts?: { wslCommand?: string; env?: Record<string, string>; spawnImpl?: typeof spawn },
): Promise<WslResult> {
const spawnImpl = opts?.spawnImpl ?? spawn
const child = spawnImpl(opts?.wslCommand ?? "wsl.exe", args, {
stdio: ["ignore", "pipe", "pipe"],
env: opts?.env ? { ...process.env, ...opts.env } : process.env,
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk))
return new Promise((resolve, reject) => {
child.once("error", reject)
child.once("close", (code) =>
resolve({
code,
stdout: decodeWslOutput(Buffer.concat(stdout)),
stderr: decodeWslOutput(Buffer.concat(stderr)),
}),
)
})
}

// `wsl --list --quiet` prints one distro per line; entries can carry a trailing
// CR and the default distro is not marked in --quiet mode.
export function parseDistroList(stdout: string) {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
}

// WSLENV is the only way to hand a variable to the distro without putting it on
// the command line, where another user on the host could read it out of the
// process table. The /u suffix means "host to WSL only".
export function wslEnvSpec(names: string[]) {
return names.map((name) => `${name}/u`).join(":")
}

// The server binds inside the distro and WSL2's localhost forwarding republishes
// the same port on the Windows loopback, so host and guest must agree on one
// number. Reserving it on the host first is the only way to avoid collisions
// between concurrent sandboxes.
export function reserveLoopbackPort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.once("error", reject)
server.listen({ host: "127.0.0.1", port: 0 }, () => {
const address = server.address()
if (address === null || typeof address === "string") {
server.close(() => reject(new Error("could not reserve a loopback port")))
return
}
server.close(() => resolve(address.port))
})
})
}
Loading
Loading