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
42 changes: 42 additions & 0 deletions control-plane/src/driver-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 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.
import { createNeonDatabaseDriver, type DatabaseDriver, type NeonDatabaseDriverConfig } from "./neon-database-driver.js";
import { createFakeTenantProvisioningDriver, type TenantProvisioningDriver } from "./tenant-provisioning-driver.js";

function nonBlank(value: string | undefined): string | undefined {
const text = value?.trim();
return text ? text : undefined;
}

/** Compose a real database driver onto an existing `TenantProvisioningDriver`, overriding only
* `provisionDatabase`/`dropDatabase` -- every other step (createContainer, injectSecrets, containerExists,
* destroyContainer, revokeSecrets) is forwarded to `base` unchanged. */
export function withRealDatabaseDriver(base: TenantProvisioningDriver, databaseDriver: DatabaseDriver): TenantProvisioningDriver {
return {
...base,
provisionDatabase: (request) => databaseDriver.provisionDatabase(request),
dropDatabase: (request) => databaseDriver.dropDatabase(request),
};
}

/** 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();
const apiKey = nonBlank(env.NEON_API_KEY);
const projectId = nonBlank(env.NEON_PROJECT_ID);
if (!apiKey || !projectId) return fake;

const config: NeonDatabaseDriverConfig = { apiKey, projectId };
return withRealDatabaseDriver(fake, createNeonDatabaseDriver(config));
}
12 changes: 12 additions & 0 deletions control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export {
createFakeTenantProvisioningDriver,
type DatabaseConnectionDetails,
type FakeDriverCall,
type FakeDriverStep,
type FakeTenantProvisioningDriver,
Expand Down Expand Up @@ -38,3 +39,14 @@ export {
type SettlementBackendDriver,
type SettlementReversalReason,
} from "./settlement-backend-driver.js";
export {
createNeonDatabaseDriver,
dropNeonDatabase,
provisionNeonDatabase,
type DatabaseDriver,
type NeonDatabaseDriverConfig,
} from "./neon-database-driver.js";
export {
createTenantProvisioningDriver,
withRealDatabaseDriver,
} from "./driver-factory.js";
203 changes: 203 additions & 0 deletions control-plane/src/neon-database-driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Real `provisionDatabase`/`dropDatabase` implementation against Neon (#7653, part of #7180's provisioning
// core -- the Postgres provider itself, Neon + Cloudflare Hyperdrive, was already decided on #7180, the same
// decision #7649/#7858 build on for APR's own per-attempt branch forking). Isolation model: ONE tenant = ONE
// Neon branch off the project's default branch, each with its own dedicated database + role -- mirrors
// #7858's own per-attempt branch-off-a-branch design, just one level up (tenant branch, not attempt branch).
//
// Deliberately does NOT implement the full `TenantProvisioningDriver` interface -- only the database methods
// (see `DatabaseDriver` below). Container creation (#7851) and secret injection (#7852) are separate,
// independently-blocked pieces; `withRealDatabaseDriver` (driver-factory.ts) composes this onto an otherwise
// fake driver so `provisionTenant`/`deprovisionTenant`'s orchestration is untouched.
//
// Does NOT create a Cloudflare Hyperdrive binding for the returned connection: `control-plane/` has no
// deployable service or `wrangler.jsonc` yet (#7654, still open) -- there is nowhere for a binding to attach
// to. This returns the raw Neon connection details; routing them through Hyperdrive is #7654's job once a
// real hosted control-plane service exists to declare that binding.
//
// Endpoint paths/response shapes below follow Neon's public v2 API (https://api-docs.neon.tech/reference) as
// documented at the time this was written -- verify against a live account before the first real deploy (the
// test suite mocks every call; no live Neon credentials are used anywhere in this repo).
import type { DatabaseConnectionDetails, TenantProvisioningRequest } from "./tenant-provisioning-driver.js";

const DEFAULT_API_BASE_URL = "https://console.neon.tech/api/v2";
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_OPERATION_POLL_INTERVAL_MS = 500;
const DEFAULT_OPERATION_POLL_TIMEOUT_MS = 30_000;

export type NeonDatabaseDriverConfig = {
apiKey: string;
projectId: string;
/** Override for tests only -- production always uses Neon's real API. */
apiBaseUrl?: string;
/** Override for tests only -- keeps operation-polling tests fast. */
operationPollIntervalMs?: number;
operationPollTimeoutMs?: number;
};

/** The database-only slice of `TenantProvisioningDriver` this module actually implements. Composed onto a full
* driver by `withRealDatabaseDriver` (driver-factory.ts), never used standalone against `provisionTenant`. */
export type DatabaseDriver = {
provisionDatabase(request: TenantProvisioningRequest): Promise<DatabaseConnectionDetails>;
dropDatabase(request: TenantProvisioningRequest): Promise<void>;
};

type NeonOperation = { id: string; status: string };

type NeonBranch = { id: string; name: string };

type NeonEndpoint = { host: string };

type NeonRole = { name: string; password?: string };

/** Neon branch names are case-sensitive but this keeps them predictable and collision-free across products
* sharing a tenant name, and safely truncated well under Neon's own length limit. */
function branchNameFor(request: TenantProvisioningRequest): string {
const raw = `tenant-${request.product}-${request.tenant.name}`.toLowerCase();
const sanitized = raw.replaceAll(/[^a-z0-9_-]+/g, "-").replaceAll(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
return sanitized.slice(0, 63);
}

/** A tenant-scoped role gets the SAME derived name as its branch -- one branch, one role, one database, no
* separate naming scheme to keep in sync. */
function roleNameFor(request: TenantProvisioningRequest): string {
return branchNameFor(request);
}

function databaseNameFor(request: TenantProvisioningRequest): string {
return branchNameFor(request);
}

class NeonApiError extends Error {
constructor(method: string, path: string, status: number, body: string) {
super(`Neon API ${method} ${path} failed (${status}): ${body.slice(0, 500)}`);
this.name = "NeonApiError";
}
}

async function neonFetch<T>(config: NeonDatabaseDriverConfig, method: string, path: string, body?: unknown): Promise<T> {
const baseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE_URL;
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
authorization: `Bearer ${config.apiKey}`,
"content-type": "application/json",
accept: "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
const text = await response.text();
if (!response.ok) throw new NeonApiError(method, path, response.status, text);
return (text ? JSON.parse(text) : undefined) as T;
}

/** Neon branch/database/role/endpoint mutations are asynchronous -- the mutating call returns pending
* `operations[]`, which must reach `"finished"` before the resource is actually usable (e.g. an endpoint
* accepting connections). Fails loudly on a `"failed"` operation or on exceeding the poll timeout, rather than
* silently returning a not-actually-ready result. */
async function waitForOperations(config: NeonDatabaseDriverConfig, operations: readonly NeonOperation[]): Promise<void> {
const intervalMs = config.operationPollIntervalMs ?? DEFAULT_OPERATION_POLL_INTERVAL_MS;
const timeoutMs = config.operationPollTimeoutMs ?? DEFAULT_OPERATION_POLL_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
let pending = operations.filter((operation) => operation.status !== "finished");
while (pending.length > 0) {
if (Date.now() >= deadline) {
throw new Error(`Neon operation(s) did not finish within ${timeoutMs}ms: ${pending.map((operation) => operation.id).join(", ")}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
const refreshed = await Promise.all(
pending.map((operation) => neonFetch<{ operation: NeonOperation }>(config, "GET", `/projects/${config.projectId}/operations/${operation.id}`)),
);
for (const { operation } of refreshed) {
if (operation.status === "failed") throw new Error(`Neon operation ${operation.id} failed`);
}
pending = refreshed.map(({ operation }) => operation).filter((operation) => operation.status !== "finished");
}
}

async function findBranchByName(config: NeonDatabaseDriverConfig, name: string): Promise<NeonBranch | undefined> {
const { branches } = await neonFetch<{ branches: NeonBranch[] }>(config, "GET", `/projects/${config.projectId}/branches`);
return branches.find((branch) => branch.name === name);
}

async function branchEndpointHost(config: NeonDatabaseDriverConfig, branchId: string): Promise<string> {
const { endpoints } = await neonFetch<{ endpoints: NeonEndpoint[] }>(config, "GET", `/projects/${config.projectId}/branches/${branchId}/endpoints`);
const endpoint = endpoints[0];
if (!endpoint) throw new Error(`Neon branch ${branchId} has no compute endpoint`);
return endpoint.host;
}

function connectionDetailsFor(host: string, database: string, user: string, password: string): DatabaseConnectionDetails {
const port = 5432;
return { host, port, database, user, password, connectionString: `postgres://${user}:${password}@${host}:${port}/${database}` };
}

/** Provision (or, idempotently, re-resolve) a tenant's dedicated Neon branch + database + role, returning
* connection details routed at that branch's own compute endpoint. Safe to call repeatedly for the same
* tenant: an existing branch is found by its stable derived name and its role's password re-revealed (Neon
* can reveal a role's current password at any time, not just at creation), rather than creating a duplicate. */
export async function provisionNeonDatabase(config: NeonDatabaseDriverConfig, request: TenantProvisioningRequest): Promise<DatabaseConnectionDetails> {
const branchName = branchNameFor(request);
const roleName = roleNameFor(request);
const databaseName = databaseNameFor(request);

const existing = await findBranchByName(config, branchName);
if (existing) {
const host = await branchEndpointHost(config, existing.id);
const { role } = await neonFetch<{ role: NeonRole }>(config, "GET", `/projects/${config.projectId}/branches/${existing.id}/roles/${roleName}/reveal_password`);
if (!role.password) throw new Error(`Neon role ${roleName} on branch ${existing.id} has no revealable password`);
return connectionDetailsFor(host, databaseName, roleName, role.password);
}

const created = await neonFetch<{ branch: NeonBranch; endpoints: NeonEndpoint[]; operations: NeonOperation[] }>(
config,
"POST",
`/projects/${config.projectId}/branches`,
{ branch: { name: branchName }, endpoints: [{ type: "read_write" }] },
);
await waitForOperations(config, created.operations);
const host = created.endpoints[0]?.host;
if (!host) throw new Error(`Neon branch ${created.branch.id} was created without a compute endpoint`);

const roleCreated = await neonFetch<{ role: NeonRole; operations: NeonOperation[] }>(
config,
"POST",
`/projects/${config.projectId}/branches/${created.branch.id}/roles`,
{ role: { name: roleName } },
);
await waitForOperations(config, roleCreated.operations);
if (!roleCreated.role.password) throw new Error(`Neon role ${roleName} was created without a password`);

const databaseCreated = await neonFetch<{ operations: NeonOperation[] }>(
config,
"POST",
`/projects/${config.projectId}/branches/${created.branch.id}/databases`,
{ database: { name: databaseName, owner_name: roleName } },
);
await waitForOperations(config, databaseCreated.operations);

return connectionDetailsFor(host, databaseName, roleName, roleCreated.role.password);
}

/** Idempotent teardown: deleting a tenant's branch cascades to its database/role/endpoint together (Neon
* deletes everything scoped to a branch when the branch itself is deleted). A tenant with no branch (never
* provisioned, or already dropped) is a safe no-op, matching every other driver's teardown contract. */
export async function dropNeonDatabase(config: NeonDatabaseDriverConfig, request: TenantProvisioningRequest): Promise<void> {
const branchName = branchNameFor(request);
const existing = await findBranchByName(config, branchName);
if (!existing) return;

// Tolerates a body-less success response (e.g. 204 No Content) -- some APIs return nothing for a DELETE that
// completed synchronously, with no operation left to poll.
const result = await neonFetch<{ operations?: NeonOperation[] } | undefined>(config, "DELETE", `/projects/${config.projectId}/branches/${existing.id}`);
await waitForOperations(config, result?.operations ?? []);
}

/** Bundles {@link provisionNeonDatabase}/{@link dropNeonDatabase} as a {@link DatabaseDriver} closed over one
* config -- the shape `withRealDatabaseDriver` composes onto a full `TenantProvisioningDriver`. */
export function createNeonDatabaseDriver(config: NeonDatabaseDriverConfig): DatabaseDriver {
return {
provisionDatabase: (request) => provisionNeonDatabase(config, request),
dropDatabase: (request) => dropNeonDatabase(config, request),
};
}
12 changes: 9 additions & 3 deletions control-plane/src/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type NotifyProvisioningFailure,
} from "./pagerduty-notify.js";
import type {
DatabaseConnectionDetails,
Product,
Tenant,
TenantLifecycleState,
Expand All @@ -25,11 +26,15 @@ import type {
} from "./tenant-provisioning-driver.js";

/** Result of a successful provision — terminal lifecycle state `"active"` (the vocabulary tenant-client.ts
* passes through from this API). */
* passes through from this API). Carries `database` (#7653) so a freshly created role's connection details --
* often retrievable from the provider only at creation time -- aren't silently discarded by this orchestration
* before any caller gets a chance to persist them. What a caller DOES with them (e.g. routing into
* `injectSecrets` via #7852's secret-injection driver) is that driver's own job, not this orchestration's. */
export type TenantProvisioningResult = {
tenant: Tenant;
product: Product;
state: Extract<TenantLifecycleState, "active">;
database: DatabaseConnectionDetails;
};

/** Result of a successful deprovision — terminal lifecycle state `"torn down"`. */
Expand Down Expand Up @@ -83,14 +88,15 @@ export async function provisionTenant(
pagerDuty: ProvisioningPagerDutyOptions = {},
): Promise<TenantProvisioningResult> {
const request: TenantProvisioningRequest = { tenant, product };
let database: DatabaseConnectionDetails;
try {
await driver.createContainer(request);
await driver.provisionDatabase(request);
database = await driver.provisionDatabase(request);
await driver.injectSecrets(request);
} catch (error) {
pageAndRethrow(tenant, product, "provision", error, pagerDuty);
}
return { tenant, product, state: "active" };
return { tenant, product, state: "active", database };
}

/** Deprovision a tenant by tearing #7180's three steps down in REVERSE order. Same product-agnostic call shape
Expand Down
31 changes: 29 additions & 2 deletions control-plane/src/tenant-provisioning-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,30 @@ export type TenantProvisioningRequest = {
product: Product;
};

/** What `provisionDatabase` hands back (#7653): everything a caller needs to actually reach the tenant's
* database. `connectionString` is the ready-to-use `postgres://` URI (what a real Neon driver's `host`/`port`/
* `user`/`password`/`database` fields compose into); kept alongside the parts so a caller that needs one
* field (e.g. just `database` for logging) doesn't have to parse the URI back apart. Routing this through a
* Cloudflare Hyperdrive binding is #7654's job once control-plane has a deployable service to attach one to
* (see neon-database-driver.ts's header comment) -- this type only carries the raw connection, not a
* Hyperdrive-specific shape. */
export type DatabaseConnectionDetails = {
host: string;
port: number;
database: string;
user: string;
password: string;
connectionString: string;
};

export interface TenantProvisioningDriver {
/** Step 1 (#7180): stand up the tenant's isolated container. Real driver → Cloudflare Containers API. */
createContainer(request: TenantProvisioningRequest): Promise<void>;
/** Step 2 (#7180): provision the tenant's database. Real driver → the chosen Postgres provider (#7524-blocked). */
provisionDatabase(request: TenantProvisioningRequest): Promise<void>;
/** Step 2 (#7180): provision the tenant's database, returning its connection details (#7653) -- a freshly
* created role's password is typically retrievable from the provider only at creation time, so the caller
* must capture this return value rather than re-deriving it later. Real driver → the chosen Postgres
* provider (Neon + Hyperdrive, decided on #7180; see neon-database-driver.ts). */
provisionDatabase(request: TenantProvisioningRequest): Promise<DatabaseConnectionDetails>;
/** Step 3 (#7180): inject the tenant's secrets. A real driver delegates to #7174's generalized broker
* (src/orb/broker.ts); the fake only records the call. No real secrets path is imported by this package. */
injectSecrets(request: TenantProvisioningRequest): Promise<void>;
Expand Down Expand Up @@ -121,6 +140,14 @@ export function createFakeTenantProvisioningDriver(): FakeTenantProvisioningDriv
async provisionDatabase(request) {
record("provisionDatabase", request);
databases.add(request.tenant.name);
// Deterministic per-tenant fake connection details -- no real IO, no state beyond the existing
// `databases` set, just enough shape for callers/tests exercising the widened (#7653) return contract.
const host = `fake-${request.tenant.name}.control-plane.invalid`;
const port = 5432;
const database = request.tenant.name;
const user = request.tenant.name;
const password = `fake-password-${request.tenant.name}`;
return { host, port, database, user, password, connectionString: `postgres://${user}:${password}@${host}:${port}/${database}` };
},
async injectSecrets(request) {
record("injectSecrets", request);
Expand Down
Loading