Skip to content

Commit 87e43b4

Browse files
Brooooooklynclaude
andcommitted
fix(platform): stop before any Worker change when a platform Worker cannot be read
A failed Cloudflare read counted as "no change", so the run could pause traffic and then fail in the Cloudflare CLI or in Void's version-record check. Void now retries a read that fails with 429, 5xx or a network error, then stops before it changes a platform Worker. A Worker without one version at 100% of traffic stops the run too, because the deploy needs one. The stop messages now say what is true at that point: no platform Worker changed and the command did not pause traffic. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH
1 parent 0c0532f commit 87e43b4

8 files changed

Lines changed: 458 additions & 94 deletions

File tree

‎docs/guide/platform/installation/ci.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ void platform install \
5151

5252
Mutations require `--yes` in CI; a read-only `--plan` does not. Custom runtimes also require `--runtime <directory>`. For a new installation, run `--plan` with the same account, name, and domain options as the unattended install. If you use `--auth-config`, pass the same file to both commands. Register `<platform-api-url>/auth/callback` when creating a login provider's OAuth or OIDC client manually; automatic Access setup manages its own application. A custom API hostname is optional.
5353

54-
In CI, `upgrade`, `repair`, `enable`, `rollback`, and `install --resume` stop before they change anything when a platform Worker was changed in the Cloudflare dashboard or with the Cloudflare API, or has an active version without a Void deployment record. See [Platform Workers changed outside Void](/guide/platform/installation/maintenance#workers-changed-outside-void).
54+
In CI, `upgrade`, `repair`, `enable`, `rollback`, and `install --resume` stop before they change a platform Worker or pause traffic when a platform Worker was changed in the Cloudflare dashboard or with the Cloudflare API, or has an active version without a Void deployment record. See [Platform Workers changed outside Void](/guide/platform/installation/maintenance#workers-changed-outside-void).
5555

5656
:::
5757

‎docs/guide/platform/installation/maintenance.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Let running rotations finish before you roll back. If an older proxy runs next t
149149

150150
Void manages the platform's own Workers. Do not change them in the Cloudflare dashboard or with the Cloudflare API. To change the platform, use the Void commands on this page.
151151

152-
`upgrade`, `repair`, `enable`, `rollback`, and `install --resume` deploy the platform Workers again. Before they change anything, Void finds each platform Worker that:
152+
`upgrade`, `repair`, `enable`, `rollback`, and `install --resume` deploy the platform Workers again. Before they change a platform Worker or pause traffic, Void finds each platform Worker that:
153153

154154
- was changed in the Cloudflare dashboard or with the Cloudflare API.
155155
- has an active version without a Void deployment record. This can occur when an earlier run replaced a change made outside Void, and then stopped or failed. If that run stopped during the cutover to the new version, the command first makes the earlier versions active again. Void checks those earlier versions too. A version that an older Void release deployed can also have no record.
@@ -158,14 +158,16 @@ The `--plan` preview of `upgrade`, `repair`, `enable`, and `rollback` shows the
158158

159159
In a terminal, Void lists these Workers and asks **Replace the configuration of these Workers?** The default answer is No.
160160

161-
- If you answer No, the command stops. Nothing changed, and the command did not pause traffic.
161+
- If you answer No, the command stops. No platform Worker changed, and the command did not pause traffic.
162162
- If you answer Yes, the command continues. First, Void prepares the platform resources and the database. Then, before it pauses traffic or deploys a Worker, the Cloudflare CLI asks you again for each Worker changed in the dashboard or with the API. For a dashboard change, it shows the differences. Void uploads a new version of each of these Workers and does not deploy it yet. For a Worker that has an active version without a Void deployment record, the Cloudflare CLI does not ask again.
163163

164164
If you refuse in the Cloudflare CLI, the command stops. Void does not undo the resource and database changes. No deployment changed, and the command did not pause traffic.
165165

166166
The replacement removes values that Void does not set, for example an `EMAIL_FROM` variable that you added. It also resets values that Void sets, for example `AUTH_REDIRECT_URI`.
167167

168-
Without a terminal, for example in CI, or with `--yes`, the command stops before it changes anything, and it does not pause traffic. Run the command that Void prints once in a terminal, and answer the questions there. After that run completes, the command works in CI again.
168+
Without a terminal, for example in CI, or with `--yes`, the command stops before it changes a platform Worker, and it does not pause traffic. Run the command that Void prints once in a terminal, and answer the questions there. After that run completes, the command works in CI again.
169+
170+
If Void cannot read a platform Worker, the command also stops before it changes a platform Worker or pauses traffic. Run the command again. If a platform Worker has no single version that gets 100% of traffic, for example after a gradual deployment, deploy one version of it to 100% of traffic in the Cloudflare dashboard. Then run the command once in a terminal, so that you can approve the dashboard change.
169171

170172
If an earlier run failed after it paused traffic, traffic stays paused after these stops. To resume traffic, see [Upgrade the platform](#upgrade-the-platform).
171173

‎docs/reference/cli/platform-installation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. See [Manag
137137

138138
Repair and upgrade preserve disabled state. New, resumed, and previously disabled installations block user traffic until all target Workers pass verification; the installer's health probes can still run. Routes and custom domains remain attached.
139139

140-
If a platform Worker was changed outside Void, or has an active version without a Void deployment record, a command that deploys the platform Workers stops before it changes anything or pauses traffic, unless you agree in a terminal to replace the changes. See [Platform Workers changed outside Void](../../guide/platform/installation/maintenance.md#workers-changed-outside-void).
140+
If a platform Worker was changed outside Void, or has an active version without a Void deployment record, a command that deploys the platform Workers stops before it changes a platform Worker or pauses traffic, unless you agree in a terminal to replace the changes. See [Platform Workers changed outside Void](../../guide/platform/installation/maintenance.md#workers-changed-outside-void).
141141

142142
`--runtime` selects a custom platform build. Relative paths resolve from your current directory. Void verifies the build before making changes; see [Platform Development](../../guide/platform/development/runtime.md#deploying-your-runtime) for creating one.
143143

‎packages/void/src/cli/platform-installer-inspection.ts‎

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -56,45 +56,52 @@ export async function readActivePlatformWorkerVersion(
5656
}
5757

5858
/**
59-
* Whether the active version of a platform Worker has Void's record of its
60-
* triggers. An upgrade refuses an active version without it, for example a
61-
* version deployed outside Void. This reads the active version as
62-
* readActivePlatformWorkerVersion does, but through the Cloudflare API. Null
63-
* when the Worker has no single active version.
59+
* The active version in a Cloudflare API answer for the deployments of a
60+
* platform Worker. deployPlatformWorker continues only when the latest
61+
* deployment sends all traffic to one version (deploymentRunsOnlyVersion).
62+
* `split` is each other answer, for example a gradual deployment.
6463
*/
65-
export async function activePlatformWorkerHasTriggerRecord(
66-
readCloudflare: (path: string) => Promise<unknown>,
67-
accountId: string,
68-
workerName: string,
69-
): Promise<boolean | null> {
70-
const base = `/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}`;
71-
const response = await readCloudflare(`${base}/deployments`);
64+
export function readActivePlatformWorkerDeployment(
65+
response: unknown,
66+
):
67+
| Readonly<{ kind: 'active'; versionId: string }>
68+
| Readonly<{ kind: 'malformed' }>
69+
| Readonly<{ kind: 'split' }> {
7270
const deployments =
73-
response && typeof response === 'object'
71+
response && typeof response === 'object' && !Array.isArray(response)
7472
? (response as { deployments?: unknown }).deployments
7573
: null;
76-
const latest = Array.isArray(deployments) ? (deployments[0] as unknown) : null;
74+
if (!Array.isArray(deployments)) {
75+
return { kind: 'malformed' };
76+
}
77+
if (deployments.length === 0) {
78+
return { kind: 'split' };
79+
}
80+
const latest: unknown = deployments[0];
7781
const versions =
7882
latest && typeof latest === 'object' ? (latest as { versions?: unknown }).versions : null;
79-
const active = Array.isArray(versions)
80-
? versions.filter(
81-
(entry) =>
82-
entry &&
83-
typeof entry === 'object' &&
84-
Number((entry as { percentage?: unknown }).percentage) === 100,
85-
)
86-
: [];
87-
const versionId =
88-
active.length === 1 ? (active[0] as { version_id?: unknown }).version_id : undefined;
89-
if (typeof versionId !== 'string') {
90-
return null;
83+
if (
84+
!Array.isArray(versions) ||
85+
versions.some(
86+
(version) =>
87+
!version ||
88+
typeof version !== 'object' ||
89+
typeof (version as { version_id?: unknown }).version_id !== 'string' ||
90+
!Number.isFinite(Number((version as { percentage?: unknown }).percentage)),
91+
)
92+
) {
93+
return { kind: 'malformed' };
9194
}
92-
return platformWorkerVersionHasTriggerRecord(readCloudflare, accountId, workerName, versionId);
95+
const [only] = versions as Array<{ percentage: unknown; version_id: string }>;
96+
return versions.length === 1 && only && Number(only.percentage) === 100
97+
? { kind: 'active', versionId: only.version_id }
98+
: { kind: 'split' };
9399
}
94100

95101
/**
96102
* Whether a version of a platform Worker has Void's record of its triggers.
97-
* This is the rule of the upgrade: it refuses a version without the record.
103+
* This is the rule of the upgrade: it refuses a version without the record,
104+
* for example a version deployed outside Void.
98105
*/
99106
export async function platformWorkerVersionHasTriggerRecord(
100107
readCloudflare: (path: string) => Promise<unknown>,

‎packages/void/src/cli/platform-installer-outside-changes.ts‎

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import { setTimeout as delay } from 'node:timers/promises';
12
import type { PlatformInstallationManifest } from '@void/platform';
23
import type { PlatformRuntime } from '@void/platform/installer';
34
import { confirm, isCancel, log, type SpinnerResult } from '@voidzero-dev/vite-plus-prompts';
45
import { note } from './output.ts';
5-
import type { PlatformCloudflareClient } from './platform-cloudflare.ts';
6+
import {
7+
PlatformCloudflareApiError,
8+
type PlatformCloudflareClient,
9+
} from './platform-cloudflare.ts';
610
import { interruptedWorkerCutover } from './platform-installer-cutover.ts';
711
import {
8-
activePlatformWorkerHasTriggerRecord,
912
platformWorkerVersionHasTriggerRecord,
13+
readActivePlatformWorkerDeployment,
1014
} from './platform-installer-inspection.ts';
1115
import { runPlatformWranglerAtApiBase } from './platform-installer-process.ts';
1216
import type {
@@ -41,6 +45,52 @@ const PLATFORM_WORKER_KEYS: ReadonlyArray<PlatformWorkerKey> = [
4145
'dispatch',
4246
];
4347

48+
// The lifecycle lock can change the database before this check. The check
49+
// itself changes no Worker and does not pause traffic.
50+
const NO_WORKER_CHANGED =
51+
'No platform Worker changed. This command did not pause application traffic.';
52+
53+
/** The wait before the second and the third read of a Worker. */
54+
const READ_RETRY_DELAYS_MS = [250, 500] as const;
55+
56+
/** Stop before the run changes a Worker, because Void cannot tell what the run replaces. */
57+
function unreadablePlatformWorker(workerName: string, error: unknown): Error {
58+
const status = error instanceof PlatformCloudflareApiError ? error.status : null;
59+
const answer =
60+
status === null
61+
? `no HTTP response: ${error instanceof Error ? error.message : String(error)}`
62+
: `HTTP ${status}`;
63+
return new Error(
64+
[
65+
`platform: Void cannot read the platform Worker ${workerName} (${answer}), so it cannot find changes made outside Void.`,
66+
NO_WORKER_CHANGED,
67+
status === 401 || status === 403
68+
? 'Make sure that the Cloudflare API token has Account → Workers Scripts → Edit for this account. Then run the command again.'
69+
: 'Run the command again.',
70+
].join(' '),
71+
{ cause: error },
72+
);
73+
}
74+
75+
/**
76+
* Read a Worker again after a rate limit, a Cloudflare server error or a
77+
* network failure. Each other failure, or a third failure, stops the run.
78+
*/
79+
async function readPlatformWorker<T>(workerName: string, read: () => Promise<T>): Promise<T> {
80+
for (let attempt = 0; ; attempt++) {
81+
try {
82+
return await read();
83+
} catch (error) {
84+
const status = error instanceof PlatformCloudflareApiError ? error.status : null;
85+
const wait = READ_RETRY_DELAYS_MS[attempt];
86+
if (wait === undefined || (status !== null && status !== 429 && status < 500)) {
87+
throw unreadablePlatformWorker(workerName, error);
88+
}
89+
await delay(wait);
90+
}
91+
}
92+
}
93+
4494
export type PlatformWorkerOutsideChange = Readonly<{
4595
key: PlatformWorkerKey;
4696
name: string;
@@ -115,35 +165,59 @@ export async function findPlatformWorkerOutsideChanges(
115165
: new Map<string, string>();
116166
const changes = await Promise.all(
117167
workers.map(async (worker): Promise<PlatformWorkerOutsideChange | null> => {
118-
// A failed read does not show a change. The upload still reports a real conflict.
119-
let source: string | null;
120-
try {
121-
source = await client.getWorkerLastDeployedFrom(plan.account.id, worker.name);
122-
} catch {
123-
return null;
168+
// A read that fails stops the run. Without the answer, the run can
169+
// pause traffic and then fail in the Cloudflare CLI or the upgrade.
170+
const readCloudflare = (path: string) =>
171+
readPlatformWorker(worker.name, () => client.request<unknown>(path));
172+
const source = await readPlatformWorker(worker.name, () =>
173+
client.getWorkerLastDeployedFrom(plan.account.id, worker.name),
174+
);
175+
const deployments = await readPlatformWorker(worker.name, () =>
176+
client
177+
.request<unknown>(
178+
`/accounts/${encodeURIComponent(plan.account.id)}/workers/scripts/${encodeURIComponent(worker.name)}/deployments`,
179+
)
180+
.catch((error: unknown) => {
181+
if (error instanceof PlatformCloudflareApiError && error.status === 404) {
182+
return null;
183+
}
184+
throw error;
185+
}),
186+
);
187+
// No deployments. The installer finds a missing Worker itself, but a
188+
// marker from outside Void still makes the Cloudflare CLI stop.
189+
if (deployments === null) {
190+
return source === 'dash' || source === 'api' ? { ...worker, source } : null;
191+
}
192+
const active = readActivePlatformWorkerDeployment(deployments);
193+
if (active.kind === 'malformed') {
194+
throw new Error(
195+
`platform: Cloudflare returned malformed deployment data for the platform Worker ${worker.name}, so Void cannot find changes made outside Void. ${NO_WORKER_CHANGED} Run the command again.`,
196+
);
197+
}
198+
// The deploy and the restore of a stopped cutover need one active version.
199+
if (active.kind === 'split') {
200+
throw new Error(
201+
`platform: The platform Worker ${worker.name} has no single version that gets 100% of traffic, for example after a gradual deployment. This command deploys a new version only when one version gets all traffic. ${NO_WORKER_CHANGED} In the Cloudflare dashboard, deploy one version of ${worker.name} to 100% of traffic. Then run this command once in a terminal, so that you can approve the dashboard change.`,
202+
);
124203
}
125204
if (source === 'dash' || source === 'api') {
126205
return { ...worker, source };
127206
}
128207
// The upgrade reads the active version. After a stopped cutover, the
129208
// installer first makes the earlier version active again.
130-
const readCloudflare = (path: string) => client.request(path);
131209
const restoredVersion = restored.get(worker.name);
132-
const recorded = await Promise.all([
133-
activePlatformWorkerHasTriggerRecord(readCloudflare, plan.account.id, worker.name).catch(
134-
() => null,
210+
const recorded = await Promise.all(
211+
[active.versionId, ...(restoredVersion === undefined ? [] : [restoredVersion])].map(
212+
(versionId) =>
213+
platformWorkerVersionHasTriggerRecord(
214+
readCloudflare,
215+
plan.account.id,
216+
worker.name,
217+
versionId,
218+
),
135219
),
136-
...(restoredVersion === undefined
137-
? []
138-
: [
139-
platformWorkerVersionHasTriggerRecord(
140-
readCloudflare,
141-
plan.account.id,
142-
worker.name,
143-
restoredVersion,
144-
).catch(() => null),
145-
]),
146-
]);
220+
);
147221
return recorded.includes(false) ? { ...worker, source: 'unrecorded' } : null;
148222
}),
149223
);
@@ -167,10 +241,10 @@ export function formatPlatformWorkerOutsideChanges(
167241
}
168242

169243
/**
170-
* Do this before a run changes anything. When platform Workers were changed
171-
* outside Void, stop, or ask the operator in a terminal. Returns the Workers
172-
* whose outside changes the operator agreed to replace. The installer uploads
173-
* them later with uploadPlatformWorkerOutsideChanges.
244+
* Do this before a run changes a Worker or pauses traffic. When platform
245+
* Workers were changed outside Void, stop, or ask the operator in a terminal.
246+
* Returns the Workers whose outside changes the operator agreed to replace.
247+
* The installer uploads them later with uploadPlatformWorkerOutsideChanges.
174248
*/
175249
export async function reviewPlatformWorkerOutsideChanges(
176250
input: Readonly<{
@@ -197,7 +271,7 @@ export async function reviewPlatformWorkerOutsideChanges(
197271
throw new Error(
198272
[
199273
`platform: ${formatPlatformWorkerOutsideChanges(changes)}`,
200-
'Nothing changed. This command did not pause application traffic.',
274+
NO_WORKER_CHANGED,
201275
'This command replaces their configuration with the Void configuration. It can ask for your approval only in a terminal.',
202276
`Run this command once in a terminal to review them:\n${input.command}`,
203277
].join('\n'),
@@ -225,9 +299,7 @@ export async function reviewPlatformWorkerOutsideChanges(
225299
initialValue: false,
226300
});
227301
if (isCancel(answer) || typeof answer === 'symbol' || !answer) {
228-
throw new Error(
229-
'platform: Cancelled. Nothing changed. This command did not pause application traffic.',
230-
);
302+
throw new Error(`platform: Cancelled. ${NO_WORKER_CHANGED}`);
231303
}
232304
return changes;
233305
}

0 commit comments

Comments
 (0)