Skip to content
Merged
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
58 changes: 44 additions & 14 deletions packages/void/src/cli/cf-version-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,25 @@ function assetConfigFromOutput(value: unknown): Json | undefined {
};
}

/**
* The version API placement. Smart placement is normalized the same way as cf's own version
* uploads. Targeted placement is rejected: the Workers beta version endpoint can drop its
* target, and an atomic upload is live before a read-back could catch that.
*/
type PlacementTarget = { kind: 'region' | 'host' | 'hostname'; value: string };

/** The region, host or hostname of a targeted placement. */
function placementTarget(
placement: ParsedOutputWorkerConfig['placement'],
): PlacementTarget | undefined {
if (!placement) {
return undefined;
}
for (const kind of ['region', 'host', 'hostname'] as const) {
const value = (placement as Record<string, unknown>)[kind];
if (typeof value === 'string' && value) {
return { kind, value };
}
}
return undefined;
}

/** The version API placement, in the shape cf's own version uploads send. */
function placementFromOutput(placement: ParsedOutputWorkerConfig['placement']): Json | undefined {
if (!placement) {
return undefined;
Expand All @@ -430,16 +444,26 @@ function placementFromOutput(placement: ParsedOutputWorkerConfig['placement']):
if (hint || placement.mode === 'smart') {
return { mode: 'smart', ...(hint ? { hint } : {}) };
}
const target =
('region' in placement && placement.region && 'region') ||
('host' in placement && placement.host && 'host') ||
('hostname' in placement && placement.hostname && 'hostname');
if (target) {
throw new Error(
`Cloudflare placement.${target} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`,
);
const target = placementTarget(placement);
return target ? { mode: 'targeted', [target.kind]: target.value } : undefined;
}

/**
* Whether the upload reply still names the requested target. The Workers beta version
* endpoint has replied `placement: {}` after dropping one (terraform-provider-cloudflare#7273).
* It now replies with the target alone, e.g. `{ region }`.
*/
function keepsPlacementTarget(placement: unknown, target: PlacementTarget): boolean {
const reply = object(placement);
if (!reply) {
return false;
}
return undefined;
const targets = reply.target === undefined ? [reply] : reply.target;
return (
Array.isArray(targets) &&
targets.length === 1 &&
object(targets[0])?.[target.kind] === target.value
);
}

async function apiResult(
Expand Down Expand Up @@ -1277,6 +1301,12 @@ export async function uploadPinnedCloudflareVersion(options: {
`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.`,
);
}
const target = placementTarget(config.placement);
if (target && !keepsPlacementTarget(uploaded.placement, target)) {
throw new Error(
`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.`,
);
}
return {
id: versionId,
urls: Array.isArray(uploaded.urls)
Expand Down
117 changes: 94 additions & 23 deletions packages/void/test/unit/cf-version-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,11 +1360,12 @@ it('uploads Worker limits, placement and cache with pinned secrets', async () =>
}
});

it.each([
{ region: 'aws:us-east-1' },
{ host: 'db.example.test:5432' },
{ hostname: 'api.example.test' },
])('rejects targeted placement with pinned secrets before any upload: %j', async (placement) => {
/** Upload with pinned secrets to a fake API whose upload reply carries `replyPlacement`. */
async function uploadTargeted(
placement: Record<string, string>,
replyPlacement: unknown,
atomic = false,
) {
const prepared = await output({ secrets: { required: ['AUTH_SECRET'] }, placement });
try {
await pinActiveWorkerSecrets({
Expand All @@ -1373,9 +1374,18 @@ it.each([
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
requiredNames: ['AUTH_SECRET'],
});
const requests: Array<string> = [];
const server = createServer((request, response) => {
requests.push(`${request.method} ${request.url}`);
const uploads: Array<{ url: string; body: Record<string, unknown> }> = [];
const server = createServer(async (request, response) => {
const chunks: Array<Buffer> = [];
for await (const chunk of request) {
chunks.push(Buffer.from(chunk));
}
if (request.method === 'POST') {
uploads.push({
url: request.url ?? '',
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
});
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
Expand All @@ -1387,27 +1397,88 @@ it.each([
id: '11111111-1111-4111-8111-111111111111',
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
}
: { id: '22222222-2222-4222-8222-222222222222', urls: [] },
: {
id: '22222222-2222-4222-8222-222222222222',
urls: [],
...(replyPlacement === undefined ? {} : { placement: replyPlacement }),
},
}),
);
});
await expect(
uploadPinnedCloudflareVersion({
output: prepared,
accountId: 'account-id',
apiBaseUrl: await listen(server),
workerName: 'void-test',
token: 'synthetic-token',
atomic: true,
verifyUploader: () => true,
}),
).rejects.toThrow(
`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' }.`,
);
expect(requests).toEqual([]);
const upload = uploadPinnedCloudflareVersion({
output: prepared,
accountId: 'account-id',
apiBaseUrl: await listen(server),
workerName: 'void-test',
token: 'synthetic-token',
atomic,
verifyUploader: () => true,
});
return {
result: await upload.then(
(value) => ({ value }),
(error) => ({ error }),
),
uploads,
};
} finally {
prepared.dispose();
}
}

it.each([
[{ region: 'aws:us-east-1' }, false],
[{ region: 'aws:us-east-1' }, true],
[{ host: 'db.example.test:5432' }, false],
[{ hostname: 'api.example.test' }, true],
])('uploads targeted placement %j with pinned secrets (atomic=%s)', async (placement, atomic) => {
// Cloudflare replies with the target alone, as observed on the live API.
const { result, uploads } = await uploadTargeted(placement, placement, atomic);

expect(result).toEqual({ value: { id: '22222222-2222-4222-8222-222222222222', urls: [] } });
expect(uploads).toHaveLength(1);
expect(uploads[0]!.url.endsWith(atomic ? '/versions?deploy=true' : '/versions')).toBe(true);
expectSameJson(uploads[0]!.body.placement, { mode: 'targeted', ...placement });
});

it('accepts an upload reply that lists the target', async () => {
const { result } = await uploadTargeted(
{ region: 'aws:us-east-1' },
{ mode: 'targeted', target: [{ id: 1, region: 'aws:us-east-1', type: 'region' }] },
);

expect(result).toHaveProperty('value.id', '22222222-2222-4222-8222-222222222222');
});

it.each([
['no placement', undefined],
['an empty placement', {}],
['a targeted mode without a target', { mode: 'targeted' }],
['smart placement', { mode: 'smart' }],
['another region', { region: 'aws:eu-west-1' }],
['the target under another kind', { hostname: 'aws:us-east-1' }],
[
'two targets',
{ mode: 'targeted', target: [{ region: 'aws:us-east-1' }, { region: 'aws:eu-west-1' }] },
],
])('stops before deploying when the upload reply has %s', async (_case, replyPlacement) => {
const { result } = await uploadTargeted({ region: 'aws:us-east-1' }, replyPlacement);

expect(result).toEqual({
error: new Error(
`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.`,
),
});
});

it('reports a live version whose atomic upload lost its target', async () => {
const { result } = await uploadTargeted({ hostname: 'api.example.test' }, {}, true);

expect(result).toEqual({
error: new Error(
`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.`,
),
});
});

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