From 7a765811a5d445bdd55fef17fb843c6f0781e587 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:47:29 -0700 Subject: [PATCH] feat(control-plane): real Neon + Hyperdrive driver for tenant database provisioning (#7653) Implements provisionDatabase/dropDatabase for real against Neon (one Neon branch + dedicated database/role per tenant, idempotent create/destroy, operation polling to completion), composed onto the existing fake driver via a new driver-factory so createContainer/injectSecrets stay untouched until #7851/#7852 land their own real implementations. Widens TenantProvisioningDriver's provisionDatabase to return connection details instead of discarding them, threading the result through provisionTenant's own return value. Does not wire a Cloudflare Hyperdrive binding: control-plane has no deployable service/wrangler.jsonc yet (#7654), so there's nowhere for one to attach to today. Tests mock every Neon API call; no live credentials anywhere in this repo. --- control-plane/src/driver-factory.ts | 42 ++++ control-plane/src/index.ts | 12 + control-plane/src/neon-database-driver.ts | 203 +++++++++++++++ control-plane/src/provisioning.ts | 12 +- .../src/tenant-provisioning-driver.ts | 31 ++- control-plane/test/driver-factory.test.ts | 119 +++++++++ .../test/neon-database-driver.test.ts | 238 ++++++++++++++++++ .../test/provisioning-pagerduty.test.ts | 14 +- control-plane/test/provisioning.test.ts | 14 +- .../test/tenant-provisioning-driver.test.ts | 15 ++ 10 files changed, 693 insertions(+), 7 deletions(-) create mode 100644 control-plane/src/driver-factory.ts create mode 100644 control-plane/src/neon-database-driver.ts create mode 100644 control-plane/test/driver-factory.test.ts create mode 100644 control-plane/test/neon-database-driver.test.ts diff --git a/control-plane/src/driver-factory.ts b/control-plane/src/driver-factory.ts new file mode 100644 index 0000000000..e2e7db9b53 --- /dev/null +++ b/control-plane/src/driver-factory.ts @@ -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 = 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)); +} diff --git a/control-plane/src/index.ts b/control-plane/src/index.ts index 5ec1edcb44..0be05b39d2 100644 --- a/control-plane/src/index.ts +++ b/control-plane/src/index.ts @@ -4,6 +4,7 @@ export { createFakeTenantProvisioningDriver, + type DatabaseConnectionDetails, type FakeDriverCall, type FakeDriverStep, type FakeTenantProvisioningDriver, @@ -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"; diff --git a/control-plane/src/neon-database-driver.ts b/control-plane/src/neon-database-driver.ts new file mode 100644 index 0000000000..83bdd54f93 --- /dev/null +++ b/control-plane/src/neon-database-driver.ts @@ -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; + dropDatabase(request: TenantProvisioningRequest): Promise; +}; + +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(config: NeonDatabaseDriverConfig, method: string, path: string, body?: unknown): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + }; +} diff --git a/control-plane/src/provisioning.ts b/control-plane/src/provisioning.ts index 99e43abd82..e8f3abfd76 100644 --- a/control-plane/src/provisioning.ts +++ b/control-plane/src/provisioning.ts @@ -17,6 +17,7 @@ import { type NotifyProvisioningFailure, } from "./pagerduty-notify.js"; import type { + DatabaseConnectionDetails, Product, Tenant, TenantLifecycleState, @@ -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; + database: DatabaseConnectionDetails; }; /** Result of a successful deprovision — terminal lifecycle state `"torn down"`. */ @@ -83,14 +88,15 @@ export async function provisionTenant( pagerDuty: ProvisioningPagerDutyOptions = {}, ): Promise { 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 diff --git a/control-plane/src/tenant-provisioning-driver.ts b/control-plane/src/tenant-provisioning-driver.ts index cb71e88162..a0f0072ecb 100644 --- a/control-plane/src/tenant-provisioning-driver.ts +++ b/control-plane/src/tenant-provisioning-driver.ts @@ -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; - /** Step 2 (#7180): provision the tenant's database. Real driver → the chosen Postgres provider (#7524-blocked). */ - provisionDatabase(request: TenantProvisioningRequest): Promise; + /** 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; /** 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; @@ -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); diff --git a/control-plane/test/driver-factory.test.ts b/control-plane/test/driver-factory.test.ts new file mode 100644 index 0000000000..500dc2963d --- /dev/null +++ b/control-plane/test/driver-factory.test.ts @@ -0,0 +1,119 @@ +// Tests for the driver-factory composition/selection (#7653). No live Neon credentials -- `globalThis.fetch` +// is stubbed for the one test that exercises the real path end to end. +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; + +import { + createFakeTenantProvisioningDriver, + createTenantProvisioningDriver, + withRealDatabaseDriver, + type DatabaseDriver, + type TenantProvisioningRequest, +} from "../dist/index.js"; + +const REQUEST: TenantProvisioningRequest = { tenant: { name: "acme" }, product: "orb" }; + +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("withRealDatabaseDriver: overrides provisionDatabase/dropDatabase, forwards every other step to base", async () => { + const base = createFakeTenantProvisioningDriver(); + const calls: string[] = []; + const databaseDriver: DatabaseDriver = { + provisionDatabase: async () => { + calls.push("real-provision"); + return { host: "h", port: 5432, database: "d", user: "u", password: "p", connectionString: "postgres://u:p@h:5432/d" }; + }, + dropDatabase: async () => { + calls.push("real-drop"); + }, + }; + + const composed = withRealDatabaseDriver(base, databaseDriver); + + const details = await composed.provisionDatabase(REQUEST); + assert.equal(details.host, "h"); + assert.deepEqual(calls, ["real-provision"]); + // The fake's own provisionDatabase never ran -- its `databases` set stays empty even though the composed + // driver's provisionDatabase resolved successfully. + assert.equal(base.databases.has("acme"), false); + + await composed.dropDatabase(REQUEST); + assert.deepEqual(calls, ["real-provision", "real-drop"]); + + // Every non-database step still runs against `base` exactly as before composition. + await composed.createContainer(REQUEST); + assert.ok(base.containers.has("acme")); + assert.equal(await composed.containerExists(REQUEST), true); + await composed.injectSecrets(REQUEST); + assert.ok(base.injectedSecrets.has("acme")); + await composed.destroyContainer(REQUEST); + assert.equal(base.containers.has("acme"), false); + await composed.revokeSecrets(REQUEST); + assert.equal(base.injectedSecrets.has("acme"), false); +}); + +test("createTenantProvisioningDriver: falls back to the plain fake when NEON_API_KEY is unset", async () => { + const driver = createTenantProvisioningDriver({}); + + const details = await driver.provisionDatabase(REQUEST); + + // The fake's own deterministic shape (tenant-provisioning-driver.test.ts asserts this same value) -- + // proves no real Neon path was selected. + assert.equal(details.host, "fake-acme.control-plane.invalid"); +}); + +test("createTenantProvisioningDriver: falls back to the fake when NEON_API_KEY is set but NEON_PROJECT_ID is missing", async () => { + const driver = createTenantProvisioningDriver({ NEON_API_KEY: "key-only" }); + + const details = await driver.provisionDatabase(REQUEST); + + assert.equal(details.host, "fake-acme.control-plane.invalid"); +}); + +test("createTenantProvisioningDriver: falls back to the fake when NEON_PROJECT_ID is set but NEON_API_KEY is missing", async () => { + const driver = createTenantProvisioningDriver({ NEON_PROJECT_ID: "proj-only" }); + + const details = await driver.provisionDatabase(REQUEST); + + assert.equal(details.host, "fake-acme.control-plane.invalid"); +}); + +test("createTenantProvisioningDriver: selects the real Neon-backed driver when both env vars are configured", async () => { + const calls: string[] = []; + globalThis.fetch = (async (url: string) => { + calls.push(url); + return 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" }); + + // Only asserting that the REAL path was selected (it reaches out via fetch to Neon's API, unlike the fake) -- + // full provision/drop behavior against a real config is neon-database-driver.test.ts's job. The mocked + // response's shape doesn't match a real branch-list response, so this necessarily rejects once the driver + // gets past the "not found" check into a create call it can't complete against this stub. + await assert.rejects(driver.provisionDatabase(REQUEST)); + assert.ok(calls.length >= 1); + assert.ok(calls.every((url) => url.includes("real-project"))); +}); + +test("createTenantProvisioningDriver: defaults env to process.env when no override is passed", async () => { + const hadKey = Object.prototype.hasOwnProperty.call(process.env, "NEON_API_KEY"); + const previousKey = process.env.NEON_API_KEY; + delete process.env.NEON_API_KEY; + + try { + const driver = createTenantProvisioningDriver(); + const details = await driver.provisionDatabase(REQUEST); + assert.equal(details.host, "fake-acme.control-plane.invalid"); + } finally { + if (hadKey) process.env.NEON_API_KEY = previousKey; + } +}); diff --git a/control-plane/test/neon-database-driver.test.ts b/control-plane/test/neon-database-driver.test.ts new file mode 100644 index 0000000000..eafcfac25e --- /dev/null +++ b/control-plane/test/neon-database-driver.test.ts @@ -0,0 +1,238 @@ +// Tests for the real Neon-backed database driver (#7653). No live Neon account or credentials anywhere here -- +// `globalThis.fetch` is stubbed with a strict, ordered response queue for every test (mirrors +// pagerduty-notify.test.ts's save/restore convention). Covers: fresh provision (branch+role+database creation, +// operation polling including a multi-poll retry and a timeout), idempotent re-provision against an existing +// branch, idempotent drop of both an existing and a never-provisioned tenant, and every documented failure mode +// (missing endpoint, missing password, a failed operation, a non-ok HTTP response). +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; + +import { + createNeonDatabaseDriver, + dropNeonDatabase, + provisionNeonDatabase, + type NeonDatabaseDriverConfig, + type TenantProvisioningRequest, +} from "../dist/index.js"; + +const CONFIG: NeonDatabaseDriverConfig = { + apiKey: "neon-test-key", + projectId: "proj-1", + operationPollIntervalMs: 1, + operationPollTimeoutMs: 50, +}; + +const REQUEST: TenantProvisioningRequest = { tenant: { name: "acme" }, product: "orb" }; +const BRANCH_NAME = "tenant-orb-acme"; + +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type QueuedResponse = { status?: number; body?: unknown; rawBody?: string }; + +function mockFetchSequence(entries: QueuedResponse[]): { calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = []; + let index = 0; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const entry = entries[index]; + index += 1; + if (!entry) throw new Error(`mockFetchSequence: no queued response for call #${index} (${init.method ?? "GET"} ${url})`); + const text = entry.rawBody ?? JSON.stringify(entry.body); + return new Response(text, { status: entry.status ?? 200 }); + }) as unknown as typeof fetch; + return { calls }; +} + +function bodyOf(init: RequestInit): unknown { + return init.body ? JSON.parse(init.body as string) : undefined; +} + +test("provisionNeonDatabase: fresh provision creates a branch, role, and database, polling each to completion", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [] } }, // 1. list branches -> not found + { body: { branch: { id: "br-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "running" }] } }, // 2. create branch + { body: { operation: { id: "op-1", status: "finished" } } }, // 3. poll branch operation + { body: { role: { name: BRANCH_NAME, password: "role-pw" }, operations: [{ id: "op-2", status: "finished" }] } }, // 4. create role (already finished, no poll) + { body: { operations: [{ id: "op-3", status: "finished" }] } }, // 5. create database (already finished, no poll) + ]); + + const details = await provisionNeonDatabase(CONFIG, REQUEST); + + assert.deepEqual(details, { + host: "ep-1.neon.tech", + port: 5432, + database: BRANCH_NAME, + user: BRANCH_NAME, + password: "role-pw", + connectionString: `postgres://${BRANCH_NAME}:role-pw@ep-1.neon.tech:5432/${BRANCH_NAME}`, + }); + + assert.equal(calls.length, 5); + assert.equal(calls[0]?.url, "https://console.neon.tech/api/v2/projects/proj-1/branches"); + assert.equal(calls[0]?.init.method, "GET"); + assert.equal(calls[1]?.init.method, "POST"); + assert.deepEqual(bodyOf(calls[1]!.init), { branch: { name: BRANCH_NAME }, endpoints: [{ type: "read_write" }] }); + assert.equal(calls[2]?.url, "https://console.neon.tech/api/v2/projects/proj-1/operations/op-1"); + assert.equal(calls[3]?.url, "https://console.neon.tech/api/v2/projects/proj-1/branches/br-1/roles"); + assert.deepEqual(bodyOf(calls[3]!.init), { role: { name: BRANCH_NAME } }); + assert.equal(calls[4]?.url, "https://console.neon.tech/api/v2/projects/proj-1/branches/br-1/databases"); + assert.deepEqual(bodyOf(calls[4]!.init), { database: { name: BRANCH_NAME, owner_name: BRANCH_NAME } }); + // Every mutating call carries the Bearer auth header. + assert.equal((calls[1]!.init.headers as Record).authorization, "Bearer neon-test-key"); +}); + +test("provisionNeonDatabase: polls through multiple non-finished states before succeeding", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { branch: { id: "br-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "scheduling" }] } }, + { body: { operation: { id: "op-1", status: "running" } } }, + { body: { operation: { id: "op-1", status: "running" } } }, + { body: { operation: { id: "op-1", status: "finished" } } }, + { body: { role: { name: BRANCH_NAME, password: "role-pw" }, operations: [{ id: "op-2", status: "finished" }] } }, + { body: { operations: [{ id: "op-3", status: "finished" }] } }, + ]); + + const details = await provisionNeonDatabase(CONFIG, REQUEST); + + assert.equal(details.password, "role-pw"); +}); + +test("provisionNeonDatabase: an operation reaching 'failed' throws", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { branch: { id: "br-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "running" }] } }, + { body: { operation: { id: "op-1", status: "failed" } } }, + ]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /Neon operation op-1 failed/); +}); + +test("provisionNeonDatabase: exceeding the poll timeout throws instead of waiting forever", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { branch: { id: "br-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "running" }] } }, + // Every poll keeps reporting "running" -- CONFIG's 50ms timeout / 1ms interval will exhaust before this + // queue ever does (more than enough entries queued). + ...Array.from({ length: 200 }, () => ({ body: { operation: { id: "op-1", status: "running" } } })), + ]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /did not finish within \d+ms/); +}); + +test("provisionNeonDatabase: throws when a created branch has no compute endpoint", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { branch: { id: "br-1", name: BRANCH_NAME }, endpoints: [], operations: [{ id: "op-1", status: "finished" }] } }, + ]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /created without a compute endpoint/); +}); + +test("provisionNeonDatabase: throws when the created role has no password", async () => { + mockFetchSequence([ + { body: { branches: [] } }, + { body: { branch: { id: "br-1", name: BRANCH_NAME }, endpoints: [{ host: "ep-1.neon.tech" }], operations: [{ id: "op-1", status: "finished" }] } }, + { body: { role: { name: BRANCH_NAME }, operations: [{ id: "op-2", status: "finished" }] } }, + ]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /created without a password/); +}); + +test("provisionNeonDatabase: a non-ok HTTP response throws a descriptive NeonApiError", async () => { + mockFetchSequence([{ status: 401, body: { message: "invalid api key" } }]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /Neon API GET .*failed \(401\)/); +}); + +test("provisionNeonDatabase: idempotent re-provision resolves an existing branch without creating a new one", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, // list -> found + { body: { endpoints: [{ host: "ep-existing.neon.tech" }] } }, // get endpoint + { body: { role: { name: BRANCH_NAME, password: "existing-pw" } } }, // reveal password + ]); + + const details = await provisionNeonDatabase(CONFIG, REQUEST); + + assert.deepEqual(details, { + host: "ep-existing.neon.tech", + port: 5432, + database: BRANCH_NAME, + user: BRANCH_NAME, + password: "existing-pw", + connectionString: `postgres://${BRANCH_NAME}:existing-pw@ep-existing.neon.tech:5432/${BRANCH_NAME}`, + }); + assert.equal(calls.length, 3); + assert.ok(calls.every((call) => call.init.method === "GET" || call.init.method === undefined)); + assert.equal(calls[2]?.url, "https://console.neon.tech/api/v2/projects/proj-1/branches/br-existing/roles/tenant-orb-acme/reveal_password"); +}); + +test("provisionNeonDatabase: throws when an existing branch's role has no revealable password", async () => { + mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, + { body: { endpoints: [{ host: "ep-existing.neon.tech" }] } }, + { body: { role: { name: BRANCH_NAME } } }, + ]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /has no revealable password/); +}); + +test("dropNeonDatabase: deletes an existing tenant's branch, polling the delete operation to completion", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, + { body: { operations: [{ id: "op-4", status: "running" } as const] } }, + { body: { operation: { id: "op-4", status: "finished" } } }, + ]); + + await dropNeonDatabase(CONFIG, REQUEST); + + assert.equal(calls.length, 3); + assert.equal(calls[1]?.init.method, "DELETE"); + assert.equal(calls[1]?.url, "https://console.neon.tech/api/v2/projects/proj-1/branches/br-existing"); +}); + +test("dropNeonDatabase: tolerates a body-less DELETE response (e.g. 204 No Content) as 'nothing to poll'", async () => { + const { calls } = mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, + { rawBody: "" }, // the DELETE call itself returns no body at all + ]); + + await dropNeonDatabase(CONFIG, REQUEST); + + assert.equal(calls.length, 2); +}); + +test("provisionNeonDatabase: throws when an existing branch has lost its compute endpoint", async () => { + mockFetchSequence([ + { body: { branches: [{ id: "br-existing", name: BRANCH_NAME }] } }, + { body: { endpoints: [] } }, + ]); + + await assert.rejects(provisionNeonDatabase(CONFIG, REQUEST), /has no compute endpoint/); +}); + +test("dropNeonDatabase: a never-provisioned tenant is an idempotent no-op (no DELETE call)", async () => { + const { calls } = mockFetchSequence([{ body: { branches: [] } }]); + + await dropNeonDatabase(CONFIG, REQUEST); + + assert.equal(calls.length, 1); +}); + +test("createNeonDatabaseDriver: bundles provision/drop closed over one config", async () => { + mockFetchSequence([{ body: { branches: [] } }]); + const driver = createNeonDatabaseDriver(CONFIG); + + await driver.dropDatabase(REQUEST); + + // Proves the returned functions are actually closed over CONFIG's projectId, not re-reading it from + // somewhere else -- the request above only succeeds against the real Neon endpoint shape if `dropDatabase` + // routed through the same config-scoped fetch helper `dropNeonDatabase` itself uses. +}); diff --git a/control-plane/test/provisioning-pagerduty.test.ts b/control-plane/test/provisioning-pagerduty.test.ts index 1c42846951..e935e32973 100644 --- a/control-plane/test/provisioning-pagerduty.test.ts +++ b/control-plane/test/provisioning-pagerduty.test.ts @@ -63,7 +63,19 @@ test("provisionTenant does NOT page PagerDuty on a successful provision (#7667)" const result = await provisionTenant(tenant, "orb", driver, { notify }); - assert.deepEqual(result, { tenant, product: "orb", state: "active" }); + assert.deepEqual(result, { + tenant, + product: "orb", + state: "active", + database: { + host: "fake-acme.control-plane.invalid", + port: 5432, + database: "acme", + user: "acme", + password: "fake-password-acme", + connectionString: "postgres://acme:fake-password-acme@fake-acme.control-plane.invalid:5432/acme", + }, + }); assert.equal(calls.length, 0); }); diff --git a/control-plane/test/provisioning.test.ts b/control-plane/test/provisioning.test.ts index d6bc611bb7..e3171b3770 100644 --- a/control-plane/test/provisioning.test.ts +++ b/control-plane/test/provisioning.test.ts @@ -17,7 +17,19 @@ test("provisionTenant runs the three #7180 steps in order and reports the tenant const result = await provisionTenant(tenant, "orb", driver); - assert.deepEqual(result, { tenant, product: "orb", state: "active" }); + assert.deepEqual(result, { + tenant, + product: "orb", + state: "active", + database: { + host: "fake-acme.control-plane.invalid", + port: 5432, + database: "acme", + user: "acme", + password: "fake-password-acme", + connectionString: "postgres://acme:fake-password-acme@fake-acme.control-plane.invalid:5432/acme", + }, + }); // create-container → provision-DB → inject-secrets, in that order. assert.deepEqual( driver.calls.map((call) => call.step), diff --git a/control-plane/test/tenant-provisioning-driver.test.ts b/control-plane/test/tenant-provisioning-driver.test.ts index e78b56b32e..33c60f7846 100644 --- a/control-plane/test/tenant-provisioning-driver.test.ts +++ b/control-plane/test/tenant-provisioning-driver.test.ts @@ -40,6 +40,21 @@ test("destroyContainer on a never-created container is an idempotent no-op", asy assert.equal(driver.containers.has("ghost"), false); }); +test("provisionDatabase returns deterministic per-tenant connection details (#7653)", async () => { + const driver = createFakeTenantProvisioningDriver(); + + const details = await driver.provisionDatabase(requestFor("acme", "orb")); + + assert.deepEqual(details, { + host: "fake-acme.control-plane.invalid", + port: 5432, + database: "acme", + user: "acme", + password: "fake-password-acme", + connectionString: "postgres://acme:fake-password-acme@fake-acme.control-plane.invalid:5432/acme", + }); +}); + test("provision/teardown steps toggle the database and secret maps too", async () => { const driver = createFakeTenantProvisioningDriver(); const request = requestFor("acme", "ams");