Skip to content

Commit 151bad8

Browse files
authored
feat(control-plane): deliver a bootstrap secret into hosted tenant containers (#8202) (#8253)
Resolves push-vs-pull: a second stub.start() call from injectSecrets can't reliably deliver a live secret (Cloudflare Containers only apply envVars at an actual cold boot, and createContainer already owns the tenant's one real start() call). Instead, provisionTenant now runs database -> secrets -> container, so injectSecrets' one-time exchange secret (previously discarded) rides the container's own cold-boot envVars as a bootstrap credential; the container exchanges it for the real custodied value via the new fetchBrokeredStoredSecret client against the broker's already-wired stored-secret path. Scoped to the mechanism + ORB (which reuses its unmodified self-host broker-client code for free); AMS's container-side wiring is a real separate lift and follow-up issue #8246.
1 parent 7a8b9b6 commit 151bad8

13 files changed

Lines changed: 261 additions & 44 deletions

control-plane/src/container-driver.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,31 @@ function bindingFor(config: ContainerDriverConfig, product: Product): ContainerN
6767
* `pinnedVersion` rides into the container, whose entrypoint resolves the versioned artifact itself. */
6868
export const PINNED_VERSION_ENV_VAR = "LOOPOVER_PINNED_VERSION";
6969

70+
/** The env var a tenant's container reads its one-time secret-bootstrap credential from at cold boot (#8202).
71+
* Deliberately product-agnostic (no `ORB_`/`AMS_` prefix), same reasoning as {@link PINNED_VERSION_ENV_VAR}:
72+
* both `OrbTenantContainer` and `AmsTenantContainer` (#8246) read the identical name. The value itself is a
73+
* one-time secret from `injectSecrets` (`TenantProvisioningRequest.bootstrapSecret`) the container exchanges
74+
* via `POST /v1/orb/token` (`src/orb/broker-client.ts`'s `fetchBrokeredStoredSecret`) for whatever the broker
75+
* actually has custodied -- this driver never sees or needs to know what that is. */
76+
export const TENANT_SECRET_ENV_VAR = "LOOPOVER_TENANT_SECRET_TOKEN";
77+
7078
/** Idempotent: an already-provisioned tenant's container is left running as-is, never restarted -- a repeat
71-
* create must not interrupt a container mid-work. A tenant with a `pinnedVersion` (#4898) starts with that
72-
* version in {@link PINNED_VERSION_ENV_VAR}; an unpinned tenant gets the exact pre-#4898 `start()` call, so
73-
* every existing tenant's behavior is byte-identical until a rollout pins it. */
79+
* create must not interrupt a container mid-work. This is also the ONLY point in a tenant's lifecycle where
80+
* `envVars` actually reach the container (confirmed against the real `@cloudflare/containers` SDK: a `start()`
81+
* call against an already-running/starting instance is a no-op or throws, never re-applies `envVars`) -- so
82+
* both of the values below must already be known by the time this runs, not supplied later. A tenant with a
83+
* `pinnedVersion` (#4898) starts with that version in {@link PINNED_VERSION_ENV_VAR}; one with a
84+
* `bootstrapSecret` (#8202, set on `request` by `provisionTenant` from `injectSecrets`' result) starts with it
85+
* in {@link TENANT_SECRET_ENV_VAR}; a tenant with neither gets the exact pre-#4898 `start()` call, so every
86+
* existing tenant's behavior is byte-identical until either rollout applies. */
7487
export async function createTenantContainer(config: ContainerDriverConfig, request: TenantProvisioningRequest): Promise<void> {
7588
const stub = bindingFor(config, request.product).getByName(instanceNameFor(request));
7689
if (await stub.isProvisioned()) return;
77-
const pinnedVersion = request.tenant.pinnedVersion;
78-
if (pinnedVersion) {
79-
await stub.start({ envVars: { [PINNED_VERSION_ENV_VAR]: pinnedVersion } });
90+
const envVars: Record<string, string> = {};
91+
if (request.tenant.pinnedVersion) envVars[PINNED_VERSION_ENV_VAR] = request.tenant.pinnedVersion;
92+
if (request.bootstrapSecret) envVars[TENANT_SECRET_ENV_VAR] = request.bootstrapSecret;
93+
if (Object.keys(envVars).length > 0) {
94+
await stub.start({ envVars });
8095
} else {
8196
await stub.start();
8297
}

control-plane/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export {
6565
destroyTenantContainer,
6666
instanceNameFor,
6767
PINNED_VERSION_ENV_VAR,
68+
TENANT_SECRET_ENV_VAR,
6869
tenantContainerExists,
6970
type ContainerDriver,
7071
type ContainerDriverConfig,

control-plane/src/provisioning.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// provisionTenant / deprovisionTenant orchestration (#7524) over the injectable `TenantProvisioningDriver`.
22
// Product-agnostic: an ORB tenant and an AMS tenant take the identical call shape — `product` is forwarded to
3-
// every driver step but never branched on. Provision runs #7180's three steps in order (create-container,
4-
// provision-DB, inject-secrets); deprovision tears them down in REVERSE (revoke-secrets, drop-DB,
5-
// destroy-container) so a secret is never left addressable after the DB/container it belonged to is gone.
3+
// every driver step but never branched on. Provision runs #7180's three steps as provision-DB, inject-secrets,
4+
// create-container (#8202 reordered this from the original create-container-first sequence: a tenant's
5+
// bootstrap secret, produced by inject-secrets, must exist BEFORE create-container's one real `stub.start()`
6+
// call, since Cloudflare Containers only ever apply `envVars` at a container's actual cold (re)start -- never
7+
// as a live update to one already running or starting, confirmed against the real `@cloudflare/containers` SDK).
8+
// Deprovision tears down in the order revoke-secrets, drop-DB, destroy-container -- REVERSE of the ORIGINAL
9+
// #7180 order, kept deliberately unchanged by #8202's reorder: revoking a secret before the DB/container it
10+
// belonged to is gone is the security property that matters here, not exact step-order symmetry with provision.
611
//
712
// #7667: a driver-step failure in EITHER direction also pages, via the same PagerDuty Events API v2 contract
813
// ORB uses in `src/services/notify-pagerduty.ts` (see ./pagerduty-notify.ts for the mirrored contract and why
@@ -81,13 +86,16 @@ function pageAndRethrow(
8186
throw error;
8287
}
8388

84-
/** Provision a tenant by running #7180's three steps in order against the injected driver. Product-agnostic:
85-
* `product` is forwarded to every step, never branched on, so ORB and AMS share one call shape. `injectSecrets`
86-
* is called with `database` already attached to the request (#8066) -- a real secret driver needs the
87-
* connection details to actually store, not just the tenant identity every other step operates on. A step
88-
* failure pages (#7667) and always rethrows — provisioning never fails silently. `onFailure` (#7677,
89-
* optional) runs first in that failure path — the caller's seam for persisting the `"failed"` lifecycle
90-
* state — and is best-effort: its own rejection is swallowed so it can never mask the step error. */
89+
/** Provision a tenant by running #7180's three steps against the injected driver, in the order database, secrets,
90+
* container (#8202 -- see this module's header for why). Product-agnostic: `product` is forwarded to every step,
91+
* never branched on, so ORB and AMS share one call shape. `injectSecrets` is called with `database` already
92+
* attached to the request (#8066) -- a real secret driver needs the connection details to actually store, not
93+
* just the tenant identity every other step operates on. `createContainer` is in turn called with `database`
94+
* still attached AND `bootstrapSecret` newly attached (#8202) whenever `injectSecrets` returned one -- a real
95+
* container driver delivers it into the container's own cold-boot environment. A step failure pages (#7667) and
96+
* always rethrows — provisioning never fails silently. `onFailure` (#7677, optional) runs first in that failure
97+
* path — the caller's seam for persisting the `"failed"` lifecycle state — and is best-effort: its own
98+
* rejection is swallowed so it can never mask the step error. */
9199
export async function provisionTenant(
92100
tenant: Tenant,
93101
product: Product,
@@ -99,9 +107,10 @@ export async function provisionTenant(
99107
let database: DatabaseConnectionDetails;
100108
let secretRef: string | undefined;
101109
try {
102-
await driver.createContainer(request);
103110
database = await driver.provisionDatabase(request);
104-
({ secretRef } = await driver.injectSecrets({ ...request, database }));
111+
const injected = await driver.injectSecrets({ ...request, database });
112+
secretRef = injected.secretRef;
113+
await driver.createContainer({ ...request, database, ...(injected.bootstrapSecret !== undefined ? { bootstrapSecret: injected.bootstrapSecret } : {}) });
105114
} catch (error) {
106115
// #7677 (ratified 2026-07-21): give the caller its chance to transition the tenant's registry record to
107116
// "failed" BEFORE the rethrow, so a customer polling the read path sees a terminal "Setup failed" instead

control-plane/src/secret-driver.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
// calls the SAME two routes, just to STORE a tenant's DB credential rather than mint a GitHub token (#8064's
1010
// `tenant_db_credential` secret type), plus a third route (#8064) to revoke it on teardown.
1111
//
12-
// Scope, deliberately narrow: this ONLY stores/revokes custody of the credential in the broker. It does NOT
13-
// deliver the secret into a running container's environment -- that's separate, not-yet-built infrastructure
14-
// (a container's own bootstrap would need to independently exchange its own enrollment secret, the same way a
15-
// self-hosted container already does against `/v1/orb/token` today). #8066's own boundary excludes it.
12+
// Scope, deliberately narrow: this ONLY stores custody of the credential in the broker and hands back the
13+
// one-time exchange secret as `bootstrapSecret` -- it does NOT itself deliver anything into a running
14+
// container's environment. That delivery is provisioning.ts's + container-driver.ts's job (#8202): provisioning
15+
// threads `bootstrapSecret` from this driver's `injectSecrets` result into the SAME tenant's `createContainer`
16+
// call, which is where it actually reaches `stub.start({envVars})`. #8066's own boundary excluded delivery
17+
// entirely; #8202 is precisely the "separate, not-yet-built infrastructure" that comment pointed at.
1618
//
1719
// Deliberately does NOT implement the full `TenantProvisioningDriver` interface -- only injectSecrets/
1820
// revokeSecrets (see `SecretDriver` below). `withRealSecretDriver` (driver-factory.ts) composes this onto an
@@ -42,7 +44,7 @@ export type SecretDriverConfig = {
4244
/** The secret-only slice of `TenantProvisioningDriver` this module actually implements. Composed onto a full
4345
* driver by `withRealSecretDriver` (driver-factory.ts), never used standalone against `provisionTenant`. */
4446
export type SecretDriver = {
45-
injectSecrets(request: TenantProvisioningRequest): Promise<{ secretRef?: string }>;
47+
injectSecrets(request: TenantProvisioningRequest): Promise<{ secretRef?: string; bootstrapSecret?: string }>;
4648
revokeSecrets(request: TenantProvisioningRequest): Promise<void>;
4749
};
4850

@@ -71,9 +73,11 @@ async function mainAppFetch<T>(config: SecretDriverConfig, method: string, path:
7173
* object is stored (JSON-encoded), not just the bare `connectionString` -- a later reader gets every field
7274
* back, not just what it can re-parse out of a URI, mirroring that type's own "kept alongside the parts"
7375
* rationale. Returns the enrollment's `enrollId` as this driver's `secretRef` -- the caller (`provisionTenant`,
74-
* via its own result) must persist this to revoke it later; the one-time exchange `secret` is intentionally
75-
* discarded here, since this driver's job ends at custody, not consumption (see this file's header comment). */
76-
export async function injectTenantSecrets(config: SecretDriverConfig, request: TenantProvisioningRequest): Promise<{ secretRef?: string }> {
76+
* via its own result) must persist this to revoke it later -- AND the one-time exchange `secret` as
77+
* `bootstrapSecret` (#8202): the caller threads this into the tenant's container at its next `createContainer`
78+
* call, so the container can itself present it to `/v1/orb/token` and get this exact value back. Previously
79+
* discarded here (see this file's former header comment); #8202 is what actually consumes it now. */
80+
export async function injectTenantSecrets(config: SecretDriverConfig, request: TenantProvisioningRequest): Promise<{ secretRef?: string; bootstrapSecret?: string }> {
7781
if (!request.database) {
7882
throw new Error(`injectTenantSecrets: no database connection details on the request for tenant "${request.tenant.name}"`);
7983
}
@@ -86,7 +90,7 @@ export async function injectTenantSecrets(config: SecretDriverConfig, request: T
8690
"/v1/internal/orb/enrollments",
8791
{ secretType: SECRET_TYPE_TENANT_DB_CREDENTIAL, secretValue: JSON.stringify(request.database) },
8892
);
89-
return { secretRef: result.enrollId };
93+
return { secretRef: result.enrollId, bootstrapSecret: result.secret };
9094
}
9195

9296
/** Idempotent teardown: a request with no `secretRef` (never provisioned with a real secret driver, or already

control-plane/src/tenant-provisioning-driver.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,21 @@ export type TenantLifecycleState =
4141
export type TenantProvisioningRequest = {
4242
tenant: Tenant;
4343
product: Product;
44-
/** The tenant's already-provisioned database connection details (#7653) -- populated ONLY for the
45-
* `injectSecrets` call, by `provisionTenant`'s own orchestration right after `provisionDatabase` resolves
46-
* (#8066). Every other step (createContainer, destroyContainer, etc.) never sees this field. */
44+
/** The tenant's already-provisioned database connection details (#7653) -- populated for the `injectSecrets`
45+
* call (and, from there on, every step after it -- see `createContainer` below) by `provisionTenant`'s own
46+
* orchestration right after `provisionDatabase` resolves (#8066). */
4747
database?: DatabaseConnectionDetails;
4848
/** An opaque, driver-specific reference to a previously injected secret (#8066) -- whatever `injectSecrets`
4949
* returned as `secretRef`, threaded back in by `deprovisionTenant` so `revokeSecrets` knows what to revoke.
5050
* Absent when a tenant was never provisioned with a real secret driver configured (idempotent revoke of an
5151
* unconfigured tenant, matching every other driver's teardown contract). */
5252
secretRef?: string;
53+
/** A one-time credential the tenant's OWN container can later exchange for a real custodied secret (#8202) --
54+
* whatever `injectSecrets` returned as `bootstrapSecret`, threaded by `provisionTenant` into the SAME
55+
* `createContainer` call that follows it (#8202 reordered provisioning so this is possible -- see
56+
* provisioning.ts). Populated ONLY for that one `createContainer` call; no other step ever sees it, and it is
57+
* never itself the delivered secret -- just the key the container uses to fetch one. */
58+
bootstrapSecret?: string;
5359
};
5460

5561
/** What `provisionDatabase` hands back (#7653): everything a caller needs to actually reach the tenant's
@@ -69,7 +75,12 @@ export type DatabaseConnectionDetails = {
6975
};
7076

7177
export interface TenantProvisioningDriver {
72-
/** Step 1 (#7180): stand up the tenant's isolated container. Real driver → Cloudflare Containers API. */
78+
/** Step 1 in call order (#7180), but the LAST of the three to run within `provisionTenant` as of #8202: stand
79+
* up the tenant's isolated container. Real driver → Cloudflare Containers API. May see `request.bootstrapSecret`
80+
* (#8202, set when `injectSecrets` returned one) to deliver into the container's own process environment at
81+
* this, its actual cold-boot `start()` call -- the only point in a container's lifecycle Cloudflare Containers
82+
* actually applies `envVars` (confirmed against the real `@cloudflare/containers` SDK: a repeat `start()` on
83+
* an already-running/starting instance is a no-op or throws, never re-applies `envVars`). */
7384
createContainer(request: TenantProvisioningRequest): Promise<void>;
7485
/** Step 2 (#7180): provision the tenant's database, returning its connection details (#7653) -- a freshly
7586
* created role's password is typically retrievable from the provider only at creation time, so the caller
@@ -78,10 +89,14 @@ export interface TenantProvisioningDriver {
7889
provisionDatabase(request: TenantProvisioningRequest): Promise<DatabaseConnectionDetails>;
7990
/** Step 3 (#7180): inject the tenant's secrets, given its database connection details (`request.database`,
8091
* #8066). Returns an opaque `secretRef` the caller must persist and thread back into a later `revokeSecrets`
81-
* call via `request.secretRef` -- `undefined` when the driver has nothing to track (e.g. the fake). A real
82-
* driver delegates to #7174's generalized broker (src/orb/broker.ts, via #8064's stored-secret type); the
83-
* fake only records the call. */
84-
injectSecrets(request: TenantProvisioningRequest): Promise<{ secretRef?: string }>;
92+
* call via `request.secretRef` -- `undefined` when the driver has nothing to track (e.g. the fake). Also
93+
* returns `bootstrapSecret` (#8202): a one-time credential the caller threads into the SAME tenant's next
94+
* `createContainer` call (provisioning.ts runs this step BEFORE createContainer specifically so this is
95+
* possible), so the running container can itself exchange it later for the real secret this step just
96+
* custodied -- `undefined` when the driver has nothing for a container to bootstrap with. A real driver
97+
* delegates to #7174's generalized broker (src/orb/broker.ts, via #8064's stored-secret type); the fake only
98+
* records the call. */
99+
injectSecrets(request: TenantProvisioningRequest): Promise<{ secretRef?: string; bootstrapSecret?: string }>;
85100
/** Teardown inverse of createContainer. MUST be idempotent — safe to call when the container was never
86101
* created — so deprovisioning a nonexistent tenant is a no-op, never a throw. */
87102
destroyContainer(request: TenantProvisioningRequest): Promise<void>;

control-plane/test/container-driver.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
createTenantContainer,
1010
destroyTenantContainer,
1111
PINNED_VERSION_ENV_VAR,
12+
TENANT_SECRET_ENV_VAR,
1213
tenantContainerExists,
1314
type ContainerDriverConfig,
1415
type ContainerNamespaceLike,
@@ -188,3 +189,38 @@ test("a repeat create of an already-provisioned pinned tenant never restarts it
188189

189190
assert.deepEqual(stub.startOptions, []);
190191
});
192+
193+
// #8202: a tenant's one-time secret-bootstrap credential rides into its container at cold boot the same way
194+
// pinnedVersion does above -- the only point in a container's lifecycle envVars are actually applied.
195+
test("a tenant with a bootstrap secret starts with TENANT_SECRET_ENV_VAR carrying it", async () => {
196+
const stub = optionCapturingStub();
197+
198+
await createTenantContainer(configFor(stub), { tenant: { name: "acme" }, product: "orb", bootstrapSecret: "orbsec_xyz" });
199+
200+
assert.deepEqual(stub.startOptions, [{ envVars: { [TENANT_SECRET_ENV_VAR]: "orbsec_xyz" } }]);
201+
});
202+
203+
test("a tenant with both a pinned version and a bootstrap secret starts with both env vars merged into one call", async () => {
204+
const stub = optionCapturingStub();
205+
206+
await createTenantContainer(configFor(stub), { tenant: { name: "acme", pinnedVersion: "v1.4.2" }, product: "orb", bootstrapSecret: "orbsec_xyz" });
207+
208+
assert.deepEqual(stub.startOptions, [{ envVars: { [PINNED_VERSION_ENV_VAR]: "v1.4.2", [TENANT_SECRET_ENV_VAR]: "orbsec_xyz" } }]);
209+
});
210+
211+
test("a tenant with neither a pinned version nor a bootstrap secret still gets the exact pre-#4898 call (no options at all)", async () => {
212+
const stub = optionCapturingStub();
213+
214+
await createTenantContainer(configFor(stub), { tenant: { name: "acme" }, product: "orb", bootstrapSecret: undefined });
215+
216+
assert.deepEqual(stub.startOptions, [undefined]);
217+
});
218+
219+
test("a repeat create of an already-provisioned tenant with a bootstrap secret never restarts it (idempotence contract holds here too)", async () => {
220+
const stub = optionCapturingStub();
221+
await stub.markProvisioned();
222+
223+
await createTenantContainer(configFor(stub), { tenant: { name: "acme" }, product: "orb", bootstrapSecret: "orbsec_xyz" });
224+
225+
assert.deepEqual(stub.startOptions, []);
226+
});

control-plane/test/driver-factory.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ test("createTenantProvisioningDriver: selects the real secret driver when both M
251251
const driver = createTenantProvisioningDriver({ MAIN_APP_BASE_URL: "https://api.loopover.test", INTERNAL_JOB_TOKEN: "internal-test-token" });
252252

253253
const result = await driver.injectSecrets({ ...REQUEST, database: { host: "h", port: 5432, database: "d", user: "u", password: "p", connectionString: "postgres://u:p@h:5432/d" } });
254-
assert.deepEqual(result, { secretRef: "orbenr_abc" });
254+
assert.deepEqual(result, { secretRef: "orbenr_abc", bootstrapSecret: "orbsec_xyz" });
255255
assert.ok(calls.some((url) => url.includes("api.loopover.test")));
256256
});
257257

@@ -270,7 +270,7 @@ test("createTenantProvisioningDriver: composes the real database, container, AND
270270
assert.equal(await driver.containerExists(REQUEST), true);
271271
await assert.rejects(driver.provisionDatabase(REQUEST));
272272
const injected = await driver.injectSecrets({ ...REQUEST, database: { host: "h", port: 5432, database: "d", user: "u", password: "p", connectionString: "postgres://u:p@h:5432/d" } });
273-
assert.deepEqual(injected, { secretRef: "orbenr_abc" });
273+
assert.deepEqual(injected, { secretRef: "orbenr_abc", bootstrapSecret: "orbsec_xyz" });
274274
});
275275

276276
test("createTenantProvisioningDriver: defaults env to process.env when no override is passed", async () => {

0 commit comments

Comments
 (0)