diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index be801e95f..c6ccb4b31 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -39,6 +39,7 @@ export type CloudflareOperation = type: 'versions.deploy'; versions: Array<{ versionId: string; percentage: number }>; message?: string; + bypassDeploymentChecks?: boolean; } & WorkerTarget) | ({ type: 'versions.upload' | 'worker.deploy'; diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index c040b8706..3e37a4710 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -2534,7 +2534,7 @@ async function deployVersionPercentages( options: Pick< Parameters[0], 'runWrangler' | 'workerName' | 'preserveTriggers' | 'configPath' - >, + > & { bypassDeploymentChecks?: boolean }, percentages: Array, message: string, ): Promise { @@ -2557,6 +2557,7 @@ async function deployVersionPercentages( configPath: options.configPath, workerName: options.workerName, message, + ...(options.bypassDeploymentChecks ? { bypassDeploymentChecks: true } : {}), }, { capture: false }, ); @@ -2569,6 +2570,7 @@ export async function deployCloudflareVersionTraffic(options: { workerName: string; percentages: Array; message: string; + bypassDeploymentChecks?: boolean; }): Promise { const config = pickWranglerTriggerContext(parseRootWranglerConfig(options.configPath)); const directory = mkdtempSync(join(tmpdir(), 'void-version-traffic-')); @@ -2582,6 +2584,7 @@ export async function deployCloudflareVersionTraffic(options: { configPath: path, workerName: options.workerName, message: options.message, + ...(options.bypassDeploymentChecks ? { bypassDeploymentChecks: true } : {}), }, { capture: false }, ); @@ -2615,7 +2618,7 @@ async function restorePreviousVersion( return 'unsafe'; } const result = await deployVersionPercentages( - options, + { ...options, bypassDeploymentChecks: true }, [`${previousVersionId}@100`], `Void automatic rollback after ${cause}.`, ); diff --git a/packages/deploy-cloudflare/test/unit/operations.test.ts b/packages/deploy-cloudflare/test/unit/operations.test.ts index 504277194..00e99526e 100644 --- a/packages/deploy-cloudflare/test/unit/operations.test.ts +++ b/packages/deploy-cloudflare/test/unit/operations.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { expect, it, vi } from 'vite-plus/test'; import type { CloudflareCommandResult, CloudflareCommandRunner } from '../../src/execute.ts'; import type { CloudflareOperation } from '../../src/operations.ts'; @@ -6,9 +9,10 @@ import { activateCloudflareVersion, encodeCloudflareVersionMessage } from '../.. const current = '11111111-1111-4111-8111-111111111111'; const next = '22222222-2222-4222-8222-222222222222'; -function setup(data: unknown) { +function setup(data: unknown, failure?: 'activation' | 'triggers') { const calls: Array = []; let uploaded = false; + let triggerFailed = false; let versions = [{ version_id: current, percentage: 100 }]; const runWrangler: CloudflareCommandRunner = vi.fn((operation: CloudflareOperation) => { calls.push(operation); @@ -22,17 +26,45 @@ function setup(data: unknown) { case 'deployments.status': return ok({ versions }); case 'versions.list': - return ok([{ id: current, number: 1 }, ...(uploaded ? [{ id: next, number: 2 }] : [])]); + return ok([ + { + id: current, + number: 1, + annotations: { + 'workers/message': encodeCloudflareVersionMessage([], { + workers_dev: true, + preview_urls: true, + routes: [], + crons: ['0 * * * *'], + queueConsumers: [], + workflows: [], + }), + }, + }, + ...(uploaded ? [{ id: next, number: 2 }] : []), + ]); case 'versions.upload': uploaded = true; return ok(data); case 'versions.deploy': + if ( + failure === 'activation' && + operation.versions.some( + (version) => version.versionId === next && version.percentage === 100, + ) + ) { + return { ...ok(), status: 1 }; + } versions = operation.versions.map(({ versionId, percentage }) => ({ version_id: versionId, percentage, })); return ok(); case 'triggers.deploy': + if (failure === 'triggers' && !triggerFailed) { + triggerFailed = true; + return { ...ok(), status: 1 }; + } return ok(); default: throw new Error(`Unexpected operation ${operation.type}.`); @@ -63,8 +95,57 @@ it('activates structured upload data independently of provider presentation', as type: 'versions.deploy', versions: [{ versionId: next, percentage: 100 }], }); + expect(calls.find((operation) => operation.type === 'versions.deploy')).not.toHaveProperty( + 'bypassDeploymentChecks', + ); }); +it.each([ + { failure: 'readiness', preserveTriggers: false, reason: 'readiness-failed' }, + { failure: 'readiness', preserveTriggers: true, reason: 'readiness-failed' }, + { failure: 'activation', preserveTriggers: false, reason: 'activation-failed' }, + { failure: 'activation', preserveTriggers: true, reason: 'activation-failed' }, + { failure: 'triggers', preserveTriggers: false, reason: 'triggers-failed' }, +] as const)( + 'bypasses deployment checks only for automatic $failure recovery with preserveTriggers=$preserveTriggers', + async ({ failure, preserveTriggers, reason }) => { + const directory = mkdtempSync(join(tmpdir(), 'void-rollback-checks-')); + const configPath = join(directory, 'wrangler.json'); + writeFileSync(configPath, JSON.stringify({ name: 'site' })); + try { + const { calls, runWrangler } = setup( + { versionId: next }, + failure === 'readiness' ? undefined : failure, + ); + const result = await activateCloudflareVersion({ + runWrangler, + configPath, + workerName: 'site', + workersDevSubdomain: 'example', + preserveTriggers, + verifyUploader: () => true, + message: encodeCloudflareVersionMessage([]), + readinessAttempts: 1, + fetch: async () => + failure === 'readiness' + ? new Response('not ready', { status: 503 }) + : Response.json({ ready: true }), + }); + expect(result).toMatchObject({ ok: false, reason, rollback: 'succeeded' }); + const deployments = calls.filter((operation) => operation.type === 'versions.deploy'); + expect(deployments.at(-1)).toMatchObject({ + versions: [{ versionId: current, percentage: 100 }], + bypassDeploymentChecks: true, + }); + for (const operation of deployments.slice(0, -1)) { + expect(operation).not.toHaveProperty('bypassDeploymentChecks'); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + it('rejects missing structured upload identity before activation', async () => { const { calls, runWrangler } = setup({ unrelated: 'value' }); const result = await activateCloudflareVersion({ diff --git a/packages/void/src/cli/cf-version-runner.ts b/packages/void/src/cli/cf-version-runner.ts index c665f2381..a4a94321b 100644 --- a/packages/void/src/cli/cf-version-runner.ts +++ b/packages/void/src/cli/cf-version-runner.ts @@ -675,6 +675,7 @@ export function createCfVersionRunner(options: { }), ] : []), + ...(operation.bypassDeploymentChecks ? ['--bypass-deployment-checks'] : []), ], command.capture, ); diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 82c1f4eba..51b77d45a 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -1014,6 +1014,7 @@ async function workerHistory(): Promise { versions, ...(operation.message ? { annotations: { 'workers/message': operation.message } } : {}), }), + ...(operation.bypassDeploymentChecks ? ['--bypass-deployment-checks'] : []), ]); } default: diff --git a/packages/void/src/cli/cloudflare-operations.ts b/packages/void/src/cli/cloudflare-operations.ts index f2728a4d9..b3102649d 100644 --- a/packages/void/src/cli/cloudflare-operations.ts +++ b/packages/void/src/cli/cloudflare-operations.ts @@ -537,6 +537,7 @@ export async function activateCloudflareExistingVersion( configPath: context.configPath, workerName: context.workerName, message: `Void automatic rollback after trigger synchronization failed.`, + bypassDeploymentChecks: true, }, { capture: false }, ); diff --git a/packages/void/src/cli/platform-installer-workers.ts b/packages/void/src/cli/platform-installer-workers.ts index bdfe30ee8..729953f35 100644 --- a/packages/void/src/cli/platform-installer-workers.ts +++ b/packages/void/src/cli/platform-installer-workers.ts @@ -1225,6 +1225,7 @@ export async function restorePlatformWorkerDeployment(input: { percentage: version.percentage, })), message: `Void platform restoration ${input.restoration.id}`, + bypassDeploymentChecks: true, }, input.accountId, input.directory, diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index f737eb8a3..45a9cc3af 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -2573,6 +2573,44 @@ it('preserves activation metadata and sorts cf version history for the existing } }); +it.each([undefined, false, true])( + 'passes bypassDeploymentChecks=%s only when true with and without a deployment body', + async (bypassDeploymentChecks) => { + const prepared = await output(); + try { + const calls: Array> = []; + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + runCf: (args) => { + calls.push(args); + return { status: 0, stdout: '', stderr: '' }; + }, + }); + for (const message of [undefined, 'Void automatic rollback after readiness failed.']) { + const result = await runner( + { + type: 'versions.deploy', + workerName: 'void-test', + versions: [{ versionId: '00000000-0000-0000-0000-000000000001', percentage: 100 }], + message, + bypassDeploymentChecks, + }, + { capture: false }, + ); + expect(result.status).toBe(0); + const args = calls.at(-1)!; + expect(args.includes('--bypass-deployment-checks')).toBe(bypassDeploymentChecks === true); + expect(args.includes('--body')).toBe(message !== undefined); + expect(args).toContain('--versions'); + } + } finally { + prepared.dispose(); + } + }, +); + it.each(['newest-first', 'oldest-first'] as const)( 'reads the actual latest ten versions when cf pages %s', async (order) => { diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index ad948d241..315301253 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -217,6 +217,69 @@ it.each([false, true])( }, ); +it.each([undefined, false, true])( + 'sends bypassDeploymentChecks=%s through cf alongside the deployment body', + async (bypassDeploymentChecks) => { + const directory = temporaryDirectory('void-cf-deployment-checks-'); + const requests: Array<{ url: string | undefined; body: unknown }> = []; + const api = await listen( + createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + requests.push({ url: request.url, body: JSON.parse(body) }); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ success: true, result: { id: 'deployment-id' } })); + }), + ); + const preload = join(directory, 'capture-cf.mjs'); + const callsPath = join(directory, 'calls.json'); + writeFileSync( + preload, + `import child from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { writeFileSync } from 'node:fs'; +const spawnSync = child.spawnSync; +child.spawnSync = (binary, args, options) => { + writeFileSync(${JSON.stringify(callsPath)}, JSON.stringify(args.slice(1))); + return spawnSync(binary, args, options); +}; +syncBuiltinESMExports(); +`, + ); + const versionId = '11111111-1111-4111-8111-111111111111'; + const message = 'Void automatic rollback after readiness failed.'; + const output = await runOperation( + directory, + { + type: 'versions.deploy', + workerName: 'test-worker', + versions: [{ versionId, percentage: 100 }], + message, + bypassDeploymentChecks, + }, + tokenEnv(api), + preload, + ); + expect(output.status, output.stderr).toBe(0); + expect(decodeCloudflareOperationResult({ ...output, status: 0 }).status).toBe(0); + const args = JSON.parse(readFileSync(callsPath, 'utf8')) as Array; + expect(args.includes('--bypass-deployment-checks')).toBe(bypassDeploymentChecks === true); + expect(args).toContain('--body'); + expect(requests).toEqual([ + { + url: `/client/v4/accounts/test-account/workers/scripts/test-worker/deployments${bypassDeploymentChecks === true ? '?force=true' : ''}`, + body: { + strategy: 'percentage', + versions: [{ version_id: versionId, percentage: 100 }], + annotations: { 'workers/message': message }, + }, + }, + ]); + }, +); + it.each([200, 503])( 'reports HTTP %i without loading the build toolchain when domain cleanup returns non-JSON', async (status) => { diff --git a/packages/void/test/unit/cloudflare-operations.test.ts b/packages/void/test/unit/cloudflare-operations.test.ts index e4065e4fe..37308d812 100644 --- a/packages/void/test/unit/cloudflare-operations.test.ts +++ b/packages/void/test/unit/cloudflare-operations.test.ts @@ -83,6 +83,7 @@ describe('Cloudflare operational parsing', () => { if (args.type === 'versions.deploy') { const config = JSON.parse(readFileSync(args.configPath!, 'utf8')); expect(config).toEqual({ account_id: 'account-1', name: 'my-worker' }); + expect(args).not.toHaveProperty('bypassDeploymentChecks'); active = 'original'; return ok(); } @@ -321,6 +322,7 @@ describe('Cloudflare version operations', () => { expect(activation[0].type === 'versions.deploy' ? activation[0].message : undefined).toBe( 'void:{"schema":2,"migrations":["0000_init.sql"],"triggerConfig":{"workers_dev":true,"preview_urls":true,"routes":[],"crons":["0 * * * *"],"queueConsumers":[],"workflows":[]}}', ); + expect(activation[0]).not.toHaveProperty('bypassDeploymentChecks'); expect(runner.mock.calls.some(([args]) => args.type === 'triggers.deploy')).toBe(true); }); @@ -370,12 +372,17 @@ describe('Cloudflare version operations', () => { ), ).rejects.toThrow(/traffic and triggers were restored to v2/); expect( - runner.mock.calls.some( - ([args]) => - args.type === 'versions.deploy' && - args.versions.some((version) => version.versionId === 'v2' && version.percentage === 100), - ), - ).toBe(true); + runner.mock.calls.filter(([args]) => args.type === 'versions.deploy').map(([args]) => args), + ).toEqual([ + expect.objectContaining({ versions: [{ versionId: 'v1', percentage: 100 }] }), + expect.objectContaining({ + versions: [{ versionId: 'v2', percentage: 100 }], + bypassDeploymentChecks: true, + }), + ]); + expect( + runner.mock.calls.find(([args]) => args.type === 'versions.deploy')![0], + ).not.toHaveProperty('bypassDeploymentChecks'); expect(runner.mock.calls.filter(([args]) => args.type === 'triggers.deploy')).toHaveLength(2); expect(runner.mock.calls.filter(([args]) => args.type === 'triggers.clear-crons')).toHaveLength( 1, diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 4bc563a20..839b9d2c2 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -1869,6 +1869,7 @@ describe('platform installer', () => { { versionId: 'version-b', percentage: 75 }, ], message: 'Void platform restoration deployment-intervening', + bypassDeploymentChecks: true, }); expect(commands.some((args) => args.type.endsWith('.delete'))).toBe(false); });