diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json new file mode 100644 index 0000000000..eca5793784 --- /dev/null +++ b/control-plane/package-lock.json @@ -0,0 +1,67 @@ +{ + "name": "@loopover/control-plane", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@loopover/control-plane", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.20.0", + "prettier": "3.9.4", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/control-plane/package.json b/control-plane/package.json new file mode 100644 index 0000000000..d0a4fe587e --- /dev/null +++ b/control-plane/package.json @@ -0,0 +1,23 @@ +{ + "name": "@loopover/control-plane", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "LoopOver control-plane — product-agnostic tenant provisioning/deprovisioning orchestration behind an injectable driver interface (#7524, part of the #7173 ORB+AMS hosting control-plane). Fake in-memory driver only; real Cloudflare/Postgres drivers are out of scope pending the Postgres-provider decision.", + "engines": { + "node": ">=20" + }, + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && npm run test:node", + "test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\"" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "prettier": "3.9.4", + "typescript": "^5.9.3" + } +} diff --git a/control-plane/src/index.ts b/control-plane/src/index.ts new file mode 100644 index 0000000000..0358eec2cc --- /dev/null +++ b/control-plane/src/index.ts @@ -0,0 +1,20 @@ +// Public entry for @loopover/control-plane: the injectable tenant-provisioning driver contract + fake, and the +// product-agnostic provisionTenant/deprovisionTenant orchestration built on it (#7524). + +export { + createFakeTenantProvisioningDriver, + type FakeDriverCall, + type FakeDriverStep, + type FakeTenantProvisioningDriver, + type Product, + type Tenant, + type TenantLifecycleState, + type TenantProvisioningDriver, + type TenantProvisioningRequest, +} from "./tenant-provisioning-driver.js"; +export { + deprovisionTenant, + provisionTenant, + type TenantDeprovisioningResult, + type TenantProvisioningResult, +} from "./provisioning.js"; diff --git a/control-plane/src/provisioning.ts b/control-plane/src/provisioning.ts new file mode 100644 index 0000000000..a5d9553691 --- /dev/null +++ b/control-plane/src/provisioning.ts @@ -0,0 +1,57 @@ +// provisionTenant / deprovisionTenant orchestration (#7524) over the injectable `TenantProvisioningDriver`. +// Product-agnostic: an ORB tenant and an AMS tenant take the identical call shape — `product` is forwarded to +// every driver step but never branched on. Provision runs #7180's three steps in order (create-container, +// provision-DB, inject-secrets); deprovision tears them down in REVERSE (revoke-secrets, drop-DB, +// destroy-container) so a secret is never left addressable after the DB/container it belonged to is gone. + +import type { + Product, + Tenant, + TenantLifecycleState, + TenantProvisioningDriver, + TenantProvisioningRequest, +} from "./tenant-provisioning-driver.js"; + +/** Result of a successful provision — terminal lifecycle state `"active"` (the vocabulary tenant-client.ts + * passes through from this API). */ +export type TenantProvisioningResult = { + tenant: Tenant; + product: Product; + state: Extract; +}; + +/** Result of a successful deprovision — terminal lifecycle state `"torn down"`. */ +export type TenantDeprovisioningResult = { + tenant: Tenant; + product: Product; + state: Extract; +}; + +/** Provision a tenant by running #7180's three steps in order against the injected driver. Product-agnostic: + * `product` is forwarded to every step, never branched on, so ORB and AMS share one call shape. */ +export async function provisionTenant( + tenant: Tenant, + product: Product, + driver: TenantProvisioningDriver, +): Promise { + const request: TenantProvisioningRequest = { tenant, product }; + await driver.createContainer(request); + await driver.provisionDatabase(request); + await driver.injectSecrets(request); + return { tenant, product, state: "active" }; +} + +/** Deprovision a tenant by tearing #7180's three steps down in REVERSE order. Same product-agnostic call shape + * as provisionTenant. Idempotent by driver contract: deprovisioning a tenant that was never provisioned is a + * safe no-op, never a throw. */ +export async function deprovisionTenant( + tenant: Tenant, + product: Product, + driver: TenantProvisioningDriver, +): Promise { + const request: TenantProvisioningRequest = { tenant, product }; + await driver.revokeSecrets(request); + await driver.dropDatabase(request); + await driver.destroyContainer(request); + return { tenant, product, state: "torn down" }; +} diff --git a/control-plane/src/tenant-provisioning-driver.ts b/control-plane/src/tenant-provisioning-driver.ts new file mode 100644 index 0000000000..cb71e88162 --- /dev/null +++ b/control-plane/src/tenant-provisioning-driver.ts @@ -0,0 +1,153 @@ +// `TenantProvisioningDriver` interface seam (#7524, part of the #7173 ORB+AMS hosting control-plane). Mirrors +// `CodingAgentDriver` (packages/loopover-engine/src/miner/coding-agent-driver.ts): a small, product-agnostic +// contract plus a minimal in-memory fake, with the orchestration (provisionTenant/deprovisionTenant) living in +// a sibling module. Implementations MAY perform real IO; this file defines only the contract and the fake. +// +// The interface names the three provisioning steps #7180's provisioning API is specified around: +// create-container, provision-DB, inject-secrets. Real drivers are OUT OF SCOPE for #7524 (blocked on an +// unmade Postgres-provider decision): a real create-container would call the Cloudflare Containers API, a real +// provision-DB the chosen Postgres provider, and a real inject-secrets would delegate to #7174's generalized +// secret broker (src/orb/broker.ts). NONE of those live paths are imported here — only the fake is. + +/** Product a tenant belongs to (e.g. `"orb"` / `"ams"`). Opaque to the orchestration and forwarded verbatim to + * every driver step — an ORB tenant and an AMS tenant take the identical call shape (#7524's product-agnostic + * requirement). A free string, matching tenant-client.ts's `product?: string` ("other fields vary by product"). */ +export type Product = string; + +/** Product-agnostic tenant identity. Keyed by `name` — the same identifier tenant-client.ts's create/destroy + * admin commands address a tenant by. */ +export type Tenant = { + name: string; +}; + +/** The full tenant lifecycle vocabulary the #7180 provisioning API reports, passed through verbatim by + * tenant-client.ts. provisionTenant/deprovisionTenant only ever produce the terminal `"active"` / `"torn down"` + * states; `"provisioning"` (transitional) and `"suspended"` (an operator action) round out the documented set. */ +export type TenantLifecycleState = + "provisioning" | "active" | "suspended" | "torn down"; + +/** Everything one provision/deprovision step needs. A single request type flows through every driver method so a + * real and a fake driver see identical inputs, and so ORB and AMS calls are shaped identically. */ +export type TenantProvisioningRequest = { + tenant: Tenant; + product: Product; +}; + +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 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; + /** Teardown inverse of createContainer. MUST be idempotent — safe to call when the container was never + * created — so deprovisioning a nonexistent tenant is a no-op, never a throw. */ + destroyContainer(request: TenantProvisioningRequest): Promise; + /** Teardown inverse of provisionDatabase. Idempotent, like destroyContainer. */ + dropDatabase(request: TenantProvisioningRequest): Promise; + /** Teardown inverse of injectSecrets. Idempotent, like destroyContainer. */ + revokeSecrets(request: TenantProvisioningRequest): Promise; + /** Reachability probe: is the tenant's container currently provisioned? A real driver health-checks the + * container; the fake checks its in-memory map. Lets callers/tests assert "exists" after provision and + * "gone" after deprovision without reaching into driver internals. */ + containerExists(request: TenantProvisioningRequest): Promise; +} + +/** The driver steps a fake records, for white-box assertions on call order (e.g. teardown runs in reverse of + * provision). */ +export type FakeDriverStep = + | "createContainer" + | "provisionDatabase" + | "injectSecrets" + | "destroyContainer" + | "dropDatabase" + | "revokeSecrets"; + +/** One recorded driver call: which step ran, and the tenant/product it ran for. */ +export type FakeDriverCall = { + step: FakeDriverStep; + tenant: Tenant; + product: Product; +}; + +/** A fake `TenantProvisioningDriver` plus the recorded state a test inspects. */ +export type FakeTenantProvisioningDriver = TenantProvisioningDriver & { + /** Tenant names whose container currently "exists" (an in-memory stand-in for real infrastructure). */ + readonly containers: ReadonlySet; + /** Tenant names whose database currently "exists". */ + readonly databases: ReadonlySet; + /** Tenant names whose secrets are currently injected. */ + readonly injectedSecrets: ReadonlySet; + /** Every driver step this fake has run, in call order. */ + readonly calls: readonly FakeDriverCall[]; +}; + +/** Minimal in-memory fake for orchestration/contract tests — three in-memory maps stand in for real infra + * ("a container exists" / "a DB exists" / "secrets injected"), toggled by the create/destroy steps, plus an + * ordered call log. NO Cloudflare, Postgres, or secret-broker IO of any kind. Mirrors + * createFakeCodingAgentDriver: implements the interface and exposes its recorded state as extra introspection + * surface beyond the contract. */ +export function createFakeTenantProvisioningDriver(): FakeTenantProvisioningDriver { + const containers = new Set(); + const databases = new Set(); + const injectedSecrets = new Set(); + const calls: FakeDriverCall[] = []; + + const record = ( + step: FakeDriverStep, + request: TenantProvisioningRequest, + ): void => { + calls.push({ step, tenant: request.tenant, product: request.product }); + }; + + return { + get containers() { + return containers; + }, + get databases() { + return databases; + }, + get injectedSecrets() { + return injectedSecrets; + }, + get calls() { + return calls; + }, + async createContainer(request) { + record("createContainer", request); + containers.add(request.tenant.name); + }, + async provisionDatabase(request) { + record("provisionDatabase", request); + databases.add(request.tenant.name); + }, + async injectSecrets(request) { + record("injectSecrets", request); + injectedSecrets.add(request.tenant.name); + }, + async destroyContainer(request) { + record("destroyContainer", request); + // Idempotent teardown: the else-branch (nothing to remove) is the "destroy-of-a-nonexistent-tenant" + // lifecycle path — a no-op, never a throw. + if (containers.has(request.tenant.name)) { + containers.delete(request.tenant.name); + } + }, + async dropDatabase(request) { + record("dropDatabase", request); + if (databases.has(request.tenant.name)) { + databases.delete(request.tenant.name); + } + }, + async revokeSecrets(request) { + record("revokeSecrets", request); + if (injectedSecrets.has(request.tenant.name)) { + injectedSecrets.delete(request.tenant.name); + } + }, + async containerExists(request) { + return containers.has(request.tenant.name); + }, + }; +} diff --git a/control-plane/test/provisioning.test.ts b/control-plane/test/provisioning.test.ts new file mode 100644 index 0000000000..d6bc611bb7 --- /dev/null +++ b/control-plane/test/provisioning.test.ts @@ -0,0 +1,94 @@ +// Orchestration tests for provisionTenant / deprovisionTenant against the fake driver. Covers the acceptance +// shape (create → container exists/reachable → destroy → container gone) and BOTH driver-lifecycle branches: +// the success path AND deprovisioning a never-provisioned tenant. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + createFakeTenantProvisioningDriver, + deprovisionTenant, + provisionTenant, + type Tenant, +} from "../dist/index.js"; + +test("provisionTenant runs the three #7180 steps in order and reports the tenant active", async () => { + const driver = createFakeTenantProvisioningDriver(); + const tenant: Tenant = { name: "acme" }; + + const result = await provisionTenant(tenant, "orb", driver); + + assert.deepEqual(result, { tenant, product: "orb", state: "active" }); + // create-container → provision-DB → inject-secrets, in that order. + assert.deepEqual( + driver.calls.map((call) => call.step), + ["createContainer", "provisionDatabase", "injectSecrets"], + ); + // Container "exists"/reachable via the fake after provision. + assert.equal(await driver.containerExists({ tenant, product: "orb" }), true); + assert.ok(driver.databases.has("acme")); + assert.ok(driver.injectedSecrets.has("acme")); +}); + +test("full lifecycle: provision → container exists → deprovision → container gone", async () => { + const driver = createFakeTenantProvisioningDriver(); + const tenant: Tenant = { name: "acme" }; + + await provisionTenant(tenant, "ams", driver); + assert.equal(await driver.containerExists({ tenant, product: "ams" }), true); + + const result = await deprovisionTenant(tenant, "ams", driver); + + assert.deepEqual(result, { tenant, product: "ams", state: "torn down" }); + assert.equal(await driver.containerExists({ tenant, product: "ams" }), false); + assert.equal(driver.databases.has("acme"), false); + assert.equal(driver.injectedSecrets.has("acme"), false); +}); + +test("deprovisionTenant tears the steps down in reverse order", async () => { + const driver = createFakeTenantProvisioningDriver(); + const tenant: Tenant = { name: "acme" }; + + await provisionTenant(tenant, "orb", driver); + const teardownStart = driver.calls.length; + await deprovisionTenant(tenant, "orb", driver); + + const teardownSteps = driver.calls + .slice(teardownStart) + .map((call) => call.step); + assert.deepEqual(teardownSteps, [ + "revokeSecrets", + "dropDatabase", + "destroyContainer", + ]); +}); + +test("deprovisionTenant on a never-provisioned tenant is a safe no-op that still reports torn down", async () => { + const driver = createFakeTenantProvisioningDriver(); + const tenant: Tenant = { name: "ghost" }; + + // The destroy-of-a-nonexistent-tenant branch: resolves, never throws, container stays gone. + const result = await deprovisionTenant(tenant, "ams", driver); + + assert.deepEqual(result, { tenant, product: "ams", state: "torn down" }); + assert.equal(await driver.containerExists({ tenant, product: "ams" }), false); + assert.equal(driver.containers.has("ghost"), false); +}); + +test("the call shape is identical for an ORB tenant and an AMS tenant (product-agnostic)", async () => { + const orb = createFakeTenantProvisioningDriver(); + const ams = createFakeTenantProvisioningDriver(); + const tenant: Tenant = { name: "acme" }; + + const orbResult = await provisionTenant(tenant, "orb", orb); + const amsResult = await provisionTenant(tenant, "ams", ams); + + // Same steps, same order — only the forwarded product differs. + assert.deepEqual( + orb.calls.map((call) => call.step), + ams.calls.map((call) => call.step), + ); + assert.equal(orbResult.product, "orb"); + assert.equal(amsResult.product, "ams"); + for (const call of orb.calls) assert.equal(call.product, "orb"); + for (const call of ams.calls) assert.equal(call.product, "ams"); +}); diff --git a/control-plane/test/tenant-provisioning-driver.test.ts b/control-plane/test/tenant-provisioning-driver.test.ts new file mode 100644 index 0000000000..e78b56b32e --- /dev/null +++ b/control-plane/test/tenant-provisioning-driver.test.ts @@ -0,0 +1,81 @@ +// Contract tests for the in-memory fake driver — the maps toggle with create/destroy, and both the +// destroy-of-an-existing and destroy-of-a-nonexistent (idempotent no-op) branches are exercised. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + createFakeTenantProvisioningDriver, + type TenantProvisioningRequest, +} from "../dist/index.js"; + +const requestFor = ( + name: string, + product: string, +): TenantProvisioningRequest => ({ + tenant: { name }, + product, +}); + +test("createContainer makes the tenant's container exist; destroyContainer removes it", async () => { + const driver = createFakeTenantProvisioningDriver(); + const request = requestFor("acme", "orb"); + + assert.equal(await driver.containerExists(request), false); + await driver.createContainer(request); + assert.equal(await driver.containerExists(request), true); + assert.ok(driver.containers.has("acme")); + + await driver.destroyContainer(request); + assert.equal(await driver.containerExists(request), false); + assert.equal(driver.containers.has("acme"), false); +}); + +test("destroyContainer on a never-created container is an idempotent no-op", async () => { + const driver = createFakeTenantProvisioningDriver(); + const request = requestFor("ghost", "ams"); + + // else-branch: nothing to remove — must not throw. + await driver.destroyContainer(request); + assert.equal(await driver.containerExists(request), false); + assert.equal(driver.containers.has("ghost"), false); +}); + +test("provision/teardown steps toggle the database and secret maps too", async () => { + const driver = createFakeTenantProvisioningDriver(); + const request = requestFor("acme", "ams"); + + await driver.provisionDatabase(request); + await driver.injectSecrets(request); + assert.ok(driver.databases.has("acme")); + assert.ok(driver.injectedSecrets.has("acme")); + + await driver.dropDatabase(request); + await driver.revokeSecrets(request); + assert.equal(driver.databases.has("acme"), false); + assert.equal(driver.injectedSecrets.has("acme"), false); +}); + +test("dropDatabase / revokeSecrets on a never-provisioned tenant are idempotent no-ops", async () => { + const driver = createFakeTenantProvisioningDriver(); + const request = requestFor("ghost", "orb"); + + await driver.dropDatabase(request); + await driver.revokeSecrets(request); + assert.equal(driver.databases.has("ghost"), false); + assert.equal(driver.injectedSecrets.has("ghost"), false); +}); + +test("the fake records every step it runs, in call order, with its tenant and product", async () => { + const driver = createFakeTenantProvisioningDriver(); + const request = requestFor("acme", "orb"); + + await driver.createContainer(request); + await driver.injectSecrets(request); + + assert.deepEqual( + driver.calls.map((call) => call.step), + ["createContainer", "injectSecrets"], + ); + assert.deepEqual(driver.calls[0]?.tenant, { name: "acme" }); + assert.equal(driver.calls[0]?.product, "orb"); +}); diff --git a/control-plane/tsconfig.json b/control-plane/tsconfig.json new file mode 100644 index 0000000000..eacb4ba98d --- /dev/null +++ b/control-plane/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true, + "inlineSources": true + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 6cd4729b10..c69c522973 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "rees:metadata:check": "npm --prefix review-enrichment run metadata:check", "rees:validate-sourcemaps": "npm --prefix review-enrichment run validate:sourcemaps", "rees:coverage": "node scripts/rees-coverage.mjs", + "control-plane:install": "npm ci --prefix control-plane --prefer-offline --no-audit --no-fund", + "control-plane:test": "npm run control-plane:install && npm --prefix control-plane test", "db:migrations:check": "tsx scripts/check-migrations.mjs", "db:schema-drift:check": "tsx scripts/check-schema-drift.mjs", "actionlint": "node scripts/actionlint.mjs",