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
5 changes: 5 additions & 0 deletions .changeset/container-ignore-globs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

Add `ignore` to `ContainerBackend`: glob patterns, such as `**/node_modules` and `!/vendor/node_modules`, for paths the container keeps on its own disk instead of syncing.
2 changes: 1 addition & 1 deletion docs/19_performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ the more realistic baseline for general usage.

## Local-only paths (`MOUNT_IGNORE`)

A path listed in `MOUNT_IGNORE` is served from the container's disk and
A path matched by `MOUNT_IGNORE` is served from the container's disk and
never enters the VFS, the store, the change-pack encoding, or the pull.

What this does **not** change is the FUSE round trip: the bytes still
Expand Down
12 changes: 6 additions & 6 deletions packages/computer/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,16 @@ export interface BackendHandle {
// Durable Object). push/pull are no-ops, and the
// reconcile-watermarks pass on connect is skipped.
sync?: "remote" | "none";
// Local-only paths this backend's container keeps on its own disk,
// as the container reports them (#179). Absent on backends with no
// such concept.
// The MOUNT_IGNORE patterns this backend's container applies, as the
// container reports them (#179). Paths they match stay on the
// container's own disk. Absent on backends with no such concept.
//
// `supported: false` means the container predates the feature, so
// every path is synced regardless of what the host asked for. Worth
// `supported: false` means the container predates patterns, so every
// path is synced regardless of what the host asked for. Worth
// logging: it is the difference between a configuration that works
// and one that silently does nothing.
ignore?: {
readonly paths: readonly string[];
readonly patterns: readonly string[];
readonly root: string | undefined;
readonly supported: boolean;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// connect()'s happy path constructs a WebSocketPair, a workerd global
// the node runner does not provide, so the full dial cannot complete
// here. These exercise the wire format the backend depends on, against
// a fake host. The comparison logic and the error text have their own
// suite in ignore-assertion.test.ts, and the end-to-end behavior is
// covered in computerd's cli tests against a real FUSE mount.
// How ContainerBackend configures and checks MOUNT_IGNORE. The first
// suite stops before the upgrade; the second stubs WebSocketPair, a
// workerd global the node runner does not provide, to run connect()
// through to the check. The comparison logic and the error text have
// their own suite in ignore-assertion.test.ts, and the end-to-end
// behavior is covered in computerd's cli tests against a real FUSE
// mount.
import { afterEach, describe, expect, test, vi } from "vitest";

import { ContainerBackend } from "./container-backend.js";
import type { ContainerRuntimeInfo, IWorkspaceContainerAPI } from "./container-host.js";
import type { ContainerLaunchSpec } from "./container-launch-record.js";
import { ContainerIgnoreMismatchError, readIgnoreReport } from "./ignore-assertion.js";
import { ContainerIgnoreMismatchError } from "./ignore-assertion.js";

interface FakeHostOptions {
// The `ignore` block /__computerd/info reports. Omitted models a
Expand All @@ -36,7 +37,7 @@ function fakeHost(opts: FakeHostOptions = {}) {
async interceptOutboundHttp() {},
async interceptAllOutboundHttp() {},
async fetchPort(port, url) {
const path = new URL(url).pathname;
const path = new URL(url instanceof Request ? url.url : url).pathname;
fetches.push({ port, path });
if (path === "/__computerd/info") {
return new Response(
Expand Down Expand Up @@ -66,50 +67,13 @@ function fakeHost(opts: FakeHostOptions = {}) {
}

describe("ContainerBackend local-only paths", () => {
const readInfo = async (host: IWorkspaceContainerAPI) => {
const res = await host.fetchPort(8080, "http://container/__computerd/info");
return readIgnoreReport(await res.json());
};

test("reads the ignore block a current container reports", async () => {
const { host } = fakeHost({
info: {
supported: true,
enabled: true,
root: "/tmp/workspace",
paths: ["node_modules", "dist"],
redundant: [],
},
});
expect(await readInfo(host)).toEqual({
paths: ["/workspace/node_modules", "/workspace/dist"],
root: "/tmp/workspace",
mountPoint: "/workspace",
supported: true,
});
});

test("treats a container with no ignore block as unsupported", async () => {
// The version-skew case the README warns about: the computerd image
// can lag the pinned client. Without this the old image looks like
// it is working while quietly syncing everything.
const { host } = fakeHost({});
expect(await readInfo(host)).toEqual({
paths: [],
root: undefined,
mountPoint: undefined,
supported: false,
});
});

test("the backend requests /__computerd/info on the container port", async () => {
// Pins the path and port, so a rename upstream fails here rather
// than silently degrading every deployment to "unsupported".
const { host, fetches } = fakeHost({
info: { supported: true, paths: [], root: "/tmp/workspace" },
});
await host.fetchPort(8080, "http://container/__computerd/info");
expect(fetches).toContainEqual({ port: 8080, path: "/__computerd/info" });
test("throws on a pattern computerd would refuse, before any container starts", () => {
// Otherwise the typo surfaces as a daemon that exits during startup.
const { host, starts } = fakeHost();
expect(() => backendWith(host, ["**/node_modules", "node_modules"])).toThrow(
/"\/node_modules" or "\*\*\/node_modules"/,
);
expect(starts).toHaveLength(0);
});

const backendWith = (host: IWorkspaceContainerAPI, ignore?: readonly string[]) =>
Expand All @@ -130,12 +94,12 @@ describe("ContainerBackend local-only paths", () => {
// in the start environment or the image would have to be rebuilt to
// change it.
const { host, starts } = fakeHost();
await backendWith(host, ["/node_modules", "/.venv", "/dist"])
await backendWith(host, ["**/node_modules", "!/vendor/node_modules", "/dist"])
.connect()
.catch(() => undefined);

expect(starts).toHaveLength(1);
expect(starts[0]?.env?.MOUNT_IGNORE).toBe("/node_modules,/.venv,/dist");
expect(starts[0]?.env?.MOUNT_IGNORE).toBe("**/node_modules,!/vendor/node_modules,/dist");
});

test("sends no MOUNT_IGNORE when `ignore` is omitted", async () => {
Expand Down Expand Up @@ -185,7 +149,7 @@ describe("ContainerBackend ignore check on a full connect", () => {
async interceptOutboundHttp() {},
async interceptAllOutboundHttp() {},
async fetchPort(_port, url, init) {
const path = new URL(url).pathname;
const path = new URL(url instanceof Request ? url.url : url).pathname;
const auth = new Headers(init?.headers).get("authorization");
if (path === "/health") return new Response("ok");
if (path === "/__computerd/info") infoAuth.push(auth);
Expand Down Expand Up @@ -249,12 +213,16 @@ describe("ContainerBackend ignore check on a full connect", () => {
}

test("reads /__computerd/info with the client secret", async () => {
const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] });
const handle = await backendFor(fake, ["/dist"]).connect();
const fake = connectingHost({
supported: true,
root: "/tmp/workspace",
patterns: ["**/node_modules", "/dist"],
});
const handle = await backendFor(fake, ["**/node_modules", "/dist"]).connect();

expect(fake.infoAuth).toEqual([`Bearer ${SECRET}`]);
expect(handle.ignore).toEqual({
paths: ["/workspace/dist"],
patterns: ["**/node_modules", "/dist"],
root: "/tmp/workspace",
mountPoint: "/workspace",
supported: true,
Expand All @@ -264,16 +232,25 @@ describe("ContainerBackend ignore check on a full connect", () => {

test("accepts a declaration spelled with the mount point", async () => {
// computerd strips the mount prefix from "/workspace/dist" and
// applies "dist". The declaration means the same thing.
const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] });
// applies "/dist". The declaration means the same thing.
const fake = connectingHost({ supported: true, root: "/tmp/workspace", patterns: ["/dist"] });
const handle = await backendFor(fake, ["/workspace/dist"]).connect();
await handle.close();
});

test("still rejects a real mismatch", async () => {
const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] });
const fake = connectingHost({ supported: true, root: "/tmp/workspace", patterns: ["/dist"] });
await expect(backendFor(fake, ["/node_modules"]).connect()).rejects.toBeInstanceOf(
ContainerIgnoreMismatchError,
);
});

test("rejects a computerd that reports paths instead of patterns", async () => {
// A computerd from before patterns would have refused or misread
// "**/node_modules", so it must not pass for one that applied it.
const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] });
await expect(backendFor(fake, ["/dist"]).connect()).rejects.toThrow(
/does not support MOUNT_IGNORE patterns/,
);
});
});
29 changes: 20 additions & 9 deletions packages/computer/src/backends/container/container-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ import { WorkspaceTransportError } from "../../transport-failure.js";
import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js";
import type { ContainerInstanceSize, ContainerLaunchSpec } from "./container-launch-record.js";
import { probeComputerdHealth } from "./health-probe.js";
import { assertIgnoreMatches, type ResolvedIgnore, readIgnoreReport } from "./ignore-assertion.js";
import {
assertIgnoreMatches,
checkIgnorePatterns,
type ResolvedIgnore,
readIgnoreReport,
} from "./ignore-assertion.js";

// What the backend's `container` factory returns: anything with
// a getWorkspaceContainer() method — the shape withWorkspaceContainer
Expand Down Expand Up @@ -112,13 +117,17 @@ export interface ContainerBackendOptions {
heartbeatIntervalMs?: number;

// Paths the container keeps on its local disk instead of the
// workspace (#179). Written as mount-relative absolute paths
// ("/node_modules"), and passed to the container at start time as
// MOUNT_IGNORE.
// workspace (#179), as glob patterns passed to the container at start
// time as MOUNT_IGNORE. Each starts with "/" (from the mount root) or
// "**/" (at any depth); "*" matches within a path segment, and a
// leading "!" excludes. The last matching pattern wins.
//
// ignore: ["**/node_modules", "!/vendor/node_modules", "/dist"]
//
// connect() reads the resolved set back off /__computerd/info and
// refuses the connection if it disagrees, which catches an image
// whose computerd is too old to honor the variable.
// The constructor throws on a pattern computerd would refuse. connect()
// reads the applied patterns back off /__computerd/info and refuses the
// connection if they disagree, which catches an image whose computerd
// is too old to read them.
ignore?: readonly string[];

// Number of forced restart attempts after startup readiness
Expand Down Expand Up @@ -261,6 +270,7 @@ export class ContainerBackend implements WorkspaceBackend {
this.id = options.id ?? "container-shell";
this.#egress = options.egress ?? { mode: "none" };
this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined;
if (options.ignore !== undefined) checkIgnorePatterns(options.ignore);
this.#options = {
container: options.container,
workspace: options.workspace,
Expand Down Expand Up @@ -654,10 +664,11 @@ export class ContainerBackend implements WorkspaceBackend {
signal: AbortSignal.timeout(this.#options.healthProbeTimeoutMs),
},
);
if (!res.ok) return { paths: [], root: undefined, mountPoint: undefined, supported: false };
if (!res.ok)
return { patterns: [], root: undefined, mountPoint: undefined, supported: false };
return readIgnoreReport(await res.json());
} catch {
return { paths: [], root: undefined, mountPoint: undefined, supported: false };
return { patterns: [], root: undefined, mountPoint: undefined, supported: false };
}
}

Expand Down
Loading
Loading