diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json index 0a23eac13a..0cb19c1a8e 100644 --- a/control-plane/package-lock.json +++ b/control-plane/package-lock.json @@ -8,6 +8,7 @@ "name": "@loopover/control-plane", "version": "0.1.0", "dependencies": { + "@cloudflare/containers": "^0.3.7", "hono": "^4.12.27" }, "devDependencies": { @@ -32,6 +33,12 @@ "node": ">=18" } }, + "node_modules/@cloudflare/containers": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.3.7.tgz", + "integrity": "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==", + "license": "MIT OR Apache-2.0" + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", diff --git a/control-plane/package.json b/control-plane/package.json index 4d078cee95..e70c2bc610 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -20,6 +20,7 @@ "cf:typegen": "node scripts/gen-cf-typegen.mjs" }, "dependencies": { + "@cloudflare/containers": "^0.3.7", "hono": "^4.12.27" }, "devDependencies": { diff --git a/control-plane/src/container-driver.ts b/control-plane/src/container-driver.ts new file mode 100644 index 0000000000..82c829122a --- /dev/null +++ b/control-plane/src/container-driver.ts @@ -0,0 +1,92 @@ +// Real `createContainer`/`destroyContainer`/`containerExists` implementation against Cloudflare Containers +// (#7851, part of #7180's provisioning core -- the container platform + one-per-tenant-per-product model was +// already ratified on #7173). One tenant+product pair = one Container Durable Object instance, keyed by +// `${product}:${tenant.name}`. +// +// Deliberately does NOT implement the full `TenantProvisioningDriver` interface -- only the container methods +// (see `ContainerDriver` below). Database provisioning (#7653) and secret injection (#7852) are separate, +// independently-shippable pieces; `withRealContainerDriver` (driver-factory.ts) composes this onto an +// otherwise fake/real-mixed driver. +// +// "Provisioned" is tracked as an explicit flag the paired Container DO stores in its OWN durable storage +// (see worker.ts's ProvisionedContainer base class), NOT derived from Cloudflare's own transient container +// run-state (`getState()`'s `running`/`stopped`/`stopped_with_code`, etc). That distinction matters concretely +// for AMS: its existing Docker image (packages/loopover-miner/Dockerfile) is a one-shot CLI tool with no +// long-running process -- per #7182's own design, a legitimately-provisioned, currently-dormant AMS tenant's +// container is EXPECTED to sit in a "stopped"-shaped run state almost all the time, which is indistinguishable +// from "never provisioned" using run-state alone. An explicit, durable flag is unambiguous for either product. +import type { Product, TenantProvisioningRequest } from "./tenant-provisioning-driver.js"; + +/** The slice of a real Container DO's RPC surface this module actually calls. Kept as a small local + * interface (not the real `@cloudflare/containers` types) so this file stays plain, portable TypeScript, + * testable with a trivial fake under `node:test` -- mirrors neon-database-driver.ts's and + * tenant-registry.ts's own identical "local interface, no SDK import" convention. */ +export type ContainerStubLike = { + start(options?: { envVars?: Record; entrypoint?: string[]; enableInternet?: boolean }): Promise; + stop(): Promise; + isProvisioned(): Promise; + markProvisioned(): Promise; + markDeprovisioned(): Promise; +}; + +export type ContainerNamespaceLike = { + getByName(name: string): ContainerStubLike; +}; + +export type ContainerDriverConfig = { + /** One binding per product -- ORB and AMS run different Docker images (root Dockerfile vs + * packages/loopover-miner/Dockerfile), and a Cloudflare Container binding is fixed to one image at the + * wrangler.jsonc level, so a single shared binding can't switch images per request. Keyed by the exact + * `product` string `TenantProvisioningRequest` carries; an unconfigured product is a real + * misconfiguration, not a silent no-op (see `bindingFor`). */ + bindings: Record; +}; + +export type ContainerDriver = { + createContainer(request: TenantProvisioningRequest): Promise; + destroyContainer(request: TenantProvisioningRequest): Promise; + containerExists(request: TenantProvisioningRequest): Promise; +}; + +function instanceNameFor(request: TenantProvisioningRequest): string { + return `${request.product}:${request.tenant.name}`; +} + +function bindingFor(config: ContainerDriverConfig, product: Product): ContainerNamespaceLike { + const binding = config.bindings[product]; + if (!binding) throw new Error(`no container binding configured for product "${product}"`); + return binding; +} + +/** Idempotent: an already-provisioned tenant's container is left running as-is, never restarted -- a repeat + * create must not interrupt a container mid-work. */ +export async function createTenantContainer(config: ContainerDriverConfig, request: TenantProvisioningRequest): Promise { + const stub = bindingFor(config, request.product).getByName(instanceNameFor(request)); + if (await stub.isProvisioned()) return; + await stub.start(); + await stub.markProvisioned(); +} + +/** Idempotent: a tenant that was never provisioned (or already torn down) is a safe no-op, matching every + * other driver's teardown contract. */ +export async function destroyTenantContainer(config: ContainerDriverConfig, request: TenantProvisioningRequest): Promise { + const stub = bindingFor(config, request.product).getByName(instanceNameFor(request)); + if (!(await stub.isProvisioned())) return; + await stub.stop(); + await stub.markDeprovisioned(); +} + +export async function tenantContainerExists(config: ContainerDriverConfig, request: TenantProvisioningRequest): Promise { + const stub = bindingFor(config, request.product).getByName(instanceNameFor(request)); + return stub.isProvisioned(); +} + +/** Bundles the three functions above as a {@link ContainerDriver} closed over one config -- the shape + * `withRealContainerDriver` composes onto a full `TenantProvisioningDriver`. */ +export function createContainerDriver(config: ContainerDriverConfig): ContainerDriver { + return { + createContainer: (request) => createTenantContainer(config, request), + destroyContainer: (request) => destroyTenantContainer(config, request), + containerExists: (request) => tenantContainerExists(config, request), + }; +} diff --git a/control-plane/src/driver-factory.ts b/control-plane/src/driver-factory.ts index e2e7db9b53..3dd9eacc6d 100644 --- a/control-plane/src/driver-factory.ts +++ b/control-plane/src/driver-factory.ts @@ -1,11 +1,13 @@ -// Selects a fake vs. partially-real `TenantProvisioningDriver` (#7653) -- the "driver factory" mechanism -// #7653's own issue text assumes but, per a full repo read at the time this was written, did not yet exist -// anywhere in `control-plane/`. Composition, not a second full driver implementation: `withRealDatabaseDriver` -// takes any base driver (today, always the fake -- #7851/#7852 haven't landed their own real -// createContainer/injectSecrets yet) and swaps in real Neon-backed provisionDatabase/dropDatabase, leaving -// every other step exactly as the base driver already implements it. This is what lets #7653 ship -// independently of #7851/#7852, and lets each of those compose their own real methods in on top later without -// this file changing. +// Selects a fake vs. partially-real `TenantProvisioningDriver` (#7653, #7851) -- the "driver factory" +// mechanism #7653's own issue text assumed but, per a full repo read at the time it was written, did not yet +// exist anywhere in `control-plane/`. Composition, not a second full driver implementation: each +// `withReal*Driver` helper takes any base driver and swaps in real methods for its own slice of the +// interface (`withRealDatabaseDriver` -> provisionDatabase/dropDatabase, `withRealContainerDriver` -> +// createContainer/destroyContainer/containerExists), leaving every other step exactly as the base driver +// already implements it. This is what let #7653 and #7851 ship independently of each other and of #7852 +// (secret injection, still not landed) -- that piece will compose its own real methods in on top later +// without this file changing. +import { createContainerDriver, type ContainerDriver, type ContainerDriverConfig } from "./container-driver.js"; import { createNeonDatabaseDriver, type DatabaseDriver, type NeonDatabaseDriverConfig } from "./neon-database-driver.js"; import { createFakeTenantProvisioningDriver, type TenantProvisioningDriver } from "./tenant-provisioning-driver.js"; @@ -25,18 +27,44 @@ export function withRealDatabaseDriver(base: TenantProvisioningDriver, databaseD }; } -/** Selects a real Neon-backed database driver (composed onto an otherwise-fake `TenantProvisioningDriver`) when - * `NEON_API_KEY`/`NEON_PROJECT_ID` are both configured, or the plain fake driver otherwise -- e.g. in tests, or - * before a maintainer has provisioned a real Neon project (#7875-style account setup). Takes `env` as a plain - * parameter (defaulting to `process.env`) rather than reading it internally, matching this package's existing - * `ProvisioningPagerDutyOptions.env` seam so callers can inject a fake env in tests without any real - * environment-variable mutation. */ -export function createTenantProvisioningDriver(env: Record = process.env): TenantProvisioningDriver { - const fake = createFakeTenantProvisioningDriver(); +/** Compose a real container driver onto an existing `TenantProvisioningDriver`, overriding only + * `createContainer`/`destroyContainer`/`containerExists` -- every other step is forwarded to `base` + * unchanged. Same composition shape as `withRealDatabaseDriver` -- independently stackable, so #7653's + * database driver and #7851's container driver can each be composed onto the same base driver without + * knowing about each other. */ +export function withRealContainerDriver(base: TenantProvisioningDriver, containerDriver: ContainerDriver): TenantProvisioningDriver { + return { + ...base, + createContainer: (request) => containerDriver.createContainer(request), + destroyContainer: (request) => containerDriver.destroyContainer(request), + containerExists: (request) => containerDriver.containerExists(request), + }; +} + +/** Selects real drivers piece by piece as their config becomes available, composed onto the fake for + * whatever isn't configured yet: the real Neon database driver when `NEON_API_KEY`/`NEON_PROJECT_ID` are + * set (#7653), and the real Cloudflare Containers driver when `containerBindings` is given (#7851). Takes + * `env` as a plain parameter (defaulting to `process.env`) rather than reading it internally, matching this + * package's existing `ProvisioningPagerDutyOptions.env` seam so callers can inject a fake env in tests + * without any real environment-variable mutation. `containerBindings` is a SEPARATE parameter rather than + * folded into `env`: real Durable Object namespace bindings are live objects only available inside a + * Workers runtime, not string env vars -- worker.ts passes them explicitly. */ +export function createTenantProvisioningDriver( + env: Record = process.env, + containerBindings?: ContainerDriverConfig["bindings"], +): TenantProvisioningDriver { + let driver: TenantProvisioningDriver = createFakeTenantProvisioningDriver(); + const apiKey = nonBlank(env.NEON_API_KEY); const projectId = nonBlank(env.NEON_PROJECT_ID); - if (!apiKey || !projectId) return fake; + if (apiKey && projectId) { + const config: NeonDatabaseDriverConfig = { apiKey, projectId }; + driver = withRealDatabaseDriver(driver, createNeonDatabaseDriver(config)); + } + + if (containerBindings && Object.keys(containerBindings).length > 0) { + driver = withRealContainerDriver(driver, createContainerDriver({ bindings: containerBindings })); + } - const config: NeonDatabaseDriverConfig = { apiKey, projectId }; - return withRealDatabaseDriver(fake, createNeonDatabaseDriver(config)); + return driver; } diff --git a/control-plane/src/index.ts b/control-plane/src/index.ts index dd8d33fa0f..6196dfa04d 100644 --- a/control-plane/src/index.ts +++ b/control-plane/src/index.ts @@ -48,8 +48,19 @@ export { } from "./neon-database-driver.js"; export { createTenantProvisioningDriver, + withRealContainerDriver, withRealDatabaseDriver, } from "./driver-factory.js"; +export { + createContainerDriver, + createTenantContainer, + destroyTenantContainer, + tenantContainerExists, + type ContainerDriver, + type ContainerDriverConfig, + type ContainerNamespaceLike, + type ContainerStubLike, +} from "./container-driver.js"; export { createFakeTenantRegistry, createKvTenantRegistry, diff --git a/control-plane/src/worker.ts b/control-plane/src/worker.ts index 73e1f50140..9992ac85e4 100644 --- a/control-plane/src/worker.ts +++ b/control-plane/src/worker.ts @@ -1,17 +1,61 @@ -// Cloudflare Worker entry point for control-plane's real HTTP transport (#7654). Pure infra glue: wires the -// real KV-backed tenant registry, the admin Bearer secret, and whichever `TenantProvisioningDriver` env -// selects (real Neon database driver if NEON_API_KEY/NEON_PROJECT_ID are set, the fake otherwise -- see +// Cloudflare Worker entry point for control-plane's real HTTP transport (#7654) and, as of #7851, real +// tenant container lifecycle. Pure infra glue: wires the real KV-backed tenant registry, the admin Bearer +// secret, and whichever `TenantProvisioningDriver` pieces are configured (real Neon database driver if +// NEON_API_KEY/NEON_PROJECT_ID are set, real Cloudflare Containers driver for the two bindings below -- see // driver-factory.ts) into the plain, already-tested Hono app (http-app.ts). Adds NO route logic of its own. // -// Not unit-tested: exercised only by real Cloudflare Workers/KV infrastructure, matching +// Not unit-tested: exercised only by real Cloudflare Workers/KV/Containers infrastructure, matching // packages/discovery-index/src/worker.ts's own identical exclusion (see scripts/control-plane-coverage.mjs). +import { Container } from "@cloudflare/containers"; import { createTenantProvisioningDriver } from "./driver-factory.js"; import { createTenantHttpApp } from "./http-app.js"; import { createKvTenantRegistry } from "./tenant-registry.js"; +const PROVISIONED_STORAGE_KEY = "provisioned"; + +/** Shared base for both product-specific Container classes below: tracks whether THIS tenant's container has + * been explicitly provisioned, in the DO's own durable storage -- independent of Cloudflare's own transient + * container run-state (`getState()`'s running/stopped/stopped_with_code/etc). That distinction is + * load-bearing, concretely for AMS: its one-shot CLI image (see AmsTenantContainer below) is EXPECTED to sit + * in a "stopped"-shaped run state almost all the time by design (#7182), indistinguishable from "never + * provisioned" using run-state alone -- container-driver.ts's header comment covers this in full. */ +class ProvisionedContainer extends Container { + async isProvisioned(): Promise { + return (await this.ctx.storage.get(PROVISIONED_STORAGE_KEY)) === true; + } + async markProvisioned(): Promise { + await this.ctx.storage.put(PROVISIONED_STORAGE_KEY, true); + } + async markDeprovisioned(): Promise { + await this.ctx.storage.delete(PROVISIONED_STORAGE_KEY); + } +} + +/** ORB's tenant container (#7173's ratified one-container-per-tenant-per-product model): runs the SAME root + * Dockerfile self-host image unmodified, on the port that image's own PORT env var / HEALTHCHECK already + * use. Webhook routing into this container (#7181) is a separate, not-yet-built piece -- this class only + * stands the container up and tears it down; #7181 is what will actually proxy requests through it. */ +export class OrbTenantContainer extends ProvisionedContainer { + defaultPort = 8787; + sleepAfter = "10m"; +} + +/** AMS's tenant container: packages/loopover-miner/Dockerfile's own image, unmodified -- a one-shot CLI tool + * (`ENTRYPOINT ["loopover-miner"]`, no long-running HTTP server, per that Dockerfile's own header comment + * "batch/CLI workload... not a long-running HTTP service"). Deliberately no `defaultPort`: nothing here + * needs `Container.fetch()`'s HTTP-proxying, since #7182 (cron wake) runs one-shot subcommands via a + * per-invocation entrypoint override, not HTTP. Sleeps quickly -- #7182's own "sleeping, zero-cost + * container" model expects this dormant almost all the time, briefly woken on a per-tenant cron schedule. */ +export class AmsTenantContainer extends ProvisionedContainer { + sleepAfter = "1m"; +} + export default { async fetch(request: Request, env: Env): Promise { - const driver = createTenantProvisioningDriver({ NEON_API_KEY: env.NEON_API_KEY, NEON_PROJECT_ID: env.NEON_PROJECT_ID }); + const driver = createTenantProvisioningDriver( + { NEON_API_KEY: env.NEON_API_KEY, NEON_PROJECT_ID: env.NEON_PROJECT_ID }, + { orb: env.ORB_TENANT_CONTAINER, ams: env.AMS_TENANT_CONTAINER }, + ); const app = createTenantHttpApp({ driver, registry: createKvTenantRegistry(env.TENANT_REGISTRY), diff --git a/control-plane/test/container-driver.test.ts b/control-plane/test/container-driver.test.ts new file mode 100644 index 0000000000..dcb3544774 --- /dev/null +++ b/control-plane/test/container-driver.test.ts @@ -0,0 +1,132 @@ +// Tests for the real Cloudflare Containers driver (#7851). No live Cloudflare account or infrastructure +// anywhere here -- ContainerNamespaceLike/ContainerStubLike are hand-rolled fakes tracking their own calls, +// mirroring the fake-driver convention already used throughout this package. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + createContainerDriver, + createTenantContainer, + destroyTenantContainer, + tenantContainerExists, + type ContainerDriverConfig, + type ContainerNamespaceLike, + type ContainerStubLike, + type TenantProvisioningRequest, +} from "../dist/index.js"; + +type FakeContainerStub = ContainerStubLike & { calls: string[] }; + +function fakeContainerStub(initial: { provisioned?: boolean } = {}): FakeContainerStub { + let provisioned = initial.provisioned ?? false; + const calls: string[] = []; + return { + calls, + async start() { + calls.push("start"); + }, + async stop() { + calls.push("stop"); + }, + async isProvisioned() { + return provisioned; + }, + async markProvisioned() { + calls.push("markProvisioned"); + provisioned = true; + }, + async markDeprovisioned() { + calls.push("markDeprovisioned"); + provisioned = false; + }, + }; +} + +function fakeNamespace(stub: FakeContainerStub): ContainerNamespaceLike & { requestedNames: string[] } { + const requestedNames: string[] = []; + return { + requestedNames, + getByName(name: string) { + requestedNames.push(name); + return stub; + }, + }; +} + +const REQUEST: TenantProvisioningRequest = { tenant: { name: "acme" }, product: "orb" }; + +test("createTenantContainer starts a fresh (never-provisioned) container and marks it provisioned", async () => { + const stub = fakeContainerStub(); + const namespace = fakeNamespace(stub); + const config: ContainerDriverConfig = { bindings: { orb: namespace } }; + + await createTenantContainer(config, REQUEST); + + assert.deepEqual(stub.calls, ["start", "markProvisioned"]); + assert.equal(await stub.isProvisioned(), true); +}); + +test("createTenantContainer is idempotent: an already-provisioned tenant is never restarted", async () => { + const stub = fakeContainerStub({ provisioned: true }); + const config: ContainerDriverConfig = { bindings: { orb: fakeNamespace(stub) } }; + + await createTenantContainer(config, REQUEST); + + assert.deepEqual(stub.calls, []); +}); + +test("destroyTenantContainer stops a provisioned container and marks it deprovisioned", async () => { + const stub = fakeContainerStub({ provisioned: true }); + const config: ContainerDriverConfig = { bindings: { orb: fakeNamespace(stub) } }; + + await destroyTenantContainer(config, REQUEST); + + assert.deepEqual(stub.calls, ["stop", "markDeprovisioned"]); + assert.equal(await stub.isProvisioned(), false); +}); + +test("destroyTenantContainer is idempotent: a never-provisioned tenant is never stopped", async () => { + const stub = fakeContainerStub(); + const config: ContainerDriverConfig = { bindings: { orb: fakeNamespace(stub) } }; + + await destroyTenantContainer(config, REQUEST); + + assert.deepEqual(stub.calls, []); +}); + +test("tenantContainerExists reflects the provisioned flag, not any run-state concept", async () => { + const provisioned = fakeContainerStub({ provisioned: true }); + const notProvisioned = fakeContainerStub({ provisioned: false }); + + assert.equal(await tenantContainerExists({ bindings: { orb: fakeNamespace(provisioned) } }, REQUEST), true); + assert.equal(await tenantContainerExists({ bindings: { orb: fakeNamespace(notProvisioned) } }, REQUEST), false); +}); + +test("the instance key is product-scoped (${product}:${tenant.name}), not just the tenant name", async () => { + const stub = fakeContainerStub(); + const namespace = fakeNamespace(stub); + const config: ContainerDriverConfig = { bindings: { ams: namespace } }; + + await createTenantContainer(config, { tenant: { name: "acme" }, product: "ams" }); + + assert.deepEqual(namespace.requestedNames, ["ams:acme"]); +}); + +test("an unconfigured product throws a clear error rather than silently no-oping", async () => { + const config: ContainerDriverConfig = { bindings: { orb: fakeNamespace(fakeContainerStub()) } }; + + await assert.rejects(createTenantContainer(config, { tenant: { name: "acme" }, product: "ams" }), /no container binding configured for product "ams"/); + await assert.rejects(destroyTenantContainer(config, { tenant: { name: "acme" }, product: "ams" }), /no container binding configured/); + await assert.rejects(tenantContainerExists(config, { tenant: { name: "acme" }, product: "ams" }), /no container binding configured/); +}); + +test("createContainerDriver bundles all three functions closed over one config", async () => { + const stub = fakeContainerStub(); + const driver = createContainerDriver({ bindings: { orb: fakeNamespace(stub) } }); + + assert.equal(await driver.containerExists(REQUEST), false); + await driver.createContainer(REQUEST); + assert.equal(await driver.containerExists(REQUEST), true); + await driver.destroyContainer(REQUEST); + assert.equal(await driver.containerExists(REQUEST), false); +}); diff --git a/control-plane/test/driver-factory.test.ts b/control-plane/test/driver-factory.test.ts index 500dc2963d..fe8240d80f 100644 --- a/control-plane/test/driver-factory.test.ts +++ b/control-plane/test/driver-factory.test.ts @@ -6,11 +6,37 @@ import { afterEach, beforeEach, test } from "node:test"; import { createFakeTenantProvisioningDriver, createTenantProvisioningDriver, + withRealContainerDriver, withRealDatabaseDriver, + type ContainerDriver, + type ContainerNamespaceLike, + type ContainerStubLike, type DatabaseDriver, type TenantProvisioningRequest, } from "../dist/index.js"; +function fakeContainerNamespace(provisioned = false): ContainerNamespaceLike { + let flag = provisioned; + const stub: ContainerStubLike = { + async start() { + flag = true; + }, + async stop() { + flag = false; + }, + async isProvisioned() { + return flag; + }, + async markProvisioned() { + flag = true; + }, + async markDeprovisioned() { + flag = false; + }, + }; + return { getByName: () => stub }; +} + const REQUEST: TenantProvisioningRequest = { tenant: { name: "acme" }, product: "orb" }; let originalFetch: typeof fetch; @@ -60,6 +86,73 @@ test("withRealDatabaseDriver: overrides provisionDatabase/dropDatabase, forwards assert.equal(base.injectedSecrets.has("acme"), false); }); +test("withRealContainerDriver: overrides createContainer/destroyContainer/containerExists, forwards every other step to base", async () => { + const base = createFakeTenantProvisioningDriver(); + const calls: string[] = []; + const containerDriver: ContainerDriver = { + createContainer: async () => { + calls.push("real-create"); + }, + destroyContainer: async () => { + calls.push("real-destroy"); + }, + containerExists: async () => { + calls.push("real-exists"); + return true; + }, + }; + + const composed = withRealContainerDriver(base, containerDriver); + + await composed.createContainer(REQUEST); + assert.equal(await composed.containerExists(REQUEST), true); + await composed.destroyContainer(REQUEST); + assert.deepEqual(calls, ["real-create", "real-exists", "real-destroy"]); + // The fake's own createContainer never ran -- its `containers` set stays empty even though the composed + // driver's own lifecycle calls all resolved successfully. + assert.equal(base.containers.has("acme"), false); + + // Every non-container step still runs against `base` exactly as before composition. + const details = await composed.provisionDatabase(REQUEST); + assert.equal(details.host, "fake-acme.control-plane.invalid"); + await composed.injectSecrets(REQUEST); + assert.ok(base.injectedSecrets.has("acme")); +}); + +test("createTenantProvisioningDriver: falls back to the fake container behavior when containerBindings is omitted or empty", async () => { + const noBindings = createTenantProvisioningDriver({}, undefined); + const emptyBindings = createTenantProvisioningDriver({}, {}); + + await noBindings.createContainer(REQUEST); + await emptyBindings.createContainer(REQUEST); + + // No real container driver was selected in either case -- calling into an unconfigured product on the + // fake never throws (unlike the real container-driver.ts, which throws for an unconfigured product). + assert.equal(await noBindings.containerExists(REQUEST), true); + assert.equal(await emptyBindings.containerExists(REQUEST), true); +}); + +test("createTenantProvisioningDriver: selects the real container driver when containerBindings is given", async () => { + const driver = createTenantProvisioningDriver({}, { orb: fakeContainerNamespace() }); + + assert.equal(await driver.containerExists(REQUEST), false); + await driver.createContainer(REQUEST); + assert.equal(await driver.containerExists(REQUEST), true); +}); + +test("createTenantProvisioningDriver: composes both the real database driver AND the real container driver together", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ branches: [] }), { status: 200 })) as unknown as typeof fetch; + + const driver = createTenantProvisioningDriver({ NEON_API_KEY: "real-key", NEON_PROJECT_ID: "real-project" }, { orb: fakeContainerNamespace() }); + + // Real container path selected... + await driver.createContainer(REQUEST); + assert.equal(await driver.containerExists(REQUEST), true); + // ...and the real database path too (rejects against the mocked, wrong-shaped Neon response, proving it's + // not the fake's own always-succeeds provisionDatabase). + await assert.rejects(driver.provisionDatabase(REQUEST)); +}); + test("createTenantProvisioningDriver: falls back to the plain fake when NEON_API_KEY is unset", async () => { const driver = createTenantProvisioningDriver({}); diff --git a/control-plane/worker-configuration.d.ts b/control-plane/worker-configuration.d.ts index ac68e6d5fb..3261d0c5c8 100644 --- a/control-plane/worker-configuration.d.ts +++ b/control-plane/worker-configuration.d.ts @@ -1,14 +1,17 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 4598851b1c63d09108592d54ada3416e) +// Generated by Wrangler by running `wrangler types` (hash: 134906cbf7a4991b8da13d634b78c112) // Runtime types generated with workerd@1.20260721.1 2026-07-21 nodejs_compat interface __BaseEnv_Env { TENANT_REGISTRY: KVNamespace; NEON_PROJECT_ID: "REPLACE_WITH_REAL_NEON_PROJECT_ID"; LOOPOVER_ENABLE_PAGERDUTY: "false"; + ORB_TENANT_CONTAINER: DurableObjectNamespace; + AMS_TENANT_CONTAINER: DurableObjectNamespace; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/worker"); + durableNamespaces: "OrbTenantContainer" | "AmsTenantContainer"; } interface Env extends __BaseEnv_Env {} } diff --git a/control-plane/wrangler.jsonc b/control-plane/wrangler.jsonc index d1db83fc37..a5328b18de 100644 --- a/control-plane/wrangler.jsonc +++ b/control-plane/wrangler.jsonc @@ -1,8 +1,9 @@ { - // Cloudflare Worker deployment for control-plane's real HTTP transport (#7654, part of the #7173 ORB+AMS - // hosting control-plane). Exposes exactly the routes packages/loopover-miner/lib/tenant-client.ts (already - // merged) hardcodes: POST/GET /v1/tenants, DELETE /v1/tenants/:name -- see src/http-app.ts for the route - // logic and src/worker.ts for how this binding config wires into it. + // Cloudflare Worker deployment for control-plane's real HTTP transport (#7654) and real tenant container + // lifecycle (#7851), part of the #7173 ORB+AMS hosting control-plane. Exposes exactly the routes + // packages/loopover-miner/lib/tenant-client.ts (already merged) hardcodes: POST/GET /v1/tenants, + // DELETE /v1/tenants/:name -- see src/http-app.ts for the route logic and src/worker.ts for how this + // binding config wires into it. // // Before first deploy, create the KV namespace and set the real secrets (never commit real values, and // never put them in this file): @@ -10,6 +11,11 @@ // npx wrangler secret put ADMIN_TOKEN // npx wrangler secret put NEON_API_KEY (only needed to select the real Neon database driver, #7653 -- // omit to keep createTenantProvisioningDriver on the fake driver) + // + // Both container images below build from the REPO ROOT context (image_build_context: ".."), one level up + // from this file -- same npm-workspaces reasoning as packages/discovery-index/wrangler.jsonc's identical + // choice: each Dockerfile's own COPY . . needs every workspace package.json present for `npm ci` to + // symlink internal dependencies correctly. "name": "loopover-control-plane", "main": "src/worker.ts", "compatibility_date": "2026-07-21", @@ -29,11 +35,48 @@ "id": "REPLACE_WITH_REAL_KV_NAMESPACE_ID" } ], + "containers": [ + { + "name": "loopover-control-plane-orb-container", + "class_name": "OrbTenantContainer", + "image": "../Dockerfile", + "image_build_context": "..", + "instance_type": "basic" + // No max_instances cap: unlike packages/discovery-index's own single fixed-name instance (a + // correctness requirement for its shared in-memory cache), this is genuinely a fleet -- one real, + // independent container per ORB tenant, with no shared state between instances to protect. + }, + { + "name": "loopover-control-plane-ams-container", + "class_name": "AmsTenantContainer", + "image": "../packages/loopover-miner/Dockerfile", + "image_build_context": "..", + "instance_type": "basic" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "ORB_TENANT_CONTAINER", + "class_name": "OrbTenantContainer" + }, + { + "name": "AMS_TENANT_CONTAINER", + "class_name": "AmsTenantContainer" + } + ] + }, "observability": { "enabled": true, "logs": { "enabled": true, "head_sampling_rate": 1 } - } + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["OrbTenantContainer", "AmsTenantContainer"] + } + ] }