diff --git a/packages/codegen/__tests__/discover-containers.test.ts b/packages/codegen/__tests__/discover-containers.test.ts index 4cdfdcf30..7139038f8 100644 --- a/packages/codegen/__tests__/discover-containers.test.ts +++ b/packages/codegen/__tests__/discover-containers.test.ts @@ -198,12 +198,13 @@ describe("emit (containers)", () => { }; it("emitContainers renders one thin DO class per definition", () => { - expect.assertions(5); + expect.assertions(6); const content = emitContainers(discover()); - expect(content).toContain('import LunoraContainer from "@lunora/container/do";'); + expect(content).toContain('import { LunoraContainer } from "@lunora/container/do";'); expect(content).toContain('import { transcoder } from "../containers.js";'); + expect(content).toContain('export { ContainerProxy } from "@lunora/container/do";'); expect(content).toContain("export class TranscoderContainer extends LunoraContainer {"); expect(content).toContain('super(ctx, env, transcoder, "transcoder");'); expect(content).toContain("Re-export them from your worker entry"); diff --git a/packages/codegen/src/emit.ts b/packages/codegen/src/emit.ts index ab8b2a8ce..903efb98b 100644 --- a/packages/codegen/src/emit.ts +++ b/packages/codegen/src/emit.ts @@ -2040,11 +2040,18 @@ export class ${container.className} extends LunoraContainer { * requires each \`containers[].class_name\` to be exported by the worker: * * \`export * from "./lunora/_generated/containers.js";\` + * + * \`ContainerProxy\` is re-exported alongside them: the egress-interception path + * (\`allowedHosts\`/\`deniedHosts\`/\`interceptHttps\` and the runtime + * \`handle.egress\` controls) routes container outbound traffic through this + * WorkerEntrypoint, so it too must be exported by the deployed worker. */ -import LunoraContainer from "@lunora/container/do"; +import { LunoraContainer } from "@lunora/container/do"; import { ${imports} } from "../containers.js"; +export { ContainerProxy } from "@lunora/container/do"; + ${classes}`; }; diff --git a/packages/container/README.md b/packages/container/README.md index 9953eca7b..a92207d4f 100644 --- a/packages/container/README.md +++ b/packages/container/README.md @@ -66,6 +66,7 @@ export const transcoder = defineContainer({ maxInstances: 5, sleepAfter: "5m", secrets: ["TRANSCODER_API_KEY"], // forwarded from Worker secrets / .dev.vars + labels: { team: "media" }, // metadata attached to every instance for metrics/observability }); ``` @@ -89,10 +90,81 @@ export const transcode = action.input({ videoId: v.id("videos") }).action(async }); ``` -`ctx.containers` is action-only (container calls are external I/O, like `ctx.fetch`); `.get(name)` handles also expose `start`/`stop`/`destroy`/`getState` lifecycle control. +`ctx.containers` is action-only (container calls are external I/O, like `ctx.fetch`); `.get(name)` handles also expose `start`/`stop`/`destroy`/`getState` lifecycle control plus `renewActivityTimeout()` (keep a busy WebSocket's container awake) and `egress.*` (adjust the allow/deny lists at runtime). The config layer (`lunora dev` / `lunora deploy`) reconciles the wrangler `containers[]` entry, the `CONTAINER_*` Durable Object binding, and the SQLite-class migration automatically; `wrangler deploy` builds the Dockerfile with local Docker and pushes it to the Cloudflare Registry. +### Multi-port containers + +Declare every port the container must be listening on with `requiredPorts` (start-up waits for all of them); `defaultPort` is the target when a request doesn't pick one. Route a single request to another port with `.port(n)` — it composes with `.get()`, `.any()`, and `.pool()`: + +```ts +export const app = defineContainer({ + image: "./containers/app", + defaultPort: 8080, + requiredPorts: [8080, 9090], // app + admin +}); + +// in an action: +await ctx.containers.app.get(tenantId).fetch("/work"); // → 8080 +await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090 +``` + +### Build-time args + +`env` and `secrets` are runtime values; for build-time `docker build --build-arg` values (wrangler `image_vars`, exposed to the Dockerfile as `ARG`) use `buildArgs`. They apply only to an image Lunora builds and are ignored for a pre-built `{ registry }` image. + +```ts +export const worker = defineContainer({ + image: "./containers/worker", + buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" }, +}); +``` + +### Egress firewall + +Pair `enableInternet: false` with an `allowedHosts` allow-list (or layer a `deniedHosts` deny-list that overrides everything) to constrain a container's outbound traffic; `interceptHttps: true` extends the lists to TLS connections (the image must trust the Cloudflare CA). Codegen re-exports the `ContainerProxy` worker entrypoint the interception path needs automatically. + +```ts +export const fetcher = defineContainer({ + image: "./containers/fetcher", + enableInternet: false, + allowedHosts: ["*.stripe.com", "api.github.com"], + deniedHosts: ["*.evil.com"], +}); + +// tighten or relax one running instance at runtime: +await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com"); +``` + +For advanced egress rewriting in worker code, `@lunora/container/do` re-exports Cloudflare's custom outbound-handler types (`OutboundHandler`, `OutboundHandlers`, `outboundParams`) — wire them onto a hand-authored `LunoraContainer` subclass to inject auth, route, or mock a container's outbound calls. + +### Readiness gating + +The platform health check waits for an open port, not necessarily a _ready_ app. `readyOn` adds application-level probes that gate request proxying: a `ctx.containers.` fetch holds until every probe responds with its expected status, so callers never hit a container still applying migrations or warming caches. Probes are declarative data (path + optional `port`/`status`), run in parallel at start, and probe the container's TCP port directly. + +```ts +export const api = defineContainer({ + image: "./containers/api", + defaultPort: 8080, + readyOn: [ + { path: "/ready" }, // expect 200 on defaultPort + { path: "/live", port: 9090, status: 204 }, // own port + expected status + ], +}); +``` + +### Hard timeout + +`sleepAfter` caps _idle_ time; `hardTimeout` caps _total_ lifetime — a runaway-cost backstop measured from start, regardless of activity (same grammar as `sleepAfter`). When it elapses the generated class's `onHardTimeoutExpired` hook runs (default: `stop()`); the timer is run-generation-stamped so a stale timer from a slept/crashed run can't kill a fresh one. + +```ts +export const job = defineContainer({ + image: "./containers/job", + hardTimeout: "1h", // never run longer than an hour, busy or not +}); +``` + ### Calling Lunora from inside a container Container code calls back into your app's functions with the bridge client (any JS runtime), over the Worker's HTTP RPC endpoint: diff --git a/packages/container/__tests__/client.test.ts b/packages/container/__tests__/client.test.ts index 5a0b7dde9..e25c80013 100644 --- a/packages/container/__tests__/client.test.ts +++ b/packages/container/__tests__/client.test.ts @@ -216,6 +216,75 @@ describe("ctx.containers..get() lifecycle controls", () => { await expect(handle.stop()).rejects.toThrow("does not expose stop()"); }); + + it("forwards renewActivityTimeout and the egress controls to the DO with the right args", async () => { + expect.assertions(2); + + const calls: { arg: unknown; method: string }[] = []; + const recordVoid = + (method: string) => + async (arg?: unknown): Promise => { + calls.push({ arg, method }); + }; + const egressNamespace: ContainerNamespaceLike = { + get: () => { + return { + allowHost: recordVoid("allowHost"), + denyHost: recordVoid("denyHost"), + fetch: async () => new Response("ok"), + removeAllowedHost: recordVoid("removeAllowedHost"), + removeDeniedHost: recordVoid("removeDeniedHost"), + renewActivityTimeout: recordVoid("renewActivityTimeout"), + setAllowedHosts: recordVoid("setAllowedHosts"), + setDeniedHosts: recordVoid("setDeniedHosts"), + }; + }, + idFromName: (name) => name, + }; + + const handle = createContainerContext({ CONTAINER_TRANSCODER: egressNamespace }, [ + { binding: "CONTAINER_TRANSCODER", exportName: "transcoder" }, + ]).transcoder!.get("video-1"); + + await handle.renewActivityTimeout(); + await handle.egress.allow("api.stripe.com"); + await handle.egress.deny("evil.com"); + await handle.egress.setAllowed(["a.com", "b.com"]); + await handle.egress.setDenied(["c.com"]); + await handle.egress.removeAllowed("a.com"); + await handle.egress.removeDenied("c.com"); + + expect(calls.map((call) => call.method)).toStrictEqual([ + "renewActivityTimeout", + "allowHost", + "denyHost", + "setAllowedHosts", + "setDeniedHosts", + "removeAllowedHost", + "removeDeniedHost", + ]); + // ReadonlyArray args are copied to a fresh mutable array before the RPC. + expect(calls.find((call) => call.method === "setAllowedHosts")!.arg).toStrictEqual(["a.com", "b.com"]); + }); + + it("routes .port(n) requests with the cf-container-target-port header across get/any/pool", async () => { + expect.assertions(4); + + const { namespace, requests } = fakeNamespace(); + const containers = createContainerContext({ CONTAINER_TRANSCODER: namespace }, [ + { binding: "CONTAINER_TRANSCODER", exportName: "transcoder", maxInstances: 3 }, + ]); + + await containers.transcoder!.get("video-1").port(9090).fetch("/admin"); + await containers.transcoder!.any().port(7000).fetch("/admin"); + await containers.transcoder!.pool().port(6000).fetch("/admin"); + await containers.transcoder!.get("video-1").fetch("/no-port"); + + expect(requests[0]!.headers.get("cf-container-target-port")).toBe("9090"); + expect(requests[1]!.headers.get("cf-container-target-port")).toBe("7000"); + expect(requests[2]!.headers.get("cf-container-target-port")).toBe("6000"); + expect(requests[3]!.headers.get("cf-container-target-port")).toBeNull(); + }); }); /** A namespace whose every `fetch` runs the next scripted step (response or throw). */ diff --git a/packages/container/__tests__/define-container.test.ts b/packages/container/__tests__/define-container.test.ts index 1c764b6c6..25a720ed0 100644 --- a/packages/container/__tests__/define-container.test.ts +++ b/packages/container/__tests__/define-container.test.ts @@ -121,6 +121,97 @@ describe(defineContainer, () => { expect(() => defineContainer({ image: { build: "" } })).toThrow("`image.build` must be a non-empty"); }); + it("accepts multi-port, egress-firewall, and labels config", () => { + expect.assertions(7); + + const definition = defineContainer({ + allowedHosts: ["*.stripe.com"], + deniedHosts: ["*.evil.com"], + entrypoint: ["node", "server.js"], + image: "./app", + interceptHttps: true, + labels: { env: "prod", tenant: "acme" }, + pingEndpoint: "/healthz", + requiredPorts: [8080, 9090], + }); + + expect(definition.requiredPorts).toStrictEqual([8080, 9090]); + expect(definition.entrypoint).toStrictEqual(["node", "server.js"]); + expect(definition.interceptHttps).toBe(true); + expect(definition.allowedHosts).toStrictEqual(["*.stripe.com"]); + expect(definition.deniedHosts).toStrictEqual(["*.evil.com"]); + expect(definition.pingEndpoint).toBe("/healthz"); + expect(definition.labels).toStrictEqual({ env: "prod", tenant: "acme" }); + }); + + it("rejects a blank entrypoint part, hostname, or label key", () => { + expect.assertions(3); + + expect(() => defineContainer({ entrypoint: ["node", " "], image: "./app" })).toThrow("`entrypoint` must be a non-empty"); + expect(() => defineContainer({ allowedHosts: [" "], image: "./app" })).toThrow("`allowedHosts` must be an array of non-empty"); + expect(() => defineContainer({ image: "./app", labels: { " ": "x" } })).toThrow("`labels` must be a record of non-empty"); + }); + + it("rejects a non-boolean interceptHttps", () => { + expect.assertions(1); + + expect(() => defineContainer({ image: "./app", interceptHttps: "yes" as unknown as boolean })).toThrow("`interceptHttps` must be a boolean"); + }); + + it("accepts hardTimeout and readyOn config", () => { + expect.assertions(3); + + const definition = defineContainer({ + defaultPort: 8080, + hardTimeout: "1h", + image: "./app", + readyOn: [{ path: "/ready" }, { path: "migrations", port: 9090, status: 204 }], + }); + + expect(definition.hardTimeout).toBe("1h"); + expect(definition.readyOn).toStrictEqual([{ path: "/ready" }, { path: "migrations", port: 9090, status: 204 }]); + expect(defineContainer({ hardTimeout: 600, image: "./app" }).hardTimeout).toBe(600); + }); + + it("rejects an invalid hardTimeout", () => { + expect.assertions(3); + + expect(() => defineContainer({ hardTimeout: "5 minutes", image: "./app" })).toThrow("`hardTimeout`"); + expect(() => defineContainer({ hardTimeout: 0, image: "./app" })).toThrow("`hardTimeout`"); + expect(() => defineContainer({ hardTimeout: -5, image: "./app" })).toThrow("`hardTimeout`"); + }); + + it("rejects an invalid readyOn check", () => { + expect.assertions(4); + + expect(() => defineContainer({ image: "./app", readyOn: [{ path: " " }] })).toThrow("`readyOn[].path`"); + expect(() => defineContainer({ image: "./app", readyOn: [{ path: " /ready " }] })).toThrow("leading or trailing whitespace"); + expect(() => defineContainer({ image: "./app", readyOn: [{ path: "/ready", port: 70_000 }] })).toThrow("readyOn[].port"); + expect(() => defineContainer({ image: "./app", readyOn: [{ path: "/ready", status: 700 }] })).toThrow("`readyOn[].status`"); + }); + + it("rejects an empty or out-of-range requiredPorts", () => { + expect.assertions(2); + + expect(() => defineContainer({ image: "./app", requiredPorts: [] })).toThrow("`requiredPorts` must be a non-empty"); + expect(() => defineContainer({ image: "./app", requiredPorts: [70_000] })).toThrow("requiredPorts[]"); + }); + + it("rejects an empty entrypoint and an empty-string entrypoint part", () => { + expect.assertions(2); + + expect(() => defineContainer({ entrypoint: [], image: "./app" })).toThrow("`entrypoint` must be a non-empty"); + expect(() => defineContainer({ entrypoint: ["node", ""], image: "./app" })).toThrow("`entrypoint` must be a non-empty"); + }); + + it("rejects an empty hostname in an egress list and an empty pingEndpoint", () => { + expect.assertions(3); + + expect(() => defineContainer({ allowedHosts: [""], image: "./app" })).toThrow("`allowedHosts` must be an array of non-empty"); + expect(() => defineContainer({ deniedHosts: [""], image: "./app" })).toThrow("`deniedHosts` must be an array of non-empty"); + expect(() => defineContainer({ image: "./app", pingEndpoint: "" })).toThrow("`pingEndpoint` must be a non-empty"); + }); + it("does not brand arbitrary objects", () => { expect.assertions(2); diff --git a/packages/container/__tests__/lunora-container.test.ts b/packages/container/__tests__/lunora-container.test.ts index 4297cf00f..7501f2449 100644 --- a/packages/container/__tests__/lunora-container.test.ts +++ b/packages/container/__tests__/lunora-container.test.ts @@ -6,11 +6,19 @@ */ import { afterEach, describe, expect, it, vi } from "vitest"; -import LunoraContainer from "../src/do/index"; +import { LunoraContainer } from "../src/do/index"; import { defineContainer } from "../src/index"; +/** Overrides for the pieces the readiness/hard-timeout paths read off the ctx. */ +interface FakeContextOverrides { + /** The `ctx.container` stub (running flag + optional `getTcpPort`). */ + container?: unknown; + /** Value `ctx.storage.get` resolves to (the stored hard-timeout generation). */ + storedGeneration?: number; +} + /** Minimal fake of the pieces `@cloudflare/containers` reads off the DO ctx. */ -const fakeDurableObjectContext = (): Record => { +const fakeDurableObjectContext = (overrides: FakeContextOverrides = {}): Record => { return { // The base ctor schedules alarms inside an un-awaited // `blockConcurrencyWhile(...)` critical section that touches the full @@ -19,10 +27,10 @@ const fakeDurableObjectContext = (): Record => { // but don't run it — otherwise the alarm machinery becomes an unhandled // rejection that fails the run. blockConcurrencyWhile: async () => {}, - container: { running: false }, + container: overrides.container ?? { running: false }, storage: { deleteAlarm: async () => {}, - get: async () => undefined, + get: async () => overrides.storedGeneration, getAlarm: async () => null, kv: { delete: () => {}, @@ -66,6 +74,39 @@ describe(LunoraContainer, () => { expect(instance.envVars).toStrictEqual({ API_KEY: "s3cret", LOG_LEVEL: "info" }); }); + it("applies the multi-port, egress-firewall, and labels fields onto the Container base", () => { + expect.assertions(7); + + const definition = defineContainer({ + allowedHosts: ["*.stripe.com"], + deniedHosts: ["*.evil.com"], + entrypoint: ["node", "server.js"], + image: "./app", + interceptHttps: true, + labels: { env: "prod", tenant: "acme" }, + pingEndpoint: "/healthz", + requiredPorts: [8080, 9090], + }); + + const instance = new LunoraContainer(fakeDurableObjectContext() as never, {}, definition, "transcoder") as unknown as { + allowedHosts: string[]; + deniedHosts: string[]; + entrypoint: string[]; + interceptHttps: boolean; + labels: Record; + pingEndpoint: string; + requiredPorts: number[]; + }; + + expect(instance.requiredPorts).toStrictEqual([8080, 9090]); + expect(instance.entrypoint).toStrictEqual(["node", "server.js"]); + expect(instance.interceptHttps).toBe(true); + expect(instance.allowedHosts).toStrictEqual(["*.stripe.com"]); + expect(instance.deniedHosts).toStrictEqual(["*.evil.com"]); + expect(instance.pingEndpoint).toBe("/healthz"); + expect(instance.labels).toStrictEqual({ env: "prod", tenant: "acme" }); + }); + it("fails fast when a declared secret is missing from the worker env", () => { expect.assertions(1); @@ -111,6 +152,24 @@ describe("lunoraContainer lifecycle logging", () => { expect(JSON.parse((spy.mock.calls.at(-1)![0] as string) ?? "{}")).toMatchObject({ event: "stop", message: "runtime_signal (exit 137)" }); }); + it("emits a sleep event when activity expires", async () => { + expect.assertions(1); + + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await build().onActivityExpired(); + + // Our envelope is logged before `super.onActivityExpired()`'s own plain + // line, so it's the first call. + expect(JSON.parse((spy.mock.calls[0]![0] as string) ?? "{}")).toMatchObject({ + container: "transcoder", + event: "sleep", + level: "info", + source: "lunora", + type: "container", + }); + }); + it("emits an error event and re-throws (the base onError contract)", () => { expect.assertions(2); @@ -120,6 +179,130 @@ describe("lunoraContainer lifecycle logging", () => { expect(JSON.parse((spy.mock.calls[0]![0] as string) ?? "{}")).toMatchObject({ event: "error", level: "error", message: "crashed" }); }); + it("gates onStart on readyOn probes until each returns its expected status", async () => { + expect.assertions(3); + + vi.spyOn(console, "log").mockImplementation(() => {}); + + const fetched: string[] = []; + const context = fakeDurableObjectContext({ + container: { + getTcpPort: (port: number) => { + return { + fetch: (url: string) => { + fetched.push(`${String(port)}:${url}`); + + return Promise.resolve({ status: url.endsWith("/live") ? 204 : 200 }); + }, + }; + }, + // `running: false` keeps the base ctor off its monitor path; the + // readiness probes only need `getTcpPort`, not a running flag. + running: false, + }, + }); + + const definition = defineContainer({ + defaultPort: 8080, + image: "./app", + readyOn: [{ path: "/ready" }, { path: "live", port: 9090, status: 204 }], + }); + const instance = new LunoraContainer(context as never, {}, definition, "transcoder"); + + await expect(instance.onStart()).resolves.toBeUndefined(); + // `/ready` resolves on the default port; `live` (no leading slash) is + // normalized and probed on its own port against status 204. + expect(fetched).toContain("8080:http://container/ready"); + expect(fetched).toContain("9090:http://container/live"); + }); + + it("fails onStart when a readyOn check has no port and no defaultPort", async () => { + expect.assertions(1); + + vi.spyOn(console, "log").mockImplementation(() => {}); + + const context = fakeDurableObjectContext({ + container: { + getTcpPort: () => { + return { fetch: () => Promise.resolve({ status: 200 }) }; + }, + running: false, + }, + }); + const definition = defineContainer({ image: "./app", readyOn: [{ path: "/ready" }] }); + const instance = new LunoraContainer(context as never, {}, definition, "transcoder"); + + await expect(instance.onStart()).rejects.toThrow("has no port"); + }); + + it("arms a hard-timeout schedule on start, stamped with the bumped run generation", async () => { + expect.assertions(2); + + vi.spyOn(console, "log").mockImplementation(() => {}); + + const definition = defineContainer({ hardTimeout: "30s", image: "./app" }); + const instance = new LunoraContainer(fakeDurableObjectContext({ storedGeneration: 4 }) as never, {}, definition, "transcoder"); + const scheduleSpy = vi.spyOn(instance, "schedule").mockResolvedValue(undefined as never); + + await instance.onStart(); + + expect(scheduleSpy).toHaveBeenCalledTimes(1); + // 30s → 30 seconds; generation 4 → 5 (bumped so a stale schedule is detectable). + expect(scheduleSpy).toHaveBeenCalledWith(30, "onHardTimeoutExpired", { generation: 5 }); + }); + + it("onHardTimeoutExpired stops a running instance whose generation matches", async () => { + expect.assertions(2); + + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const definition = defineContainer({ hardTimeout: "30s", image: "./app" }); + // `monitor` is reached by the base ctor when `running` is true. + const instance = new LunoraContainer( + fakeDurableObjectContext({ container: { monitor: () => Promise.resolve(), running: true }, storedGeneration: 3 }) as never, + {}, + definition, + "transcoder", + ); + const stopSpy = vi.spyOn(instance, "stop").mockResolvedValue(undefined); + + await instance.onHardTimeoutExpired({ generation: 3 }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse((spy.mock.calls.at(-1)![0] as string) ?? "{}")).toMatchObject({ event: "stop", message: "hard timeout reached" }); + }); + + it("onHardTimeoutExpired ignores a stale generation or an already-stopped instance", async () => { + expect.assertions(2); + + vi.spyOn(console, "log").mockImplementation(() => {}); + + const definition = defineContainer({ hardTimeout: "30s", image: "./app" }); + + const stale = new LunoraContainer( + fakeDurableObjectContext({ container: { monitor: () => Promise.resolve(), running: true }, storedGeneration: 5 }) as never, + {}, + definition, + "transcoder", + ); + const staleStop = vi.spyOn(stale, "stop").mockResolvedValue(undefined); + + await stale.onHardTimeoutExpired({ generation: 2 }); + + const stopped = new LunoraContainer( + fakeDurableObjectContext({ container: { running: false }, storedGeneration: 1 }) as never, + {}, + definition, + "transcoder", + ); + const stoppedStop = vi.spyOn(stopped, "stop").mockResolvedValue(undefined); + + await stopped.onHardTimeoutExpired({ generation: 1 }); + + expect(staleStop).not.toHaveBeenCalled(); + expect(stoppedStop).not.toHaveBeenCalled(); + }); + it("does not break onStart when the ShardDO push throws (best-effort)", async () => { expect.assertions(2); diff --git a/packages/container/docs/index.mdx b/packages/container/docs/index.mdx index 873122810..de1559f59 100644 --- a/packages/container/docs/index.mdx +++ b/packages/container/docs/index.mdx @@ -148,6 +148,115 @@ named instances you own. call for stateless work until then; for batch work, drive instances from a [scheduler](/docs/packages/scheduler) cron or `runAfter`. +## Multi-port containers + +A container that listens on more than one port (an app port plus an admin port, +say) declares every one with `requiredPorts` — start-up waits for all of them to +be listening. `defaultPort` is the target when a request doesn't pick a port; +route a single request elsewhere with `.port(n)`, which composes with `.get()`, +`.any()`, and `.pool()`: + +```ts +export const app = defineContainer({ + image: "./containers/app", + defaultPort: 8080, + requiredPorts: [8080, 9090], // app + admin +}); +``` + +```ts +// in an action: +await ctx.containers.app.get(tenantId).fetch("/work"); // → 8080 (defaultPort) +await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090 +``` + +## Egress firewall + +By default a container may open any outbound connection (`enableInternet: true`) +— and egress is billed per GB. Constrain it by pairing `enableInternet: false` +with an `allowedHosts` allow-list, or layer a `deniedHosts` deny-list that +overrides everything, including `enableInternet: true` and `allowedHosts`. Glob +patterns like `*.stripe.com` are supported. + +```ts +export const fetcher = defineContainer({ + image: "./containers/fetcher", + enableInternet: false, + allowedHosts: ["*.stripe.com", "api.github.com"], + deniedHosts: ["*.evil.com"], + interceptHttps: true, // extend the lists to TLS traffic (image must trust the Cloudflare CA) +}); +``` + +`interceptHttps` extends the allow/deny lists to **HTTPS** connections, not just +plain HTTP; it requires the image to trust the Cloudflare CA at +`/etc/cloudflare/certs/cloudflare-containers-ca.crt`. The interception path runs +through the `ContainerProxy` worker entrypoint, which codegen re-exports from the +generated container file automatically. + +Tighten or relax a single running instance at runtime through its named handle — +`egress.allow` / `deny` add one host, `removeAllowed` / `removeDenied` drop one, +and `setAllowed` / `setDenied` replace a whole list: + +```ts +await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com"); +await ctx.containers.fetcher.get(tenantId).egress.setDenied(["*.evil.com", "*.tracking.example"]); +``` + +For advanced egress _rewriting_ in worker code (inject auth, route, or mock a +container's outbound calls), `@lunora/container/do` re-exports Cloudflare's +custom outbound-handler types (`OutboundHandler`, `OutboundHandlers`, +`outboundParams`) — wire them onto a hand-authored `LunoraContainer` subclass. + +## Readiness gating + +The platform health check waits for an **open port** (`defaultPort`) — and, if +`pingEndpoint` is set, for an HTTP probe on that path on the same container — not +necessarily a _ready_ app. `readyOn` adds +application-level probes that gate request proxying: a `ctx.containers.` +fetch holds until every probe responds with its expected status, so callers +never hit a container still applying migrations or warming caches. + +```ts +export const api = defineContainer({ + image: "./containers/api", + defaultPort: 8080, + readyOn: [ + { path: "/ready" }, // expect 200 on defaultPort + { path: "/live", port: 9090, status: 204 }, // own port + expected status + ], +}); +``` + +Each probe declares a `path` (a leading slash is optional), an optional `port` +(defaults to `defaultPort`), and an optional `status` (defaults to `200`). +Probes are declarative **data** — no handler functions — so codegen and the +config layer read them without evaluating code. At start they run in parallel, +poll the container's TCP port directly, and fail the start if any probe doesn't +go ready within the readiness budget. + +## Hard timeout + +`sleepAfter` caps _idle_ time; `hardTimeout` caps _total_ lifetime — a +runaway-cost backstop that fires regardless of activity, measured from start. It +uses the same grammar as `sleepAfter` (`"30s"`, `"5m"`, `"1h"`, or a plain +number of seconds): + +```ts +export const job = defineContainer({ + image: "./containers/job", + sleepAfter: "5m", // sleep after 5 min idle + hardTimeout: "1h", // …but never run longer than an hour, busy or not +}); +``` + +When it elapses, the generated class's `onHardTimeoutExpired` hook runs — the +default action is `stop()`. The timer is armed through the container's own +scheduler and stamped with a run generation, so a stale timer left over from a +previous (slept or crashed) run never kills a fresh one. Advanced apps that +hand-author their container class can override `onHardTimeoutExpired` (the base +class lives in `@lunora/container/do`) to drain or checkpoint before stopping. + ## Calling Lunora from inside a container Container code calls back into your app's functions with the bridge client @@ -264,16 +373,28 @@ in the Studio **Advisors** table and at codegen time: ## Configuration -| Field | Purpose | -| ---------------- | -------------------------------------------------------------------------------------------- | -| `image` | Local Dockerfile path/directory, or `{ registry }` for a pre-built image. **Required.** | -| `defaultPort` | Port the container listens on; `fetch` targets it. Must be `EXPOSE`d for local dev. | -| `instanceType` | `lite` \| `basic` \| `standard-1..4`, or a custom `{ vcpu, memoryMib, diskMb }`. | -| `maxInstances` | Cap on concurrently running instances (and the default `.any()` pool size). | -| `sleepAfter` | Idle timeout before an instance sleeps, e.g. `"5m"`, `"30s"`, or seconds. Default `"10m"`. | -| `env` | Static environment variables baked into the container. | -| `secrets` | Names of Worker secrets forwarded into the container env at start. | -| `enableInternet` | Whether the container may open outbound connections. Default `true` — note egress is billed. | +| Field | Purpose | +| ---------------- | --------------------------------------------------------------------------------------------------------------- | +| `image` | Local Dockerfile path/directory, `{ registry }` for a pre-built image, or `{ build }` (Railpack). **Required.** | +| `defaultPort` | Port the container listens on; `fetch` targets it. Must be `EXPOSE`d for local dev. | +| `requiredPorts` | Every port start-up must wait for (multi-port); route a request to one with `.port(n)`. | +| `instanceType` | `lite` \| `basic` \| `standard-1..4`, or a custom `{ vcpu, memoryMib, diskMb }`. | +| `maxInstances` | Cap on concurrently running instances (and the default `.any()` pool size). | +| `sleepAfter` | Idle timeout before an instance sleeps, e.g. `"5m"`, `"30s"`, or seconds. Default `"10m"`. | +| `hardTimeout` | Hard cap on total lifetime from start, ignoring activity. Same grammar as `sleepAfter`. | +| `readyOn` | Application-level readiness probes that gate request proxying until the app reports ready. | +| `pingEndpoint` | HTTP path the platform polls to decide an instance is healthy. Defaults to upstream's `"ping"`. | +| `env` | Static environment variables passed to the container on every start (runtime). For secrets use `secrets`. | +| `secrets` | Names of Worker secrets forwarded into the container env at start. | +| `buildArgs` | Build-time args for a built image (wrangler `image_vars`, like `--build-arg`); ignored for `{ registry }`. | +| `entrypoint` | Override the image's `ENTRYPOINT`/`CMD` for every start. | +| `enableInternet` | Whether the container may open outbound connections. Default `true` — note egress is billed. | +| `allowedHosts` | Egress allow-list (globs) — hosts reachable even with `enableInternet: false`. | +| `deniedHosts` | Egress deny-list (globs) — overrides everything, including `allowedHosts`. | +| `interceptHttps` | Extend the egress lists to HTTPS too (image must trust the Cloudflare CA). Default `false`. | +| `labels` | Key-value metadata attached to every instance for metrics/observability. | +| `name` | Override the wrangler `containers[].name` identifier. | +| `rollout` | Rolling-deploy tuning: `{ stepPercentage, gracePeriodSeconds }`. | ### Secrets and environment @@ -291,6 +412,19 @@ export const worker = defineContainer({ }); ``` +Both `env` and `secrets` are **runtime** values, available when the container +starts. For **build-time** values — `docker build --build-arg`, exposed to the +Dockerfile as `ARG` (wrangler's `image_vars`) — use `buildArgs`. They only apply +to an image Lunora builds (a Dockerfile or Railpack `{ build }` source) and are +ignored for a pre-built `{ registry }` image: + +```ts +export const worker = defineContainer({ + image: "./containers/worker", + buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" }, // → docker --build-arg +}); +``` + ## Dockerfile The scaffolded Dockerfile encodes the platform's requirements: diff --git a/packages/container/src/client.ts b/packages/container/src/client.ts index 0ec77bc09..86036dda4 100644 --- a/packages/container/src/client.ts +++ b/packages/container/src/client.ts @@ -23,14 +23,26 @@ interface ContainerStartOptions { /** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */ interface ContainerInstanceState { [key: string]: unknown; + /** Process exit code, present once the instance has `stopped_with_code`. */ + exitCode?: number; + /** Epoch-ms of the last state transition. */ lastChange?: number; + /** Lifecycle status. Widening union — Cloudflare adds values over time. */ + status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping"; } -/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */ +/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */ interface ContainerStubLike { + allowHost?: (hostname: string) => Promise; + denyHost?: (hostname: string) => Promise; destroy?: () => Promise; fetch: (input: Request) => Promise; getState?: () => Promise; + removeAllowedHost?: (hostname: string) => Promise; + removeDeniedHost?: (hostname: string) => Promise; + renewActivityTimeout?: () => Promise; + setAllowedHosts?: (hosts: string[]) => Promise; + setDeniedHosts?: (hosts: string[]) => Promise; start?: (options?: ContainerStartOptions) => Promise; stop?: (signal?: number | string) => Promise; } @@ -81,6 +93,16 @@ interface ContainerHandle { * `Request`/URL passes through unchanged. */ fetch: (input: Request | string, init?: RequestInit) => Promise; + + /** + * Return a handle that routes every request to `targetPort` on the + * container instead of the definition's `defaultPort` — for multi-port + * containers (declare the ports in `requiredPorts`). Sets the + * `cf-container-target-port` header the way `@cloudflare/containers`' + * `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`: + * `ctx.containers.app.get("u1").port(9090).fetch("/admin")`. + */ + port: (targetPort: number) => ContainerHandle; } /** @@ -93,14 +115,52 @@ interface ContainerHandle { interface ContainerInstanceHandle extends ContainerHandle { /** Stop and discard the instance (its ephemeral disk is lost). */ destroy: () => Promise; + + /** + * Adjust this instance's egress allow/deny lists at runtime — the dynamic + * counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for + * per-tenant egress policy. Requires the worker to export `ContainerProxy` + * (codegen re-exports it from the generated container file whenever any + * container is defined, so the runtime controls always work). + */ + egress: ContainerEgressControls; /** Read the instance's current runtime state. */ getState: () => Promise; + + /** + * Reset the instance's `sleepAfter` idle timer. The platform renews it on + * each request automatically, but WebSocket message activity does not yet + * renew it (cloudflare/containers#147) — call this on inbound WS traffic to + * keep a busy socket's container awake. + */ + renewActivityTimeout: () => Promise; /** Explicitly start the instance, optionally with per-instance env/entrypoint. */ start: (options?: ContainerStartOptions) => Promise; /** Stop the instance (optionally with a signal); it can start again on the next request. */ stop: (signal?: number | string) => Promise; } +/** + * Runtime egress-firewall controls for a named instance (`handle.egress.*`). + * Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so + * an app can tighten or relax a single instance's allowed/denied hosts after + * start without redeploying. + */ +interface ContainerEgressControls { + /** Add one hostname (or glob) to the allow-list. */ + allow: (hostname: string) => Promise; + /** Add one hostname (or glob) to the deny-list. */ + deny: (hostname: string) => Promise; + /** Remove one hostname from the allow-list. */ + removeAllowed: (hostname: string) => Promise; + /** Remove one hostname from the deny-list. */ + removeDenied: (hostname: string) => Promise; + /** Replace the entire allow-list. */ + setAllowed: (hosts: ReadonlyArray) => Promise; + /** Replace the entire deny-list. */ + setDenied: (hosts: ReadonlyArray) => Promise; +} + /** The per-definition accessor exposed as `ctx.containers.<exportName>`. */ interface ContainerAccessor { /** @@ -174,27 +234,39 @@ const DEFAULT_POOL_SIZE = 3; */ const DEFAULT_MAX_BACKOFF_MS = 30_000; -const toRequest = (input: Request | string, init?: RequestInit): Request => { - if (typeof input === "string" && input.startsWith("/")) { - return new Request(`http://container${input}`, init); +/** The header `@cloudflare/containers`' `switchPort` sets to target a non-default container port. */ +const TARGET_PORT_HEADER = "cf-container-target-port"; + +const toRequest = (input: Request | string, init?: RequestInit, port?: number): Request => { + const request = typeof input === "string" && input.startsWith("/") ? new Request(`http://container${input}`, init) : new Request(input, init); + + if (port !== undefined) { + request.headers.set(TARGET_PORT_HEADER, String(port)); } - return new Request(input, init); + return request; }; -const handleFor = (namespace: ContainerNamespaceLike, instanceName: string): ContainerHandle => { +/** + * A fetch-only handle over a `send` function, carrying an optional target port. + * `.port(n)` re-binds the same `send` to a different port, so multi-port + * routing composes uniformly across `.get()`, `.any()`, and `.pool()`. + */ +const sendingHandle = (send: (request: Request) => Promise, port?: number): ContainerHandle => { return { - fetch: async (input, init) => namespace.get(namespace.idFromName(instanceName)).fetch(toRequest(input, init)), + fetch: async (input, init) => send(toRequest(input, init, port)), + port: (targetPort) => sendingHandle(send, targetPort), }; }; -/** Invoke an optional lifecycle RPC on a stub, with a directed error if the runtime doesn't expose it. */ -const lifecycleCall = async ( - stub: ContainerStubLike, - method: "destroy" | "getState" | "start" | "stop", - binding: string, - argument?: unknown, -): Promise => { +const handleFor = (namespace: ContainerNamespaceLike, instanceName: string): ContainerHandle => + sendingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request)); + +/** Lifecycle/egress RPCs `instanceHandleFor` forwards to the container DO stub. */ +type ContainerStubMethod = keyof Omit; + +/** Invoke an optional lifecycle/egress RPC on a stub, with a directed error if the runtime doesn't expose it. */ +const lifecycleCall = async (stub: ContainerStubLike, method: ContainerStubMethod, binding: string, argument?: unknown): Promise => { const rpc = stub[method]; if (typeof rpc !== "function") { @@ -204,14 +276,32 @@ const lifecycleCall = async ( return (rpc as (argument?: unknown) => Promise)(argument); }; -/** A named-instance handle: `fetch` plus the container DO's lifecycle RPCs. */ +/** + * The runtime egress controls for a named instance, each mapping a `handle.egress.*` + * method to its `@cloudflare/containers` `Container` RPC. Re-derives the stub per + * call (DO stubs are cheap and shouldn't be cached across the await boundary). + */ +const egressControlsFor = (stub: () => ContainerStubLike, binding: string): ContainerEgressControls => { + return { + allow: async (hostname) => lifecycleCall(stub(), "allowHost", binding, hostname), + deny: async (hostname) => lifecycleCall(stub(), "denyHost", binding, hostname), + removeAllowed: async (hostname) => lifecycleCall(stub(), "removeAllowedHost", binding, hostname), + removeDenied: async (hostname) => lifecycleCall(stub(), "removeDeniedHost", binding, hostname), + setAllowed: async (hosts) => lifecycleCall(stub(), "setAllowedHosts", binding, [...hosts]), + setDenied: async (hosts) => lifecycleCall(stub(), "setDeniedHosts", binding, [...hosts]), + }; +}; + +/** A named-instance handle: `fetch`/`.port()` plus the container DO's lifecycle + egress RPCs. */ const instanceHandleFor = (namespace: ContainerNamespaceLike, spec: ContainerBindingSpec, instanceName: string): ContainerInstanceHandle => { const stub = (): ContainerStubLike => namespace.get(namespace.idFromName(instanceName)); return { + ...sendingHandle(async (request) => stub().fetch(request)), destroy: async () => lifecycleCall(stub(), "destroy", spec.binding), - fetch: async (input, init) => stub().fetch(toRequest(input, init)), + egress: egressControlsFor(stub, spec.binding), getState: async () => lifecycleCall(stub(), "getState", spec.binding), + renewActivityTimeout: async () => lifecycleCall(stub(), "renewActivityTimeout", spec.binding), start: async (options) => lifecycleCall(stub(), "start", spec.binding, options), stop: async (signal) => lifecycleCall(stub(), "stop", spec.binding, signal), }; @@ -241,7 +331,7 @@ const retryOnServerError = (response: Response): boolean => response.status >= 5 * backoff. Pure over the namespace, so it's testable with a fake. The final * attempt's outcome (response or thrown error) is returned/propagated as-is. */ -const poolHandleFor = (namespace: ContainerNamespaceLike, spec: ContainerBindingSpec, options: PoolOptions = {}): ContainerHandle => { +const poolHandleFor = (namespace: ContainerNamespaceLike, spec: ContainerBindingSpec, options: PoolOptions = {}, port?: number): ContainerHandle => { const size = options.size ?? spec.maxInstances ?? DEFAULT_POOL_SIZE; const attempts = Math.max(1, options.attempts ?? 3); const baseBackoff = options.backoffMs ?? 100; @@ -260,7 +350,7 @@ const poolHandleFor = (namespace: ContainerNamespaceLike, spec: ContainerBinding await sleep(Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff)); } - const request = toRequest(input, init); + const request = toRequest(input, init, port); try { // eslint-disable-next-line no-await-in-loop -- attempts are inherently sequential @@ -277,6 +367,7 @@ const poolHandleFor = (namespace: ContainerNamespaceLike, spec: ContainerBinding // Exhausted attempts after a thrown error on the last try. throw lastError instanceof Error ? lastError : new Error(`ctx.containers.${spec.exportName}.pool(): all ${String(attempts)} attempts failed`); }, + port: (targetPort) => poolHandleFor(namespace, spec, options, targetPort), }; }; @@ -328,6 +419,35 @@ const createContainerContext = ( /** A test handler: receives the request plus the targeted instance name. */ type ContainerTestHandler = (request: Request, instance: { name: string }) => Promise | Response; +/** + * A fake DO namespace backing the test double: every instance's `fetch` plays + * the user's handler, and the lifecycle/egress RPCs are inert (resolve void / a + * stub state). Built so the double reuses the *real* `instanceHandleFor` / + * `handleFor` wiring — it can't drift from the production handle shape — while + * staying Docker-free. `idFromName` is identity so the handler sees the + * instance name unchanged. + */ +const testNamespaceFor = (handler: ContainerTestHandler): ContainerNamespaceLike => { + const stubFor = (name: string): ContainerStubLike => { + return { + allowHost: () => Promise.resolve(), + denyHost: () => Promise.resolve(), + destroy: () => Promise.resolve(), + fetch: (request) => Promise.resolve(handler(request, { name })), + getState: () => Promise.resolve({ lastChange: 0 }), + removeAllowedHost: () => Promise.resolve(), + removeDeniedHost: () => Promise.resolve(), + renewActivityTimeout: () => Promise.resolve(), + setAllowedHosts: () => Promise.resolve(), + setDeniedHosts: () => Promise.resolve(), + start: () => Promise.resolve(), + stop: () => Promise.resolve(), + }; + }; + + return { get: (id) => stubFor(String(id)), idFromName: (name) => name }; +}; + /** * Docker-free test double for `ctx.containers`: each export name maps to a * fetch handler that plays the container. Mirrors the real shape exactly, so @@ -343,31 +463,16 @@ const createContainerTestContext = (handlers: Record = {}; for (const [exportName, handler] of Object.entries(handlers)) { - const testHandleFor = (instanceName: string): ContainerHandle => { - return { - fetch: async (input, init) => handler(toRequest(input, init), { name: instanceName }), - }; - }; - - // Lifecycle calls in the double are inert (resolve void / a stub state) - // so action tests that stop/destroy/inspect an instance don't blow up; - // the double exercises action logic, not real container behavior. - const testInstanceHandleFor = (instanceName: string): ContainerInstanceHandle => { - return { - ...testHandleFor(instanceName), - destroy: () => Promise.resolve(), - getState: () => Promise.resolve({ lastChange: 0 }), - start: () => Promise.resolve(), - stop: () => Promise.resolve(), - }; - }; + const namespace = testNamespaceFor(handler); + const spec: ContainerBindingSpec = { binding: `CONTAINER_${exportName.toUpperCase()}`, exportName }; containers[exportName] = { - any: () => testHandleFor("pool-0"), - get: (name) => testInstanceHandleFor(name), - // The double doesn't simulate failure/retry — pool() just routes to - // the handler like any other call, so tests stay deterministic. - pool: () => testHandleFor("pool-0"), + // `.any()`/`.pool()` route to a fixed `pool-0` so the handler's + // `instance.name` is deterministic under test; the double doesn't + // simulate the random-pick or retry/backoff the real pool does. + any: () => handleFor(namespace, "pool-0"), + get: (name) => instanceHandleFor(namespace, spec, name), + pool: () => handleFor(namespace, "pool-0"), }; } @@ -377,6 +482,7 @@ const createContainerTestContext = (handlers: Record> = { h: 3600, m: 60, s: 1 }; + +/** + * Parse a `sleepAfter`/`hardTimeout` duration into whole seconds. A `number` is + * treated as seconds (floored); a string follows the `<digits><s|m|h>` grammar + * {@link SLEEP_AFTER_PATTERN} enforces. Shared by the runtime DO so the wire + * value the container sees is derived from the exact same logic that validates + * it. Throws on an unparseable string (already rejected by `defineContainer`). + */ +const parseDurationSeconds = (duration: number | string): number => { + if (typeof duration === "number") { + return Math.floor(duration); + } + + const match = SLEEP_AFTER_PATTERN.exec(duration); + + if (match === null) { + throw new TypeError(`Invalid duration "${duration}" — expected a number of seconds or "[smh]"`); + } + + return Number(duration.slice(0, -1)) * (DURATION_UNIT_SECONDS[duration.slice(-1)] ?? 1); +}; + /** Last path segment of a `/`-separated path (input is posix-style config). */ const basename = (path: string): string => { const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; @@ -173,11 +197,113 @@ const assertValidEnvAndSecrets = (config: ContainerConfig): void => { } }; +/** Validate a port is an integer in the TCP range, with a directed error naming the field. */ +const assertValidPort = (port: number, field: string): void => { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new TypeError(`defineContainer: \`${field}\` must be an integer in 1–65535 (got ${String(port)})`); + } +}; + +/** Validate the optional `readyOn` application-readiness probes (path, port, and status). */ +const assertValidReadyOnChecks = (config: ContainerConfig): void => { + for (const check of config.readyOn ?? []) { + if (typeof check.path !== "string" || check.path.trim().length === 0) { + throw new TypeError("defineContainer: `readyOn[].path` must be a non-empty HTTP path string"); + } + + if (check.path !== check.path.trim()) { + throw new TypeError("defineContainer: `readyOn[].path` must not have leading or trailing whitespace"); + } + + if (check.port !== undefined) { + assertValidPort(check.port, "readyOn[].port"); + } + + if (check.status !== undefined && (!Number.isInteger(check.status) || check.status < 100 || check.status > 599)) { + throw new TypeError(`defineContainer: \`readyOn[].status\` must be an HTTP status code in 100–599 (got ${String(check.status)})`); + } + } +}; + +/** Validate the optional `hardTimeout` hard-cap lifetime — same grammar as `sleepAfter`. */ +const assertValidHardTimeout = (hardTimeout: ContainerConfig["hardTimeout"]): void => { + if (hardTimeout === undefined) { + return; + } + + if (typeof hardTimeout === "string") { + if (!SLEEP_AFTER_PATTERN.test(hardTimeout)) { + throw new TypeError( + `defineContainer: \`hardTimeout\` string "${hardTimeout}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`, + ); + } + } else if (!Number.isInteger(hardTimeout) || hardTimeout < 1) { + throw new TypeError( + `defineContainer: \`hardTimeout\` must be a positive integer number of seconds or a duration string like "5m" (got ${String(hardTimeout)})`, + ); + } +}; + +/** Validate the egress-firewall fields — host allow/deny lists and `interceptHttps`. */ +const assertValidEgressFields = (config: ContainerConfig): void => { + for (const field of ["allowedHosts", "deniedHosts"] as const) { + const hosts = config[field]; + + if (hosts?.some((host) => typeof host !== "string" || host.trim().length === 0)) { + throw new TypeError(`defineContainer: \`${field}\` must be an array of non-empty hostname patterns`); + } + } + + if (config.interceptHttps !== undefined && typeof config.interceptHttps !== "boolean") { + throw new TypeError("defineContainer: `interceptHttps` must be a boolean, or omitted"); + } +}; + +/** + * Validate the runtime fields the generated class applies onto the `Container` + * base after `super()` — multi-port, the egress firewall, and observability + * `labels`. Caught here at authoring time rather than failing inside the + * worker. Blank (whitespace-only) strings are rejected too, since they'd reach + * the platform as bogus hostnames/paths. + */ +const assertValidContainerRuntimeFields = (config: ContainerConfig): void => { + if (config.requiredPorts !== undefined) { + if (config.requiredPorts.length === 0) { + throw new TypeError("defineContainer: `requiredPorts` must be a non-empty array of ports, or omitted"); + } + + for (const port of config.requiredPorts) { + assertValidPort(port, "requiredPorts[]"); + } + } + + if ( + config.entrypoint !== undefined && + (config.entrypoint.length === 0 || config.entrypoint.some((part) => typeof part !== "string" || part.trim().length === 0)) + ) { + throw new TypeError("defineContainer: `entrypoint` must be a non-empty array of non-empty strings, or omitted"); + } + + assertValidEgressFields(config); + + if (config.pingEndpoint !== undefined && (typeof config.pingEndpoint !== "string" || config.pingEndpoint.trim().length === 0)) { + throw new TypeError("defineContainer: `pingEndpoint` must be a non-empty path string"); + } + + for (const [key, value] of Object.entries(config.labels ?? {})) { + if (key.trim().length === 0 || typeof value !== "string") { + throw new TypeError("defineContainer: `labels` must be a record of non-empty keys to string values"); + } + } + + assertValidReadyOnChecks(config); +}; + const defineContainer = (config: ContainerConfig): ContainerDefinition => { assertValidImage(config.image); - if (config.defaultPort !== undefined && (!Number.isInteger(config.defaultPort) || config.defaultPort < 1 || config.defaultPort > 65_535)) { - throw new TypeError(`defineContainer: \`defaultPort\` must be an integer in 1–65535 (got ${String(config.defaultPort)})`); + if (config.defaultPort !== undefined) { + assertValidPort(config.defaultPort, "defaultPort"); } const stepPercentage = config.rollout?.stepPercentage; @@ -202,7 +328,9 @@ const defineContainer = (config: ContainerConfig): ContainerDefinition => { ); } + assertValidHardTimeout(config.hardTimeout); assertValidEnvAndSecrets(config); + assertValidContainerRuntimeFields(config); return { ...config, isLunoraContainer: true }; }; @@ -244,5 +372,6 @@ export { defineContainer, isContainerDefinition, normalizeContainerImage, + parseDurationSeconds, resolveContainerEnvVariables as resolveContainerEnvVars, }; diff --git a/packages/container/src/do/index.ts b/packages/container/src/do/index.ts index e1c161588..3d0be85ea 100644 --- a/packages/container/src/do/index.ts +++ b/packages/container/src/do/index.ts @@ -9,14 +9,28 @@ import type { StopParams } from "@cloudflare/containers"; import { Container } from "@cloudflare/containers"; -import { resolveContainerEnvVars as resolveContainerEnvVariables } from "../define-container"; +import { parseDurationSeconds, resolveContainerEnvVars as resolveContainerEnvVariables } from "../define-container"; import { emitContainerLifecycle } from "../lifecycle-event"; -import type { ContainerDefinition } from "../types"; +import type { ContainerDefinition, ContainerReadinessCheck } from "../types"; import type { DurableObjectJurisdiction } from "./report-lifecycle"; import { reportContainerLifecycle } from "./report-lifecycle"; type DurableObjectContext = ConstructorParameters[0]; +/** Interval between `readyOn` probe attempts while waiting for the app to come up. */ +const READINESS_POLL_INTERVAL_MS = 500; + +/** Upper bound on how long the `readyOn` probes block container start before failing. */ +const READINESS_TIMEOUT_MS = 30_000; + +/** + * Durable-storage key holding a monotonically-increasing "run generation". Each + * start bumps it and stamps the `hardTimeout` schedule with the new value, so a + * stale schedule left over from a previous run (the container slept or crashed, + * then restarted) is recognised and ignored instead of killing the fresh run. + */ +const HARD_TIMEOUT_GENERATION_KEY = "__lunoraHardTimeoutGeneration"; + /** * Base class for the generated Container DO classes. Applies a * `defineContainer` definition onto `@cloudflare/containers`' `Container`: @@ -43,6 +57,12 @@ class LunoraContainer extends Container { private readonly lunoraJurisdiction?: DurableObjectJurisdiction; /** The `lunora/containers.ts` export name, for lifecycle log correlation. */ private readonly lunoraName: string; + /** Default port the readiness probes target when a check omits its own `port`. */ + private readonly lunoraDefaultPort?: number; + /** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */ + private readonly lunoraHardTimeoutSeconds?: number; + /** Declarative readiness probes that gate request proxying (from the `readyOn` config). */ + private readonly lunoraReadyOn: ReadonlyArray; public constructor( context: DurableObjectContext, @@ -53,6 +73,7 @@ class LunoraContainer extends Container { ) { super(context, env, { defaultPort: definition.defaultPort, + entrypoint: definition.entrypoint ? [...definition.entrypoint] : undefined, envVars: resolveContainerEnvVariables(definition, env as Record, exportName), sleepAfter: definition.sleepAfter, }); @@ -61,8 +82,50 @@ class LunoraContainer extends Container { this.enableInternet = definition.enableInternet; } + // Multi-port + egress-firewall fields are `Container` instance + // properties (not constructor options), applied here from the + // definition. Each is guarded so an un-set field leaves the + // `@cloudflare/containers` default in place. + if (definition.requiredPorts !== undefined) { + this.requiredPorts = [...definition.requiredPorts]; + } + + if (definition.interceptHttps !== undefined) { + this.interceptHttps = definition.interceptHttps; + } + + if (definition.allowedHosts !== undefined) { + this.allowedHosts = [...definition.allowedHosts]; + } + + if (definition.deniedHosts !== undefined) { + this.deniedHosts = [...definition.deniedHosts]; + } + + if (definition.pingEndpoint !== undefined) { + this.pingEndpoint = definition.pingEndpoint; + } + + if (definition.labels !== undefined) { + this.labels = { ...definition.labels }; + } + this.lunoraName = exportName ?? "container"; this.lunoraJurisdiction = jurisdiction; + this.lunoraDefaultPort = definition.defaultPort; + this.lunoraReadyOn = definition.readyOn ? [...definition.readyOn] : []; + this.lunoraHardTimeoutSeconds = definition.hardTimeout === undefined ? undefined : parseDurationSeconds(definition.hardTimeout); + } + + public override async onActivityExpired(): Promise { + // The container slept after its `sleepAfter` idle window. Surfacing it + // makes the WebSocket-keepalive gap (cloudflare/containers#147) visible + // in the dev log + Studio rather than a silent disappearance. + const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "sleep"); + + this.surfaceInStudioLogs(envelope); + + await super.onActivityExpired(); } public override onError(error: unknown): unknown { @@ -79,6 +142,42 @@ class LunoraContainer extends Container { this.surfaceInStudioLogs(envelope); await super.onStart(); + + // The base calls `onStart()` after the ports are healthy and inside a + // `blockConcurrencyWhile`, and `containerFetch` routes through that same + // start path — so arming the hard timeout here makes it count from the + // real start, and awaiting readiness here gates request proxying until + // the app reports ready. + await this.armHardTimeout(); + await this.awaitContainerReadiness(); + } + + /** + * Hook run when the container's `hardTimeout` elapses (dispatched by the base + * scheduler via the run-generation-stamped schedule armed in + * {@link onStart}). Default: stop the instance. Override to drain/checkpoint + * first. A stale schedule from a previous run, or an already-stopped + * instance, is ignored (upstream cloudflare/containers#85). + */ + public async onHardTimeoutExpired(payload?: { generation?: number }): Promise { + const current = await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY); + + // Ignore a schedule left over from a previous run (the container slept or + // crashed and restarted between arming and firing) — killing the fresh + // run early would be a surprising, hard-to-debug shutdown. + if (payload?.generation !== undefined && payload.generation !== current) { + return; + } + + if (this.ctx.container?.running !== true) { + return; + } + + const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", "hard timeout reached"); + + this.surfaceInStudioLogs(envelope); + + await this.stop(); } public override async onStop(parameters: StopParams): Promise { @@ -89,6 +188,88 @@ class LunoraContainer extends Container { await super.onStop(parameters); } + /** + * Arm the hard-timeout kill via the base scheduler (so it integrates with + * the container's own alarm machinery instead of fighting it). Bumps the run + * generation and stamps the schedule with it, so {@link onHardTimeoutExpired} + * can tell a fresh schedule from a stale one. No-op without a `hardTimeout`. + */ + private async armHardTimeout(): Promise { + if (this.lunoraHardTimeoutSeconds === undefined) { + return; + } + + const generation = ((await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY)) ?? 0) + 1; + + await this.ctx.storage.put(HARD_TIMEOUT_GENERATION_KEY, generation); + await this.schedule(this.lunoraHardTimeoutSeconds, "onHardTimeoutExpired", { generation }); + } + + /** + * Block until every `readyOn` probe responds with its expected status, or + * throw once the readiness budget is spent. Probes run in parallel and hit + * the container's TCP port directly (NOT `containerFetch`, which would + * recurse back into the start path). No-op without `readyOn`. + */ + private async awaitContainerReadiness(): Promise { + if (this.lunoraReadyOn.length === 0) { + return; + } + + const { container } = this.ctx; + + if (container === undefined) { + return; + } + + const deadline = Date.now() + READINESS_TIMEOUT_MS; + + await Promise.all(this.lunoraReadyOn.map(async (check) => this.awaitReadinessCheck(container, check, deadline))); + } + + /** Poll one readiness probe until it returns its expected status or the shared deadline passes. */ + private async awaitReadinessCheck( + container: NonNullable, + check: ContainerReadinessCheck, + deadline: number, + ): Promise { + const port = check.port ?? this.lunoraDefaultPort; + + if (port === undefined) { + throw new Error( + `container "${this.lunoraName}": readyOn check "${check.path}" has no port — set the check's \`port\` or the container \`defaultPort\`.`, + ); + } + + const expectedStatus = check.status ?? 200; + const path = check.path.startsWith("/") ? check.path : `/${check.path}`; + const tcpPort = container.getTcpPort(port); + + for (;;) { + try { + // eslint-disable-next-line no-await-in-loop -- sequential poll: each probe waits on the previous attempt before retrying. + const response = await tcpPort.fetch(`http://container${path}`); + + if (response.status === expectedStatus) { + return; + } + } catch { + // Connection refused / app not up yet — fall through and retry below. + } + + if (Date.now() >= deadline) { + throw new Error( + `container "${this.lunoraName}": readiness check "${check.path}" (port ${String(port)}) did not return ${String(expectedStatus)} within ${String(READINESS_TIMEOUT_MS)}ms`, + ); + } + + // eslint-disable-next-line no-await-in-loop -- back-off between poll attempts is intentionally sequential. + await new Promise((resolve) => { + setTimeout(resolve, READINESS_POLL_INTERVAL_MS); + }); + } + } + /** * Best-effort push of `envelope` into the root ShardDO's log buffer so it * also appears in the Studio Logs panel (the terminal already has it via @@ -119,4 +300,19 @@ class LunoraContainer extends Container { } } -export default LunoraContainer; +export { LunoraContainer }; +// Re-exported so the generated `_generated/containers.ts` can surface it from the +// worker entry: the `Container` outbound-interception path (egress allow/deny +// lists, `interceptHttps`, runtime egress controls) routes container traffic +// through this WorkerEntrypoint, which the deployed worker must therefore export. +// Funneling it through `@lunora/container/do` keeps the app depending only on +// `@lunora/container`, never on `@cloudflare/containers` directly. +export { ContainerProxy } from "@cloudflare/containers"; +// Custom outbound-interception handlers (cloudflare/containers#135). These let a +// subclass of `LunoraContainer` rewrite/route a container's egress in worker +// code (e.g. inject auth, mock an upstream, enforce a proxy). They're worker-side +// functions, so they don't fit the data-only `defineContainer` config — surface +// the upstream types/helper here so an advanced app can wire them on its own +// generated subclass without depending on `@cloudflare/containers` directly. +export type { OutboundHandler, OutboundHandlerContext, OutboundHandlerParams, OutboundHandlerParamsOf, OutboundHandlers } from "@cloudflare/containers"; +export { outboundParams } from "@cloudflare/containers"; diff --git a/packages/container/src/index.ts b/packages/container/src/index.ts index 967f7f891..6b8a48b1f 100644 --- a/packages/container/src/index.ts +++ b/packages/container/src/index.ts @@ -11,6 +11,7 @@ export type { ContainerAccessor, ContainerBindingSpec, + ContainerEgressControls, ContainerHandle, ContainerInstanceHandle, ContainerInstanceState, @@ -36,6 +37,7 @@ export type { ContainerDefinition, ContainerImageSource, ContainerInstanceType, + ContainerReadinessCheck, ContainerRollout, CustomContainerInstanceType, NamedContainerInstanceType, diff --git a/packages/container/src/lifecycle-event.ts b/packages/container/src/lifecycle-event.ts index bc057d6e5..af8c89487 100644 --- a/packages/container/src/lifecycle-event.ts +++ b/packages/container/src/lifecycle-event.ts @@ -12,7 +12,7 @@ */ /** A container lifecycle transition worth a log line. */ -type ContainerLifecycle = "error" | "start" | "stop"; +type ContainerLifecycle = "error" | "sleep" | "start" | "stop"; /** The `source` tag every Lunora console event carries (mirrors `LUNORA_EVENT_SOURCE`). */ const LUNORA_EVENT_SOURCE = "lunora"; diff --git a/packages/container/src/types.ts b/packages/container/src/types.ts index 8fbd7a703..611ed39b2 100644 --- a/packages/container/src/types.ts +++ b/packages/container/src/types.ts @@ -60,7 +60,39 @@ interface BuildImageSource { */ type ContainerImageSource = BuildImageSource | RegistryImageSource | string; +/** + * An application-level readiness probe that gates request proxying. Layered on + * top of the platform's own port/`pingEndpoint` health wait, it lets you hold + * traffic back until the app inside the container is *functionally* ready — + * migrations applied, caches warmed — which an open-port check can't see. + * + * Declarative on purpose: a `defineContainer` value stays pure data (no handler + * functions), so codegen and the config layer can read it without evaluating + * code. (Upstream cloudflare/containers#188 expresses the same idea as handler + * functions; the Lunora config is data-only, so it's modelled as descriptors.) + */ +interface ContainerReadinessCheck { + /** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */ + path: string; + /** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */ + port?: number; + /** HTTP status that means "ready". Defaults to `200`. */ + status?: number; +} + interface ContainerConfig { + /** + * Hostnames the container may reach **even when {@link ContainerConfig.enableInternet} + * is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob + * patterns like `*.stripe.com` are supported. Pair with `enableInternet: + * false` to deny all egress except these hosts (the firewall pattern + * upstream issue cloudflare/containers#30 asked for). The interception path + * needs the `ContainerProxy` worker entrypoint, which codegen re-exports + * from the generated container file automatically; the named-instance + * handle's `egress` controls adjust the lists at runtime. + */ + allowedHosts?: ReadonlyArray; + /** * Build-time variables for a Dockerfile/Railpack image — wrangler's * `image_vars` (equivalent to `docker build --build-arg`). For *runtime* @@ -71,17 +103,35 @@ interface ContainerConfig { /** * The port the container listens on. Worker → container requests target - * this port. Locally the Dockerfile must also `EXPOSE` it. + * this port. Locally the Dockerfile must also `EXPOSE` it. For a + * multi-port container also declare {@link ContainerConfig.requiredPorts} + * and route per request with the handle's `.port(n)`. */ defaultPort?: number; + /** + * Hostnames the container may **never** reach — an egress deny-list + * (Cloudflare's `deniedHosts`). Overrides everything else, including + * `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob + * patterns like `*.evil.com` are supported. + */ + deniedHosts?: ReadonlyArray; + /** * Whether the container may open outbound internet connections. Defaults * to `true` — the platform default. Note that container egress is billed - * per GB by Cloudflare. + * per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} / + * {@link ContainerConfig.deniedHosts} for a precise egress firewall. */ enableInternet?: boolean; + /** + * Default command to run inside the container, overriding the image's + * `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is + * still available via the named-instance handle's `start({ entrypoint })`. + */ + entrypoint?: ReadonlyArray; + /** * Static environment variables passed to the container on every start. * For secret values use {@link ContainerConfig.secrets} instead so they @@ -89,6 +139,16 @@ interface ContainerConfig { */ env?: Readonly>; + /** + * Hard cap on how long an instance may run, measured from start regardless + * of activity — a runaway-cost backstop on top of the idle + * {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter` + * (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses, + * the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`). + * (Upstream cloudflare/containers#85.) + */ + hardTimeout?: number | string; + /** Image source — a local Dockerfile path/directory or a registry reference. */ image: ContainerImageSource; @@ -98,6 +158,23 @@ interface ContainerConfig { */ instanceType?: ContainerInstanceType; + /** + * Intercept the container's outbound **HTTPS** traffic so the egress + * allow/deny lists apply to TLS connections too (Cloudflare's + * `interceptHttps`). Requires the image to trust the Cloudflare CA at + * `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false` + * (HTTP egress is gated regardless). + */ + interceptHttps?: boolean; + + /** + * Key-value metadata attached to every instance for metrics/observability + * (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`. + * A per-start override is available via the named-instance handle's + * `start({ labels })`. + */ + labels?: Readonly>; + /** * Maximum number of concurrently *running* instances. Stopped (slept) * containers don't count. Also the default pool size for `.any()`. @@ -110,6 +187,33 @@ interface ContainerConfig { */ name?: string; + /** + * HTTP path Cloudflare polls to decide an instance is healthy + * (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`; + * either `"ping"` or `"/healthz"`-style paths are accepted. Set this when + * the container exposes its readiness check under a different route. + */ + pingEndpoint?: string; + + /** + * Application-level readiness probes that gate request proxying: a + * `ctx.containers.<name>` fetch waits until every probe responds with its + * expected status before the request reaches the container — on top of the + * platform's port/`pingEndpoint` health wait. All probes run in parallel. + * Use these for readiness an open-port check can't see (migrations applied, + * caches warm). (Upstream cloudflare/containers#188.) + */ + readyOn?: ReadonlyArray; + + /** + * Ports the container must be listening on before it's considered ready + * (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up + * waits for every listed port, and the handle's `.port(n)` routes a request + * to any of them; {@link ContainerConfig.defaultPort} is the target when a + * request doesn't pick one. + */ + requiredPorts?: ReadonlyArray; + /** * Rolling-deploy tuning. `stepPercentage` is the share of instances updated * per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds` @@ -168,6 +272,7 @@ export type { ContainerDefinition, ContainerImageSource, ContainerInstanceType, + ContainerReadinessCheck, ContainerRollout, CustomContainerInstanceType, NamedContainerInstanceType,