Skip to content
Closed
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
1 change: 1 addition & 0 deletions .loopover.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ wantedPaths:
- migrations/
- scripts/
- review-enrichment/
- control-plane/
- .github/workflows/
- wrangler.jsonc
- apps/loopover-ui/
Expand Down
15 changes: 15 additions & 0 deletions control-plane/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# @loopover/control-plane

Hosting control-plane package for LoopOver ORB/AMS tenant lifecycle (#7173 / #7180 / #7524).

This package hosts **orchestration only**: `provisionTenant()` / `deprovisionTenant()` run against an
injectable `TenantProvisioningDriver`. The in-tree driver is a fake/in-memory implementation for
tests. Real Cloudflare Containers / Postgres providers are intentionally out of scope until the
Postgres-provider decision on #7180 lands.

Secrets injection goes through the `#7174` broker-shaped `TenantSecretBroker` seam (typed
`secretType`), not a hand-rolled credentials path.

Codecov note: this package is a standalone (non-workspace) tree like `review-enrichment/`. It is
**not** under root `vitest.config.ts` `coverage.include` (`src/**`), so Codecov's patch gate does
not apply numerically here — coverage is enforced by this package's own `npm test` suite.
119 changes: 119 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.

22 changes: 22 additions & 0 deletions control-plane/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@loopover/control-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "LoopOver hosting control-plane — tenant provision/deprovision orchestration behind an injectable driver (#7524 / #7180). Fake/in-memory driver only in this package today; no live Cloudflare Containers or Postgres SDKs.",
"engines": {
"node": ">=20"
},
"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",
"typescript": "^5.9.3"
},
"dependencies": {
"loopover": "file:.."
}
}
78 changes: 78 additions & 0 deletions control-plane/src/deprovision-tenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// `deprovisionTenant` orchestration (#7524 / #7180). Reverse of provision: revoke secrets → destroy DB →
// destroy container. Destroy of a nonexistent tenant is idempotent (`alreadyAbsent: true`).
//
// #7539 follow-up: when `provisioned` handles are omitted (e.g. caller restarted and lost in-memory IDs),
// still ask the driver to tear down by tenant — never return `{ state: "gone" }` without driver calls.

import { ControlPlaneError, isControlPlaneError } from "./errors.js";
import type { TenantProvisioningDriver } from "./tenant-provisioning-driver.js";
import type { DeprovisionedTenant, SecretsHandle, TenantRef } from "./types.js";
import { assertValidTenantRef } from "./validate-tenant.js";

export type DeprovisionTenantOptions = {
driver: TenantProvisioningDriver;
/**
* Optional handles from a prior `provisionTenant` result. When omitted, the driver is asked to
* destroy/revoke by tenant with absent-tolerant calls (IDs left undefined so the driver resolves
* whatever it still has recorded for that tenant).
*/
provisioned?:
| {
containerId: string;
databaseId: string;
secrets: SecretsHandle;
}
| undefined;
};

/**
* Tear down a hosted tenant through the driver seam. Idempotent when the tenant is already gone.
* Always invokes revoke/destroy on the driver — even when `provisioned` is omitted.
*/
export async function deprovisionTenant(
tenantInput: TenantRef,
options: DeprovisionTenantOptions,
): Promise<DeprovisionedTenant> {
const tenant = assertValidTenantRef(tenantInput);
const { driver, provisioned } = options;

let secretsResult: "revoked" | "absent";
let databaseResult: "destroyed" | "absent";
let containerResult: "destroyed" | "absent";

try {
secretsResult = await driver.revokeSecrets(tenant, provisioned?.secrets.enrollId);
} catch (error) {
if (isControlPlaneError(error)) throw error;
throw new ControlPlaneError(
"secrets_revoke_failed",
`Secrets revoke failed for ${tenant.product}/${tenant.tenantId}`,
error,
);
}
try {
databaseResult = await driver.destroyDatabase(tenant, provisioned?.databaseId);
} catch (error) {
if (isControlPlaneError(error)) throw error;
throw new ControlPlaneError(
"database_destroy_failed",
`Database destroy failed for ${tenant.product}/${tenant.tenantId}`,
error,
);
}
try {
containerResult = await driver.destroyContainer(tenant, provisioned?.containerId);
} catch (error) {
if (isControlPlaneError(error)) throw error;
throw new ControlPlaneError(
"container_destroy_failed",
`Container destroy failed for ${tenant.product}/${tenant.tenantId}`,
error,
);
}

const alreadyAbsent =
secretsResult === "absent" && databaseResult === "absent" && containerResult === "absent";

return { tenant, state: "gone", alreadyAbsent };
}
28 changes: 28 additions & 0 deletions control-plane/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Control-plane error types (#7524). Keep messages operator-safe: no secrets, wallets, or raw tokens.

export type ControlPlaneErrorCode =
| "invalid_tenant"
| "invalid_product"
| "container_create_failed"
| "database_provision_failed"
| "secrets_inject_failed"
| "container_destroy_failed"
| "database_destroy_failed"
| "secrets_revoke_failed"
| "rollback_failed";

export class ControlPlaneError extends Error {
readonly code: ControlPlaneErrorCode;
readonly causeError?: unknown;

constructor(code: ControlPlaneErrorCode, message: string, causeError?: unknown) {
super(message);
this.name = "ControlPlaneError";
this.code = code;
this.causeError = causeError;
}
}

export function isControlPlaneError(value: unknown): value is ControlPlaneError {
return value instanceof ControlPlaneError;
}
57 changes: 57 additions & 0 deletions control-plane/src/fake-secret-broker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// In-memory `TenantSecretBroker` for control-plane tests (#7524). Records enrollments by id and
// secretType the same way #7174's broker column distinguishes mint strategies — without Worker Env.

import {
type TenantSecretBroker,
type TenantSecretBrokerIssueInput,
type TenantSecretBrokerIssueResult,
type TenantSecretBrokerRevokeResult,
} from "./secret-broker.js";

export type FakeSecretBrokerEnrollment = {
enrollId: string;
tenantId: string;
product: string;
secretType: string;
revoked: boolean;
};

export type FakeTenantSecretBroker = TenantSecretBroker & {
readonly enrollments: ReadonlyMap<string, FakeSecretBrokerEnrollment>;
reset(): void;
};

export function createFakeTenantSecretBroker(
options: { idPrefix?: string; sequence?: { next: number } } = {},
): FakeTenantSecretBroker {
const enrollments = new Map<string, FakeSecretBrokerEnrollment>();
const sequence = options.sequence ?? { next: 1 };
const idPrefix = options.idPrefix ?? "enr";

return {
get enrollments() {
return enrollments;
},
reset() {
enrollments.clear();
sequence.next = 1;
},
async issueEnrollment(input: TenantSecretBrokerIssueInput): Promise<TenantSecretBrokerIssueResult> {
const enrollId = `${idPrefix}_${sequence.next++}`;
enrollments.set(enrollId, {
enrollId,
tenantId: input.tenantId,
product: input.product,
secretType: input.secretType,
revoked: false,
});
return { enrollId, secretShownOnce: true };
},
async revokeEnrollment(enrollId: string): Promise<TenantSecretBrokerRevokeResult> {
const row = enrollments.get(enrollId);
if (!row || row.revoked) return "not_found";
row.revoked = true;
return "revoked";
},
};
}
Loading
Loading