Skip to content

Commit b63fa69

Browse files
authored
feat(control-plane): pin per-tenant image versions for fleet rollout and rollback (#4898) (#8056)
1 parent 8fcbfd9 commit b63fa69

7 files changed

Lines changed: 286 additions & 2 deletions

File tree

control-plane/src/container-driver.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,25 @@ function bindingFor(config: ContainerDriverConfig, product: Product): ContainerN
5858
return binding;
5959
}
6060

61+
/** The env var a tenant's container reads its pinned image version from at (re)start (#4898). A Cloudflare
62+
* Container binding is fixed to one image at the wrangler.jsonc level (see `ContainerDriverConfig.bindings`),
63+
* so per-tenant versioning cannot swap the image reference binding-side — instead the tenant's own
64+
* `pinnedVersion` rides into the container, whose entrypoint resolves the versioned artifact itself. */
65+
export const PINNED_VERSION_ENV_VAR = "LOOPOVER_PINNED_VERSION";
66+
6167
/** Idempotent: an already-provisioned tenant's container is left running as-is, never restarted -- a repeat
62-
* create must not interrupt a container mid-work. */
68+
* create must not interrupt a container mid-work. A tenant with a `pinnedVersion` (#4898) starts with that
69+
* version in {@link PINNED_VERSION_ENV_VAR}; an unpinned tenant gets the exact pre-#4898 `start()` call, so
70+
* every existing tenant's behavior is byte-identical until a rollout pins it. */
6371
export async function createTenantContainer(config: ContainerDriverConfig, request: TenantProvisioningRequest): Promise<void> {
6472
const stub = bindingFor(config, request.product).getByName(instanceNameFor(request));
6573
if (await stub.isProvisioned()) return;
66-
await stub.start();
74+
const pinnedVersion = request.tenant.pinnedVersion;
75+
if (pinnedVersion) {
76+
await stub.start({ envVars: { [PINNED_VERSION_ENV_VAR]: pinnedVersion } });
77+
} else {
78+
await stub.start();
79+
}
6780
await stub.markProvisioned();
6881
}
6982

control-plane/src/http-app.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,25 @@ function safeRecord(record: Pick<TenantRegistryRecord, "tenant" | "product" | "s
3535
return { tenant: record.tenant, product: record.product, state: record.state };
3636
}
3737

38+
/** Validated body of `POST /v1/tenants/rollout` (#4898): an explicit tenant-name list (no percentage/canary
39+
* selector — no such primitive exists elsewhere in this codebase to build on) plus the version to pin.
40+
* `pinnedVersion: null` is an explicit unpin (revert to the release channel's default). */
41+
type RolloutRequest = { names: string[]; pinnedVersion: string | null };
42+
43+
function parseRolloutRequest(body: unknown): RolloutRequest | string {
44+
if (body === null || typeof body !== "object" || Array.isArray(body)) return "body must be a JSON object";
45+
const { names, pinnedVersion } = body as Record<string, unknown>;
46+
if (!Array.isArray(names) || names.length === 0) return "names must be a non-empty array of tenant names";
47+
if (!names.every((name): name is string => typeof name === "string" && name.trim() !== "")) {
48+
return "names must be a non-empty array of tenant names";
49+
}
50+
if (new Set(names).size !== names.length) return "names must not repeat a tenant";
51+
if (pinnedVersion !== null && (typeof pinnedVersion !== "string" || !pinnedVersion.trim())) {
52+
return "pinnedVersion must be a non-blank string, or null to unpin";
53+
}
54+
return { names, pinnedVersion: pinnedVersion === null ? null : pinnedVersion.trim() };
55+
}
56+
3857
export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono {
3958
const app = new Hono();
4059

@@ -80,6 +99,42 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono {
8099
return c.json({ tenants: records.map((record) => ({ ...safeRecord(record), createdAt: record.createdAt, updatedAt: record.updatedAt })) });
81100
});
82101

102+
// #4898: rollout/rollback = updating one or more tenants' pinnedVersion via an explicit list. Validates the
103+
// WHOLE list before touching any record (all-or-nothing) so a typo'd name can never leave a fleet half
104+
// rolled out; each updated tenant's container picks its new version up at its next (re)start
105+
// (container-driver.ts's PINNED_VERSION_ENV_VAR). Every unlisted tenant is untouched by construction —
106+
// the per-tenant-independence guarantee this endpoint exists to keep.
107+
app.post("/v1/tenants/rollout", async (c) => {
108+
const body: unknown = await c.req.json().catch(() => null);
109+
if (body === null) return c.json({ error: "invalid_json" }, 400);
110+
const parsed = parseRolloutRequest(body);
111+
if (typeof parsed === "string") return c.json({ error: "invalid_request", message: parsed }, 400);
112+
113+
const existing = new Map<string, TenantRegistryRecord>();
114+
for (const name of parsed.names) {
115+
const record = await deps.registry.get(name);
116+
if (!record) return c.json({ error: "tenant_not_found", message: `unknown tenant "${name}"` }, 404);
117+
// A torn-down tenant has no container to ever read the pin — surfacing the mistake beats silently
118+
// stamping a version onto a terminated record (same conflict posture as the create route's 409).
119+
if (record.state === "torn down") return c.json({ error: "tenant_torn_down", message: `tenant "${name}" is torn down` }, 409);
120+
existing.set(name, record);
121+
}
122+
123+
const now = new Date().toISOString();
124+
const updated: TenantRegistryRecord[] = [];
125+
for (const name of parsed.names) {
126+
const record = existing.get(name)!;
127+
const next: TenantRegistryRecord = {
128+
...record,
129+
tenant: { ...record.tenant, pinnedVersion: parsed.pinnedVersion },
130+
updatedAt: now,
131+
};
132+
await deps.registry.upsert(next);
133+
updated.push(next);
134+
}
135+
return c.json({ tenants: updated.map((record) => ({ ...safeRecord(record), createdAt: record.createdAt, updatedAt: record.updatedAt })) });
136+
});
137+
83138
app.delete("/v1/tenants/:name", async (c) => {
84139
const name = c.req.param("name");
85140
// Product is required so the registry can resolve the same `${product}:${name}` key used at create (#8024).

control-plane/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export {
5555
createContainerDriver,
5656
createTenantContainer,
5757
destroyTenantContainer,
58+
PINNED_VERSION_ENV_VAR,
5859
tenantContainerExists,
5960
type ContainerDriver,
6061
type ContainerDriverConfig,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export type Product = string;
1818
* admin commands address a tenant by. */
1919
export type Tenant = {
2020
name: string;
21+
/** #4898 (fleet rollout, decision ratified 2026-07-21): the image version THIS tenant's container resolves
22+
* at (re)start, instead of a shared `:latest` tag. Product-agnostic — the same field for ORB and AMS
23+
* tenants. Absent/null = unpinned (the tenant follows its release channel's default, exactly the pre-#4898
24+
* behavior). A rollout updates this field on an explicit list of tenants; rollback reverts it — see
25+
* http-app.ts's `POST /v1/tenants/rollout`. */
26+
pinnedVersion?: string | null;
2127
};
2228

2329
/** The full tenant lifecycle vocabulary the #7180 provisioning API reports, passed through verbatim by

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createContainerDriver,
99
createTenantContainer,
1010
destroyTenantContainer,
11+
PINNED_VERSION_ENV_VAR,
1112
tenantContainerExists,
1213
type ContainerDriverConfig,
1314
type ContainerNamespaceLike,
@@ -130,3 +131,60 @@ test("createContainerDriver bundles all three functions closed over one config",
130131
await driver.destroyContainer(REQUEST);
131132
assert.equal(await driver.containerExists(REQUEST), false);
132133
});
134+
135+
// #4898: a tenant's pinnedVersion rides into its container at (re)start as PINNED_VERSION_ENV_VAR — the only
136+
// per-tenant versioning seam available when the image reference itself is fixed at the wrangler.jsonc binding
137+
// level. The stub here captures start()'s options, which the package's shared fake deliberately doesn't.
138+
type StartOptions = Parameters<ContainerStubLike["start"]>[0];
139+
140+
function optionCapturingStub(): ContainerStubLike & { startOptions: StartOptions[] } {
141+
let provisioned = false;
142+
const startOptions: StartOptions[] = [];
143+
return {
144+
startOptions,
145+
async start(options?: StartOptions) {
146+
startOptions.push(options);
147+
},
148+
async stop() {},
149+
async isProvisioned() {
150+
return provisioned;
151+
},
152+
async markProvisioned() {
153+
provisioned = true;
154+
},
155+
async markDeprovisioned() {
156+
provisioned = false;
157+
},
158+
};
159+
}
160+
161+
function configFor(stub: ContainerStubLike): ContainerDriverConfig {
162+
return { bindings: { orb: { getByName: () => stub } } };
163+
}
164+
165+
test("a pinned tenant's container starts with PINNED_VERSION_ENV_VAR carrying its own version (#4898)", async () => {
166+
const stub = optionCapturingStub();
167+
168+
await createTenantContainer(configFor(stub), { tenant: { name: "acme", pinnedVersion: "v1.4.2" }, product: "orb" });
169+
170+
assert.deepEqual(stub.startOptions, [{ envVars: { [PINNED_VERSION_ENV_VAR]: "v1.4.2" } }]);
171+
});
172+
173+
test("an unpinned tenant's container start is byte-identical to the pre-#4898 call (no options at all)", async () => {
174+
for (const tenant of [{ name: "acme" }, { name: "acme", pinnedVersion: null }]) {
175+
const stub = optionCapturingStub();
176+
177+
await createTenantContainer(configFor(stub), { tenant, product: "orb" });
178+
179+
assert.deepEqual(stub.startOptions, [undefined]);
180+
}
181+
});
182+
183+
test("a repeat create of an already-provisioned pinned tenant never restarts it (#4898 keeps the idempotence contract)", async () => {
184+
const stub = optionCapturingStub();
185+
await stub.markProvisioned();
186+
187+
await createTenantContainer(configFor(stub), { tenant: { name: "acme", pinnedVersion: "v2.0.0" }, product: "orb" });
188+
189+
assert.deepEqual(stub.startOptions, []);
190+
});

control-plane/test/http-app.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,142 @@ test("a driver failure surfaces as a logged 500 via onError, not an unhandled re
291291
assert.match(errors[0]!, /control_plane_http_error/);
292292
assert.match(errors[0]!, /cloudflare containers api unavailable/);
293293
});
294+
295+
// #4898: POST /v1/tenants/rollout — pin/unpin an explicit list of tenants' pinnedVersion, all-or-nothing.
296+
// The registry-seeding style mirrors the GET /v1/tenants tests above (records seeded directly, no driver run).
297+
298+
function rollout(app: ReturnType<typeof createTenantHttpApp>, body: unknown) {
299+
return app.request(
300+
"/v1/tenants/rollout",
301+
authed({ method: "POST", headers: { "content-type": "application/json" }, body: typeof body === "string" ? body : JSON.stringify(body) }),
302+
);
303+
}
304+
305+
test("POST /v1/tenants/rollout pins exactly the listed tenants and leaves every other tenant untouched (#4898 acceptance)", async () => {
306+
const registry = createFakeTenantRegistry();
307+
await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
308+
await registry.upsert({ tenant: { name: "beta" }, product: "ams", state: "active", createdAt: "t0", updatedAt: "t0" });
309+
await registry.upsert({ tenant: { name: "gamma" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
310+
const app = createTenantHttpApp(baseDeps({ registry }));
311+
312+
const res = await rollout(app, { names: ["acme", "gamma"], pinnedVersion: "v1.4.2" });
313+
314+
assert.equal(res.status, 200);
315+
const payload = (await res.json()) as { tenants: Array<{ tenant: { name: string; pinnedVersion?: string | null } }> };
316+
assert.deepEqual(payload.tenants.map((t) => t.tenant), [
317+
{ name: "acme", pinnedVersion: "v1.4.2" },
318+
{ name: "gamma", pinnedVersion: "v1.4.2" },
319+
]);
320+
// The unlisted tenant is completely unaffected — no pin, no updatedAt churn.
321+
const beta = await registry.get("beta");
322+
assert.deepEqual(beta?.tenant, { name: "beta" });
323+
assert.equal(beta?.updatedAt, "t0");
324+
// The pinned tenants' records persisted the pin and kept their createdAt.
325+
const acme = await registry.get("acme");
326+
assert.deepEqual(acme?.tenant, { name: "acme", pinnedVersion: "v1.4.2" });
327+
assert.equal(acme?.createdAt, "t0");
328+
assert.notEqual(acme?.updatedAt, "t0");
329+
});
330+
331+
test("POST /v1/tenants/rollout rolls back independently: re-pinning one tenant leaves another tenant's pin alone", async () => {
332+
const registry = createFakeTenantRegistry();
333+
await registry.upsert({ tenant: { name: "acme", pinnedVersion: "v2.0.0" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
334+
await registry.upsert({ tenant: { name: "beta", pinnedVersion: "v2.0.0" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
335+
const app = createTenantHttpApp(baseDeps({ registry }));
336+
337+
const back = await rollout(app, { names: ["acme"], pinnedVersion: "v1.9.0" });
338+
assert.equal(back.status, 200);
339+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme", pinnedVersion: "v1.9.0" });
340+
assert.deepEqual((await registry.get("beta"))?.tenant, { name: "beta", pinnedVersion: "v2.0.0" });
341+
342+
// Explicit unpin (null) reverts the tenant to its release channel's default.
343+
const unpin = await rollout(app, { names: ["acme"], pinnedVersion: null });
344+
assert.equal(unpin.status, 200);
345+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme", pinnedVersion: null });
346+
assert.deepEqual((await registry.get("beta"))?.tenant, { name: "beta", pinnedVersion: "v2.0.0" });
347+
});
348+
349+
test("POST /v1/tenants/rollout trims the pinned version before storing it", async () => {
350+
const registry = createFakeTenantRegistry();
351+
await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
352+
const app = createTenantHttpApp(baseDeps({ registry }));
353+
354+
const res = await rollout(app, { names: ["acme"], pinnedVersion: " v1.4.2 " });
355+
356+
assert.equal(res.status, 200);
357+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme", pinnedVersion: "v1.4.2" });
358+
});
359+
360+
test("POST /v1/tenants/rollout 400s malformed bodies without touching any record", async () => {
361+
const registry = createFakeTenantRegistry();
362+
await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
363+
const app = createTenantHttpApp(baseDeps({ registry }));
364+
365+
const notJson = await rollout(app, "not json at all");
366+
assert.equal(notJson.status, 400);
367+
assert.deepEqual(await notJson.json(), { error: "invalid_json" });
368+
369+
for (const [body, message] of [
370+
[[], "body must be a JSON object"],
371+
[{ names: [], pinnedVersion: "v1" }, "names must be a non-empty array of tenant names"],
372+
[{ names: "acme", pinnedVersion: "v1" }, "names must be a non-empty array of tenant names"],
373+
[{ names: ["acme", " "], pinnedVersion: "v1" }, "names must be a non-empty array of tenant names"],
374+
[{ names: ["acme", 7], pinnedVersion: "v1" }, "names must be a non-empty array of tenant names"],
375+
[{ names: ["acme", "acme"], pinnedVersion: "v1" }, "names must not repeat a tenant"],
376+
[{ names: ["acme"], pinnedVersion: " " }, "pinnedVersion must be a non-blank string, or null to unpin"],
377+
[{ names: ["acme"], pinnedVersion: 7 }, "pinnedVersion must be a non-blank string, or null to unpin"],
378+
[{ names: ["acme"] }, "pinnedVersion must be a non-blank string, or null to unpin"],
379+
] as const) {
380+
const res = await rollout(app, body);
381+
assert.equal(res.status, 400, JSON.stringify(body));
382+
assert.deepEqual(await res.json(), { error: "invalid_request", message });
383+
}
384+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme" });
385+
});
386+
387+
test("POST /v1/tenants/rollout is all-or-nothing: one unknown name 404s and applies nothing", async () => {
388+
const registry = createFakeTenantRegistry();
389+
await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
390+
const app = createTenantHttpApp(baseDeps({ registry }));
391+
392+
const res = await rollout(app, { names: ["acme", "ghost"], pinnedVersion: "v1.4.2" });
393+
394+
assert.equal(res.status, 404);
395+
assert.deepEqual(await res.json(), { error: "tenant_not_found", message: 'unknown tenant "ghost"' });
396+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme" });
397+
});
398+
399+
test("POST /v1/tenants/rollout 409s a torn-down tenant and applies nothing", async () => {
400+
const registry = createFakeTenantRegistry();
401+
await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
402+
await registry.upsert({ tenant: { name: "gone" }, product: "orb", state: "torn down", createdAt: "t0", updatedAt: "t0" });
403+
const app = createTenantHttpApp(baseDeps({ registry }));
404+
405+
const res = await rollout(app, { names: ["acme", "gone"], pinnedVersion: "v1.4.2" });
406+
407+
assert.equal(res.status, 409);
408+
assert.deepEqual(await res.json(), { error: "tenant_torn_down", message: 'tenant "gone" is torn down' });
409+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme" });
410+
});
411+
412+
test("POST /v1/tenants/rollout sits behind the same Bearer wall as every other /v1/tenants route", async () => {
413+
const app = createTenantHttpApp(baseDeps());
414+
415+
const res = await app.request("/v1/tenants/rollout", { method: "POST", body: JSON.stringify({ names: ["acme"], pinnedVersion: "v1" }) });
416+
417+
assert.equal(res.status, 401);
418+
assert.deepEqual(await res.json(), { error: "unauthorized" });
419+
});
420+
421+
test("GET /v1/tenants surfaces each tenant's pinnedVersion once one is set (#4898 admin visibility)", async () => {
422+
const registry = createFakeTenantRegistry();
423+
await registry.upsert({ tenant: { name: "acme", pinnedVersion: "v1.4.2" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" });
424+
const app = createTenantHttpApp(baseDeps({ registry }));
425+
426+
const res = await app.request("/v1/tenants", authed());
427+
428+
assert.equal(res.status, 200);
429+
assert.deepEqual(await res.json(), {
430+
tenants: [{ tenant: { name: "acme", pinnedVersion: "v1.4.2" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" }],
431+
});
432+
});

control-plane/test/tenant-registry.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,15 @@ test("createKvTenantRegistry: list tolerates a key disappearing between the list
150150

151151
assert.deepEqual(await registry.list(), []);
152152
});
153+
154+
test("a tenant's pinnedVersion (#4898) survives the KV JSON round-trip, and its absence stays absent", async () => {
155+
const kv = fakeKv();
156+
const registry = createKvTenantRegistry(kv);
157+
158+
await registry.upsert({ ...recordFor("acme"), tenant: { name: "acme", pinnedVersion: "v1.4.2" } });
159+
await registry.upsert(recordFor("beta"));
160+
161+
assert.deepEqual((await registry.get("acme"))?.tenant, { name: "acme", pinnedVersion: "v1.4.2" });
162+
// A pre-#4898 record (no pinnedVersion key at all) reads back exactly as stored — unpinned.
163+
assert.deepEqual((await registry.get("beta"))?.tenant, { name: "beta" });
164+
});

0 commit comments

Comments
 (0)