Skip to content

Commit 406f562

Browse files
Brooooooklynclaude
andauthored
fix(cloudflare): deploy targeted placement for Workers with secrets (#600)
Fixes #546. `void deploy --platform cloudflare` refused `cloudflare.placement` with `region`, `host` or `hostname` once the Worker had any secret. Workers with secrets upload through the Workers beta version API, the only endpoint that can pin secrets to a chosen version. That endpoint used to drop the placement target ([terraform-provider-cloudflare#7273](cloudflare/terraform-provider-cloudflare#7273)), so Void blocked it. ## Checked live (2026-10-26) A throwaway Worker with one secret, deleted after the run: ``` beta POST, not deployed, secret inherited from the live version {mode:'targeted', region} → kept reply {region} {region} → kept reply {region} {mode:'targeted', target:[{region}]} → kept reply {region} (the #7273 form) {mode:'targeted', hostname} → kept reply {hostname} {mode:'targeted', host} → kept reply {host} classic GET of each version → placement_mode "targeted", target listed beta GET / list, region version live → 200 (workers-sdk#15642's 500 did not show up) next pinned upload after that → works ``` ## What changed ``` targeted placement + secrets send {mode:'targeted', region|host|hostname} ← same shape cf's own uploads send upload reply still names the target? yes → deploy goes on as before no, staged deploy → stop before any traffic moves ("so it was not deployed") no, atomic deploy → the version is already live; fail and say so smart placement / none → same requests as before no secrets (cf CLI) → unchanged ``` The check reads the upload reply, so it costs no extra request. It accepts both reply shapes Cloudflare uses: `{ region }`, and `{ mode: 'targeted', target: [{ region, … }] }` with exactly one target. ## Tests - `cf-version-runner.test.ts`: the old "rejects targeted placement" cases are replaced. - Upload: region, host and hostname, both staged and atomic. Checks the body shape and that `?deploy=true` is set only for atomic. - Reply with a target list: accepted. - Bad replies, each stopping the deploy: no placement, `{}`, a targeted mode with no target, smart, another region, the value under another kind, two targets. - Atomic upload that lost its target: the error says the version is live. - Mutation checks, each made a test fail: no reply check, no single-target rule, the old body shape, ignoring the value, the atomic wording. - `vp run check`, `vp lint`, `vp fmt`, `vp run test` and one full `vp test run` pass. - An independent review in a separate worktree found no defects. Its one point is listed below. ## Choices to check - **Atomic deploys keep `?deploy=true`.** The issue's repro (`void secret put`, then the first deploy) takes this path. If Cloudflare ever drops the target again, that version is live before the check, and the deploy fails with an error that names the version. The headline above that error still says "could not bootstrap/publish". The container check already has the same wording gap. Carrying the live version id through the activation result would change the protocol in `deploy-cloudflare`, so it is left out here. - **No docs change.** No doc mentioned the refusal, and the new errors say what to do. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent a1f00e6 commit 406f562

2 files changed

Lines changed: 138 additions & 37 deletions

File tree

‎packages/void/src/cli/cf-version-api.ts‎

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -417,11 +417,25 @@ function assetConfigFromOutput(value: unknown): Json | undefined {
417417
};
418418
}
419419

420-
/**
421-
* The version API placement. Smart placement is normalized the same way as cf's own version
422-
* uploads. Targeted placement is rejected: the Workers beta version endpoint can drop its
423-
* target, and an atomic upload is live before a read-back could catch that.
424-
*/
420+
type PlacementTarget = { kind: 'region' | 'host' | 'hostname'; value: string };
421+
422+
/** The region, host or hostname of a targeted placement. */
423+
function placementTarget(
424+
placement: ParsedOutputWorkerConfig['placement'],
425+
): PlacementTarget | undefined {
426+
if (!placement) {
427+
return undefined;
428+
}
429+
for (const kind of ['region', 'host', 'hostname'] as const) {
430+
const value = (placement as Record<string, unknown>)[kind];
431+
if (typeof value === 'string' && value) {
432+
return { kind, value };
433+
}
434+
}
435+
return undefined;
436+
}
437+
438+
/** The version API placement, in the shape cf's own version uploads send. */
425439
function placementFromOutput(placement: ParsedOutputWorkerConfig['placement']): Json | undefined {
426440
if (!placement) {
427441
return undefined;
@@ -430,16 +444,26 @@ function placementFromOutput(placement: ParsedOutputWorkerConfig['placement']):
430444
if (hint || placement.mode === 'smart') {
431445
return { mode: 'smart', ...(hint ? { hint } : {}) };
432446
}
433-
const target =
434-
('region' in placement && placement.region && 'region') ||
435-
('host' in placement && placement.host && 'host') ||
436-
('hostname' in placement && placement.hostname && 'hostname');
437-
if (target) {
438-
throw new Error(
439-
`Cloudflare placement.${target} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`,
440-
);
447+
const target = placementTarget(placement);
448+
return target ? { mode: 'targeted', [target.kind]: target.value } : undefined;
449+
}
450+
451+
/**
452+
* Whether the upload reply still names the requested target. The Workers beta version
453+
* endpoint has replied `placement: {}` after dropping one (terraform-provider-cloudflare#7273).
454+
* It now replies with the target alone, e.g. `{ region }`.
455+
*/
456+
function keepsPlacementTarget(placement: unknown, target: PlacementTarget): boolean {
457+
const reply = object(placement);
458+
if (!reply) {
459+
return false;
441460
}
442-
return undefined;
461+
const targets = reply.target === undefined ? [reply] : reply.target;
462+
return (
463+
Array.isArray(targets) &&
464+
targets.length === 1 &&
465+
object(targets[0])?.[target.kind] === target.value
466+
);
443467
}
444468

445469
async function apiResult(
@@ -1277,6 +1301,12 @@ export async function uploadPinnedCloudflareVersion(options: {
12771301
`Cloudflare Worker version ${versionId} was uploaded${options.atomic ? ' and may already be published' : ''}, but its Container applications could not be verified. Read back the Worker deployment and Container identities before retrying.`,
12781302
);
12791303
}
1304+
const target = placementTarget(config.placement);
1305+
if (target && !keepsPlacementTarget(uploaded.placement, target)) {
1306+
throw new Error(
1307+
`Cloudflare did not keep placement.${target.kind} "${target.value}" on Worker version ${versionId}${options.atomic ? ', which is already live' : ', so it was not deployed'}. Deploy again, or set cloudflare.placement to { mode: 'smart' } in void.config.ts.`,
1308+
);
1309+
}
12801310
return {
12811311
id: versionId,
12821312
urls: Array.isArray(uploaded.urls)

‎packages/void/test/unit/cf-version-runner.test.ts‎

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,11 +1360,12 @@ it('uploads Worker limits, placement and cache with pinned secrets', async () =>
13601360
}
13611361
});
13621362

1363-
it.each([
1364-
{ region: 'aws:us-east-1' },
1365-
{ host: 'db.example.test:5432' },
1366-
{ hostname: 'api.example.test' },
1367-
])('rejects targeted placement with pinned secrets before any upload: %j', async (placement) => {
1363+
/** Upload with pinned secrets to a fake API whose upload reply carries `replyPlacement`. */
1364+
async function uploadTargeted(
1365+
placement: Record<string, string>,
1366+
replyPlacement: unknown,
1367+
atomic = false,
1368+
) {
13681369
const prepared = await output({ secrets: { required: ['AUTH_SECRET'] }, placement });
13691370
try {
13701371
await pinActiveWorkerSecrets({
@@ -1373,9 +1374,18 @@ it.each([
13731374
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
13741375
requiredNames: ['AUTH_SECRET'],
13751376
});
1376-
const requests: Array<string> = [];
1377-
const server = createServer((request, response) => {
1378-
requests.push(`${request.method} ${request.url}`);
1377+
const uploads: Array<{ url: string; body: Record<string, unknown> }> = [];
1378+
const server = createServer(async (request, response) => {
1379+
const chunks: Array<Buffer> = [];
1380+
for await (const chunk of request) {
1381+
chunks.push(Buffer.from(chunk));
1382+
}
1383+
if (request.method === 'POST') {
1384+
uploads.push({
1385+
url: request.url ?? '',
1386+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
1387+
});
1388+
}
13791389
response.writeHead(200, { 'Content-Type': 'application/json' });
13801390
response.end(
13811391
JSON.stringify({
@@ -1387,27 +1397,88 @@ it.each([
13871397
id: '11111111-1111-4111-8111-111111111111',
13881398
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
13891399
}
1390-
: { id: '22222222-2222-4222-8222-222222222222', urls: [] },
1400+
: {
1401+
id: '22222222-2222-4222-8222-222222222222',
1402+
urls: [],
1403+
...(replyPlacement === undefined ? {} : { placement: replyPlacement }),
1404+
},
13911405
}),
13921406
);
13931407
});
1394-
await expect(
1395-
uploadPinnedCloudflareVersion({
1396-
output: prepared,
1397-
accountId: 'account-id',
1398-
apiBaseUrl: await listen(server),
1399-
workerName: 'void-test',
1400-
token: 'synthetic-token',
1401-
atomic: true,
1402-
verifyUploader: () => true,
1403-
}),
1404-
).rejects.toThrow(
1405-
`Cloudflare placement.${Object.keys(placement)[0]} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`,
1406-
);
1407-
expect(requests).toEqual([]);
1408+
const upload = uploadPinnedCloudflareVersion({
1409+
output: prepared,
1410+
accountId: 'account-id',
1411+
apiBaseUrl: await listen(server),
1412+
workerName: 'void-test',
1413+
token: 'synthetic-token',
1414+
atomic,
1415+
verifyUploader: () => true,
1416+
});
1417+
return {
1418+
result: await upload.then(
1419+
(value) => ({ value }),
1420+
(error) => ({ error }),
1421+
),
1422+
uploads,
1423+
};
14081424
} finally {
14091425
prepared.dispose();
14101426
}
1427+
}
1428+
1429+
it.each([
1430+
[{ region: 'aws:us-east-1' }, false],
1431+
[{ region: 'aws:us-east-1' }, true],
1432+
[{ host: 'db.example.test:5432' }, false],
1433+
[{ hostname: 'api.example.test' }, true],
1434+
])('uploads targeted placement %j with pinned secrets (atomic=%s)', async (placement, atomic) => {
1435+
// Cloudflare replies with the target alone, as observed on the live API.
1436+
const { result, uploads } = await uploadTargeted(placement, placement, atomic);
1437+
1438+
expect(result).toEqual({ value: { id: '22222222-2222-4222-8222-222222222222', urls: [] } });
1439+
expect(uploads).toHaveLength(1);
1440+
expect(uploads[0]!.url.endsWith(atomic ? '/versions?deploy=true' : '/versions')).toBe(true);
1441+
expectSameJson(uploads[0]!.body.placement, { mode: 'targeted', ...placement });
1442+
});
1443+
1444+
it('accepts an upload reply that lists the target', async () => {
1445+
const { result } = await uploadTargeted(
1446+
{ region: 'aws:us-east-1' },
1447+
{ mode: 'targeted', target: [{ id: 1, region: 'aws:us-east-1', type: 'region' }] },
1448+
);
1449+
1450+
expect(result).toHaveProperty('value.id', '22222222-2222-4222-8222-222222222222');
1451+
});
1452+
1453+
it.each([
1454+
['no placement', undefined],
1455+
['an empty placement', {}],
1456+
['a targeted mode without a target', { mode: 'targeted' }],
1457+
['smart placement', { mode: 'smart' }],
1458+
['another region', { region: 'aws:eu-west-1' }],
1459+
['the target under another kind', { hostname: 'aws:us-east-1' }],
1460+
[
1461+
'two targets',
1462+
{ mode: 'targeted', target: [{ region: 'aws:us-east-1' }, { region: 'aws:eu-west-1' }] },
1463+
],
1464+
])('stops before deploying when the upload reply has %s', async (_case, replyPlacement) => {
1465+
const { result } = await uploadTargeted({ region: 'aws:us-east-1' }, replyPlacement);
1466+
1467+
expect(result).toEqual({
1468+
error: new Error(
1469+
`Cloudflare did not keep placement.region "aws:us-east-1" on Worker version 22222222-2222-4222-8222-222222222222, so it was not deployed. Deploy again, or set cloudflare.placement to { mode: 'smart' } in void.config.ts.`,
1470+
),
1471+
});
1472+
});
1473+
1474+
it('reports a live version whose atomic upload lost its target', async () => {
1475+
const { result } = await uploadTargeted({ hostname: 'api.example.test' }, {}, true);
1476+
1477+
expect(result).toEqual({
1478+
error: new Error(
1479+
`Cloudflare did not keep placement.hostname "api.example.test" on Worker version 22222222-2222-4222-8222-222222222222, which is already live. Deploy again, or set cloudflare.placement to { mode: 'smart' } in void.config.ts.`,
1480+
),
1481+
});
14111482
});
14121483

14131484
it.each(['missing name', 'duplicate name', 'external collision', 'class collision'] as const)(

0 commit comments

Comments
 (0)