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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions control-plane/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"cf:typegen": "node scripts/gen-cf-typegen.mjs"
},
"dependencies": {
"@cloudflare/containers": "^0.3.7",
"hono": "^4.12.27"
},
"devDependencies": {
Expand Down
92 changes: 92 additions & 0 deletions control-plane/src/container-driver.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; entrypoint?: string[]; enableInternet?: boolean }): Promise<void>;
stop(): Promise<void>;
isProvisioned(): Promise<boolean>;
markProvisioned(): Promise<void>;
markDeprovisioned(): Promise<void>;
};

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<Product, ContainerNamespaceLike>;
};

export type ContainerDriver = {
createContainer(request: TenantProvisioningRequest): Promise<void>;
destroyContainer(request: TenantProvisioningRequest): Promise<void>;
containerExists(request: TenantProvisioningRequest): Promise<boolean>;
};

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<void> {
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<void> {
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<boolean> {
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),
};
}
66 changes: 47 additions & 19 deletions control-plane/src/driver-factory.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string, string | undefined> = 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<string, string | undefined> = 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;
}
11 changes: 11 additions & 0 deletions control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 49 additions & 5 deletions control-plane/src/worker.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return (await this.ctx.storage.get<boolean>(PROVISIONED_STORAGE_KEY)) === true;
}
async markProvisioned(): Promise<void> {
await this.ctx.storage.put(PROVISIONED_STORAGE_KEY, true);
}
async markDeprovisioned(): Promise<void> {
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<Response> {
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),
Expand Down
Loading