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
17 changes: 17 additions & 0 deletions packages/codev-sandbox/src/bwrap/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ test("never nests a mount destination inside a read-only rootfs bind", () => {
expect(destinations.filter((destination) => destination.startsWith("/opt/"))).toEqual(["/opt/codev"])
})

test("binds extra mounts rw under /mnt, after the rootfs binds", () => {
const joined = buildBwrapArgs({
...spec,
rootfsEntries: [...spec.rootfsEntries, { name: "mnt", symlinkTarget: null }],
mounts: [
{ path: "/host/docs", guestPath: "/mnt/docs" },
{ path: "/host/data", guestPath: "/mnt/data" },
],
}).join(" ")
expect(joined).toContain("--bind /host/docs /mnt/docs")
expect(joined).toContain("--bind /host/data /mnt/data")
// The image's own /mnt must not be ro-bound: bwrap can only create the
// /mnt/<name> mount points on its tmpfs root.
expect(joined).not.toContain("/rootfs/mnt")
expect(joined.indexOf("--bind /host/docs")).toBeGreaterThan(joined.indexOf("--ro-bind /data/default/rootfs/usr"))
})

test("clears the environment before setting the boot env", () => {
const args = buildBwrapArgs(spec)
const joined = args.join(" ")
Expand Down
7 changes: 6 additions & 1 deletion packages/codev-sandbox/src/bwrap/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type BwrapSpec = {
rootfsDir: string
rootfsEntries: RootfsEntry[]
projectDir: string
// Extra host folders, already resolved to their /mnt/<name> guest paths.
mounts?: { path: string; guestPath: string }[]
bundleDir: string
bridgeDir: string
runtimeDir: string
Expand All @@ -33,7 +35,9 @@ export type BwrapSpec = {
}

// Mounts we manage ourselves; a rootfs copy of these must never shadow them.
const MANAGED_ROOTFS_ENTRIES = new Set(["proc", "dev", "sys", "tmp", "run", "home", "workspace"])
// "mnt" is here so extra-mount points under /mnt land on bwrap's tmpfs root,
// where bwrap can create them — inside a read-only rootfs bind it cannot.
const MANAGED_ROOTFS_ENTRIES = new Set(["proc", "dev", "sys", "tmp", "run", "home", "workspace", "mnt"])

export function bindableRootfsEntries(entries: RootfsEntry[]) {
return entries.filter((entry) => !MANAGED_ROOTFS_ENTRIES.has(entry.name))
Expand Down Expand Up @@ -66,6 +70,7 @@ export function buildBwrapArgs(spec: BwrapSpec) {
"--bind",
spec.projectDir,
GUEST_WORKSPACE,
...(spec.mounts ?? []).flatMap((mount) => ["--bind", mount.path, mount.guestPath]),
"--ro-bind",
spec.bundleDir,
GUEST_BUNDLE,
Expand Down
14 changes: 14 additions & 0 deletions packages/codev-sandbox/src/bwrap/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { createBrokerServer, type SandboxEgress } from "../broker"
import { readImageMarker, stageAsset, writeImageMarker } from "../image"
import type { SandboxAsset } from "../manifest"
import { resolveGuestMounts } from "../mounts"
import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider"
import {
GUEST_BRIDGE,
Expand Down Expand Up @@ -114,6 +115,17 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi
await mkdir(dirs.bridge, { recursive: true })
await mkdir(dirs.home, { recursive: true })
await writeFile(join(dirs.bridge, "forward.mjs"), GUEST_FORWARD_SCRIPT)
// Extra mounts, unlike projectDir, must not brick the start: a folder
// deleted since it was added is skipped, not fatal.
const accessible = []
for (const mount of config.mounts ?? []) {
const found = await stat(mount.path).catch(() => null)
if (found?.isDirectory()) accessible.push(mount)
}
const guestMounts = resolveGuestMounts(accessible).map((mount) => ({
path: mount.path,
guestPath: mount.guestPath,
}))

// Listening before boot: the guest-side bridge dials broker.sock per
// connection and drops it when nothing serves the socket.
Expand Down Expand Up @@ -142,6 +154,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi
rootfsDir: dirs.rootfs,
rootfsEntries: await readRootfsEntries(dirs.rootfs),
projectDir: config.projectDir,
mounts: guestMounts,
bundleDir: options.bundleDir,
bridgeDir: dirs.bridge,
runtimeDir: dirs.runtime,
Expand Down Expand Up @@ -198,6 +211,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi
url,
username: credentials.username,
password: credentials.password,
mounts: guestMounts,
stop: () => {
child.kill()
void forwarder.close()
Expand Down
168 changes: 165 additions & 3 deletions packages/codev-sandbox/src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function fakeRunning(url: string): FakeRunning {
url,
username: "codev",
password: "secret",
mounts: [],
stopped: false,
stop: () => {
sandbox.stopped = true
Expand Down Expand Up @@ -70,7 +71,13 @@ test("adds a sandbox, persists it, and reports the running server", async () =>
const runtime = await waitForRuntimeKind(controller, config.id, "ready")
expect(created).toBe(1)
expect(persisted).toEqual([config])
expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:4096", username: "codev", password: "secret" })
expect(runtime).toEqual({
kind: "ready",
url: "http://127.0.0.1:4096",
username: "codev",
password: "secret",
mounts: [],
})
expect(controller.getState().job).toBe(null)
})

Expand Down Expand Up @@ -217,7 +224,13 @@ test("ignores stale sandboxes after a rapid restart", async () => {
const runtime = await waitForRuntimeKind(controller, config.id, "ready")
expect(stale.stopped).toBe(true)
expect(fresh.stopped).toBe(false)
expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:2222", username: "codev", password: "secret" })
expect(runtime).toEqual({
kind: "ready",
url: "http://127.0.0.1:2222",
username: "codev",
password: "secret",
mounts: [],
})
})

test("removing a sandbox stops it, destroys it, and forgets it", async () => {
Expand Down Expand Up @@ -269,7 +282,156 @@ test("reset destroys guest state and restarts the sandbox", async () => {
const runtime = await waitForRuntimeKind(controller, config.id, "ready")
expect(resets).toBe(1)
expect(starts).toBe(2)
expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:5002", username: "codev", password: "secret" })
expect(runtime).toEqual({
kind: "ready",
url: "http://127.0.0.1:5002",
username: "codev",
password: "secret",
mounts: [],
})
})

test("updating mounts on a failed sandbox persists without starting it", async () => {
let persisted: SandboxConfig[] = [config]
let starts = 0
const controller = createSandboxController({
providers: {
bwrap: fakeProvider({
start: async () => {
starts++
throw new Error("down")
},
}),
},
readSandboxes: () => persisted,
writeSandboxes: (sandboxes) => {
persisted = sandboxes
},
})
await controller.initialize()
await waitForRuntimeKind(controller, config.id, "failed")
const startsBefore = starts
await controller.updateSandboxMounts(config.id, [{ path: "/extra" }])
expect(starts).toBe(startsBefore)
expect(persisted).toEqual([{ ...config, mounts: [{ path: "/extra" }] }])
expect(controller.getState().sandboxes[0]?.config.mounts).toEqual([{ path: "/extra" }])
})

test("updating mounts restarts a running sandbox when the provider cannot apply hot", async () => {
let persisted: SandboxConfig[] = [config]
const startConfigs: SandboxConfig[] = []
const instances: FakeRunning[] = []
const controller = createSandboxController({
providers: {
bwrap: fakeProvider({
start: async (startConfig) => {
startConfigs.push(startConfig)
const instance = fakeRunning(`http://127.0.0.1:600${startConfigs.length}`)
instances.push(instance)
return instance
},
}),
},
readSandboxes: () => persisted,
writeSandboxes: (sandboxes) => {
persisted = sandboxes
},
})
await controller.initialize()
await waitForRuntimeKind(controller, config.id, "ready")
await controller.updateSandboxMounts(config.id, [{ path: "/extra" }])
await waitForRuntimeKind(controller, config.id, "ready")
expect(startConfigs).toHaveLength(2)
expect(startConfigs[1]?.mounts).toEqual([{ path: "/extra" }])
expect(instances[0]?.stopped).toBe(true)
expect(instances[1]?.stopped).toBe(false)
expect(persisted).toEqual([{ ...config, mounts: [{ path: "/extra" }] }])
})

test("updating mounts applies hot when the provider supports it", async () => {
let persisted: SandboxConfig[] = [config]
let starts = 0
let applied: SandboxConfig | undefined
const controller = createSandboxController({
providers: {
bwrap: fakeProvider({
start: async () => {
starts++
return fakeRunning("http://127.0.0.1:4096")
},
applyMounts: async (applyConfig) => {
applied = applyConfig
return [{ path: "/extra", guestPath: "/mnt/extra" }]
},
}),
},
readSandboxes: () => persisted,
writeSandboxes: (sandboxes) => {
persisted = sandboxes
},
})
await controller.initialize()
await waitForRuntimeKind(controller, config.id, "ready")
await controller.updateSandboxMounts(config.id, [{ path: "/extra" }])
expect(starts).toBe(1)
expect(applied?.mounts).toEqual([{ path: "/extra" }])
expect(persisted).toEqual([{ ...config, mounts: [{ path: "/extra" }] }])
const runtime = runtimeOf(controller, config.id)
expect(runtime).toEqual({
kind: "ready",
url: "http://127.0.0.1:4096",
username: "codev",
password: "secret",
mounts: [{ path: "/extra", guestPath: "/mnt/extra" }],
})
})

test("a failed hot apply persists nothing", async () => {
let persisted: SandboxConfig[] = [config]
const controller = createSandboxController({
providers: {
bwrap: fakeProvider({
applyMounts: async () => {
throw new Error("folder does not exist")
},
}),
},
readSandboxes: () => persisted,
writeSandboxes: (sandboxes) => {
persisted = sandboxes
},
})
await controller.initialize()
await waitForRuntimeKind(controller, config.id, "ready")
expect(controller.updateSandboxMounts(config.id, [{ path: "/gone" }])).rejects.toThrow("folder does not exist")
expect(persisted).toEqual([config])
expect(controller.getState().sandboxes[0]?.config.mounts).toBeUndefined()
})

test("updating mounts is rejected while the sandbox has a job in flight", async () => {
let persisted: SandboxConfig[] = [config]
let resolveReset: (() => void) | undefined
const controller = createSandboxController({
providers: {
bwrap: fakeProvider({
reset: () =>
new Promise((resolve) => {
resolveReset = () => resolve(undefined)
}),
}),
},
readSandboxes: () => persisted,
writeSandboxes: (sandboxes) => {
persisted = sandboxes
},
})
await controller.initialize()
await waitForRuntimeKind(controller, config.id, "ready")
const resetting = controller.resetSandbox(config.id)
expect(controller.updateSandboxMounts(config.id, [{ path: "/extra" }])).rejects.toThrow("operation in progress")
resolveReset?.()
await resetting
expect(persisted).toEqual([config])
})

test("stopAll stops running sandboxes and invalidates pending starts", async () => {
Expand Down
39 changes: 39 additions & 0 deletions packages/codev-sandbox/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
SandboxBackend,
SandboxConfig,
SandboxItem,
SandboxMount,
SandboxProgress,
SandboxProvider,
SandboxRuntime,
Expand Down Expand Up @@ -155,6 +156,7 @@ export function createSandboxController(options: SandboxControllerOptions) {
url: sandbox.url,
username: sandbox.username,
password: sandbox.password,
mounts: sandbox.mounts,
})
sandbox.onExit((code, signal) => {
if (running.get(id) !== sandbox) return
Expand Down Expand Up @@ -244,6 +246,43 @@ export function createSandboxController(options: SandboxControllerOptions) {
await startSandbox(id)
},

// Replaces the extra-mount list. Applied hot where the provider can
// (WSL), otherwise by restarting a live sandbox; a stopped or failed
// sandbox just persists the change for its next start.
async updateSandboxMounts(id: string, mounts: SandboxMount[]) {
const item = state.sandboxes.find((x) => x.config.id === id)
if (!item) throw new Error(`no sandbox with id ${id}`)
if (state.job?.id === id) throw new Error("the sandbox has an operation in progress; wait for it to finish")
const provider = requireProvider(item.config.backend)
const config = { ...item.config, mounts }
// The persisted store and state.sandboxes[].config are separate; both
// must move together (see addSandbox).
const persist = () => {
options.writeSandboxes(options.readSandboxes().map((c) => (c.id === id ? { ...c, mounts } : c)))
setState({ sandboxes: state.sandboxes.map((x) => (x.config.id === id ? { ...x, config } : x)) })
}
const current = running.get(id)
if (current && provider.applyMounts) {
// Hot path: apply against the live guest before persisting, so a bad
// path never lands in the stored config.
const resolved = await provider.applyMounts(config)
persist()
// The instance can change under the await; the persisted config still
// wins on the next start, but the stale runtime must not be patched.
if (running.get(id) !== current) return
const runtime = state.sandboxes.find((x) => x.config.id === id)?.runtime
if (runtime?.kind === "ready") setRuntime(id, { ...runtime, mounts: resolved })
logger?.log("sandbox mounts applied", { id, mounts: resolved.length })
return
}
// Restart path: persist first so the new mount set survives a failed
// restart, then restart only a live sandbox.
persist()
if (item.runtime.kind !== "ready" && item.runtime.kind !== "starting") return
logger?.log("sandbox restarting to apply mounts", { id })
await startSandbox(id)
},

startSandbox,

stopAll() {
Expand Down
1 change: 1 addition & 0 deletions packages/codev-sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./provider"
export * from "./policy"
export * from "./controller"
export * from "./mounts"
export * from "./manifest"
export * from "./image"
export * from "./broker"
Expand Down
Loading
Loading