From 0c0532f54ce13b4e88f8a098071abb0bd8ecdebd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 15 Oct 2026 23:33:50 +0800 Subject: [PATCH 1/2] fix(platform): stop before the traffic pause when an upgrade cannot finish A platform Worker changed in the Cloudflare dashboard or API made every upgrade fail after traffic was paused: the Cloudflare CLI asks before it replaces such a change, and Void runs it without a terminal. Void now finds these Workers before it changes anything. In a terminal it asks, then lets the Cloudflare CLI show the changes and upload an undeployed version after resources are ready and before traffic pauses. Without a terminal it stops and names the command to run once in a terminal. The runtime token's Email Routing permissions are checked before the pause when platform email is on, and the late setup error names the missing permission. Platform mail now has a default sender at noreply@, so operators no longer set EMAIL_FROM by hand. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/email/sending.md | 2 +- docs/guide/platform/administration/email.md | 8 +- docs/guide/platform/installation/ci.md | 2 + .../platform/installation/credentials.md | 28 +- .../platform/installation/maintenance.md | 32 +- docs/guide/platform/installation/setup.md | 2 +- docs/reference/cli/platform-installation.md | 2 + packages/deploy-cloudflare/src/operations.ts | 5 + .../src/cli/cloudflare-operation-process.ts | 19 +- packages/void/src/cli/platform-cloudflare.ts | 106 +++ .../void/src/cli/platform-install-workers.ts | 3 + .../void/src/cli/platform-installer-errors.ts | 58 ++ .../void/src/cli/platform-installer-health.ts | 22 + .../src/cli/platform-installer-inspection.ts | 73 ++ .../cli/platform-installer-outside-changes.ts | 292 ++++++ .../void/src/cli/platform-installer-types.ts | 14 + .../src/cli/platform-installer-workers.ts | 61 ++ packages/void/src/cli/platform-installer.ts | 69 +- packages/void/src/cli/platform-lifecycle.ts | 35 + packages/void/src/cli/platform-management.ts | 69 ++ packages/void/src/cli/provision.ts | 11 +- .../unit/cloudflare-operation-process.test.ts | 80 ++ .../test/unit/platform-cloudflare.test.ts | 179 ++++ .../unit/platform-installer-errors.test.ts | 146 ++- ...platform-installer-outside-changes.test.ts | 861 ++++++++++++++++++ .../void/test/unit/platform-installer.test.ts | 203 +++++ .../unit/platform-management-upgrade.test.ts | 517 ++++++++++- .../test/unit/platform-setup-flow.test.ts | 364 +++++++- .../unit/provision-runner-interactive.test.ts | 71 ++ platform/packages/api/README.md | 19 +- platform/packages/api/src/email.ts | 42 +- platform/packages/api/test/unit/email.test.ts | 63 +- 32 files changed, 3384 insertions(+), 74 deletions(-) create mode 100644 packages/void/src/cli/platform-installer-outside-changes.ts create mode 100644 packages/void/test/unit/platform-installer-outside-changes.test.ts create mode 100644 packages/void/test/unit/provision-runner-interactive.test.ts diff --git a/docs/guide/email/sending.md b/docs/guide/email/sending.md index 25176090d..6c5d1cc9b 100644 --- a/docs/guide/email/sending.md +++ b/docs/guide/email/sending.md @@ -34,7 +34,7 @@ Check both failure shapes: `result.error` reports a request failure, while `resu ## Setup {#setup} -On a platform with email enabled, deploy without additional email configuration. Ask your administrator for its shared mail domain. Administrators [enable email during installation or upgrade](/guide/platform/installation/credentials#runtime-token-permissions). +On a platform with email enabled, deploy without additional email configuration. Ask your administrator for its shared mail domain. Administrators [enable email during installation or upgrade](/guide/platform/installation/credentials#enable-email). Each project on an email-enabled platform has: diff --git a/docs/guide/platform/administration/email.md b/docs/guide/platform/administration/email.md index 23abafcd1..b22a843ed 100644 --- a/docs/guide/platform/administration/email.md +++ b/docs/guide/platform/administration/email.md @@ -27,6 +27,12 @@ reconciliation verifies delivery afterward; a deleting project stays closed and its deletion can be retried. See [operator email commands](../../../reference/cli/platform-email.md#operator-email) for the recovery requirements. +### Mail that the platform sends {#platform-sender} + +The platform sends its own mail, for example invitations, welcome mail, failed-deploy notices, and error alerts. This mail comes from `noreply@`, and the sender name is the platform's display name. Void sets this sender, so you do not need to configure one. Do not set a different sender on the platform Workers in the Cloudflare dashboard. That change stops the next `upgrade`, `repair`, `enable`, or `rollback` until you let Void replace it. See [Platform Workers changed outside Void](/guide/platform/installation/maintenance#workers-changed-outside-void). + +Mail to an address that is not verified needs Cloudflare Email Sending onboarding and [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans). + ### Deciding who mail may reach By default, mail from the shared sender reaches only the recipients each project has verified. For a team whose applications mainly mail colleagues, widen that once for the whole installation: @@ -114,7 +120,7 @@ Use the apex of the selected Cloudflare zone. Platform-managed custom inbound em void platform email domain-add other.example --project hr-portal --token-stdin --yes < token.txt ``` -Project owners see administrator-registered domains in `void email domain list` and `status`; `status` names the `void platform email` command for any step they cannot take themselves, and `sync` and `remove` point them at `domain-sync` and `domain-remove`. A domain an owner registered before you switched to `admin` stays theirs to renew, sync and remove. The runtime token needs the email permissions listed in the [self-hosting guide](/guide/platform/installation/credentials#runtime-token-permissions) for this to work. +Project owners see administrator-registered domains in `void email domain list` and `status`; `status` names the `void platform email` command for any step they cannot take themselves, and `sync` and `remove` point them at `domain-sync` and `domain-remove`. A domain an owner registered before you switched to `admin` stays theirs to renew, sync and remove. The runtime token needs the [email permissions](/guide/platform/installation/credentials#enable-email) for this to work. A permission refusal leaves setup blocked. After correcting it, run `domain-sync` to resume that same setup operation; repeat `domain-rotate-secret` to resume a diff --git a/docs/guide/platform/installation/ci.md b/docs/guide/platform/installation/ci.md index 994abc88c..00fdb15eb 100644 --- a/docs/guide/platform/installation/ci.md +++ b/docs/guide/platform/installation/ci.md @@ -51,6 +51,8 @@ void platform install \ Mutations require `--yes` in CI; a read-only `--plan` does not. Custom runtimes also require `--runtime `. 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 `/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. +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). + ::: ## Customize the Platform diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index d56108789..153548cdd 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -68,7 +68,25 @@ Void checks access before provisioning. If it reports a missing permission, upda ## Enable Email {#enable-email} -Choose a shared sender domain and its Cloudflare zone ID. The mail zone may differ from the application zone, but it must belong to the platform's Cloudflare account. To enable email on an existing installation, run: +Choose a shared sender domain and its Cloudflare zone ID. The mail zone may differ from the application zone, but it must belong to the platform's Cloudflare account. + +Before you enable email, enable [Cloudflare Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/) for the exact shared sender domain and confirm that its MX records point to Cloudflare. For a subdomain, add that name under the zone's **Email Routing → Settings → Subdomains**. Cloudflare adds the required MX and SPF records. The installer's shared-mail bootstrap verifies those records; it does not add them. Keep existing mail-provider records on other domain names in place. + +Enabling email lets administrators [register email domains for projects](/guide/platform/administration/email#registering-email-domains-for-projects) and lets projects register destination addresses through the runtime token. The runtime token needs these permissions: + +| Scope | Permission | Access | +| ------- | ----------------------- | ------ | +| Account | Email Routing Addresses | Edit | +| Account | Email Sending | Edit | +| Zone | Zone | Read | +| Zone | Zone Settings | Edit | +| Zone | Email Routing Rules | Edit | + +The zone permissions must include each zone that will carry mail, including the mail zone. Email Sending onboarding for arbitrary recipients needs Workers Paid. + +For a new installation, set `VOID_EMAIL_SENDER_DOMAIN` and `VOID_EMAIL_SHARED_ZONE_ID` before `void platform install`. The runtime-token link then preselects these permissions. + +For an existing installation, add the permissions to its runtime token first. Edit the token in the Cloudflare dashboard. The token value stays the same. Or set `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN` to a new token that has all runtime permissions. Then run: ```sh VOID_EMAIL_SENDER_DOMAIN=mail.example.com \ @@ -76,13 +94,13 @@ VOID_EMAIL_SHARED_ZONE_ID=your-zone-id \ void platform upgrade your-installation-id ``` -Set the same two variables before `void platform install` to enable email during a new installation. Void records the pair for later upgrades; an ordinary upgrade cannot replace it. +Void records the domain and zone for later upgrades; an ordinary upgrade cannot replace them. -Before installing, enable [Cloudflare Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/) for the exact shared sender domain and confirm that its MX records point to Cloudflare. For a subdomain, add that name under the zone's **Email Routing → Settings → Subdomains**. Cloudflare adds the required MX and SPF records. The installer's shared-mail bootstrap verifies those records; it does not add them. Keep existing mail-provider records on other domain names in place. +Before the CLI changes anything or pauses traffic, it checks that the runtime token can read the mail zone and its Email Routing settings, rules, and addresses. If Cloudflare denies a request, the command stops and names each missing permission. Void keeps a saved runtime token, so you can add the permission to the same token and run the command again. The check cannot confirm Edit access or Email Sending, so select those permissions as the table shows. -Enabling email lets administrators [register email domains for projects](/guide/platform/administration/email#registering-email-domains-for-projects) and lets projects register destination addresses through the runtime token. That needs **Email Routing Addresses: Edit** and **Email Sending: Edit** on the account, plus **Zone: Read**, **Zone Settings: Edit** and **Email Routing Rules: Edit** on the zones that will carry mail. The runtime-token link preselects them when email is enabled. Email Sending onboarding for arbitrary recipients needs Workers Paid. +Without `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`, `upgrade`, `repair`, `enable`, and `rollback` check the runtime token saved on this machine. The platform can use a newer token, for example one set from another machine. If Cloudflare does not accept the saved token, the CLI shows a warning and continues without the check. To check the token that the platform uses, set `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN` to it. -The installer deploys the email gateway, prepares the shared mail route, and verifies inbound readiness before opening platform traffic. If setup fails, correct the reported permission, exact-domain MX records, or routing conflict, then rerun the same install or upgrade command. A fresh install resumes with `void platform install --resume --name `; it continues the recorded email operation after the configuration is corrected. +The installer deploys the email gateway, prepares the shared mail route, and verifies inbound readiness before opening platform traffic. If setup fails, fix the problem that the error names, for example a missing permission, the exact-domain MX records, or a routing conflict. Then run the same install or upgrade command again. A fresh install resumes with `void platform install --resume --name `; it continues the recorded email operation after the configuration is corrected. ## R2 Upload Credentials diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 99e733aba..1fb824ec4 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -113,9 +113,13 @@ An upgrade validates the runtime, applies supported pending migrations, deploys Before starting, Void checks access to the installation's Cloudflare account, Workers, and existing R2 buckets. If credentials are rejected, set `CLOUDFLARE_API_TOKEN` to a management token scoped to that account and retry. This overrides the installation's saved management token. -::: details Migration safety, credential rotation, and interrupted upgrades +Some upgrades pause application traffic during the cutover to the new version. Traffic resumes after the new version passes its health checks. If an upgrade or another lifecycle command fails after it paused traffic, traffic stays paused until a later run succeeds. To resume: -Some upgrades briefly put the platform into maintenance mode. Traffic resumes after the new version passes health checks. If an upgrade is interrupted during maintenance, rerun the same command to complete it. +1. Run `void platform status ` to see the saved progress. +2. Fix the problem that the error names. +3. Run the resume command that the CLI printed. It is the same command, with the same runtime. + +::: details Migration safety, credential rotation, and rollback To update the runtime Cloudflare token, GitHub OAuth credentials when GitHub login is enabled, or R2 credentials, provide their `VOID_PLATFORM_*` environment variables when running the upgrade. @@ -141,6 +145,30 @@ Let running rotations finish before you roll back. If an older proxy runs next t ::: +### Platform Workers changed outside Void {#workers-changed-outside-void} + +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. + +`upgrade`, `repair`, `enable`, `rollback`, and `install --resume` deploy the platform Workers again. Before they change anything, Void finds each platform Worker that: + +- was changed in the Cloudflare dashboard or with the Cloudflare API. +- 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. + +The `--plan` preview of `upgrade`, `repair`, `enable`, and `rollback` shows the same list as a warning. + +In a terminal, Void lists these Workers and asks **Replace the configuration of these Workers?** The default answer is No. + +- If you answer No, the command stops. Nothing changed, and the command did not pause traffic. +- 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. + +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. + +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`. + +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. + +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). + ### Follow upgrade and rollback progress Watching progress is optional: a running upgrade or rollback proceeds through diff --git a/docs/guide/platform/installation/setup.md b/docs/guide/platform/installation/setup.md index 326fd43bf..1c89d93b8 100644 --- a/docs/guide/platform/installation/setup.md +++ b/docs/guide/platform/installation/setup.md @@ -260,6 +260,6 @@ You can also rerun `void platform install` and choose **Continue setup** for an Resume restores your selected Hyperdrive. If that selection is missing from the saved checkpoint, add `--hyperdrive ` when resuming, using the same configuration you originally selected. -If Cloudflare rejects a saved runtime token during continued credential setup, Void opens the token page and asks for a replacement in the same run. Network or service failures do not discard saved tokens. Tokens supplied through the environment must be corrected there instead. +If Cloudflare rejects a saved runtime token during continued credential setup, Void opens the token page and asks for a replacement in the same run. A missing [email permission](/guide/platform/installation/credentials#enable-email) does not discard the saved token: add the permission to the same token, then resume. Network or service failures do not discard saved tokens. Tokens supplied through the environment must be corrected there instead. ::: diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 04e52133d..4bb79c511 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -137,6 +137,8 @@ After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. See [Manag 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. +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). + `--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. Without `--runtime`, the CLI uses its packaged platform version. diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index 799dfff32..b0fd230d2 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -40,6 +40,11 @@ export type CloudflareOperation = assetsDirectory?: string; previewAlias?: string; noBundle?: boolean; + /** + * Attach the Cloudflare CLI to this terminal so that it can ask the + * operator. The result then has only the exit status. + */ + interactive?: boolean; } & WorkerTarget) | ({ type: 'triggers.deploy'; assets?: boolean } & WorkerTarget) | ({ type: 'triggers.clear-crons' | 'triggers.clear-domains' } & WorkerTarget) diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 2a8eeb154..91492e022 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -105,6 +105,7 @@ function runCf( command: Array, cwd = process.cwd(), bounds?: { timeoutMs: number; maxOutputBytes: number }, + interactive = false, ): Result { const env = cfEnvironment(); env.CF_QUIET = '1'; @@ -116,7 +117,8 @@ function runCf( windowsHide: true, maxBuffer: bounds?.maxOutputBytes ?? 32 * 1024 * 1024, ...(bounds ? { timeout: bounds.timeoutMs } : {}), - stdio: ['ignore', 'pipe', 'pipe'], + // An interactive command shares this process's terminal, so cf can ask there. + stdio: interactive ? 'inherit' : ['ignore', 'pipe', 'pipe'], }); const stderr = [ child.stderr || '', @@ -1060,9 +1062,12 @@ async function workerChanges(): Promise { ...(request.secretsFilePath ? ['--secrets-file', request.secretsFilePath] : []), ], directory, + undefined, + request.interactive === true, ), ); - if (called.status !== 0) { + // cf wrote an interactive upload to the terminal, so only its exit status is known. + if (called.status !== 0 || request.interactive === true) { return called; } // cf renders uploads for the terminal; decode once at the process boundary. @@ -1272,9 +1277,15 @@ async function main(): Promise { return resources(); } +// Login, tail and an interactive upload write to the terminal, not a result envelope. +const terminalOperation = + operation?.type === 'auth.login' || + operation?.type === 'tail' || + ((operation?.type === 'versions.upload' || operation?.type === 'worker.deploy') && + operation.interactive === true); try { const output = await main(); - if (operation.type !== 'auth.login' && operation.type !== 'tail') { + if (!terminalOperation) { let data = output.data; let humanOutput = ''; if (data === undefined && output.stdout.trim()) { @@ -1303,7 +1314,7 @@ try { process.exitCode = output.status; } catch (error) { const message = error instanceof Error ? error.message : 'Cloudflare operation failed.'; - if (operation?.type !== 'auth.login' && operation?.type !== 'tail') { + if (!terminalOperation) { process.stdout.write(JSON.stringify({ operationResult: 1, errors: [{ message }] }) + '\n'); } process.stderr.write(message); diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index a32a1e859..43a1e8cd7 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -39,6 +39,21 @@ export class PlatformWorkerRoutesChangedError extends Error { } } +/** + * The runtime token cannot read the shared email zone. The operator can add + * the permission to the same token, so callers keep its saved value. + */ +export class PlatformRuntimeTokenEmailPermissionError extends Error { + /** Cloudflare answered HTTP 401 at least once: it did not accept the token. */ + readonly tokenNotValid: boolean; + + constructor(message: string, tokenNotValid: boolean, options?: ErrorOptions) { + super(message, options); + this.name = 'PlatformRuntimeTokenEmailPermissionError'; + this.tokenNotValid = tokenNotValid; + } +} + export type CloudflareAccount = Readonly<{ id: string; name: string }>; export type CloudflareNamedResource = Readonly<{ /** When Cloudflare created it, where the API reports that. */ @@ -130,6 +145,8 @@ export async function verifyPlatformRuntimeTokenPermissions( accountId: string; applicationDomain: string; customDomains: boolean; + /** The shared email zone, only when platform email is on. */ + emailZoneId?: string; zoneId: string | null; }, ): Promise { @@ -139,6 +156,13 @@ export async function verifyPlatformRuntimeTokenPermissions( throw runtimeTokenPreflightError('Account → Hyperdrive → Write', error); } + if (input.emailZoneId) { + await verifyPlatformRuntimeTokenEmailPermissions(client, { + accountId: input.accountId, + zoneId: input.emailZoneId, + }); + } + if (!input.zoneId) { return; } @@ -164,6 +188,67 @@ export async function verifyPlatformRuntimeTokenPermissions( } } +/** + * Shared email setup uses the runtime token after the platform pauses traffic. + * Read-only requests find a missing Email Routing permission before that. A + * read cannot prove Edit access. Email Sending is not checked, because its + * answer depends on the account plan. + */ +export async function verifyPlatformRuntimeTokenEmailPermissions( + client: Pick, + input: { + accountId: string; + zoneId: string; + /** + * The token is the one saved on this machine, and the run does not send + * it to the platform Workers. They can use a different token. + */ + savedToken?: boolean; + }, +): Promise { + const zone = `/zones/${encodeURIComponent(input.zoneId)}`; + const probes = [ + ['Zone → Zone → Read', zone], + ['Zone → Zone Settings → Edit', `${zone}/email/routing`], + ['Zone → Email Routing Rules → Edit', `${zone}/email/routing/rules?per_page=5`], + [ + 'Account → Email Routing Addresses → Edit', + `/accounts/${encodeURIComponent(input.accountId)}/email/routing/addresses?per_page=5`, + ], + ] as const; + const missing: Array = []; + let denial: PlatformCloudflareApiError | undefined; + let tokenNotValid = false; + for (const [permission, path] of probes) { + try { + await client.request(path); + } catch (error) { + // Only an authorization answer shows a missing permission. A rate limit, + // a Cloudflare outage or a network failure does not stop the command. + if ( + error instanceof PlatformCloudflareApiError && + (error.status === 401 || error.status === 403) + ) { + missing.push(permission); + denial ??= error; + // A token that is not valid gets 403 on the zone read, but 401 on + // the Email Routing reads. A valid token without access gets 403. + tokenNotValid ||= error.status === 401; + } + } + } + if (denial) { + const denied = `Cloudflare denied: ${missing.join(', ')} (HTTP ${denial.status}).`; + throw new PlatformRuntimeTokenEmailPermissionError( + input.savedToken + ? `platform: The runtime API token saved on this machine cannot use platform email. ${denied} Void checked this token because VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN is not set. If the platform uses a different runtime token, set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to that token, and Void checks that token instead. Otherwise, check that the zone permissions of the saved token include the email zone ${input.zoneId}. To add a missing permission, edit the token in the Cloudflare dashboard. The token value stays the same. Then run the command again.` + : `platform: The runtime API token cannot use platform email. ${denied} Check that the token is valid and that its zone permissions include the email zone ${input.zoneId}. To add a missing permission, edit the token in the Cloudflare dashboard. The token value stays the same. Or set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to a valid token that has these permissions. Then run the command again.`, + tokenNotValid, + { cause: denial }, + ); + } +} + function runtimeTokenPreflightError(permission: string, cause: unknown): Error { const status = cause instanceof PlatformCloudflareApiError ? cause.status : null; const message = @@ -927,6 +1012,27 @@ export class PlatformCloudflareClient { return bindings; } + /** + * Where the last change to a Worker came from, for example `cf_cli`, `dash` + * (Cloudflare dashboard) or `api` (Workers API). Null when the Worker does + * not exist or Cloudflare does not report it. + */ + async getWorkerLastDeployedFrom(accountId: string, workerName: string): Promise { + let service: { default_environment?: { script?: { last_deployed_from?: unknown } } } | null; + try { + service = await this.request( + `/accounts/${encodeURIComponent(accountId)}/workers/services/${encodeURIComponent(workerName)}`, + ); + } catch (error) { + if (error instanceof PlatformCloudflareApiError && error.status === 404) { + return null; + } + throw error; + } + const source = service?.default_environment?.script?.last_deployed_from; + return typeof source === 'string' && source !== '' ? source : null; + } + async deleteKvValue(accountId: string, namespaceId: string, key: string): Promise { try { await this.request( diff --git a/packages/void/src/cli/platform-install-workers.ts b/packages/void/src/cli/platform-install-workers.ts index cccea4146..ac6efe01a 100644 --- a/packages/void/src/cli/platform-install-workers.ts +++ b/packages/void/src/cli/platform-install-workers.ts @@ -327,6 +327,9 @@ export async function uploadInstallationWorkers(input: { preservedRoutes, previousConfig: previousWorkerConfig, previousPublicEndpoints: previousPublicEndpoints, + ...(dependencies.replaceOutsideChanges?.includes(action.name) + ? { replaceOutsideChanges: true } + : {}), runner: parallelWorkerUploads ? runParallelPinnedPlatformWrangler : runPinnedPlatformWrangler, diff --git a/packages/void/src/cli/platform-installer-errors.ts b/packages/void/src/cli/platform-installer-errors.ts index c0d7e67f0..5efdb6d41 100644 --- a/packages/void/src/cli/platform-installer-errors.ts +++ b/packages/void/src/cli/platform-installer-errors.ts @@ -1,3 +1,4 @@ +import { stripVTControlCharacters } from 'node:util'; import { cloudflareCommandDiagnostic, stabilizeCloudflareVersionTriggers, @@ -10,11 +11,68 @@ import { // the dashboard once enables it (cloudflare/workers-sdk#9312). const ANALYTICS_ENGINE_NOT_ENABLED = 10089; +// Without a terminal, the Cloudflare CLI stops an upload when it must ask +// about a conflict. Its advice to remove `--strict` does not apply: the CLI +// sets that mode itself. +const UPLOAD_CONFLICT = 'Aborting the upload operation because of conflicts'; + +/** Why the Cloudflare CLI stopped an upload, and what to do. Null for other failures. */ +function uploadConflictMessage(result: CloudflareCommandResult, operation: string): string | null { + const output = stripVTControlCharacters(`${result.stdout}\n${result.stderr}`); + if (!output.includes(UPLOAD_CONFLICT)) { + return null; + } + const lines = output.split(/\r?\n/).map((line) => line.replace(/^[\s▲│]+/, '').trim()); + // The Worker was last changed in the dashboard or with the Workers API. Keep + // the first line only: a dashboard diff shows values. + const outsideChange = lines.find( + (line) => + line.includes('last updated via the script API') || + line.includes('set via the Cloudflare Dashboard'), + ); + if (outsideChange) { + return `platform: The Cloudflare CLI did not ${operation}: ${outsideChange.replace(/:$/, '.')} This Worker was changed outside Void. Run the same command in a terminal. Void then lists the changes and asks you before it replaces them.`; + } + // A config variable or binding has the name of a secret on the Worker. Keep + // only the names: the CLI stopped, so its sentence about the replacement of + // the secrets is not true. + const secret = lines.find((line) => / with (?:an )?existing remote secrets?\./.test(line)); + const secretNames = [...(secret?.split(' conflict')[0] ?? '').matchAll(/`[^`]+`/g)].map( + ([name]) => name, + ); + if (secretNames.length === 1) { + return `platform: The Cloudflare CLI did not ${operation}: the Worker has a secret with the same name as a value that Void sets: ${secretNames[0]}. Delete that secret from the Worker in the Cloudflare dashboard, then run the command again.`; + } + if (secretNames.length > 1) { + return `platform: The Cloudflare CLI did not ${operation}: the Worker has secrets with the same names as values that Void sets: ${new Intl.ListFormat('en').format(secretNames)}. Delete those secrets from the Worker in the Cloudflare dashboard, then run the command again.`; + } + // Another Worker owns a workflow that this Worker binds. Keep the workflow + // and Worker names, and not the advice about a config file. + const workflow = lines.findIndex((line) => + line.includes('already exist and belong to different workers'), + ); + if (workflow !== -1) { + const owners = lines.slice(workflow + 1); + const end = owners.findIndex((line) => !line.startsWith('- ')); + const workflows = owners.slice(0, end === -1 ? undefined : end); + return `platform: The Cloudflare CLI did not ${operation}: ${[lines[workflow], ...workflows].join(' ')}. ${ + workflows.length > 1 + ? 'Other Workers own these workflows. Delete each workflow, or rename it in the configuration of the Worker that owns it.' + : 'Another Worker owns this workflow. Delete the workflow, or rename it in the configuration of that Worker.' + } Then run the command again.`; + } + return `platform: The Cloudflare CLI did not ${operation}. It stopped the upload because of a conflict that it could not confirm without a terminal. Fix the conflict, then run the command again.`; +} + export function assertCleanPlatformWrangler( result: CloudflareCommandResult, operation: string, ): void { if (result.status !== 0 || result.signal || result.error) { + const conflict = uploadConflictMessage(result, operation); + if (conflict) { + throw new Error(conflict); + } const diagnostic = cloudflareCommandDiagnostic({ ...result, stderr: result.stderr.trim() || result.errors?.map((error) => error.message).join('\n') || '', diff --git a/packages/void/src/cli/platform-installer-health.ts b/packages/void/src/cli/platform-installer-health.ts index 536ef50c3..a92480e70 100644 --- a/packages/void/src/cli/platform-installer-health.ts +++ b/packages/void/src/cli/platform-installer-health.ts @@ -269,6 +269,16 @@ export async function verifyPlatformDeploymentExecutionDrain( } } +// The Cloudflare permission that each shared email step uses with the runtime +// token: Email Routing DNS and settings need Zone Settings, and the sender +// onboarding needs Email Sending. +const sharedEmailStepPermissions: ReadonlyMap = new Map([ + ['routing_enable', 'Zone → Zone Settings → Edit'], + ['subaddressing_ensure', 'Zone → Zone Settings → Edit'], + ['inbound_probe', 'Zone → Zone Settings → Edit'], + ['sending_onboard', 'Account → Email Sending → Edit'], +]); + export async function bootstrapPlatformSharedEmail( apiUrl: string, healthProbeToken: string, @@ -416,6 +426,18 @@ export async function bootstrapPlatformSharedEmail( `Enable Email Routing for ${domain} in the Cloudflare dashboard, then resume this installation.`, ); } + } else if ( + failureCode === 'blocked' && + currentStep && + sharedEmailStepPermissions.has(currentStep) + ) { + // A blocked step got a definite 4xx answer, often a missing permission. + // The API does not return provider text, so name the permission that the + // step uses. + const permission = sharedEmailStepPermissions.get(currentStep); + details.push( + `This step needs ${permission} on the runtime API token. If the token does not have it, edit the token in the Cloudflare dashboard and add it. The token value stays the same. Or set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to a token that has it. Then resume this installation.`, + ); } const detail = details.length ? ` ${details.join(' ')}` : ''; throw new Error( diff --git a/packages/void/src/cli/platform-installer-inspection.ts b/packages/void/src/cli/platform-installer-inspection.ts index a25284e0f..7143fb893 100644 --- a/packages/void/src/cli/platform-installer-inspection.ts +++ b/packages/void/src/cli/platform-installer-inspection.ts @@ -55,6 +55,67 @@ export async function readActivePlatformWorkerVersion( return (active[0] as { version_id: string }).version_id; } +/** + * Whether the active version of a platform Worker has Void's record of its + * triggers. An upgrade refuses an active version without it, for example a + * version deployed outside Void. This reads the active version as + * readActivePlatformWorkerVersion does, but through the Cloudflare API. Null + * when the Worker has no single active version. + */ +export async function activePlatformWorkerHasTriggerRecord( + readCloudflare: (path: string) => Promise, + accountId: string, + workerName: string, +): Promise { + const base = `/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}`; + const response = await readCloudflare(`${base}/deployments`); + const deployments = + response && typeof response === 'object' + ? (response as { deployments?: unknown }).deployments + : null; + const latest = Array.isArray(deployments) ? (deployments[0] as unknown) : null; + const versions = + latest && typeof latest === 'object' ? (latest as { versions?: unknown }).versions : null; + const active = Array.isArray(versions) + ? versions.filter( + (entry) => + entry && + typeof entry === 'object' && + Number((entry as { percentage?: unknown }).percentage) === 100, + ) + : []; + const versionId = + active.length === 1 ? (active[0] as { version_id?: unknown }).version_id : undefined; + if (typeof versionId !== 'string') { + return null; + } + return platformWorkerVersionHasTriggerRecord(readCloudflare, accountId, workerName, versionId); +} + +/** + * Whether a version of a platform Worker has Void's record of its triggers. + * This is the rule of the upgrade: it refuses a version without the record. + */ +export async function platformWorkerVersionHasTriggerRecord( + readCloudflare: (path: string) => Promise, + accountId: string, + workerName: string, + versionId: string, +): Promise { + const version = await readCloudflare( + `/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}/versions/${encodeURIComponent(versionId)}`, + ); + const annotations = + version && typeof version === 'object' + ? (version as { annotations?: unknown }).annotations + : null; + const message = + annotations && typeof annotations === 'object' + ? (annotations as Record)['workers/message'] + : null; + return parseCloudflareVersionMessage(typeof message === 'string' ? message : null)?.schema === 2; +} + export async function readPlatformWorkerDeployments( configPath: string, accountId: string, @@ -224,6 +285,7 @@ export async function readActivePlatformWorkerSnapshot( workerName: string, expectedOwnershipMarker: string, runner: typeof runPlatformWrangler = runPlatformWrangler, + unrecordedTriggerConfig?: CloudflareTriggerConfig, ): Promise<{ contentHash?: string; migrationTag: string | null; @@ -252,6 +314,7 @@ export async function readActivePlatformWorkerSnapshot( ); } }, + unrecordedTriggerConfig, ); return snapshot; } @@ -264,6 +327,7 @@ export async function readPlatformWorkerVersionSnapshot( versionId: string, runner: typeof runPlatformWrangler, validateCurrentVersion?: () => Promise, + unrecordedTriggerConfig?: CloudflareTriggerConfig, ): Promise>> { const result = await runner( { type: 'versions.view', versionId, configPath, workerName }, @@ -308,6 +372,15 @@ export async function readPlatformWorkerVersionSnapshot( ? ((annotations as Record)['workers/message'] ?? null) : null; const metadata = parseCloudflareVersionMessage(message); + // A version made outside Void has no Void record of its triggers. Only a run + // that replaces those changes, with the operator's consent, gives Void's own. + if (metadata?.schema !== 2 && unrecordedTriggerConfig) { + return { + migrationTag: readCloudflareVersionMigrationTag(value), + triggerConfig: unrecordedTriggerConfig, + versionId, + }; + } if (metadata?.schema !== 2) { throw new Error( `platform: Active Worker ${workerName} predates safe trigger snapshots. Refusing to upgrade because its routes, cron, and queue consumers could not be restored after a failed cutover.`, diff --git a/packages/void/src/cli/platform-installer-outside-changes.ts b/packages/void/src/cli/platform-installer-outside-changes.ts new file mode 100644 index 000000000..420b2503b --- /dev/null +++ b/packages/void/src/cli/platform-installer-outside-changes.ts @@ -0,0 +1,292 @@ +import type { PlatformInstallationManifest } from '@void/platform'; +import type { PlatformRuntime } from '@void/platform/installer'; +import { confirm, isCancel, log, type SpinnerResult } from '@voidzero-dev/vite-plus-prompts'; +import { note } from './output.ts'; +import type { PlatformCloudflareClient } from './platform-cloudflare.ts'; +import { interruptedWorkerCutover } from './platform-installer-cutover.ts'; +import { + activePlatformWorkerHasTriggerRecord, + platformWorkerVersionHasTriggerRecord, +} from './platform-installer-inspection.ts'; +import { runPlatformWranglerAtApiBase } from './platform-installer-process.ts'; +import type { + PlatformDatabaseLifecycleLock, + PlatformWorkerKey, +} from './platform-installer-types.ts'; +import { + platformWorkerResourceKey, + uploadPlatformWorkerForReview, + type runPlatformWrangler, +} from './platform-installer-workers.ts'; +import type { PlatformInstallPlan } from './platform-plan.ts'; + +const OUTSIDE_SOURCES = { + // The Cloudflare CLI asks before it uploads a Worker last changed from one of + // these sources. Without a terminal it stops the upload, and no option skips that. + dash: 'changed in the Cloudflare dashboard', + api: 'changed in the Cloudflare API', + // The upgrade refuses an active version without Void's record of its + // triggers. A run can stop after the Cloudflare CLI replaced a change and + // before Void deployed its own version, or restore the earlier version. + // The Worker then shows no change, but that version stays active. Void + // releases before the record deployed versions without it too. + unrecorded: 'has an active version without a Void deployment record', +} as const; + +const PLATFORM_WORKER_KEYS: ReadonlyArray = [ + 'api', + 'proxy', + 'emailGateway', + 'tail', + 'dispatch', +]; + +export type PlatformWorkerOutsideChange = Readonly<{ + key: PlatformWorkerKey; + name: string; + source: keyof typeof OUTSIDE_SOURCES; +}>; + +/** The installation that a run continues. */ +export type PlatformOutsideChangeInstallation = Readonly<{ + manifest: PlatformInstallationManifest; + /** + * The database read that the installer uses. Without it, Void expects the + * run to restore the earlier versions of a stopped Worker cutover. + */ + readAuthenticationState?: PlatformDatabaseLifecycleLock['readAuthenticationState']; +}>; + +/** The Cloudflare CLI asks only about these changes. The others need no upload for review. */ +function needsReviewUpload(change: PlatformWorkerOutsideChange): boolean { + return change.source !== 'unrecorded'; +} + +/** + * The earlier version of each Worker that the installer makes active again + * before it deploys. A run that stopped during a Worker cutover left Void's + * version active, and the next run first restores the earlier one. Like the + * installer, keep Void's version while the platform changes to configurable + * sign-in and already requires sign-in. Every Worker that this check reads + * exists, so the installation had Workers before. + */ +async function restoredPlatformWorkerVersions( + installation: PlatformOutsideChangeInstallation, + runtime: PlatformRuntime, +): Promise> { + const cutover = interruptedWorkerCutover(installation.manifest); + if ( + !cutover || + (installation.manifest.deployment.authentication.configurable !== true && + runtime.manifest.authenticationProtocolVersion === 1 && + (await installation.readAuthenticationState?.())?.sessionsRequired === true) + ) { + return new Map(); + } + return new Map( + [...cutover.staged.values()].flatMap((snapshot) => + snapshot.previousVersionId === null + ? [] + : [[snapshot.workerName, snapshot.previousVersionId] as const], + ), + ); +} + +/** Find the existing platform Workers that a run uploads and that were changed outside Void. */ +export async function findPlatformWorkerOutsideChanges( + client: Pick, + plan: PlatformInstallPlan, + runtime: PlatformRuntime, + installation: PlatformOutsideChangeInstallation, +): Promise> { + const workers = PLATFORM_WORKER_KEYS.flatMap((key) => { + // The installer does not upload an email gateway that the runtime does not include. + if (key === 'emailGateway' && runtime.manifest.artifacts.emailGateway === undefined) { + return []; + } + const action = plan.actions.find((entry) => entry.key === platformWorkerResourceKey(key)); + return action?.kind === 'worker' && action.action !== 'create' + ? [{ key, name: action.name }] + : []; + }); + const restored = + workers.length > 0 + ? await restoredPlatformWorkerVersions(installation, runtime) + : new Map(); + const changes = await Promise.all( + workers.map(async (worker): Promise => { + // A failed read does not show a change. The upload still reports a real conflict. + let source: string | null; + try { + source = await client.getWorkerLastDeployedFrom(plan.account.id, worker.name); + } catch { + return null; + } + if (source === 'dash' || source === 'api') { + return { ...worker, source }; + } + // The upgrade reads the active version. After a stopped cutover, the + // installer first makes the earlier version active again. + const readCloudflare = (path: string) => client.request(path); + const restoredVersion = restored.get(worker.name); + const recorded = await Promise.all([ + activePlatformWorkerHasTriggerRecord(readCloudflare, plan.account.id, worker.name).catch( + () => null, + ), + ...(restoredVersion === undefined + ? [] + : [ + platformWorkerVersionHasTriggerRecord( + readCloudflare, + plan.account.id, + worker.name, + restoredVersion, + ).catch(() => null), + ]), + ]); + return recorded.includes(false) ? { ...worker, source: 'unrecorded' } : null; + }), + ); + return changes.filter((change) => change !== null); +} + +function listPlatformWorkerOutsideChanges( + changes: ReadonlyArray, +): string { + return changes.map((change) => `${change.name}: ${OUTSIDE_SOURCES[change.source]}`).join('\n'); +} + +/** + * The list of Workers after a heading. The heading must be true for each + * source: an older Void release can have deployed the active version. + */ +export function formatPlatformWorkerOutsideChanges( + changes: ReadonlyArray, +): string { + return `These platform Workers need your approval before this command replaces their configuration:\n${listPlatformWorkerOutsideChanges(changes)}`; +} + +/** + * Do this before a run changes anything. When platform Workers were changed + * outside Void, stop, or ask the operator in a terminal. Returns the Workers + * whose outside changes the operator agreed to replace. The installer uploads + * them later with uploadPlatformWorkerOutsideChanges. + */ +export async function reviewPlatformWorkerOutsideChanges( + input: Readonly<{ + client: Pick; + plan: PlatformInstallPlan; + runtime: PlatformRuntime; + installation: PlatformOutsideChangeInstallation; + /** True only when input and output are a terminal and --yes is not set. */ + interactive: boolean; + /** The command to run once in a terminal to review the changes. */ + command: string; + }>, +): Promise> { + const changes = await findPlatformWorkerOutsideChanges( + input.client, + input.plan, + input.runtime, + input.installation, + ); + if (changes.length === 0) { + return []; + } + if (!input.interactive) { + throw new Error( + [ + `platform: ${formatPlatformWorkerOutsideChanges(changes)}`, + 'Nothing changed. This command did not pause application traffic.', + 'This command replaces their configuration with the Void configuration. It can ask for your approval only in a terminal.', + `Run this command once in a terminal to review them:\n${input.command}`, + ].join('\n'), + ); + } + note( + [ + listPlatformWorkerOutsideChanges(changes), + '', + 'This command replaces the configuration of these Workers with the Void configuration.', + 'It removes values that Void does not set, for example an EMAIL_FROM that you added.', + 'It resets values that Void sets, for example AUTH_REDIRECT_URI.', + ...(changes.some(needsReviewUpload) + ? [ + 'First, it 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 Cloudflare dashboard or API. For a dashboard change, it shows the differences.', + 'It uploads a new version of each of these Workers and does not deploy it.', + ] + : []), + ].join('\n'), + 'Approval needed', + ); + const answer = await confirm({ + message: 'Replace the configuration of these Workers?', + initialValue: false, + }); + if (isCancel(answer) || typeof answer === 'symbol' || !answer) { + throw new Error( + 'platform: Cancelled. Nothing changed. This command did not pause application traffic.', + ); + } + return changes; +} + +/** + * Upload a new version of each Worker that the operator agreed to replace, and + * do not deploy it. The installer gives the configs after it creates the + * resources that they use, and before it pauses traffic. The Cloudflare CLI + * uses this terminal: it shows what the upload replaces and asks the operator. + */ +export async function uploadPlatformWorkerOutsideChanges( + configs: ReadonlyArray>, + input: Readonly<{ + /** The changes that the operator agreed to replace. */ + changes: ReadonlyArray; + client: Pick; + operatorApiToken: string; + /** It stops while the Cloudflare CLI uses the terminal, so it cannot write over the question. */ + progress?: Pick; + runner?: typeof runPlatformWrangler; + }>, +): Promise { + const { client } = input; + const reviewed = configs.filter((config) => + input.changes.some((change) => change.name === config.name && needsReviewUpload(change)), + ); + if (reviewed.length === 0) { + return; + } + const runner: typeof runPlatformWrangler = + input.runner ?? + ((operation, accountId, cwd, capture) => + runPlatformWranglerAtApiBase( + operation, + accountId, + cwd, + capture, + client.apiBaseUrl, + false, + input.operatorApiToken, + )); + input.progress?.pause(); + try { + for (const config of reviewed) { + const name = String(config.name); + log.step(`Uploading a new version of ${name} without deploying it`); + const uploaded = await uploadPlatformWorkerForReview(config, { + readCloudflare: (path) => client.request(path), + runner, + }); + // A refused upload can still exit with status 0, so read the source again. + const source = await client.getWorkerLastDeployedFrom(String(config.account_id), name); + if (uploaded.status !== 0 || source === 'dash' || source === 'api') { + throw new Error( + `platform: The Cloudflare CLI did not replace the changes to ${name}. Its output above shows why. No deployment changed. This command did not pause application traffic.`, + ); + } + } + } finally { + input.progress?.resume(); + } +} diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index ad3fdcfdd..544abeba8 100644 --- a/packages/void/src/cli/platform-installer-types.ts +++ b/packages/void/src/cli/platform-installer-types.ts @@ -288,6 +288,15 @@ export type PlatformInstallerDependencies = Readonly<{ replanWorkers?: ( manifest: PlatformInstallationManifest, ) => Promise>; + /** Workers whose changes made outside Void the operator agreed to replace. */ + replaceOutsideChanges?: ReadonlyArray; + /** + * Upload a version of each Worker in replaceOutsideChanges, and do not deploy + * it. The installer gives the configs that the run deploys. It calls this + * after it creates the resources that the configs use, and before it pauses + * traffic. + */ + uploadOutsideChanges?: (configs: ReadonlyArray>) => Promise; revalidateBeforeWorkerDeploy: ( manifest: PlatformInstallationManifest, action: PlatformInstallPlanAction, @@ -362,6 +371,11 @@ export type PlatformWorkerDeploymentOptions = Readonly<{ retainNewWorker: () => Promise; /** False uploads a new version even when the active one has identical input. */ reuseMatchingVersion?: boolean; + /** + * The operator agreed to replace changes made to this Worker outside Void. + * Its active version can then lack Void's record of its triggers. + */ + replaceOutsideChanges?: boolean; runner: typeof runPlatformWrangler; workerExisted: boolean; }>; diff --git a/packages/void/src/cli/platform-installer-workers.ts b/packages/void/src/cli/platform-installer-workers.ts index 0f2b9187b..bdfe30ee8 100644 --- a/packages/void/src/cli/platform-installer-workers.ts +++ b/packages/void/src/cli/platform-installer-workers.ts @@ -660,6 +660,7 @@ export async function deployPlatformWorker( workerName, readPlatformWorkerOwnershipMarker(config), readRunner, + options.replaceOutsideChanges ? previousTriggers : undefined, ); previousVersionId = previous.versionId; const deployments = await readPlatformWorkerDeployments( @@ -943,6 +944,66 @@ export async function deployPlatformWorker( } } +/** + * Upload a version of a platform Worker that was changed outside Void, and do + * not deploy it. The Cloudflare CLI uses this terminal: it shows what the + * upload replaces and asks the operator. After the operator agrees, later + * uploads of this Worker work without a terminal again. + */ +export async function uploadPlatformWorkerForReview( + config: Record, + options: Readonly<{ + readCloudflare: (path: string) => Promise; + runner: typeof runPlatformWrangler; + }>, +): Promise { + const directory = mkdtempSync(join(tmpdir(), 'void-platform-review-')); + const workerName = String(config.name); + const accountId = String(config.account_id); + const readRunner = platformWorkerApiReadRunner( + accountId, + workerName, + options.readCloudflare, + options.runner, + ); + try { + const { wranglerConfig } = isolatePlatformWorkerCustomDomains(config); + const configPath = join(directory, 'wrangler.json'); + const writeConfig = (value: Record) => + writeFileSync(configPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + writeConfig(withoutPlatformWorkerTrafficAttachments(wranglerConfig)); + // Like deployPlatformWorker, leave out the Durable Object migrations that + // the active version already ran. + const active = await readActivePlatformWorkerSnapshot( + configPath, + accountId, + workerName, + readPlatformWorkerOwnershipMarker(config), + readRunner, + readTriggerConfigFromObject(wranglerConfig), + ); + writeConfig( + withoutCompletedPlatformWorkerMigrations( + withoutPlatformWorkerTrafficAttachments(wranglerConfig), + active.migrationTag, + ), + ); + return await options.runner( + { + type: 'versions.upload', + configPath, + message: 'Void: replace changes made outside Void', + interactive: true, + }, + accountId, + directory, + true, + ); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + export async function recoverFailedPlatformWorkerDeployment(input: { checkpointUnsafeTraffic: () => Promise; closeRoutingGate: () => Promise; diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index 83e39c7db..aa161e3ea 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -585,30 +585,6 @@ export async function installPlatform( requiresSandboxDrain || (!!state.plan.deployment.appAccess && !state.manifest.steps.some((step) => step.key === 'app-access-initialized:v1')); - if (requiresTrafficGuard) { - await dependencies.client.putKvValue( - state.plan.account.id, - requireId(ids, 'routing-kv'), - PLATFORM_DISABLED_KEY, - PLATFORM_DISABLED_VALUE, - ); - workerTrafficGuarded = true; - dependencies.onProgress?.('Application traffic paused for a safe platform cutover'); - } - if (!preserveDisabled) { - const trafficGeneration = nextTrafficGeneration(state.manifest); - state.manifest = recordInstallationStep( - state.manifest, - { - completedAt: now(), - key: `traffic-enabled:${trafficGeneration}`, - resultHash: String(trafficGeneration), - }, - now(), - ); - writeInstallationManifest(state.manifest); - await writeRemoteInstallationCheckpoint(dependencies.client, state.plan, state.manifest); - } const configInput = { authenticationDatabaseUncached: true, proxyHealthBinding: true, @@ -639,6 +615,45 @@ export async function installPlatform( } : {}), }; + const enabledConfigs = createPlatformWorkerConfigs({ + ...configInput, + privatePreviews: runtime.privatePreviews === 1, + publicEndpoints: !preserveDisabled, + trafficState: preserveDisabled ? 'staging' : 'enabled', + }); + // The Cloudflare CLI asks the operator before it replaces changes made + // outside Void. Let it ask now: the resources that these configs use + // exist, and this run has not paused traffic or deployed a Worker. + const outsideChangeConfigs = workerKeys + .map((key) => enabledConfigs[key]) + .filter((config) => dependencies.replaceOutsideChanges?.includes(String(config.name))); + if (outsideChangeConfigs.length > 0) { + await dependencies.uploadOutsideChanges?.(outsideChangeConfigs); + } + if (requiresTrafficGuard) { + await dependencies.client.putKvValue( + state.plan.account.id, + requireId(ids, 'routing-kv'), + PLATFORM_DISABLED_KEY, + PLATFORM_DISABLED_VALUE, + ); + workerTrafficGuarded = true; + dependencies.onProgress?.('Application traffic paused for a safe platform cutover'); + } + if (!preserveDisabled) { + const trafficGeneration = nextTrafficGeneration(state.manifest); + state.manifest = recordInstallationStep( + state.manifest, + { + completedAt: now(), + key: `traffic-enabled:${trafficGeneration}`, + resultHash: String(trafficGeneration), + }, + now(), + ); + writeInstallationManifest(state.manifest); + await writeRemoteInstallationCheckpoint(dependencies.client, state.plan, state.manifest); + } // Preparation can generate new setup credentials. Snapshot only after all // preparation has completed so the deployed API can verify the handoff. const workerDeploymentSecrets: PlatformInstallationSecrets = { @@ -646,12 +661,6 @@ export async function installPlatform( workerSecretUpdates: state.workerSecretUpdateIntent?.updates ?? {}, }; const requiresStagedCommit = !preserveDisabled && requiresTrafficGuard; - const enabledConfigs = createPlatformWorkerConfigs({ - ...configInput, - privatePreviews: runtime.privatePreviews === 1, - publicEndpoints: !preserveDisabled, - trafficState: preserveDisabled ? 'staging' : 'enabled', - }); const configs = requiresStagedCommit ? createPlatformWorkerConfigs({ ...configInput, diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index 67f0c09d5..ebc711360 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -32,6 +32,7 @@ import { import { PlatformCloudflareClient, PlatformCloudflareApiError, + PlatformRuntimeTokenEmailPermissionError, verifyPlatformRuntimeTokenPermissions, type CloudflareAccount, } from './platform-cloudflare.ts'; @@ -40,6 +41,10 @@ import { collectInstallationAuthentication, platformManagementAccessForAuthentication, } from './platform-install-auth.ts'; +import { + reviewPlatformWorkerOutsideChanges, + uploadPlatformWorkerOutsideChanges, +} from './platform-installer-outside-changes.ts'; import { acquirePlatformDatabaseLifecycleLock, claimPlatformDatabase, @@ -967,6 +972,9 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr accountId, applicationDomain, customDomains: plan.deployment.capabilities.customDomains, + ...(plan.deployment.capabilities.email && plan.deployment.emailSharedZoneId + ? { emailZoneId: plan.deployment.emailSharedZoneId } + : {}), zoneId: plan.zone?.id ?? null, }); checkingRuntimePermissions = false; @@ -984,6 +992,8 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr if ( credentialSetup && token === values.cloudflareApiToken && + // The operator adds a missing email permission to the same token. + !(error instanceof PlatformRuntimeTokenEmailPermissionError) && isPlatformTokenAuthorizationError( error, checkingRuntimePermissions ? undefined : '/ai-gateway/', @@ -1191,6 +1201,19 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr }), ); } + // A resumed install uploads the Workers it created before. Without a + // terminal, the Cloudflare CLI stops that upload after a change outside Void, + // and the install refuses an active version without Void's record of its + // triggers. The installer takes the database lock later, so this check + // cannot read the database. + const outsideChanges = await reviewPlatformWorkerOutsideChanges({ + client, + plan, + runtime, + installation: { manifest: existing }, + interactive: process.stdin.isTTY === true && process.stdout.isTTY === true && !args.yes, + command: `${cliCommandName()} platform install --resume --name ${existing.id}`, + }); progress = startProgress('Installing the platform'); if (credentialSetup) { // Stop treating this as an untouched local draft before any database claim is attempted. @@ -1210,6 +1233,18 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr }, client, operatorApiToken: managementApiToken || operatorCredential.token, + ...(outsideChanges.length > 0 + ? { + replaceOutsideChanges: outsideChanges.map((change) => change.name), + uploadOutsideChanges: (configs: ReadonlyArray>) => + uploadPlatformWorkerOutsideChanges(configs, { + changes: outsideChanges, + client, + operatorApiToken: managementApiToken || operatorCredential.token, + progress, + }), + } + : {}), runtime, healthCheckResumeCommand: `${cliCommandName()} platform install --resume --name ${existing.id}`, onProgress: (message) => progress?.message(message), diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 1b8ebeca8..7073e0620 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -29,6 +29,8 @@ import { hyperdriveOriginFromConnectionString, hyperdriveOriginMatches, PlatformCloudflareClient, + PlatformRuntimeTokenEmailPermissionError, + verifyPlatformRuntimeTokenEmailPermissions, verifyPlatformRuntimeTokenPermissions, } from './platform-cloudflare.ts'; import { @@ -36,6 +38,12 @@ import { readLoginReplacementFile, replacePendingInstallationAuthentication, } from './platform-install-auth.ts'; +import { + findPlatformWorkerOutsideChanges, + formatPlatformWorkerOutsideChanges, + reviewPlatformWorkerOutsideChanges, + uploadPlatformWorkerOutsideChanges, +} from './platform-installer-outside-changes.ts'; import { acquirePlatformDatabaseLifecycleLock, createPlatformHealthProbeToken, @@ -463,6 +471,15 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise 0) { + log.warn( + `${formatPlatformWorkerOutsideChanges(outsideChanges)}\nThis command replaces their configuration with the Void configuration. Run it in a terminal to review them first.`, + ); + } outro('No Cloudflare or local installation state was changed.'); return; } @@ -574,6 +591,12 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise { @@ -654,6 +711,18 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise 0 + ? { + replaceOutsideChanges: outsideChanges.map((change) => change.name), + uploadOutsideChanges: (configs: ReadonlyArray>) => + uploadPlatformWorkerOutsideChanges(configs, { + changes: outsideChanges, + client, + operatorApiToken: operatorCredential.token, + progress, + }), + } + : {}), databaseRollbackSource: rollbackSourceRuntime ?? undefined, onProgress: (message) => { markPhase(message); diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 82a22a318..930cb1508 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -972,6 +972,10 @@ export function createDefaultRunWrangler( new Promise((resolvePromise, rejectPromise) => { const parallelWorkerUpload = options?.parallelWorkerUpload === true && args.type === 'versions.upload'; + // The Cloudflare CLI asks only when both its input and output are a terminal. + const interactive = + (args.type === 'versions.upload' || args.type === 'worker.deploy') && + args.interactive === true; // A fatal-signal teardown is in progress: start no new work. if (activeTeardown !== undefined) { rejectPromise( @@ -1006,7 +1010,9 @@ export function createDefaultRunWrangler( }), ...(opts.suppressLogs ? { WRANGLER_WRITE_LOGS: 'false' } : {}), }, - stdio: [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + stdio: interactive + ? 'inherit' + : [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true, detached: !isWindows, }); @@ -1114,7 +1120,8 @@ export function createDefaultRunWrangler( settled = true; clearTimeout(commandTimer); deregisterSelf(); - resolvePromise(decodeCloudflareOperationResult(result)); + // An interactive process writes to the terminal, so it has no result envelope. + resolvePromise(interactive ? result : decodeCloudflareOperationResult(result)); }; // Reject with a quarantine error: the child could not be confirmed dead. diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index 65dacfaec..7b6e2241a 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -1281,3 +1281,83 @@ it('renders binary and text frames from a real Node WebSocket in order and remov body: '', }); }, 30_000); + +it.each([false, true])( + 'attaches cf to the terminal only for an interactive platform upload (interactive: %s)', + async (interactive) => { + const directory = temporaryDirectory('void-cf-interactive-upload-'); + const requests: Array = []; + const api = await listen( + createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.statusCode = 500; + response.end(); + }), + ); + const configPath = join(directory, 'wrangler.json'); + writeFileSync( + join(directory, 'index.js'), + 'export default { fetch() { return new Response("ok") } };', + ); + writeFileSync( + configPath, + JSON.stringify({ + name: 'test-worker', + account_id: 'test-account', + compatibility_date: '2026-09-29', + main: 'index.js', + no_bundle: true, + }), + ); + const record = join(directory, 'cf-stdio.json'); + const preload = join(directory, 'mock-interactive-upload.mjs'); + writeFileSync( + preload, + `import child from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +const actualSpawnSync=child.spawnSync; +child.spawnSync = (binary, args, options) => { + if(args.slice(1,4).join(' ') !== 'workers versions create') return actualSpawnSync(binary,args,options); + writeFileSync(${JSON.stringify(record)}, JSON.stringify({stdio:options.stdio,args:args.slice(1)})); + return options.stdio === 'inherit' + ? { status:0, stdout:null, stderr:null } + : { status:0, stdout:JSON.stringify({id:'11111111-1111-4111-8111-111111111111'}), stderr:'' }; +}; +syncBuiltinESMExports();`, + ); + const output = await runOperation( + directory, + { + type: 'versions.upload', + configPath, + message: 'review', + ...(interactive ? { interactive } : {}), + }, + unproxiedEnv(api), + preload, + ); + expect(output.status, output.stderr).toBe(0); + const called = JSON.parse(readFileSync(record, 'utf8')); + expect(called.args).toEqual([ + 'workers', + 'versions', + 'create', + '--prebuilt', + '--message', + 'review', + ]); + if (interactive) { + expect(called.stdio).toBe('inherit'); + // The terminal shows cf's own output; no result envelope follows it. + expect(output.stdout).toBe(''); + } else { + expect(called.stdio).toEqual(['ignore', 'pipe', 'pipe']); + expect(JSON.parse(output.stdout).data).toEqual({ + versionId: '11111111-1111-4111-8111-111111111111', + }); + } + expect(requests).toEqual([]); + }, + 30_000, +); diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index 254de6b9b..e4dc9397a 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -5,7 +5,9 @@ import { parsePostgresConnectionString, PlatformCloudflareApiError, PlatformCloudflareClient, + PlatformRuntimeTokenEmailPermissionError, PlatformWorkerCustomDomainsChangedError, + verifyPlatformRuntimeTokenEmailPermissions, verifyPlatformRuntimeTokenPermissions, } from '../../src/cli/platform-cloudflare.ts'; @@ -557,6 +559,183 @@ describe('platform Cloudflare client', () => { await expect(failure).rejects.not.toThrow('Required permission or scope'); }); + describe('runtime-token email access', () => { + const emailProbes = [ + ['Zone → Zone → Read', '/client/v4/zones/mail-zone'], + ['Zone → Zone Settings → Edit', '/client/v4/zones/mail-zone/email/routing'], + ['Zone → Email Routing Rules → Edit', '/client/v4/zones/mail-zone/email/routing/rules'], + [ + 'Account → Email Routing Addresses → Edit', + '/client/v4/accounts/account/email/routing/addresses', + ], + ] as const; + const emailClient = (answer: (pathname: string) => Response | Promise) => { + const requests: Array = []; + const client = new PlatformCloudflareClient('synthetic-runtime-secret', { + fetcher: async (input, init) => { + const url = new URL(String(input)); + requests.push(`${init?.method ?? 'GET'} ${url.pathname}${url.search}`); + return answer(url.pathname); + }, + }); + return { client, requests }; + }; + const verifyEmail = (client: PlatformCloudflareClient) => + verifyPlatformRuntimeTokenPermissions(client, { + accountId: 'account', + applicationDomain: '', + customDomains: false, + emailZoneId: 'mail-zone', + zoneId: null, + }); + + it('reads each Email Routing area of the shared email zone only when email is on', async () => { + const { client, requests } = emailClient(() => Response.json({ success: true, result: [] })); + + await verifyPlatformRuntimeTokenPermissions(client, { + accountId: 'account', + applicationDomain: 'apps.example.com', + customDomains: true, + zoneId: 'zone', + }); + expect(requests.filter((request) => /\/email\/|\/zones\/zone$/.test(request))).toEqual([]); + + requests.length = 0; + await verifyEmail(client); + + expect(requests).toEqual([ + 'GET /client/v4/accounts/account/hyperdrive/configs?page=1&per_page=50', + 'GET /client/v4/zones/mail-zone', + 'GET /client/v4/zones/mail-zone/email/routing', + 'GET /client/v4/zones/mail-zone/email/routing/rules?per_page=5', + 'GET /client/v4/accounts/account/email/routing/addresses?per_page=5', + ]); + }); + + it.each(emailProbes)('names %s when Cloudflare denies its check', async (permission, path) => { + const { client } = emailClient((pathname) => + pathname === path + ? Response.json( + { success: false, errors: [{ message: 'upstream echoed synthetic-runtime-secret' }] }, + { status: 403 }, + ) + : Response.json({ success: true, result: [] }), + ); + + const failure = await verifyEmail(client).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(PlatformRuntimeTokenEmailPermissionError); + expect((failure as PlatformRuntimeTokenEmailPermissionError).tokenNotValid).toBe(false); + const message = String(failure); + expect(message).toContain(`Cloudflare denied: ${permission} (HTTP 403)`); + for (const [other] of emailProbes) { + if (other !== permission) { + expect(message).not.toContain(other); + } + } + expect(message).toContain('email zone mail-zone'); + expect(message).toContain('The token value stays the same'); + expect(message).toContain('VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN'); + expect(message).not.toContain('synthetic-runtime-secret'); + expect(message).not.toContain('upstream echoed'); + expect((failure as Error).cause).toBeInstanceOf(PlatformCloudflareApiError); + }); + + it('marks a token that Cloudflare does not accept', async () => { + // Cloudflare's answers to a token that is not valid: 403 on the zone + // read and 401 on the Email Routing reads. + const { client } = emailClient((pathname) => + pathname.endsWith('/hyperdrive/configs') + ? Response.json({ success: true, result: [] }) + : Response.json( + { + success: false, + errors: [ + pathname === '/client/v4/zones/mail-zone' + ? { code: 9109, message: 'Invalid access token synthetic-runtime-secret' } + : { code: 10000, message: 'Authentication error synthetic-runtime-secret' }, + ], + }, + { status: pathname === '/client/v4/zones/mail-zone' ? 403 : 401 }, + ), + ); + + const failure = await verifyEmail(client).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(PlatformRuntimeTokenEmailPermissionError); + expect((failure as PlatformRuntimeTokenEmailPermissionError).tokenNotValid).toBe(true); + expect(String(failure)).not.toContain('synthetic-runtime-secret'); + expect(String(failure)).not.toContain('Invalid access token'); + expect(String(failure)).not.toContain('Authentication error'); + }); + + it('says that it checked the token saved on this machine', async () => { + // The run does not send the saved token to the platform Workers, so + // they can use a different token. + const { client } = emailClient((pathname) => + pathname.endsWith('/email/routing/rules') + ? Response.json({ success: false, errors: [] }, { status: 403 }) + : Response.json({ success: true, result: [] }), + ); + const check = (savedToken?: boolean) => + verifyPlatformRuntimeTokenEmailPermissions(client, { + accountId: 'account', + zoneId: 'mail-zone', + ...(savedToken === undefined ? {} : { savedToken }), + }).catch((error: Error) => error.message); + + await expect(check(true)).resolves.toBe( + 'platform: The runtime API token saved on this machine cannot use platform email. Cloudflare denied: Zone → Email Routing Rules → Edit (HTTP 403). Void checked this token because VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN is not set. If the platform uses a different runtime token, set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to that token, and Void checks that token instead. Otherwise, check that the zone permissions of the saved token include the email zone mail-zone. To add a missing permission, edit the token in the Cloudflare dashboard. The token value stays the same. Then run the command again.', + ); + // A token from VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN, or one that + // the run sends to the Workers, keeps the message. + for (const savedToken of [undefined, false]) { + await expect(check(savedToken)).resolves.toBe( + 'platform: The runtime API token cannot use platform email. Cloudflare denied: Zone → Email Routing Rules → Edit (HTTP 403). Check that the token is valid and that its zone permissions include the email zone mail-zone. To add a missing permission, edit the token in the Cloudflare dashboard. The token value stays the same. Or set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to a valid token that has these permissions. Then run the command again.', + ); + } + }); + + it('names every denied permission in one failure', async () => { + const { client, requests } = emailClient((pathname) => + pathname.endsWith('/hyperdrive/configs') + ? Response.json({ success: true, result: [] }) + : Response.json({ success: false, errors: [] }, { status: 401 }), + ); + + await expect(verifyEmail(client)).rejects.toThrow( + `Cloudflare denied: ${emailProbes.map(([permission]) => permission).join(', ')} (HTTP 401)`, + ); + expect(requests).toHaveLength(5); + }); + + it.each([429, 500, 503])( + 'does not treat HTTP %s as a missing email permission', + async (status) => { + const { client, requests } = emailClient((pathname) => + pathname.endsWith('/hyperdrive/configs') + ? Response.json({ success: true, result: [] }) + : Response.json({ success: false, errors: [] }, { status }), + ); + + await expect(verifyEmail(client)).resolves.toBeUndefined(); + expect(requests).toHaveLength(5); + }, + ); + + it('does not treat a network failure as a missing email permission', async () => { + const { client, requests } = emailClient((pathname) => { + if (pathname.endsWith('/hyperdrive/configs')) { + return Response.json({ success: true, result: [] }); + } + throw new Error('network failure'); + }); + + await expect(verifyEmail(client)).resolves.toBeUndefined(); + expect(requests).toHaveLength(5); + }); + }); + it('filters KV namespaces by exact title instead of trusting an unsupported API filter', async () => { const entries = [ { id: 'routing-id', title: 'void-example-routing' }, diff --git a/packages/void/test/unit/platform-installer-errors.test.ts b/packages/void/test/unit/platform-installer-errors.test.ts index c1e55e3e3..440d6b6fd 100644 --- a/packages/void/test/unit/platform-installer-errors.test.ts +++ b/packages/void/test/unit/platform-installer-errors.test.ts @@ -1,9 +1,12 @@ -import { expect, it } from 'vite-plus/test'; +import { describe, expect, it } from 'vite-plus/test'; import { assertCleanPlatformWrangler } from '../../src/cli/platform-installer-errors.ts'; -it('reports the cf upload conflict from stdout without binding values or completion output', () => { - const diagnostic = - 'Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag'; +const UPLOAD_CONFLICT = + 'Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag'; +const OUTSIDE_CHANGE_ADVICE = + ' This Worker was changed outside Void. Run the same command in a terminal. Void then lists the changes and asks you before it replaces them.'; + +it('explains the cf upload conflict after a Workers API change without binding values', () => { const result = { status: 1, stderr: '', @@ -12,7 +15,7 @@ it('reports the cf upload conflict from stdout without binding values or complet '├ Upload', '▲ You are about to upload a Worker that was last updated via the script API.', '│ Edits that have been made via the script API will be overridden by your local code and config.', - `■ ${diagnostic}`, + `■ ${UPLOAD_CONFLICT}`, '│', '◆ Version upload complete', '', @@ -20,10 +23,137 @@ it('reports the cf upload conflict from stdout without binding values or complet }; expect(() => assertCleanPlatformWrangler(result, 'upload test-worker')).toThrow( - 'platform: Wrangler failed to upload test-worker (' + - 'You are about to upload a Worker that was last updated via the script API.\n' + - `${diagnostic}).`, + 'platform: The Cloudflare CLI did not upload test-worker: ' + + 'You are about to upload a Worker that was last updated via the script API.' + + OUTSIDE_CHANGE_ADVICE, + ); + expect(() => assertCleanPlatformWrangler(result, 'upload test-worker')).not.toThrow( + /do-not-echo|--strict|Wrangler/, + ); +}); + +it('explains the cf upload conflict after a dashboard change without its diff', () => { + const result = { + status: 1, + stderr: '', + stdout: [ + '├ Upload', + '▲ The local configuration being used (generated from your local configuration file) differs from the remote configuration of your Worker set via the Cloudflare Dashboard:', + '│ vars: {', + '│ - EMAIL_FROM: "Team "', + '│ }', + '│ Uploading the Worker will override the remote configuration with your local one.', + `■ ${UPLOAD_CONFLICT}`, + '◆ Version upload complete', + ].join('\n'), + }; + + expect(() => assertCleanPlatformWrangler(result, 'upload void-team-api')).toThrow( + 'platform: The Cloudflare CLI did not upload void-team-api: ' + + 'The local configuration being used (generated from your local configuration file) differs from the remote configuration of your Worker set via the Cloudflare Dashboard.' + + OUTSIDE_CHANGE_ADVICE, ); + expect(() => assertCleanPlatformWrangler(result, 'upload void-team-api')).not.toThrow( + /team@example\.com|--strict/, + ); +}); + +describe('cf upload conflicts that do not show a change outside Void', () => { + const CONFLICT_ADVICE = + ' It stopped the upload because of a conflict that it could not confirm without a terminal. Fix the conflict, then run the command again.'; + const upload = (warning: ReadonlyArray) => + assertCleanPlatformWrangler( + { + status: 1, + stderr: '', + stdout: [ + '├ Upload', + ...warning, + `■ ${UPLOAD_CONFLICT}`, + '│', + '◆ Version upload complete', + ].join('\n'), + }, + 'upload void-x-api', + ); + + const message = (warning: ReadonlyArray) => { + try { + upload(warning); + } catch (error) { + return (error as Error).message; + } + return null; + }; + // The Cloudflare CLI stopped, so its sentence about the replacement of the + // secrets is not true. + const SECRET_REPLACEMENT = /This deployment will replace|--strict|Wrangler|outside Void/; + + it('names the variables that have the name of a secret on the Worker', () => { + const output = message([ + '▲ Environment variables `AUTH_REDIRECT_URI` and `DASHBOARD_URL` conflict with existing remote secrets. This deployment will replace these remote secrets with your environment variables.', + ]); + expect(output).toBe( + 'platform: The Cloudflare CLI did not upload void-x-api: the Worker has secrets with the same names as values that Void sets: `AUTH_REDIRECT_URI` and `DASHBOARD_URL`. Delete those secrets from the Worker in the Cloudflare dashboard, then run the command again.', + ); + expect(output).not.toMatch(SECRET_REPLACEMENT); + }); + + it('names a binding that has the name of a secret on the Worker', () => { + const output = message([ + '▲ Binding `ROUTING` conflicts with an existing remote secret. This deployment will replace the remote secret with your binding.', + ]); + expect(output).toBe( + 'platform: The Cloudflare CLI did not upload void-x-api: the Worker has a secret with the same name as a value that Void sets: `ROUTING`. Delete that secret from the Worker in the Cloudflare dashboard, then run the command again.', + ); + expect(output).not.toMatch(SECRET_REPLACEMENT); + }); + + it('names variables and bindings that have the names of secrets on the Worker', () => { + const output = message([ + '▲ Configuration values (`AUTH_REDIRECT_URI`, `DASHBOARD_URL`, and `ROUTING`) conflict with existing remote secrets. This deployment will replace these remote secrets with the configuration values.', + ]); + expect(output).toBe( + 'platform: The Cloudflare CLI did not upload void-x-api: the Worker has secrets with the same names as values that Void sets: `AUTH_REDIRECT_URI`, `DASHBOARD_URL`, and `ROUTING`. Delete those secrets from the Worker in the Cloudflare dashboard, then run the command again.', + ); + expect(output).not.toMatch(SECRET_REPLACEMENT); + }); + + it('names a workflow that another Worker owns', () => { + const output = message([ + '▲ The following workflow(s) already exist and belong to different workers:', + '│ - "void-x-jobs" (currently belongs to "someone-else")', + '│', + '│ Deploying will reassign these workflows to "void-x-api". Workflow names must be unique per account. If this reassignment is unintended, rename the workflow(s) in your Wrangler config file.', + ]); + expect(output).toBe( + 'platform: The Cloudflare CLI did not upload void-x-api: The following workflow(s) already exist and belong to different workers: - "void-x-jobs" (currently belongs to "someone-else"). Another Worker owns this workflow. Delete the workflow, or rename it in the configuration of that Worker. Then run the command again.', + ); + expect(output).not.toMatch(/outside Void|--strict|Wrangler|reassign/); + }); + + it('names each workflow that other Workers own', () => { + expect( + message([ + '▲ The following workflow(s) already exist and belong to different workers:', + '│ - "void-x-jobs" (currently belongs to "someone-else")', + '│ - "void-x-cleanup" (currently belongs to "another-worker")', + '│', + '│ Deploying will reassign these workflows to "void-x-api".', + ]), + ).toBe( + 'platform: The Cloudflare CLI did not upload void-x-api: The following workflow(s) already exist and belong to different workers: - "void-x-jobs" (currently belongs to "someone-else") - "void-x-cleanup" (currently belongs to "another-worker"). Other Workers own these workflows. Delete each workflow, or rename it in the configuration of the Worker that owns it. Then run the command again.', + ); + }); + + it('does not guess the cause of a conflict that it does not know', () => { + expect(() => upload(['▲ Something else needs an answer.'])).toThrow( + `platform: The Cloudflare CLI did not upload void-x-api.${CONFLICT_ADVICE}`, + ); + expect(() => upload(['▲ Something else needs an answer.'])).not.toThrow( + /outside Void|Something else/, + ); + }); }); it('reports structured provider errors when stderr is empty using existing credential redaction', () => { diff --git a/packages/void/test/unit/platform-installer-outside-changes.test.ts b/packages/void/test/unit/platform-installer-outside-changes.test.ts new file mode 100644 index 000000000..03a04d103 --- /dev/null +++ b/packages/void/test/unit/platform-installer-outside-changes.test.ts @@ -0,0 +1,861 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + encodeCloudflareVersionMessage, + type CloudflareOperation, + type CloudflareTriggerConfig, +} from '@void/deploy-cloudflare'; +import { + createPlatformDeploymentManifest, + createPlatformInstallationManifest, + createPlatformResourceOwnershipMarker, + recordInstallationResource, + type InstallationResourceKind, + type PlatformInstallationManifest, +} from '@void/platform'; +import { getPlatformRuntimeDirectory, resolvePlatformRuntime } from '@void/platform/installer'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +vi.mock('@voidzero-dev/vite-plus-prompts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, confirm: vi.fn(), log: { ...actual.log, step: vi.fn() }, note: vi.fn() }; +}); + +import { confirm, log, note } from '@voidzero-dev/vite-plus-prompts'; +import { + PlatformCloudflareApiError, + PlatformCloudflareClient, +} from '../../src/cli/platform-cloudflare.ts'; +import { + recordWorkerCutoverResolution, + recordWorkerCutoverStage, +} from '../../src/cli/platform-installer-cutover.ts'; +import { + findPlatformWorkerOutsideChanges, + reviewPlatformWorkerOutsideChanges, + uploadPlatformWorkerOutsideChanges, + type PlatformOutsideChangeInstallation, + type PlatformWorkerOutsideChange, +} from '../../src/cli/platform-installer-outside-changes.ts'; +import { + deployPlatformWorker, + type runPlatformWrangler, +} from '../../src/cli/platform-installer-workers.ts'; +import type { + PlatformInstallPlan, + PlatformInstallPlanAction, +} from '../../src/cli/platform-plan.ts'; + +const runtime = resolvePlatformRuntime(getPlatformRuntimeDirectory()); +const deployment = createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', +}); +const names = deployment.resources; + +function installationManifest(): PlatformInstallationManifest { + let manifest = createPlatformInstallationManifest({ + account: { id: 'account', name: 'Account' }, + createdAt: '2026-10-15T00:00:00.000Z', + deployment, + id: 'example-a1b2c3d4', + ownershipNonce: '0123456789abcdef0123456789abcdef', + platformVersion: '1.1.10', + }); + for (const [key, kind] of [ + ['hyperdrive', 'hyperdrive'], + ['isr-cache-kv', 'kv-namespace'], + ['legacy-assets-kv', 'kv-namespace'], + ['routing-kv', 'kv-namespace'], + ] as const satisfies ReadonlyArray) { + manifest = recordInstallationResource( + manifest, + { + configHash: 'hash', + id: `${key}-id`, + key, + kind, + name: key, + ownership: 'created', + state: 'ready', + }, + '2026-10-15T00:00:01.000Z', + ); + } + return manifest; +} + +function worker( + key: string, + name: string, + action: PlatformInstallPlanAction['action'] = 'update', +): PlatformInstallPlanAction { + return { action, id: null, key, kind: 'worker', name, reason: 'installed' }; +} + +function installPlan(actions: ReadonlyArray): PlatformInstallPlan { + return { + account: { id: 'account', name: 'Account' }, + actions, + apiUrl: 'https://api.example.com', + deployment, + installationId: 'example-a1b2c3d4', + proxyUrl: 'https://proxy.example.com', + workersSubdomain: 'example', + zone: null, + }; +} + +const allWorkers = [ + worker('api-worker', names.apiWorker), + worker('proxy-worker', names.proxyWorker), + worker('email-gateway-worker', names.emailGatewayWorker), + worker('tail-worker', names.tailWorker), + worker('dispatch-worker', names.dispatchWorker), +]; + +// An installation without a stopped Worker cutover. +const installed: PlatformOutsideChangeInstallation = { manifest: installationManifest() }; + +const triggers: CloudflareTriggerConfig = { + workers_dev: true, + preview_urls: false, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], +}; +// Every version that Void deploys has this record of its triggers. +const recordedMessage = encodeCloudflareVersionMessage([], triggers); + +/** Cloudflare API answers for a Worker whose active version has the given message. */ +function activeVersionReads( + workerName: string, + message: string | undefined, + ownershipMarker = 'owned-by-test', +) { + const versionId = '22222222-2222-4222-8222-222222222222'; + const script = `/accounts/account/workers/scripts/${workerName}`; + return vi.fn(async (path: string): Promise => { + if (path === `${script}/deployments`) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: versionId, percentage: 100 }], + }, + ], + }; + } + if (path === `${script}/versions/${versionId}`) { + return { + annotations: message === undefined ? {} : { 'workers/message': message }, + resources: { + bindings: [ + { name: 'PLATFORM_OWNERSHIP_MARKER', type: 'plain_text', text: ownershipMarker }, + ], + }, + }; + } + throw new Error(`unexpected read ${path}`); + }); +} + +describe('Worker change source', () => { + const client = (response: () => Response) => { + const paths: Array = []; + return { + paths, + client: new PlatformCloudflareClient('operator-token', { + apiBase: 'https://cloudflare.example.test/client/v4', + fetcher: async (input) => { + paths.push(new URL(String(input)).pathname); + return response(); + }, + }), + }; + }; + + it('reads where the last change to a Worker came from', async () => { + const reader = client(() => + Response.json({ + success: true, + result: { default_environment: { script: { last_deployed_from: 'dash' } } }, + }), + ); + await expect(reader.client.getWorkerLastDeployedFrom('account', 'void-a-api')).resolves.toBe( + 'dash', + ); + expect(reader.paths).toEqual(['/client/v4/accounts/account/workers/services/void-a-api']); + }); + + it('returns null when Cloudflare does not report the source', async () => { + const reader = client(() => + Response.json({ success: true, result: { default_environment: { script: {} } } }), + ); + await expect(reader.client.getWorkerLastDeployedFrom('account', 'void-a-api')).resolves.toBe( + null, + ); + }); + + it('returns null for a Worker that does not exist', async () => { + const reader = client(() => + Response.json( + { + success: false, + result: null, + errors: [{ code: 10090, message: 'This Worker does not exist on this account.' }], + }, + { status: 404 }, + ), + ); + await expect(reader.client.getWorkerLastDeployedFrom('account', 'void-a-api')).resolves.toBe( + null, + ); + }); + + it('reports other failed reads', async () => { + const reader = client(() => + Response.json( + { success: false, errors: [{ code: 10000, message: 'Internal error' }] }, + { status: 500 }, + ), + ); + await expect( + reader.client.getWorkerLastDeployedFrom('account', 'void-a-api'), + ).rejects.toBeInstanceOf(PlatformCloudflareApiError); + }); +}); + +describe('platform Workers changed outside Void', () => { + const sources = new Map(); + const getWorkerLastDeployedFrom = vi.fn(async (_account: string, name: string) => { + const source = sources.get(name) ?? 'cf_cli'; + if (source instanceof Error) { + throw source; + } + return source; + }); + // The message of each Worker's active version. By default, Void deployed it. + const activeMessages = new Map(); + const readCloudflare = vi.fn(async (path: string): Promise => { + const name = /\/workers\/scripts\/([^/]+)\//.exec(path)?.[1] ?? ''; + const message = activeMessages.has(name) ? activeMessages.get(name) : recordedMessage; + if (message instanceof Error) { + throw message; + } + return activeVersionReads(name, message)(path); + }); + const client = { + getWorkerLastDeployedFrom, + request: async (path: string) => (await readCloudflare(path)) as T, + }; + + beforeEach(() => { + vi.clearAllMocks(); + sources.clear(); + activeMessages.clear(); + }); + + it('reads only existing Workers that the run uploads and flags dashboard and API changes', async () => { + sources.set(names.apiWorker, 'dash'); + sources.set(names.proxyWorker, 'api'); + sources.set(names.tailWorker, null); + sources.set(names.dispatchWorker, new PlatformCloudflareApiError('Internal error', 500)); + const plan = installPlan([ + ...allWorkers.slice(0, 4), + worker('dispatch-worker', names.dispatchWorker, 'reuse'), + worker('extra-worker', 'void-example-other', 'create'), + ]); + + await expect( + findPlatformWorkerOutsideChanges(client, plan, runtime, installed), + ).resolves.toEqual([ + { key: 'api', name: names.apiWorker, source: 'dash' }, + { key: 'proxy', name: names.proxyWorker, source: 'api' }, + ]); + expect(getWorkerLastDeployedFrom.mock.calls.map(([, name]) => name)).toEqual( + runtime.manifest.artifacts.emailGateway + ? [ + names.apiWorker, + names.proxyWorker, + names.emailGatewayWorker, + names.tailWorker, + names.dispatchWorker, + ] + : [names.apiWorker, names.proxyWorker, names.tailWorker, names.dispatchWorker], + ); + }); + + it('also flags an existing Worker whose active version has no Void record of its triggers', async () => { + // A run stopped after the Cloudflare CLI replaced the change, before Void + // deployed its own version. The marker shows the Cloudflare CLI. + activeMessages.set(names.proxyWorker, undefined); + // Void wrote this older record, which has no triggers. The upgrade refuses it too. + sources.set(names.tailWorker, null); + activeMessages.set(names.tailWorker, 'void:{"schema":1,"migrations":[]}'); + // The marker already shows the change, so the version is not read. + sources.set(names.apiWorker, 'dash'); + activeMessages.set(names.apiWorker, undefined); + // A failed read does not show a change. When the marker is unknown, it is + // not known whether the Cloudflare CLI asks, so the version is not read. + sources.set(names.dispatchWorker, new PlatformCloudflareApiError('Internal error', 500)); + activeMessages.set(names.dispatchWorker, undefined); + activeMessages.set( + names.emailGatewayWorker, + new PlatformCloudflareApiError('Internal error', 500), + ); + + await expect( + findPlatformWorkerOutsideChanges(client, installPlan(allWorkers), runtime, installed), + ).resolves.toEqual([ + { key: 'api', name: names.apiWorker, source: 'dash' }, + { key: 'proxy', name: names.proxyWorker, source: 'unrecorded' }, + { key: 'tail', name: names.tailWorker, source: 'unrecorded' }, + ]); + const reads = readCloudflare.mock.calls.map(([path]) => path); + expect(reads.filter((path) => path.includes(`/${names.apiWorker}/`))).toEqual([]); + expect(reads.filter((path) => path.includes(`/${names.dispatchWorker}/`))).toEqual([]); + expect(reads.filter((path) => path.includes(`/${names.proxyWorker}/`))).toEqual([ + `/accounts/account/workers/scripts/${names.proxyWorker}/deployments`, + `/accounts/account/workers/scripts/${names.proxyWorker}/versions/22222222-2222-4222-8222-222222222222`, + ]); + }); + + it('does not read Workers that the run creates', async () => { + const plan = installPlan(allWorkers.map((action) => worker(action.key, action.name, 'create'))); + await expect( + findPlatformWorkerOutsideChanges(client, plan, runtime, installed), + ).resolves.toEqual([]); + expect(getWorkerLastDeployedFrom).not.toHaveBeenCalled(); + }); + + it('skips an email gateway that the runtime does not include', async () => { + sources.set(names.emailGatewayWorker, 'dash'); + const { emailGateway: _, ...artifacts } = runtime.manifest.artifacts; + const withoutGateway = { ...runtime, manifest: { ...runtime.manifest, artifacts } }; + await expect( + findPlatformWorkerOutsideChanges(client, installPlan(allWorkers), withoutGateway, installed), + ).resolves.toEqual([]); + expect(getWorkerLastDeployedFrom.mock.calls.map(([, name]) => name)).not.toContain( + names.emailGatewayWorker, + ); + }); + + describe('after a run stopped during a Worker cutover', () => { + const voidVersion = '22222222-2222-4222-8222-222222222222'; + const earlierVersion = '33333333-3333-4333-8333-333333333333'; + const earlierPath = `/accounts/account/workers/scripts/${names.apiWorker}/versions/${earlierVersion}`; + let earlierMessage: string | undefined; + const reads = vi.fn(async (path: string): Promise => + path === earlierPath + ? { annotations: earlierMessage === undefined ? {} : { 'workers/message': earlierMessage } } + : readCloudflare(path), + ); + const find = (installation: PlatformOutsideChangeInstallation) => + findPlatformWorkerOutsideChanges( + { getWorkerLastDeployedFrom, request: async (path: string) => (await reads(path)) as T }, + installPlan(allWorkers), + runtime, + installation, + ); + // The run agreed to replace a change made outside Void on the API Worker. + // It made Void's version active and stopped before the cutover finished. + // The marker shows the Cloudflare CLI, and the active version has the record. + const stopped = (manifest = installationManifest()) => + recordWorkerCutoverStage( + manifest, + 1, + 'api', + earlierVersion, + voidVersion, + names.apiWorker, + triggers, + '2026-10-15T00:00:02.000Z', + ); + const authentication = (sessionsRequired: boolean) => + vi.fn(async () => ({ enabledProviders: [], protectedByAccess: false, sessionsRequired })); + const earlierReads = () => reads.mock.calls.filter(([path]) => path === earlierPath); + + beforeEach(() => { + reads.mockClear(); + earlierMessage = undefined; + }); + + it('flags a Worker when the run restores an earlier version without a Void record', async () => { + // The installer restores the earlier version first, and then the upgrade + // would refuse it. + await expect(find({ manifest: stopped() })).resolves.toEqual([ + { key: 'api', name: names.apiWorker, source: 'unrecorded' }, + ]); + // The active and the earlier version are read at the same time. + expect( + reads.mock.calls + .map(([path]) => path) + .filter((path) => path.includes(names.apiWorker)) + .sort(), + ).toEqual([ + `/accounts/account/workers/scripts/${names.apiWorker}/deployments`, + `/accounts/account/workers/scripts/${names.apiWorker}/versions/${voidVersion}`, + earlierPath, + ]); + // The other Workers have no staged version, so they get no extra read. + expect(reads).toHaveBeenCalledTimes( + (runtime.manifest.artifacts.emailGateway ? 5 : 4) * 2 + 1, + ); + + // Without an open cutover, the run restores nothing. + reads.mockClear(); + for (const resolution of ['committed', 'restored'] as const) { + await expect( + find({ + manifest: recordWorkerCutoverResolution( + stopped(), + 1, + resolution, + '2026-10-15T00:00:03.000Z', + ), + }), + ).resolves.toEqual([]); + } + await expect(find(installed)).resolves.toEqual([]); + expect(earlierReads()).toEqual([]); + }); + + it('does not flag an earlier version that has the record', async () => { + earlierMessage = recordedMessage; + await expect(find({ manifest: stopped() })).resolves.toEqual([]); + expect(earlierReads()).toHaveLength(1); + }); + + it('decides like the installer whether the run restores the earlier version', async () => { + const configurable = (manifest: PlatformInstallationManifest) => ({ + ...manifest, + deployment: { + ...manifest.deployment, + authentication: { ...manifest.deployment.authentication, configurable: true as const }, + }, + }); + // While the platform changes to configurable sign-in and already requires + // sign-in, the installer keeps Void's version. + const required = authentication(true); + await expect( + find({ manifest: stopped(), readAuthenticationState: required }), + ).resolves.toEqual([]); + expect(required).toHaveBeenCalledOnce(); + expect(earlierReads()).toEqual([]); + + const flagged = [{ key: 'api', name: names.apiWorker, source: 'unrecorded' }]; + const notRequired = authentication(false); + await expect( + find({ manifest: stopped(), readAuthenticationState: notRequired }), + ).resolves.toEqual(flagged); + // An installation with configurable sign-in always restores, so the + // database is not read. + const unread = authentication(true); + await expect( + find({ + manifest: stopped(configurable(installationManifest())), + readAuthenticationState: unread, + }), + ).resolves.toEqual(flagged); + expect(unread).not.toHaveBeenCalled(); + // Without the database read, expect the restore. + await expect(find({ manifest: stopped() })).resolves.toEqual(flagged); + }); + }); + + describe('review before a run', () => { + const review = (interactive: boolean) => + reviewPlatformWorkerOutsideChanges({ + client, + plan: installPlan(allWorkers), + runtime, + installation: installed, + interactive, + command: 'void platform upgrade example-a1b2c3d4', + }); + + it('changes nothing when no Worker was changed outside Void', async () => { + await expect(review(true)).resolves.toEqual([]); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('stops without a terminal and names each Worker and the source of its change', async () => { + sources.set(names.apiWorker, 'dash'); + activeMessages.set(names.proxyWorker, undefined); + sources.set(names.tailWorker, 'api'); + await expect(review(false)).rejects.toThrow( + [ + // An older Void release can have deployed the active version, so + // the heading does not say that each Worker was changed outside Void. + 'platform: These platform Workers need your approval before this command replaces their configuration:', + `${names.apiWorker}: changed in the Cloudflare dashboard`, + `${names.proxyWorker}: has an active version without a Void deployment record`, + `${names.tailWorker}: changed in the Cloudflare API`, + // An earlier run can have paused traffic. This one did not. + 'Nothing changed. This command did not pause application traffic.', + 'This command replaces their configuration with the Void configuration. It can ask for your approval only in a terminal.', + 'Run this command once in a terminal to review them:', + 'void platform upgrade example-a1b2c3d4', + ].join('\n'), + ); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('stops when the operator does not agree', async () => { + sources.set(names.apiWorker, 'api'); + vi.mocked(confirm).mockResolvedValueOnce(false); + await expect(review(true)).rejects.toThrow( + 'platform: Cancelled. Nothing changed. This command did not pause application traffic.', + ); + expect(confirm).toHaveBeenCalledWith({ + message: 'Replace the configuration of these Workers?', + initialValue: false, + }); + }); + + it('returns the Workers that the operator agreed to replace and uploads nothing yet', async () => { + sources.set(names.apiWorker, 'api'); + vi.mocked(confirm).mockResolvedValueOnce(true); + + await expect(review(true)).resolves.toEqual([ + { key: 'api', name: names.apiWorker, source: 'api' }, + ]); + + const shown = String(vi.mocked(note).mock.calls[0]?.[0]); + expect(vi.mocked(note).mock.calls[0]?.[1]).toBe('Approval needed'); + expect(shown).toContain(`${names.apiWorker}: changed in the Cloudflare API`); + expect(shown).toContain( + 'First, it prepares the platform resources and the database.\nThen, before it pauses traffic or deploys a Worker, the Cloudflare CLI asks you again for each Worker changed in the Cloudflare dashboard or API.', + ); + expect(shown).toContain( + 'It uploads a new version of each of these Workers and does not deploy it.', + ); + // The installer uploads later. Here the source is read once, to find the change. + expect( + getWorkerLastDeployedFrom.mock.calls.filter(([, name]) => name === names.apiWorker), + ).toHaveLength(1); + }); + + it('does not say that the Cloudflare CLI asks when no marker shows a change', async () => { + activeMessages.set(names.apiWorker, undefined); + vi.mocked(confirm).mockResolvedValueOnce(true); + + await expect(review(true)).resolves.toEqual([ + { key: 'api', name: names.apiWorker, source: 'unrecorded' }, + ]); + + const shown = String(vi.mocked(note).mock.calls[0]?.[0]); + expect(shown).toBe( + [ + `${names.apiWorker}: has an active version without a Void deployment record`, + '', + 'This command replaces the configuration of these Workers with the Void configuration.', + 'It removes values that Void does not set, for example an EMAIL_FROM that you added.', + 'It resets values that Void sets, for example AUTH_REDIRECT_URI.', + ].join('\n'), + ); + }); + }); + + describe('upload after the operator agrees', () => { + const installation = installationManifest(); + const activeVersion = '11111111-1111-4111-8111-111111111111'; + const ownershipMarker = JSON.stringify( + createPlatformResourceOwnershipMarker({ + accountId: 'account', + installationId: installation.id, + ownershipNonce: installation.ownershipNonce, + resourceId: names.apiWorker, + resourceKey: 'api-worker', + resourceKind: 'worker', + resourceName: names.apiWorker, + }), + ); + // The installer gives this config. A run can create a resource that it uses. + const config = { + account_id: 'account', + compatibility_date: '2026-09-30', + kv_namespaces: [{ binding: 'ISR_CACHE', id: 'isr-cache-created-in-run' }], + main: `${runtime.directory}/${runtime.manifest.artifacts.api.path}`, + name: names.apiWorker, + routes: [{ pattern: 'api.example.com', custom_domain: true }], + vars: { PLATFORM_OWNERSHIP_MARKER: ownershipMarker }, + workers_dev: true, + }; + // The active version of a Worker changed with the Workers API has no Void message. + const request = vi.fn(async (path: string): Promise => { + if (path.endsWith('/deployments')) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: activeVersion, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/versions/${activeVersion}`)) { + return { + annotations: { 'workers/triggered_by': 'upload' }, + resources: { + bindings: [ + { name: 'PLATFORM_OWNERSHIP_MARKER', type: 'plain_text', text: ownershipMarker }, + ], + }, + }; + } + throw new Error(`unexpected read ${path}`); + }); + const events: Array = []; + const uploads: Array<{ + operation: Parameters[0]; + config: Record; + }> = []; + const runner = vi.fn(async (operation) => { + events.push('upload'); + uploads.push({ + operation, + config: JSON.parse(readFileSync(String(operation.configPath), 'utf8')), + }); + return { status: 0, stdout: '', stderr: '' }; + }); + const progress = { + pause: vi.fn(() => { + events.push('pause'); + }), + resume: vi.fn(() => { + events.push('resume'); + }), + }; + const upload = ( + changes: ReadonlyArray = [ + { key: 'api', name: names.apiWorker, source: 'api' }, + ], + ) => + uploadPlatformWorkerOutsideChanges([config], { + changes, + client: { + apiBaseUrl: 'https://cloudflare.example.test', + getWorkerLastDeployedFrom: async (account, name) => { + events.push('read'); + return getWorkerLastDeployedFrom(account, name); + }, + request: async (path: string) => (await request(path)) as T, + }, + operatorApiToken: 'operator-token', + progress, + runner, + }); + + beforeEach(() => { + events.length = 0; + uploads.length = 0; + }); + + it('stops the progress display while the Cloudflare CLI asks, and uploads without a deployment', async () => { + sources.set(names.apiWorker, 'api'); + runner.mockImplementationOnce(async (operation) => { + events.push('upload'); + uploads.push({ + operation, + config: JSON.parse(readFileSync(String(operation.configPath), 'utf8')), + }); + sources.set(names.apiWorker, 'cf_cli'); + return { status: 0, stdout: '', stderr: '' }; + }); + + await upload(); + + expect(events).toEqual(['pause', 'upload', 'read', 'resume']); + expect(log.step).toHaveBeenCalledWith( + `Uploading a new version of ${names.apiWorker} without deploying it`, + ); + expect(uploads).toHaveLength(1); + expect(uploads[0]!.operation).toEqual({ + type: 'versions.upload', + configPath: expect.any(String), + message: 'Void: replace changes made outside Void', + interactive: true, + }); + expect(runner.mock.calls[0]!.slice(1)).toEqual(['account', expect.any(String), true]); + const { routes: _, ...uploaded } = config; + expect(uploads[0]!.config).toEqual(uploaded); + }); + + it('stops when the Cloudflare CLI does not replace the change', async () => { + sources.set(names.apiWorker, 'dash'); + + await expect(upload()).rejects.toThrow( + `platform: The Cloudflare CLI did not replace the changes to ${names.apiWorker}. Its output above shows why. No deployment changed. This command did not pause application traffic.`, + ); + expect(runner).toHaveBeenCalledOnce(); + expect(events).toEqual(['pause', 'upload', 'read', 'resume']); + }); + + it('uploads nothing for a version that the Cloudflare CLI does not ask about', async () => { + // The deploy replaces this version. The marker shows the Cloudflare CLI, + // so the Cloudflare CLI would not ask. + await upload([{ key: 'api', name: names.apiWorker, source: 'unrecorded' }]); + + expect(events).toEqual([]); + expect(runner).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + expect(log.step).not.toHaveBeenCalled(); + }); + + it('stops when the Cloudflare CLI exits with an error', async () => { + runner.mockResolvedValueOnce({ status: 1, stdout: '', stderr: '' }); + + await expect(upload()).rejects.toThrow( + `platform: The Cloudflare CLI did not replace the changes to ${names.apiWorker}.`, + ); + expect(progress.resume).toHaveBeenCalledOnce(); + }); + }); +}); + +describe('installer upload after the operator agrees', () => { + const version = '11111111-1111-4111-8111-111111111111'; + + async function deploy( + replaceOutsideChanges: boolean, + readCloudflare?: (path: string) => Promise, + ) { + const directory = mkdtempSync(join(tmpdir(), 'void-outside-change-')); + const commands: Array = []; + try { + const main = join(directory, 'worker.js'); + writeFileSync(main, 'export default { fetch() { return new Response("void"); } };'); + const config = { + account_id: 'account', + compatibility_date: '2026-09-30', + main, + name: 'void-test-api', + vars: { PLATFORM_OWNERSHIP_MARKER: 'owned-by-test' }, + workers_dev: true, + }; + const runner = async (operation: CloudflareOperation) => { + commands.push(operation.type); + switch (operation.type) { + case 'deployments.status': + return { + status: 0, + stdout: '', + stderr: '', + data: { versions: [{ percentage: 100, version_id: version }] }, + }; + case 'deployments.list': + return { + status: 0, + stdout: '', + stderr: '', + data: [ + { + created_on: '2026-10-15T00:00:00.000Z', + id: 'deployment', + versions: [{ percentage: 100, version_id: version }], + }, + ], + }; + case 'versions.view': + // A Workers API edit leaves no Void message on the active version. + return { + status: 0, + stdout: '', + stderr: '', + data: { + annotations: { 'workers/triggered_by': 'upload' }, + resources: { + bindings: [ + { + name: 'PLATFORM_OWNERSHIP_MARKER', + type: 'plain_text', + text: 'owned-by-test', + }, + ], + }, + }, + }; + case 'versions.upload': + return { status: 1, stdout: '', stderr: 'upload reached' }; + default: + throw new Error(`Unexpected command: ${operation.type}`); + } + }; + const error = await deployPlatformWorker( + config, + { SECRET: 'value' }, + { + apiBaseUrl: 'https://api.cloudflare.com/client/v4', + inspectNewWorker: async () => 'owned', + managedRoutes: [], + onStaged: async () => {}, + onTrafficPublished: async () => {}, + onUnsafeTraffic: async () => {}, + preservedRoutes: [], + previousConfig: config, + previousPublicEndpoints: true, + publishCustomDomains: async () => {}, + publishRoutes: async () => {}, + ...(readCloudflare ? { readCloudflare } : {}), + retainNewWorker: async () => {}, + ...(replaceOutsideChanges ? { replaceOutsideChanges } : {}), + runner, + workerExisted: true, + }, + ).then( + () => null, + (failure: unknown) => failure as Error, + ); + return { commands, error }; + } finally { + rmSync(directory, { force: true, recursive: true }); + } + } + + it('refuses an active version without a Void record of its triggers', async () => { + const { commands, error } = await deploy(false); + expect(error?.message).toContain('predates safe trigger snapshots'); + expect(commands).not.toContain('versions.upload'); + }); + + it("uses Void's own triggers when the operator agreed to replace the outside changes", async () => { + const { commands, error } = await deploy(true); + expect(error?.message).toContain('upload reached'); + expect(commands).toContain('versions.upload'); + }); + + it('flags a Worker before a run exactly when the upgrade refuses its active version', async () => { + const results = []; + for (const message of [undefined, 'Edited', recordedMessage]) { + // The check and the upgrade read the same Cloudflare API answers. + const reads = activeVersionReads('void-test-api', message); + const changes = await findPlatformWorkerOutsideChanges( + { + getWorkerLastDeployedFrom: async () => 'cf_cli', + request: async (path: string) => (await reads(path)) as T, + }, + installPlan([worker('api-worker', 'void-test-api')]), + runtime, + installed, + ); + const { error } = await deploy(false, reads); + results.push({ + flagged: changes.map((change) => change.source), + refused: error?.message.includes('predates safe trigger snapshots'), + }); + } + + expect(results).toEqual([ + { flagged: ['unrecorded'], refused: true }, + { flagged: ['unrecorded'], refused: true }, + { flagged: [], refused: false }, + ]); + }); +}); diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index d03b37a0a..4bc563a20 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -2553,6 +2553,71 @@ describe('platform installer', () => { } }); + describe('shared email permission hints', () => { + const blocked = (fields: Record) => + (async () => + Response.json( + { + version: 1, + state: 'incomplete', + operationId: 'eop-blocked', + operationStatus: 'blocked', + failureCode: 'blocked', + domain: 'mail.example.com', + inboundStatus: 'pending', + failureDetail: 'Cloudflare API error: Authentication error (HTTP 403)', + ...fields, + }, + { status: 503 }, + )) as typeof fetch; + const failure = (fields: Record) => + bootstrapPlatformSharedEmail('https://api.example.com', 'probe-token', blocked(fields)).then( + () => { + throw new Error('Expected readiness failure'); + }, + String, + ); + + it.each([ + ['routing_enable', 'Zone → Zone Settings → Edit'], + ['subaddressing_ensure', 'Zone → Zone Settings → Edit'], + ['inbound_probe', 'Zone → Zone Settings → Edit'], + ['sending_onboard', 'Account → Email Sending → Edit'], + ])('names the runtime-token permission for a blocked %s step', async (step, permission) => { + const message = await failure({ currentStep: step }); + + expect(message).toContain( + `Step: ${step}. Failure: blocked. Domain: mail.example.com (inbound: pending). This step needs ${permission} on the runtime API token.`, + ); + expect(message).toContain('The token value stays the same.'); + expect(message).toContain('VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN'); + expect(message).toMatch(/Then resume this installation\. Traffic remains disabled\.$/); + expect(message).not.toContain('Authentication error'); + }); + + it('names a permission only for a refused step that uses one', async () => { + expect(await failure({ currentStep: 'routing_enable' })).toContain('runtime API token'); + + for (const fields of [ + // An uncertain outcome is not a refusal. + { currentStep: 'routing_enable', failureCode: 'outcome_unknown' }, + // The step does not call Cloudflare. + { currentStep: 'publish' }, + // An inherited object key is not a step. + { currentStep: 'constructor' }, + // A known routing prerequisite already explains the failure. + { + currentStep: 'inbound_probe', + failureDetail: 'zone catch-all does not point to this ingress', + }, + ]) { + const message = await failure(fields); + expect(message).toContain('Traffic remains disabled'); + expect(message).not.toContain('runtime API token'); + } + }); + }); + it.each([ 'https://api.example.com/health', 'https://api.example.com/.well-known/void-platform', @@ -5113,6 +5178,144 @@ describe('platform installer', () => { expect(upgraded.manifest.resources).toHaveLength(16); }); + describe('Workers changed outside Void', () => { + async function upgradeWithOutsideChange( + uploadOutsideChanges: NonNullable, + ) { + const { deployment, directory, secrets, sourceManifest, upgradeClient, upgradedPlan } = + await installedPlatform(); + // An older installation has no ISR cache yet, so this run creates it. + const source = { + ...sourceManifest, + resources: sourceManifest.resources.filter((resource) => resource.key !== 'isr-cache-kv'), + }; + const plan = { + ...upgradedPlan, + actions: upgradedPlan.actions.map((action) => + action.key === 'isr-cache-kv' + ? { ...action, action: 'create' as const, id: null, reason: 'new in this version' } + : action, + ), + }; + const events: Array = []; + const created: Array = []; + const error = await installPlatform(plan, secrets, source, { + acquireDatabaseLifecycleLock, + claimDatabase, + client: { + ...upgradeClient, + createKvNamespace: async (account, name) => { + const namespace = await upgradeClient.createKvNamespace(account, name); + created.push(namespace.id); + events.push(`create:${name}`); + return namespace; + }, + putKvValue: async (_account, _namespace, key) => { + events.push(`put:${key}`); + }, + }, + deployWorker: async (config) => { + events.push(`deploy:${String(config.name)}`); + }, + onProgress: (message) => { + events.push(`progress:${message}`); + }, + prepareDatabase, + replaceOutsideChanges: [deployment.resources.apiWorker], + revalidateBeforeWorkerDeploy: async () => {}, + runtime: resolvePlatformRuntime(getPlatformRuntimeDirectory()), + uploadOutsideChanges: async (configs) => { + events.push('outside-change-upload'); + await uploadOutsideChanges(configs); + }, + verifyHealth: async () => {}, + }).then( + () => null, + (failure: unknown) => failure as Error, + ); + return { created, deployment, directory, error, events, source }; + } + + it('uploads them after the run creates resources and before traffic pauses', async () => { + const uploaded: Array> = []; + const { created, deployment, error, events, source } = await upgradeWithOutsideChange( + async (configs) => { + uploaded.push(...configs); + }, + ); + + expect(error).toBeNull(); + const at = (index: number) => { + expect(index).toBeGreaterThan(-1); + return index; + }; + const upload = at(events.indexOf('outside-change-upload')); + expect(at(events.indexOf(`create:${deployment.resources.isrCacheKv}`))).toBeLessThan(upload); + expect(at(events.indexOf('progress:Checking platform safety'))).toBeLessThan(upload); + expect(upload).toBeLessThan(at(events.indexOf(`put:${PLATFORM_DISABLED_KEY}`))); + expect(upload).toBeLessThan(at(events.findIndex((event) => event.startsWith('deploy:')))); + expect(events.filter((event) => event === 'outside-change-upload')).toHaveLength(1); + // The upload uses the config that the run deploys, with the ISR cache it created. + expect(uploaded.map((config) => config.name)).toEqual([deployment.resources.apiWorker]); + expect(created).toHaveLength(1); + expect(source.resources.map((resource) => resource.id)).not.toContain(created[0]); + expect(uploaded[0]!.kv_namespaces).toContainEqual({ binding: 'ISR_CACHE', id: created[0] }); + }); + + it('uploads them also when the run does not pause traffic', async () => { + const { secrets, deployment, upgradedPlan, upgradeClient, upgraded } = + await upgradedPlatform(); + const events: Array = []; + const uploaded: Array> = []; + await installPlatform(upgradedPlan, secrets, upgraded.manifest, { + acquireDatabaseLifecycleLock, + claimDatabase, + client: { + ...upgradeClient, + putKvValue: async (_account, _namespace, key) => { + events.push(`put:${key}`); + }, + }, + deployWorker: async (config) => { + events.push(`deploy:${String(config.name)}`); + }, + prepareDatabase, + replaceOutsideChanges: [deployment.resources.apiWorker], + revalidateBeforeWorkerDeploy: async () => {}, + runtime: resolvePlatformRuntime(getPlatformRuntimeDirectory()), + uploadOutsideChanges: async (configs) => { + events.push('outside-change-upload'); + uploaded.push(...configs); + }, + verifyHealth: async () => {}, + }); + + expect(events).not.toContain(`put:${PLATFORM_DISABLED_KEY}`); + expect(events.indexOf('outside-change-upload')).toBeGreaterThan(-1); + expect(events.indexOf('outside-change-upload')).toBeLessThan( + events.findIndex((event) => event.startsWith('deploy:')), + ); + expect(uploaded.map((config) => config.name)).toEqual([deployment.resources.apiWorker]); + }); + + it('leaves traffic and Workers alone when that upload fails', async () => { + const refusal = + 'platform: The Cloudflare CLI did not replace the changes to the Worker. Its output above shows why. No deployment changed, and application traffic was not paused.'; + const { directory, error, events } = await upgradeWithOutsideChange(async () => { + throw new Error(refusal); + }); + + expect(error?.message).toBe(refusal); + expect(events).toContain('outside-change-upload'); + expect(events).not.toContain(`put:${PLATFORM_DISABLED_KEY}`); + expect(events.filter((event) => event.startsWith('deploy:'))).toEqual([]); + expect(listInstallationManifests(directory)[0]).toMatchObject({ + status: 'failed', + operation: { phase: 'failed' }, + }); + }); + }); + it('resumes an interrupted Worker secret rotation and commits the next one', async () => { const { directory, diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 160cac816..3a9916e63 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -1,12 +1,15 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; +import { encodeCloudflareVersionMessage } from '@void/deploy-cloudflare'; import { createPlatformDeploymentManifest, parsePlatformAuthenticationSetup, createPlatformInstallationManifest, + createPlatformResourceOwnershipMarker, + PLATFORM_DISABLED_KEY, recordInstallationResource, recordInstallationStep, transitionInstallation, @@ -17,7 +20,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { printSuccess } from '../../src/cli/output.ts'; const state = vi.hoisted(() => ({ + cfRunner: vi.fn(), createSecrets: vi.fn(), + getWorkerLastDeployedFrom: vi.fn(), clientTokens: [] as Array, listAccounts: vi.fn(), listWorkerNames: vi.fn(), @@ -32,7 +37,15 @@ const state = vi.hoisted(() => ({ readProjectZeroTrust: vi.fn(), releaseDatabase: vi.fn(), spinner: vi.fn(), - progress: { start: vi.fn(), message: vi.fn(), stop: vi.fn(), error: vi.fn(), clear: vi.fn() }, + progress: { + start: vi.fn(), + message: vi.fn(), + stop: vi.fn(), + error: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + }, runtime: undefined as ReturnType | undefined, })); @@ -45,7 +58,7 @@ vi.mock('../../src/cli/output.ts', async (importOriginal) => ({ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ confirm: vi.fn(), isCancel: () => false, - log: { info: vi.fn(), success: vi.fn() }, + log: { info: vi.fn(), step: vi.fn(), success: vi.fn(), warn: vi.fn() }, note: vi.fn(), outro: vi.fn(), select: vi.fn(), @@ -67,6 +80,7 @@ vi.mock('../../src/cli/platform-cloudflare.ts', async (importOriginal) => { listAccounts = state.listAccounts; listWorkerNames = state.listWorkerNames; request = state.request; + getWorkerLastDeployedFrom = state.getWorkerLastDeployedFrom; async getKvValue() { return JSON.stringify(state.manifest); } @@ -165,6 +179,11 @@ vi.mock('../../src/cli/platform-installer.ts', async (importOriginal) => { }; }); +vi.mock('../../src/cli/platform-installer-process.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runPlatformWranglerAtApiBase: state.cfRunner, +})); + vi.mock('../../src/cli/platform-lifecycle.ts', () => ({ assertNoninteractivePlatformMutationAcknowledged: vi.fn(), resolvePlatformInstallDatabaseUrl: () => 'postgres://database', @@ -215,6 +234,7 @@ vi.mock('../../src/cli/platform-registry.ts', async (importOriginal) => { import { confirm, log, note } from '@voidzero-dev/vite-plus-prompts'; import { PlatformCloudflareApiError } from '../../src/cli/platform-cloudflare.ts'; import { authenticationSetupRequestHash } from '../../src/cli/platform-install-auth.ts'; +import { recordWorkerCutoverStage } from '../../src/cli/platform-installer-cutover.ts'; import { acquirePlatformDatabaseLifecycleLock, installPlatform, @@ -269,6 +289,8 @@ describe('installed platform upgrade', () => { state.listAccounts.mockReset().mockResolvedValue([{ id: 'account', name: 'Account' }]); state.listWorkerNames.mockReset().mockResolvedValue([]); state.request.mockReset().mockResolvedValue({ name: 'assets' }); + state.getWorkerLastDeployedFrom.mockReset().mockResolvedValue('cf_cli'); + state.cfRunner.mockReset(); vi.mocked(readInstallationRecoverySecrets).mockReturnValue({ databaseUrl: 'postgres://database', }); @@ -891,6 +913,164 @@ describe('installed platform upgrade', () => { }); }); + describe('runtime token email access', () => { + const emailPaths = [ + '/zones/mail-zone', + '/zones/mail-zone/email/routing', + '/zones/mail-zone/email/routing/rules?per_page=5', + '/accounts/account/email/routing/addresses?per_page=5', + ]; + const emailRequests = () => + state.events.filter( + (event) => event.startsWith('request:') && /mail-zone|\/email\//.test(event), + ); + const enableEmail = (desiredState: 'disabled' | 'enabled' = 'enabled') => { + state.manifest = { + ...state.manifest!, + deployment: createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + capabilities: { email: true }, + displayName: 'Example', + emailSenderDomain: 'mail.example.com', + emailSharedZoneId: 'mail-zone', + installationSlug: 'example', + }), + ...(desiredState === 'disabled' ? { desiredState, status: 'disabled' as const } : {}), + }; + }; + + beforeEach(() => { + state.createSecrets.mockReturnValue({ cloudflareApiToken: 'saved-runtime-token' }); + state.request.mockImplementation(async (path: string) => { + state.events.push(`request:${path}`); + return { name: 'assets' }; + }); + }); + + it('checks the saved runtime token before the installer can pause traffic', async () => { + await upgrade(); + expect(emailRequests()).toEqual([]); + expect(state.clientTokens).not.toContain('saved-runtime-token'); + + state.events.length = 0; + state.progress.stop.mockClear(); + enableEmail(); + await upgrade(); + + expect(state.clientTokens).toContain('saved-runtime-token'); + expect(emailRequests()).toEqual(emailPaths.map((path) => `request:${path}`)); + const installerStart = state.events.indexOf('installer-start'); + expect(installerStart).toBeGreaterThan(-1); + for (const request of emailRequests()) { + expect(state.events.indexOf(request)).toBeLessThan(installerStart); + } + }); + + it('checks an explicit replacement token with the core runtime checks', async () => { + enableEmail(); + state.createSecrets.mockReturnValue({ + cloudflareApiToken: 'replacement-token', + workerSecretUpdates: { CF_API_TOKEN: 'replacement-token' }, + }); + + await upgrade(); + + expect(state.clientTokens).toContain('replacement-token'); + expect(state.clientTokens).not.toContain('saved-runtime-token'); + expect(emailRequests()).toEqual(emailPaths.map((path) => `request:${path}`)); + }); + + it('stops before the installer and names the missing email permissions', async () => { + enableEmail(); + state.request.mockImplementation(async (path: string) => { + state.events.push(`request:${path}`); + if (path.includes('/email/routing')) { + throw new PlatformCloudflareApiError('Authentication error', 403, path); + } + return { name: 'assets' }; + }); + + // The Workers can use a newer token than the one saved on this machine. + await expect(upgrade()).rejects.toThrow( + 'platform: The runtime API token saved on this machine cannot use platform email. Cloudflare denied: Zone → Zone Settings → Edit, Zone → Email Routing Rules → Edit, Account → Email Routing Addresses → Edit (HTTP 403). Void checked this token because VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN is not set. If the platform uses a different runtime token, set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to that token, and Void checks that token instead.', + ); + expect(installPlatform).not.toHaveBeenCalled(); + + // A token from VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN goes to the + // Workers, so the message does not talk about the saved token. + state.createSecrets.mockReturnValue({ + cloudflareApiToken: 'replacement-token', + workerSecretUpdates: { CF_API_TOKEN: 'replacement-token' }, + }); + const explicit = await upgrade().then( + () => null, + (failure: unknown) => failure as Error, + ); + expect(explicit?.message).toContain( + 'platform: The runtime API token cannot use platform email. Cloudflare denied: Zone → Zone Settings → Edit, Zone → Email Routing Rules → Edit, Account → Email Routing Addresses → Edit (HTTP 403). Check that the token is valid', + ); + expect(explicit?.message).not.toContain('saved on this machine'); + expect(installPlatform).not.toHaveBeenCalled(); + expect(state.events.filter((event) => event.startsWith('put:'))).toEqual([]); + }); + + it('continues with one warning only when the token saved on this machine is not valid', async () => { + enableEmail(); + // Cloudflare's answers to a token that is not valid. + state.request.mockImplementation(async (path: string) => { + state.events.push(`request:${path}`); + if (path === '/zones/mail-zone') { + throw new PlatformCloudflareApiError('Invalid access token', 403, path); + } + if (path.includes('/email/routing')) { + throw new PlatformCloudflareApiError('Authentication error', 401, path); + } + return { name: 'assets' }; + }); + + await upgrade(); + + expect(vi.mocked(log.warn).mock.calls).toEqual([ + [ + 'The runtime API token saved on this machine is not valid (HTTP 401). Void cannot check the platform email permissions before it changes the platform, and it continues without this check. To do the check, set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to the runtime token of the platform.', + ], + ]); + expect(emailRequests()).toEqual(emailPaths.map((path) => `request:${path}`)); + expect(installPlatform).toHaveBeenCalledOnce(); + + vi.mocked(log.warn).mockClear(); + state.createSecrets.mockReturnValue({ + cloudflareApiToken: 'replacement-token', + workerSecretUpdates: { CF_API_TOKEN: 'replacement-token' }, + }); + + await expect(upgrade()).rejects.toThrow( + 'Cloudflare denied: Zone → Zone → Read, Zone → Zone Settings → Edit, Zone → Email Routing Rules → Edit, Account → Email Routing Addresses → Edit (HTTP 403)', + ); + expect(log.warn).not.toHaveBeenCalled(); + expect(installPlatform).toHaveBeenCalledOnce(); + }); + + it('checks a disabled installation only when the command enables it', async () => { + enableEmail('disabled'); + await upgrade(); + expect(emailRequests()).toEqual([]); + expect(installPlatform).toHaveBeenCalledOnce(); + + state.progress.stop.mockClear(); + await runInstalledPlatformCommand({ + subcommand: 'enable', + selector: state.manifest!.id, + plan: false, + runtime: '', + yes: true, + }); + + expect(emailRequests()).toEqual(emailPaths.map((path) => `request:${path}`)); + expect(installPlatform).toHaveBeenCalledTimes(2); + }); + }); + describe('uninstall with project Zero Trust', () => { const enabled = { active: true, @@ -947,4 +1127,335 @@ describe('installed platform upgrade', () => { expect(state.events).toEqual([]); }); }); + + describe('platform Workers changed outside Void', () => { + const apiWorker = () => state.manifest!.deployment.resources.apiWorker; + const withApiWorker = () => { + const original = vi.mocked(createPlatformInstallPlan).getMockImplementation()!; + vi.mocked(createPlatformInstallPlan).mockImplementationOnce(async (...args) => { + const plan = await original(...args); + return { + ...plan, + actions: [ + ...plan.actions, + { + action: 'update', + id: null, + key: 'api-worker', + kind: 'worker', + name: apiWorker(), + reason: 'installed', + }, + ], + }; + }); + }; + const gateWrites = () => state.events.filter((event) => event.startsWith('put:')); + + it('stops before any change when there is no terminal', async () => { + withApiWorker(); + state.getWorkerLastDeployedFrom.mockResolvedValue('dash'); + + const error = await upgrade().then( + () => null, + (failure: unknown) => failure as Error, + ); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain( + `These platform Workers need your approval before this command replaces their configuration:\n${apiWorker()}: changed in the Cloudflare dashboard\nNothing changed. This command did not pause application traffic.`, + ); + expect(error?.message).toContain( + `Run this command once in a terminal to review them:\nvoid platform upgrade ${state.manifest!.id}`, + ); + expect(error?.message).not.toContain('--yes'); + expect(state.getWorkerLastDeployedFrom).toHaveBeenCalledExactlyOnceWith( + 'account', + apiWorker(), + ); + expect(installPlatform).not.toHaveBeenCalled(); + expect(state.progress.start).not.toHaveBeenCalled(); + expect(gateWrites()).toEqual([]); + expect(state.cfRunner).not.toHaveBeenCalled(); + }); + + it('runs unchanged when Void made the last change', async () => { + withApiWorker(); + + await upgrade(); + + expect(state.getWorkerLastDeployedFrom).toHaveBeenCalledExactlyOnceWith( + 'account', + apiWorker(), + ); + expect(installPlatform).toHaveBeenCalledOnce(); + expect(vi.mocked(installPlatform).mock.calls[0]![3]).not.toHaveProperty( + 'replaceOutsideChanges', + ); + expect(state.cfRunner).not.toHaveBeenCalled(); + }); + + it('warns in a plan and changes nothing', async () => { + withApiWorker(); + state.getWorkerLastDeployedFrom.mockResolvedValue('api'); + + await upgrade({ plan: true }); + + expect(log.warn).toHaveBeenCalledWith( + `These platform Workers need your approval before this command replaces their configuration:\n${apiWorker()}: changed in the Cloudflare API\nThis command replaces their configuration with the Void configuration. Run it in a terminal to review them first.`, + ); + expect(installPlatform).not.toHaveBeenCalled(); + expect(state.events).toEqual([]); + }); + + describe('after a run stopped during a Worker cutover', () => { + const voidVersion = '22222222-2222-4222-8222-222222222222'; + const earlierVersion = '33333333-3333-4333-8333-333333333333'; + const triggers = { + workers_dev: true, + preview_urls: false, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }; + // The run agreed to replace a change made outside Void on the API + // Worker. It made Void's version active and stopped before the cutover + // finished. The marker shows the Cloudflare CLI. + const stoppedRun = (sessionsRequired: boolean) => { + withApiWorker(); + state.manifest = recordWorkerCutoverStage( + state.manifest!, + 1, + 'api', + earlierVersion, + voidVersion, + apiWorker(), + triggers, + '2026-09-14T00:00:03.000Z', + ); + state.request.mockImplementation(async (path: string) => { + if (path.endsWith(`/${apiWorker()}/deployments`)) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-09-14T00:00:03.000Z', + versions: [{ version_id: voidVersion, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/versions/${voidVersion}`)) { + return { + annotations: { 'workers/message': encodeCloudflareVersionMessage([], triggers) }, + }; + } + if (path.endsWith(`/versions/${earlierVersion}`)) { + return { annotations: {} }; + } + return { name: 'assets' }; + }); + // This installation has not changed to configurable sign-in yet. + state.readAuthenticationState.mockResolvedValue({ + enabledProviders: [], + protectedByAccess: false, + sessionsRequired, + }); + }; + + it('stops before any change when the run restores an earlier version without a Void record', async () => { + stoppedRun(false); + + const error = await upgrade().then( + () => null, + (failure: unknown) => failure as Error, + ); + + expect(error?.message).toContain( + `${apiWorker()}: has an active version without a Void deployment record\nNothing changed.`, + ); + expect(state.request).toHaveBeenCalledWith( + `/accounts/account/workers/scripts/${apiWorker()}/versions/${earlierVersion}`, + ); + expect(installPlatform).not.toHaveBeenCalled(); + expect(gateWrites()).toEqual([]); + }); + + it('runs when the installer keeps the version of Void, as the database shows', async () => { + stoppedRun(true); + + await upgrade(); + + expect(state.request).not.toHaveBeenCalledWith( + `/accounts/account/workers/scripts/${apiWorker()}/versions/${earlierVersion}`, + ); + expect(installPlatform).toHaveBeenCalledOnce(); + expect(vi.mocked(installPlatform).mock.calls[0]![3]).not.toHaveProperty( + 'replaceOutsideChanges', + ); + }); + }); + + describe('in a terminal, after the operator agrees', () => { + const createdCacheId = 'isr-cache-created-in-run'; + const runInTerminal = async () => { + withApiWorker(); + const version = '11111111-1111-4111-8111-111111111111'; + const marker = JSON.stringify( + createPlatformResourceOwnershipMarker({ + accountId: 'account', + installationId: state.manifest!.id, + ownershipNonce: state.manifest!.ownershipNonce, + resourceId: apiWorker(), + resourceKey: 'api-worker', + resourceKind: 'worker', + resourceName: apiWorker(), + }), + ); + state.request.mockImplementation(async (path: string) => { + if (path.endsWith('/deployments')) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: version, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/versions/${version}`)) { + return { + resources: { + bindings: [{ name: 'PLATFORM_OWNERSHIP_MARKER', type: 'plain_text', text: marker }], + }, + }; + } + return { name: 'assets' }; + }); + // Like the installer: it creates a resource, uploads the Workers changed + // outside Void with the configs that the run deploys, then pauses traffic. + const installer = vi.mocked(installPlatform).getMockImplementation()!; + vi.mocked(installPlatform).mockImplementation( + async (plan, _secrets, existing, dependencies) => { + state.events.push('installer-start', 'create:isr-cache-kv'); + await dependencies.uploadOutsideChanges!([ + { + account_id: 'account', + compatibility_date: '2026-09-30', + kv_namespaces: [{ binding: 'ISR_CACHE', id: createdCacheId }], + main: join(state.runtime!.directory, state.runtime!.manifest.artifacts.api.path), + name: apiWorker(), + vars: { PLATFORM_OWNERSHIP_MARKER: marker }, + }, + ]); + await dependencies.client.putKvValue( + plan.account.id, + 'routing-kv-id', + PLATFORM_DISABLED_KEY, + 'true', + ); + return { + manifest: existing!, + waitingForDns: false, + waitingForSandboxDrain: false, + zoneNameServers: [], + }; + }, + ); + state.progress.pause.mockImplementation(() => state.events.push('progress-pause')); + state.progress.resume.mockImplementation(() => state.events.push('progress-resume')); + vi.mocked(confirm).mockResolvedValueOnce(true); + const tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + try { + return await upgrade({ yes: false }).then( + () => null, + (failure: unknown) => failure as Error, + ); + } finally { + vi.mocked(installPlatform).mockImplementation(installer); + Object.defineProperty(process.stdin, 'isTTY', { value: tty.stdin, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: tty.stdout, configurable: true }); + } + }; + + it('uploads with the run config when the installer asks, before traffic pauses', async () => { + state.getWorkerLastDeployedFrom.mockResolvedValueOnce('api').mockResolvedValue('cf_cli'); + const uploaded: Array = []; + state.cfRunner.mockImplementation(async (operation: { configPath: string }) => { + state.events.push('review-upload'); + uploaded.push(await readFile(operation.configPath, 'utf8')); + return { status: 0, stdout: '', stderr: '' }; + }); + + expect(await runInTerminal()).toBeNull(); + + expect(confirm).toHaveBeenCalledWith({ + message: 'Replace the configuration of these Workers?', + initialValue: false, + }); + expect(state.cfRunner).toHaveBeenCalledOnce(); + expect(state.cfRunner.mock.calls[0]![0]).toMatchObject({ + type: 'versions.upload', + interactive: true, + }); + expect(state.events).toEqual([ + 'installer-start', + 'create:isr-cache-kv', + 'progress-pause', + 'review-upload', + 'progress-resume', + `put:${PLATFORM_DISABLED_KEY}`, + 'outer-release', + 'local-release', + ]); + expect(uploaded).toHaveLength(1); + expect(uploaded[0]).toContain(createdCacheId); + expect(JSON.stringify(state.manifest!.resources)).not.toContain(createdCacheId); + expect(vi.mocked(installPlatform).mock.calls[0]![3]).toMatchObject({ + replaceOutsideChanges: [apiWorker()], + }); + }); + + it('stops before traffic pauses and shows why when the Cloudflare CLI refuses', async () => { + state.getWorkerLastDeployedFrom.mockResolvedValue('api'); + state.cfRunner.mockImplementation(async () => { + state.events.push('review-upload'); + return { status: 1, stdout: '', stderr: '' }; + }); + + const error = await runInTerminal(); + + expect(error?.message).toBe( + `platform: The Cloudflare CLI did not replace the changes to ${apiWorker()}. Its output above shows why. No deployment changed. This command did not pause application traffic.`, + ); + expect(state.events).toContain('review-upload'); + expect(gateWrites()).toEqual([]); + expect(state.progress.resume).toHaveBeenCalledOnce(); + expect(state.progress.error).toHaveBeenCalledWith('Platform upgrade failed.'); + }); + + it('replaces an active version that Void did not deploy without a review upload', async () => { + // An earlier run replaced the change with the Cloudflare CLI and then + // stopped. The marker shows the Cloudflare CLI, but the active version + // in runInTerminal still has no Void record of its triggers. + expect(await runInTerminal()).toBeNull(); + + expect(confirm).toHaveBeenCalledOnce(); + expect(state.cfRunner).not.toHaveBeenCalled(); + expect(state.events).toEqual([ + 'installer-start', + 'create:isr-cache-kv', + `put:${PLATFORM_DISABLED_KEY}`, + 'outer-release', + 'local-release', + ]); + expect(vi.mocked(installPlatform).mock.calls[0]![3]).toMatchObject({ + replaceOutsideChanges: [apiWorker()], + }); + }); + }); + }); }); diff --git a/packages/void/test/unit/platform-setup-flow.test.ts b/packages/void/test/unit/platform-setup-flow.test.ts index 1548a1323..897595b44 100644 --- a/packages/void/test/unit/platform-setup-flow.test.ts +++ b/packages/void/test/unit/platform-setup-flow.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; +import { encodeCloudflareVersionMessage } from '@void/deploy-cloudflare'; import { createPlatformDeploymentManifest, recordInstallationResource, @@ -15,7 +16,9 @@ import { parsePlatformArgs } from '../../src/cli/platform-args.ts'; import { PlatformCloudflareApiError, PlatformCloudflareClient, + verifyPlatformRuntimeTokenEmailPermissions, } from '../../src/cli/platform-cloudflare.ts'; +import { recordWorkerCutoverStage } from '../../src/cli/platform-installer-cutover.ts'; import { PlatformAccessBlockedError } from '../../src/cli/platform-installer.ts'; import { runPlatformLifecycleCommand } from '../../src/cli/platform-lifecycle.ts'; import { @@ -47,7 +50,16 @@ const mocks = vi.hoisted(() => ({ plan: vi.fn(), database: vi.fn(), spinner: vi.fn(), - progress: { start: vi.fn(), stop: vi.fn(), message: vi.fn(), error: vi.fn(), clear: vi.fn() }, + progress: { + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + error: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + }, + cfRunner: vi.fn(), })); vi.mock('@voidzero-dev/vite-plus-prompts', async (original) => ({ ...(await original()), @@ -96,6 +108,10 @@ vi.mock('../../src/cli/platform-installer.ts', async (original) => ({ resolveBootstrapGitHubId: async () => '12345', readPlatformDatabaseLifecycleManifest: mocks.database, })); +vi.mock('../../src/cli/platform-installer-process.ts', async (original) => ({ + ...(await original()), + runPlatformWranglerAtApiBase: mocks.cfRunner, +})); vi.mock('../../src/cli/platform-plan.ts', async (original) => ({ ...(await original()), createPlatformInstallPlan: mocks.plan, @@ -1468,6 +1484,45 @@ describe('platform credential onboarding', () => { expect(mocks.install).not.toHaveBeenCalled(); }); + it('keeps a saved token that lacks an email permission and asks for no replacement', async () => { + const draft = await savedSetup(); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + mocks.preflight.mockImplementationOnce(async (_client, input: { accountId: string }) => + verifyPlatformRuntimeTokenEmailPermissions( + new PlatformCloudflareClient('saved-runtime-token', { + fetcher: async (request) => + new URL(String(request)).pathname.endsWith('/email/routing/addresses') + ? Response.json( + { success: false, errors: [{ message: 'upstream echoed saved-runtime-token' }] }, + { status: 403 }, + ) + : Response.json({ success: true, result: {} }), + }), + { accountId: input.accountId, zoneId: 'mail-zone' }, + ), + ); + + const failure = await runPlatformLifecycleCommand(parsePlatformArgs(['install'])).then( + () => null, + (error: unknown) => error as Error, + ); + + expect(failure?.message).toContain( + 'Cloudflare denied: Account → Email Routing Addresses → Edit (HTTP 403)', + ); + expect(failure?.message).toContain('The token value stays the same'); + expect(failure?.message).not.toContain('saved-runtime-token'); + expect(failure?.message).not.toContain('upstream echoed'); + expect(readPendingInstallationSecrets(draft)?.cloudflareApiToken).toBe('saved-runtime-token'); + expect(warn).not.toHaveBeenCalledWith( + 'Cloudflare rejected the saved runtime token. Create a replacement to continue setup.', + ); + expect(mocks.preflight).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.password).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + }); + it('offers a named saved setup and a fresh installation instead of automatically continuing', async () => { const draft = await savedSetup(); await runPlatformLifecycleCommand(parsePlatformArgs(['install', '--plan'])); @@ -1873,6 +1928,27 @@ describe('platform credential onboarding', () => { expect(mocks.install).toHaveBeenCalledOnce(); expect(info.mock.calls.map(([message]) => message).join('\n')).not.toContain('openssl rand'); }); + it('checks runtime-token email access only when platform email is on', async () => { + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + vi.stubEnv('VOID_EMAIL_SHARED_ZONE_ID', ''); + vi.stubEnv('VOID_EMAIL_SENDER_DOMAIN', ''); + await runPlatformLifecycleCommand(parsePlatformArgs(args)); + expect(mocks.preflight.mock.calls[0]![1]).not.toHaveProperty('emailZoneId'); + + vi.stubEnv('VOID_EMAIL_SHARED_ZONE_ID', 'mail-zone'); + vi.stubEnv('VOID_EMAIL_SENDER_DOMAIN', 'mail.example.com'); + const emailArgs = args.map((value) => (value === 'test-setup' ? 'email-setup' : value)); + await runPlatformLifecycleCommand(parsePlatformArgs(emailArgs)); + + expect(mocks.preflight).toHaveBeenCalledTimes(2); + expect(mocks.preflight.mock.calls[1]![1]).toMatchObject({ emailZoneId: 'mail-zone' }); + expect(mocks.install).toHaveBeenCalledTimes(2); + expect(mocks.preflight.mock.invocationCallOrder[1]).toBeLessThan( + mocks.install.mock.invocationCallOrder[1]!, + ); + }); it('checkpoints a generated email key before leaving the setup draft', async () => { for (const [key, value] of Object.entries(environment)) { vi.stubEnv(key, value); @@ -1900,4 +1976,290 @@ describe('platform credential onboarding', () => { expect(readPendingInstallationSecrets(draft)?.jwtSecret).toBeUndefined(); expect(mocks.install).not.toHaveBeenCalled(); }); + + const withApiWorker = (action: 'create' | 'update') => { + const original = mocks.plan.getMockImplementation()!; + mocks.plan.mockImplementation(async (...input: Parameters) => { + const plan = await original(...input); + return { + ...plan, + actions: [ + { + action, + id: null, + key: 'api-worker', + kind: 'worker', + name: plan.deployment.resources.apiWorker, + reason: 'installer Worker', + }, + ], + }; + }); + }; + it('stops a resumed install before the installer when its Worker was changed outside Void', async () => { + const manifest = await interruptedInstall(); + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + withApiWorker('update'); + const source = vi + .spyOn(PlatformCloudflareClient.prototype, 'getWorkerLastDeployedFrom') + .mockResolvedValue('api'); + + await expect( + runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id, '--yes']), + ), + ).rejects.toThrow( + new RegExp( + `Nothing changed\\. This command did not pause application traffic\\.[\\s\\S]*platform install --resume --name ${manifest.id}$`, + ), + ); + expect(source).toHaveBeenCalledOnce(); + expect(mocks.install).not.toHaveBeenCalled(); + }); + it('uploads a resumed install’s Worker changed outside Void when the installer asks', async () => { + const manifest = await interruptedInstall(); + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + withApiWorker('update'); + const apiWorker = manifest.deployment.resources.apiWorker; + const createdCacheId = 'isr-cache-created-in-run'; + const marker = 'owned-by-this-installation'; + const version = '11111111-1111-4111-8111-111111111111'; + const source = vi + .spyOn(PlatformCloudflareClient.prototype, 'getWorkerLastDeployedFrom') + .mockResolvedValueOnce('dash') + .mockResolvedValue('cf_cli'); + vi.spyOn(PlatformCloudflareClient.prototype, 'request').mockImplementation( + async (path: string) => { + if (path.endsWith('/deployments')) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: version, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/versions/${version}`)) { + return { + resources: { + bindings: [{ name: 'PLATFORM_OWNERSHIP_MARKER', type: 'plain_text', text: marker }], + }, + }; + } + throw new Error(`unexpected read ${path}`); + }, + ); + const events: Array = []; + const uploaded: Array = []; + mocks.cfRunner.mockImplementation(async (operation: { configPath: string }) => { + events.push('review-upload'); + uploaded.push(readFileSync(operation.configPath, 'utf8')); + return { status: 0, stdout: '', stderr: '' }; + }); + mocks.progress.pause.mockImplementation(() => events.push('progress-pause')); + mocks.progress.resume.mockImplementation(() => events.push('progress-resume')); + // Like the installer: it creates a resource, uploads the Workers changed + // outside Void with the configs that the run deploys, then pauses traffic. + mocks.install.mockImplementation(async (_plan, _secrets, existing, dependencies) => { + events.push('installer-start', 'create:isr-cache-kv'); + await dependencies.uploadOutsideChanges([ + { + account_id: 'account', + compatibility_date: '2026-09-30', + kv_namespaces: [{ binding: 'ISR_CACHE', id: createdCacheId }], + main: join(directory, 'api.js'), + name: apiWorker, + vars: { PLATFORM_OWNERSHIP_MARKER: marker }, + }, + ]); + events.push('traffic-paused'); + return { manifest: existing, waitingForDns: false, waitingForSandboxDrain: false }; + }); + const stdout = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + try { + await runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id]), + ); + } finally { + if (stdout) { + Object.defineProperty(process.stdout, 'isTTY', stdout); + } else { + Reflect.deleteProperty(process.stdout, 'isTTY'); + } + } + + expect(mocks.confirm).toHaveBeenCalledWith({ + message: 'Replace the configuration of these Workers?', + initialValue: false, + }); + expect(events).toEqual([ + 'installer-start', + 'create:isr-cache-kv', + 'progress-pause', + 'review-upload', + 'progress-resume', + 'traffic-paused', + ]); + expect(mocks.cfRunner.mock.calls[0]![0]).toMatchObject({ + type: 'versions.upload', + interactive: true, + }); + expect(uploaded).toHaveLength(1); + expect(uploaded[0]).toContain(createdCacheId); + expect(JSON.stringify(manifest.resources)).not.toContain(createdCacheId); + expect(source).toHaveBeenCalledTimes(2); + expect(mocks.install.mock.calls[0]![3]).toMatchObject({ + replaceOutsideChanges: [apiWorker], + }); + }); + it('replaces a resumed install’s active version that Void did not deploy without a review upload', async () => { + const manifest = await interruptedInstall(); + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + withApiWorker('update'); + const apiWorker = manifest.deployment.resources.apiWorker; + const version = '11111111-1111-4111-8111-111111111111'; + // An earlier run stopped after the Cloudflare CLI replaced the change, so + // the marker shows the Cloudflare CLI. The active version has no Void record. + vi.spyOn(PlatformCloudflareClient.prototype, 'getWorkerLastDeployedFrom').mockResolvedValue( + 'cf_cli', + ); + vi.spyOn(PlatformCloudflareClient.prototype, 'request').mockImplementation( + async (path: string) => { + if (path.endsWith(`/${apiWorker}/deployments`)) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: version, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/${apiWorker}/versions/${version}`)) { + return { annotations: { 'workers/triggered_by': 'upload' } }; + } + throw new Error(`unexpected read ${path}`); + }, + ); + mocks.install.mockImplementation(async (_plan, _secrets, existing, dependencies) => { + await dependencies.uploadOutsideChanges([{ account_id: 'account', name: apiWorker }]); + return { manifest: existing, waitingForDns: false, waitingForSandboxDrain: false }; + }); + const stdout = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + try { + await runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id]), + ); + } finally { + if (stdout) { + Object.defineProperty(process.stdout, 'isTTY', stdout); + } else { + Reflect.deleteProperty(process.stdout, 'isTTY'); + } + } + + expect(mocks.confirm).toHaveBeenCalledWith({ + message: 'Replace the configuration of these Workers?', + initialValue: false, + }); + expect(mocks.cfRunner).not.toHaveBeenCalled(); + expect(mocks.progress.pause).not.toHaveBeenCalled(); + expect(mocks.install.mock.calls[0]![3]).toMatchObject({ + replaceOutsideChanges: [apiWorker], + }); + }); + it('stops a resumed install that restores an earlier version without a Void record', async () => { + const manifest = await interruptedInstall(); + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + withApiWorker('update'); + const apiWorker = manifest.deployment.resources.apiWorker; + const voidVersion = '22222222-2222-4222-8222-222222222222'; + const earlierVersion = '33333333-3333-4333-8333-333333333333'; + const triggers = { + workers_dev: true, + preview_urls: false, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }; + // An earlier run agreed to replace a change made outside Void, made + // Void's version active and stopped before the cutover finished. The + // installer first restores the earlier version. + writeInstallationManifest( + recordWorkerCutoverStage( + manifest, + 1, + 'api', + earlierVersion, + voidVersion, + apiWorker, + triggers, + new Date().toISOString(), + ), + ); + vi.spyOn(PlatformCloudflareClient.prototype, 'getWorkerLastDeployedFrom').mockResolvedValue( + 'cf_cli', + ); + const request = vi + .spyOn(PlatformCloudflareClient.prototype, 'request') + .mockImplementation(async (path: string) => { + if (path.endsWith(`/${apiWorker}/deployments`)) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: voidVersion, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/${apiWorker}/versions/${voidVersion}`)) { + return { + annotations: { 'workers/message': encodeCloudflareVersionMessage([], triggers) }, + }; + } + if (path.endsWith(`/${apiWorker}/versions/${earlierVersion}`)) { + return { annotations: {} }; + } + throw new Error(`unexpected read ${path}`); + }); + + await expect( + runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id, '--yes']), + ), + ).rejects.toThrow(`${apiWorker}: has an active version without a Void deployment record\n`); + expect(request).toHaveBeenCalledWith( + `/accounts/${manifest.account.id}/workers/scripts/${apiWorker}/versions/${earlierVersion}`, + ); + expect(mocks.install).not.toHaveBeenCalled(); + }); + it('reads no Worker that a fresh install creates', async () => { + for (const [key, value] of Object.entries(environment)) { + vi.stubEnv(key, value); + } + withApiWorker('create'); + const source = vi.spyOn(PlatformCloudflareClient.prototype, 'getWorkerLastDeployedFrom'); + + await runPlatformLifecycleCommand(parsePlatformArgs(args)); + + expect(source).not.toHaveBeenCalled(); + expect(mocks.install).toHaveBeenCalledOnce(); + expect(mocks.install.mock.calls[0]![3]).not.toHaveProperty('replaceOutsideChanges'); + }); }); diff --git a/packages/void/test/unit/provision-runner-interactive.test.ts b/packages/void/test/unit/provision-runner-interactive.test.ts new file mode 100644 index 000000000..9bf524d79 --- /dev/null +++ b/packages/void/test/unit/provision-runner-interactive.test.ts @@ -0,0 +1,71 @@ +import type { ChildProcess } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CloudflareOperation } from '@void/deploy-cloudflare'; +import { afterEach, expect, it } from 'vite-plus/test'; +import { createDefaultRunWrangler } from '../../src/cli/provision.ts'; + +const directories: Array = []; +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +// A stand-in for the operation process: it writes a result envelope unless the +// operation is interactive, which writes to the terminal instead. +async function runWith(operation: CloudflareOperation) { + const directory = mkdtempSync(join(tmpdir(), 'void-runner-interactive-')); + directories.push(directory); + const entry = join(directory, 'operation.mjs'); + writeFileSync( + entry, + `const operation = JSON.parse(process.argv[2]); +if (!operation.interactive) { + process.stdout.write(JSON.stringify({ operationResult: 1, data: { versionId: 'decoded' } }) + '\\n'); +}`, + ); + let child: ChildProcess | undefined; + const run = createDefaultRunWrangler(directory, { + launcher: [process.execPath, entry], + onSpawnedChild: (spawned) => { + child = spawned; + }, + }); + const result = await run(operation, { accountId: 'account' }); + return { child: child!, result }; +} + +it('keeps the default operation process off the terminal', async () => { + for (const operation of [ + { type: 'versions.upload', configPath: 'wrangler.json' }, + { type: 'worker.deploy', configPath: 'wrangler.json' }, + { type: 'versions.upload', configPath: 'wrangler.json', interactive: false }, + ] as const) { + const { child, result } = await runWith(operation); + // stdin 'ignore' has no stream; stdout and stderr are pipes. + expect(child.stdin).toBeNull(); + expect(child.stdout).not.toBeNull(); + expect(child.stderr).not.toBeNull(); + expect(result.status).toBe(0); + expect(result.data).toEqual({ versionId: 'decoded' }); + } +}); + +it('gives only an interactive upload the terminal and reads only its exit status', async () => { + for (const type of ['versions.upload', 'worker.deploy'] as const) { + const { child, result } = await runWith({ + type, + configPath: 'wrangler.json', + interactive: true, + }); + // 'inherit' passes this process's streams through, so the child has none of its own. + expect(child.stdin).toBeNull(); + expect(child.stdout).toBeNull(); + expect(child.stderr).toBeNull(); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result).not.toHaveProperty('data'); + } +}); diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index 65745adfa..50b203a9d 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -468,7 +468,7 @@ Optional vars: - `AUTH_REDIRECT_URI` - `DASHBOARD_URL` — optional frontend origin for project links - `PLATFORM_DISPLAY_NAME` — name shown on onboarding pages and emails -- `EMAIL_FROM` — verified sender for the optional `EMAIL` binding +- `EMAIL_FROM` — optional sender override for the `EMAIL` binding. Without it, an installation sends from `noreply@` with `PLATFORM_DISPLAY_NAME` as the name - `VOID_PROXY_SERVICE` - `SITE_DOMAIN` - `EMAIL_SENDER_DOMAIN` — platform mail domain for this environment (`mail.void.cloud` in production). Set on the proxy worker too, and handed to deployed user workers as `__VOID_EMAIL_DOMAIN`. See [Email](#email). @@ -481,13 +481,16 @@ Optional vars: The GitHub App secrets are optional: when unset, `/webhooks/github` and `/auth/github-oidc` return `503` and the rest of the worker runs normally. They are the App for Deploy-on-GitHub, separate from the sign-in OAuth app above. The ingress secret is also optional and does not enable any route unless the standalone Worker and service binding are configured. -When enabling email on a self-hosted or custom-origin platform, configure -`EMAIL_FROM` with a Cloudflare-verified sender and `AUTH_REDIRECT_URI` with the -platform's public OAuth callback URL. Emails use that origin and -`PLATFORM_DISPLAY_NAME`; an installed platform does not default to Void Cloud -senders or dashboard links. The core installer includes the email gateway and -enables platform email when both `VOID_EMAIL_SENDER_DOMAIN` and -`VOID_EMAIL_SHARED_ZONE_ID` are configured. +The core installer includes the email gateway and enables platform email when +both `VOID_EMAIL_SENDER_DOMAIN` and `VOID_EMAIL_SHARED_ZONE_ID` are configured. +It sets `AUTH_REDIRECT_URI` to the platform's OAuth callback URL, and it sets +`PLATFORM_DISPLAY_NAME` and `EMAIL_SENDER_DOMAIN`. Platform mail uses the origin +of `AUTH_REDIRECT_URI` for links and comes from +`noreply@`; an installed platform does not default to Void +Cloud senders or dashboard links. Do not set these values or `EMAIL_FROM` on +installed Workers in the Cloudflare dashboard. The next lifecycle command stops +until the operator lets Void replace that change; see +[Platform Workers changed outside Void](../../../docs/guide/platform/installation/maintenance.md#workers-changed-outside-void). Administrators manage access with `void platform signup` and `void platform invitation`. Signup eligibility comes from the installation's own allowlist and bootstrap administrator configuration. diff --git a/platform/packages/api/src/email.ts b/platform/packages/api/src/email.ts index 7eb4270fb..e5813c1e4 100644 --- a/platform/packages/api/src/email.ts +++ b/platform/packages/api/src/email.ts @@ -9,6 +9,7 @@ type PlatformEmailEnv = Pick< ApiEnv['Bindings'], | 'EMAIL' | 'EMAIL_FROM' + | 'EMAIL_SENDER_DOMAIN' | 'PLATFORM_DISPLAY_NAME' | 'PLATFORM_INSTALLATION_ID' | 'AUTH_REDIRECT_URI' @@ -32,17 +33,44 @@ function button(href: string, label: string): string { return `${escapeHtml(label)}`; } -function emailConfig(env: PlatformEmailEnv) { - const apiUrl = getPlatformApiUrl(env); - const hosted = - !env.PLATFORM_INSTALLATION_ID && - ['https://api.void.cloud', 'https://api.staging.void.cloud'].includes(apiUrl); +/** + * EMAIL_FROM overrides the sender. Without it, an installation sends from + * `noreply@`, the mailbox that destination verification + * mail already uses; `noreply` is a reserved slug, so no project owns it. + */ +function platformSender( + env: PlatformEmailEnv, + hosted: boolean, + name: string, +): string | EmailAddress { const from = env.EMAIL_FROM?.trim() || (hosted ? 'Void ' : ''); - if (!from || /[\r\n]/.test(from)) { + const domain = env.EMAIL_SENDER_DOMAIN?.trim(); + if ((!from && !domain) || /[\r\n]/.test(from)) { throw new Error( 'Set EMAIL_FROM to a Cloudflare-verified sender before enabling platform email.', ); } + if (from) { + return from; + } + // The display name goes in its own field, so the binding encodes it and a + // name such as `Acme ` cannot change the address. Control characters + // could still split the From header. + if (/\p{Cc}/u.test(name + domain)) { + throw new Error( + 'PLATFORM_DISPLAY_NAME and EMAIL_SENDER_DOMAIN must not contain control characters.', + ); + } + return { name, email: `noreply@${domain}` }; +} + +function emailConfig(env: PlatformEmailEnv) { + const apiUrl = getPlatformApiUrl(env); + const hosted = + !env.PLATFORM_INSTALLATION_ID && + ['https://api.void.cloud', 'https://api.staging.void.cloud'].includes(apiUrl); + const name = getPlatformDisplayName(env); + const from = platformSender(env, hosted, name); const dashboardUrl = env.DASHBOARD_URL || (hosted @@ -60,7 +88,7 @@ function emailConfig(env: PlatformEmailEnv) { throw new Error('DASHBOARD_URL must be an HTTP(S) URL without credentials.'); } } - return { apiUrl, from, dashboardUrl, name: getPlatformDisplayName(env) }; + return { apiUrl, from, dashboardUrl, name }; } function dashboardProjectUrl(dashboardUrl: string | undefined, projectSlug: string): string | null { diff --git a/platform/packages/api/test/unit/email.test.ts b/platform/packages/api/test/unit/email.test.ts index a474b0fc9..46aee0381 100644 --- a/platform/packages/api/test/unit/email.test.ts +++ b/platform/packages/api/test/unit/email.test.ts @@ -7,7 +7,13 @@ import { sendDeployFailedEmail, } from '../../src/email.ts'; -type SentMessage = { from: string; to: string; subject: string; html: string; text: string }; +type SentMessage = { + from: string | EmailAddress; + to: string; + subject: string; + html: string; + text: string; +}; function mockBinding() { const sent: Array = []; @@ -222,6 +228,61 @@ describe('platform email identity', () => { expect(sent[0].text).toContain("void connect 'api.example.com'"); }); + describe('default sender', () => { + const installation = { + ...identity, + EMAIL_FROM: undefined, + EMAIL_SENDER_DOMAIN: 'mail.example.com', + }; + const defaultSender = { name: 'Example ', email: 'noreply@mail.example.com' }; + + it('sends every platform mail from noreply on the platform mail domain', async () => { + const { binding, sent } = mockBinding(); + const env = { ...binding, ...installation }; + await sendInvitationEmail(env, 'member@example.com'); + await sendProjectInvitationEmail(env, { + to: 'member@example.com', + invitationId: 'pinv_a1b2c3d4e5f6', + projectSlug: 'shared-project', + inviterLogin: 'owner', + role: 'reader', + }); + await sendWelcomeEmail(env, 'member@example.com', 'new-member'); + await sendDeployFailedEmail(env, 'member@example.com', 'my-project', 'failed'); + await sendErrorAlertEmail(env, 'member@example.com', 'my-project', 5, null); + expect(sent.map(({ from }) => from)).toEqual(Array(5).fill(defaultSender)); + }); + + it('keeps an explicit EMAIL_FROM and the hosted sender ahead of it', async () => { + const { binding, sent } = mockBinding(); + await sendInvitationEmail({ ...binding, ...installation }, 'member@example.com'); + await sendInvitationEmail( + { ...binding, ...installation, EMAIL_FROM: identity.EMAIL_FROM }, + 'member@example.com', + ); + await sendInvitationEmail( + { ...binding, EMAIL_SENDER_DOMAIN: 'mail.void.cloud' }, + 'member@example.com', + ); + expect(sent.map(({ from }) => from)).toEqual([ + defaultSender, + identity.EMAIL_FROM, + 'Void ', + ]); + }); + + it.each([ + { ...installation, PLATFORM_DISPLAY_NAME: 'Example\r\nBcc: attacker@example.com' }, + { ...installation, EMAIL_SENDER_DOMAIN: 'mail.example.com\r\nBcc: attacker@example.com' }, + ])('rejects control characters in the sender: %j', async (config) => { + const { binding, sent } = mockBinding(); + await expect( + sendInvitationEmail({ ...binding, ...config }, 'member@example.com'), + ).rejects.toThrow('must not contain control characters'); + expect(sent).toEqual([]); + }); + }); + it('preserves the Void Cloud sender only for its hosted deployment', async () => { const { binding, sent } = mockBinding(); await sendInvitationEmail(binding, 'member@example.com'); From 87e43b40aeb4bc60270dc548d6e2dd7cc2b6dcd2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 16 Oct 2026 00:12:01 +0800 Subject: [PATCH 2/2] 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) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/platform/installation/ci.md | 2 +- .../platform/installation/maintenance.md | 8 +- docs/reference/cli/platform-installation.md | 2 +- .../src/cli/platform-installer-inspection.ts | 63 +++--- .../cli/platform-installer-outside-changes.ts | 134 ++++++++++--- ...platform-installer-outside-changes.test.ts | 183 +++++++++++++++--- .../unit/platform-management-upgrade.test.ts | 140 +++++++++++++- .../test/unit/platform-setup-flow.test.ts | 20 +- 8 files changed, 458 insertions(+), 94 deletions(-) diff --git a/docs/guide/platform/installation/ci.md b/docs/guide/platform/installation/ci.md index 00fdb15eb..2f01bb5b7 100644 --- a/docs/guide/platform/installation/ci.md +++ b/docs/guide/platform/installation/ci.md @@ -51,7 +51,7 @@ void platform install \ Mutations require `--yes` in CI; a read-only `--plan` does not. Custom runtimes also require `--runtime `. 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 `/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. -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). +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). ::: diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 1fb824ec4..5405b607b 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -149,7 +149,7 @@ Let running rotations finish before you roll back. If an older proxy runs next t 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. -`upgrade`, `repair`, `enable`, `rollback`, and `install --resume` deploy the platform Workers again. Before they change anything, Void finds each platform Worker that: +`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: - was changed in the Cloudflare dashboard or with the Cloudflare API. - 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 In a terminal, Void lists these Workers and asks **Replace the configuration of these Workers?** The default answer is No. -- If you answer No, the command stops. Nothing changed, and the command did not pause traffic. +- If you answer No, the command stops. No platform Worker changed, and the command did not pause traffic. - 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. 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. 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`. -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. +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. + +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. 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). diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 4bb79c511..604e7c359 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -137,7 +137,7 @@ After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. See [Manag 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. -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). +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). `--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. diff --git a/packages/void/src/cli/platform-installer-inspection.ts b/packages/void/src/cli/platform-installer-inspection.ts index 7143fb893..39326162e 100644 --- a/packages/void/src/cli/platform-installer-inspection.ts +++ b/packages/void/src/cli/platform-installer-inspection.ts @@ -56,45 +56,52 @@ export async function readActivePlatformWorkerVersion( } /** - * Whether the active version of a platform Worker has Void's record of its - * triggers. An upgrade refuses an active version without it, for example a - * version deployed outside Void. This reads the active version as - * readActivePlatformWorkerVersion does, but through the Cloudflare API. Null - * when the Worker has no single active version. + * The active version in a Cloudflare API answer for the deployments of a + * platform Worker. deployPlatformWorker continues only when the latest + * deployment sends all traffic to one version (deploymentRunsOnlyVersion). + * `split` is each other answer, for example a gradual deployment. */ -export async function activePlatformWorkerHasTriggerRecord( - readCloudflare: (path: string) => Promise, - accountId: string, - workerName: string, -): Promise { - const base = `/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}`; - const response = await readCloudflare(`${base}/deployments`); +export function readActivePlatformWorkerDeployment( + response: unknown, +): + | Readonly<{ kind: 'active'; versionId: string }> + | Readonly<{ kind: 'malformed' }> + | Readonly<{ kind: 'split' }> { const deployments = - response && typeof response === 'object' + response && typeof response === 'object' && !Array.isArray(response) ? (response as { deployments?: unknown }).deployments : null; - const latest = Array.isArray(deployments) ? (deployments[0] as unknown) : null; + if (!Array.isArray(deployments)) { + return { kind: 'malformed' }; + } + if (deployments.length === 0) { + return { kind: 'split' }; + } + const latest: unknown = deployments[0]; const versions = latest && typeof latest === 'object' ? (latest as { versions?: unknown }).versions : null; - const active = Array.isArray(versions) - ? versions.filter( - (entry) => - entry && - typeof entry === 'object' && - Number((entry as { percentage?: unknown }).percentage) === 100, - ) - : []; - const versionId = - active.length === 1 ? (active[0] as { version_id?: unknown }).version_id : undefined; - if (typeof versionId !== 'string') { - return null; + if ( + !Array.isArray(versions) || + versions.some( + (version) => + !version || + typeof version !== 'object' || + typeof (version as { version_id?: unknown }).version_id !== 'string' || + !Number.isFinite(Number((version as { percentage?: unknown }).percentage)), + ) + ) { + return { kind: 'malformed' }; } - return platformWorkerVersionHasTriggerRecord(readCloudflare, accountId, workerName, versionId); + const [only] = versions as Array<{ percentage: unknown; version_id: string }>; + return versions.length === 1 && only && Number(only.percentage) === 100 + ? { kind: 'active', versionId: only.version_id } + : { kind: 'split' }; } /** * Whether a version of a platform Worker has Void's record of its triggers. - * This is the rule of the upgrade: it refuses a version without the record. + * This is the rule of the upgrade: it refuses a version without the record, + * for example a version deployed outside Void. */ export async function platformWorkerVersionHasTriggerRecord( readCloudflare: (path: string) => Promise, diff --git a/packages/void/src/cli/platform-installer-outside-changes.ts b/packages/void/src/cli/platform-installer-outside-changes.ts index 420b2503b..2f5720e26 100644 --- a/packages/void/src/cli/platform-installer-outside-changes.ts +++ b/packages/void/src/cli/platform-installer-outside-changes.ts @@ -1,12 +1,16 @@ +import { setTimeout as delay } from 'node:timers/promises'; import type { PlatformInstallationManifest } from '@void/platform'; import type { PlatformRuntime } from '@void/platform/installer'; import { confirm, isCancel, log, type SpinnerResult } from '@voidzero-dev/vite-plus-prompts'; import { note } from './output.ts'; -import type { PlatformCloudflareClient } from './platform-cloudflare.ts'; +import { + PlatformCloudflareApiError, + type PlatformCloudflareClient, +} from './platform-cloudflare.ts'; import { interruptedWorkerCutover } from './platform-installer-cutover.ts'; import { - activePlatformWorkerHasTriggerRecord, platformWorkerVersionHasTriggerRecord, + readActivePlatformWorkerDeployment, } from './platform-installer-inspection.ts'; import { runPlatformWranglerAtApiBase } from './platform-installer-process.ts'; import type { @@ -41,6 +45,52 @@ const PLATFORM_WORKER_KEYS: ReadonlyArray = [ 'dispatch', ]; +// The lifecycle lock can change the database before this check. The check +// itself changes no Worker and does not pause traffic. +const NO_WORKER_CHANGED = + 'No platform Worker changed. This command did not pause application traffic.'; + +/** The wait before the second and the third read of a Worker. */ +const READ_RETRY_DELAYS_MS = [250, 500] as const; + +/** Stop before the run changes a Worker, because Void cannot tell what the run replaces. */ +function unreadablePlatformWorker(workerName: string, error: unknown): Error { + const status = error instanceof PlatformCloudflareApiError ? error.status : null; + const answer = + status === null + ? `no HTTP response: ${error instanceof Error ? error.message : String(error)}` + : `HTTP ${status}`; + return new Error( + [ + `platform: Void cannot read the platform Worker ${workerName} (${answer}), so it cannot find changes made outside Void.`, + NO_WORKER_CHANGED, + status === 401 || status === 403 + ? 'Make sure that the Cloudflare API token has Account → Workers Scripts → Edit for this account. Then run the command again.' + : 'Run the command again.', + ].join(' '), + { cause: error }, + ); +} + +/** + * Read a Worker again after a rate limit, a Cloudflare server error or a + * network failure. Each other failure, or a third failure, stops the run. + */ +async function readPlatformWorker(workerName: string, read: () => Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await read(); + } catch (error) { + const status = error instanceof PlatformCloudflareApiError ? error.status : null; + const wait = READ_RETRY_DELAYS_MS[attempt]; + if (wait === undefined || (status !== null && status !== 429 && status < 500)) { + throw unreadablePlatformWorker(workerName, error); + } + await delay(wait); + } + } +} + export type PlatformWorkerOutsideChange = Readonly<{ key: PlatformWorkerKey; name: string; @@ -115,35 +165,59 @@ export async function findPlatformWorkerOutsideChanges( : new Map(); const changes = await Promise.all( workers.map(async (worker): Promise => { - // A failed read does not show a change. The upload still reports a real conflict. - let source: string | null; - try { - source = await client.getWorkerLastDeployedFrom(plan.account.id, worker.name); - } catch { - return null; + // A read that fails stops the run. Without the answer, the run can + // pause traffic and then fail in the Cloudflare CLI or the upgrade. + const readCloudflare = (path: string) => + readPlatformWorker(worker.name, () => client.request(path)); + const source = await readPlatformWorker(worker.name, () => + client.getWorkerLastDeployedFrom(plan.account.id, worker.name), + ); + const deployments = await readPlatformWorker(worker.name, () => + client + .request( + `/accounts/${encodeURIComponent(plan.account.id)}/workers/scripts/${encodeURIComponent(worker.name)}/deployments`, + ) + .catch((error: unknown) => { + if (error instanceof PlatformCloudflareApiError && error.status === 404) { + return null; + } + throw error; + }), + ); + // No deployments. The installer finds a missing Worker itself, but a + // marker from outside Void still makes the Cloudflare CLI stop. + if (deployments === null) { + return source === 'dash' || source === 'api' ? { ...worker, source } : null; + } + const active = readActivePlatformWorkerDeployment(deployments); + if (active.kind === 'malformed') { + throw new Error( + `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.`, + ); + } + // The deploy and the restore of a stopped cutover need one active version. + if (active.kind === 'split') { + throw new Error( + `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.`, + ); } if (source === 'dash' || source === 'api') { return { ...worker, source }; } // The upgrade reads the active version. After a stopped cutover, the // installer first makes the earlier version active again. - const readCloudflare = (path: string) => client.request(path); const restoredVersion = restored.get(worker.name); - const recorded = await Promise.all([ - activePlatformWorkerHasTriggerRecord(readCloudflare, plan.account.id, worker.name).catch( - () => null, + const recorded = await Promise.all( + [active.versionId, ...(restoredVersion === undefined ? [] : [restoredVersion])].map( + (versionId) => + platformWorkerVersionHasTriggerRecord( + readCloudflare, + plan.account.id, + worker.name, + versionId, + ), ), - ...(restoredVersion === undefined - ? [] - : [ - platformWorkerVersionHasTriggerRecord( - readCloudflare, - plan.account.id, - worker.name, - restoredVersion, - ).catch(() => null), - ]), - ]); + ); return recorded.includes(false) ? { ...worker, source: 'unrecorded' } : null; }), ); @@ -167,10 +241,10 @@ export function formatPlatformWorkerOutsideChanges( } /** - * Do this before a run changes anything. When platform Workers were changed - * outside Void, stop, or ask the operator in a terminal. Returns the Workers - * whose outside changes the operator agreed to replace. The installer uploads - * them later with uploadPlatformWorkerOutsideChanges. + * Do this before a run changes a Worker or pauses traffic. When platform + * Workers were changed outside Void, stop, or ask the operator in a terminal. + * Returns the Workers whose outside changes the operator agreed to replace. + * The installer uploads them later with uploadPlatformWorkerOutsideChanges. */ export async function reviewPlatformWorkerOutsideChanges( input: Readonly<{ @@ -197,7 +271,7 @@ export async function reviewPlatformWorkerOutsideChanges( throw new Error( [ `platform: ${formatPlatformWorkerOutsideChanges(changes)}`, - 'Nothing changed. This command did not pause application traffic.', + NO_WORKER_CHANGED, 'This command replaces their configuration with the Void configuration. It can ask for your approval only in a terminal.', `Run this command once in a terminal to review them:\n${input.command}`, ].join('\n'), @@ -225,9 +299,7 @@ export async function reviewPlatformWorkerOutsideChanges( initialValue: false, }); if (isCancel(answer) || typeof answer === 'symbol' || !answer) { - throw new Error( - 'platform: Cancelled. Nothing changed. This command did not pause application traffic.', - ); + throw new Error(`platform: Cancelled. ${NO_WORKER_CHANGED}`); } return changes; } diff --git a/packages/void/test/unit/platform-installer-outside-changes.test.ts b/packages/void/test/unit/platform-installer-outside-changes.test.ts index 03a04d103..73f7401cd 100644 --- a/packages/void/test/unit/platform-installer-outside-changes.test.ts +++ b/packages/void/test/unit/platform-installer-outside-changes.test.ts @@ -22,6 +22,13 @@ vi.mock('@voidzero-dev/vite-plus-prompts', async (importOriginal) => { return { ...actual, confirm: vi.fn(), log: { ...actual.log, step: vi.fn() }, note: vi.fn() }; }); +// Record the waits before a read is done again, and do not wait. +const wait = vi.hoisted(() => vi.fn(async (_milliseconds: number) => {})); +vi.mock('node:timers/promises', async (importOriginal) => ({ + ...(await importOriginal()), + setTimeout: wait, +})); + import { confirm, log, note } from '@voidzero-dev/vite-plus-prompts'; import { PlatformCloudflareApiError, @@ -231,22 +238,15 @@ describe('Worker change source', () => { }); describe('platform Workers changed outside Void', () => { - const sources = new Map(); - const getWorkerLastDeployedFrom = vi.fn(async (_account: string, name: string) => { - const source = sources.get(name) ?? 'cf_cli'; - if (source instanceof Error) { - throw source; - } - return source; - }); + const sources = new Map(); + const getWorkerLastDeployedFrom = vi.fn( + async (_account: string, name: string) => sources.get(name) ?? 'cf_cli', + ); // The message of each Worker's active version. By default, Void deployed it. - const activeMessages = new Map(); + const activeMessages = new Map(); const readCloudflare = vi.fn(async (path: string): Promise => { const name = /\/workers\/scripts\/([^/]+)\//.exec(path)?.[1] ?? ''; const message = activeMessages.has(name) ? activeMessages.get(name) : recordedMessage; - if (message instanceof Error) { - throw message; - } return activeVersionReads(name, message)(path); }); const client = { @@ -264,7 +264,6 @@ describe('platform Workers changed outside Void', () => { sources.set(names.apiWorker, 'dash'); sources.set(names.proxyWorker, 'api'); sources.set(names.tailWorker, null); - sources.set(names.dispatchWorker, new PlatformCloudflareApiError('Internal error', 500)); const plan = installPlan([ ...allWorkers.slice(0, 4), worker('dispatch-worker', names.dispatchWorker, 'reuse'), @@ -297,17 +296,10 @@ describe('platform Workers changed outside Void', () => { // Void wrote this older record, which has no triggers. The upgrade refuses it too. sources.set(names.tailWorker, null); activeMessages.set(names.tailWorker, 'void:{"schema":1,"migrations":[]}'); - // The marker already shows the change, so the version is not read. + // The marker already shows the change, so the version is not read. The + // deployments are read: the deploy needs one active version. sources.set(names.apiWorker, 'dash'); activeMessages.set(names.apiWorker, undefined); - // A failed read does not show a change. When the marker is unknown, it is - // not known whether the Cloudflare CLI asks, so the version is not read. - sources.set(names.dispatchWorker, new PlatformCloudflareApiError('Internal error', 500)); - activeMessages.set(names.dispatchWorker, undefined); - activeMessages.set( - names.emailGatewayWorker, - new PlatformCloudflareApiError('Internal error', 500), - ); await expect( findPlatformWorkerOutsideChanges(client, installPlan(allWorkers), runtime, installed), @@ -317,8 +309,9 @@ describe('platform Workers changed outside Void', () => { { key: 'tail', name: names.tailWorker, source: 'unrecorded' }, ]); const reads = readCloudflare.mock.calls.map(([path]) => path); - expect(reads.filter((path) => path.includes(`/${names.apiWorker}/`))).toEqual([]); - expect(reads.filter((path) => path.includes(`/${names.dispatchWorker}/`))).toEqual([]); + expect(reads.filter((path) => path.includes(`/${names.apiWorker}/`))).toEqual([ + `/accounts/account/workers/scripts/${names.apiWorker}/deployments`, + ]); expect(reads.filter((path) => path.includes(`/${names.proxyWorker}/`))).toEqual([ `/accounts/account/workers/scripts/${names.proxyWorker}/deployments`, `/accounts/account/workers/scripts/${names.proxyWorker}/versions/22222222-2222-4222-8222-222222222222`, @@ -345,6 +338,141 @@ describe('platform Workers changed outside Void', () => { ); }); + describe('when Void cannot read a Worker', () => { + type Read = 'source' | 'deployments' | 'version'; + const failure = (status: number) => new PlatformCloudflareApiError('Cloudflare error', status); + const notReadable = (answer: string) => + `platform: Void cannot read the platform Worker ${names.apiWorker} (${answer}), so it cannot find changes made outside Void. No platform Worker changed. This command did not pause application traffic.`; + /** Each read takes the next answer of its kind, then the answer of a Worker that Void deployed. */ + const find = (answers: Partial>>) => { + const reads: Array = []; + const recorded = activeVersionReads(names.apiWorker, recordedMessage); + const answer = async (read: Read, otherwise: () => Promise) => { + reads.push(read); + const next = answers[read]?.length ? answers[read].shift() : otherwise; + if (next instanceof Error) { + throw next; + } + return typeof next === 'function' ? (next as () => Promise)() : next; + }; + return { + reads, + result: findPlatformWorkerOutsideChanges( + { + getWorkerLastDeployedFrom: async () => + (await answer('source', async () => 'cf_cli')) as string | null, + request: async (path: string) => + (await answer(path.endsWith('/deployments') ? 'deployments' : 'version', () => + recorded(path), + )) as T, + }, + installPlan([worker('api-worker', names.apiWorker)]), + runtime, + installed, + ), + }; + }; + + it.each([ + { + name: 'Cloudflare refuses the token', + answers: { source: [failure(403)] }, + reads: ['source'], + waits: [], + message: `${notReadable('HTTP 403')} Make sure that the Cloudflare API token has Account → Workers Scripts → Edit for this account. Then run the command again.`, + }, + { + name: 'Cloudflare fails three times', + answers: { source: [failure(500), failure(502), failure(500)] }, + reads: ['source', 'source', 'source'], + waits: [[250], [500]], + message: `${notReadable('HTTP 500')} Run the command again.`, + }, + { + name: 'the network fails three times', + answers: { deployments: [1, 2, 3].map(() => new TypeError('fetch failed')) }, + reads: ['source', 'deployments', 'deployments', 'deployments'], + waits: [[250], [500]], + message: `${notReadable('no HTTP response: fetch failed')} Run the command again.`, + }, + { + name: 'the version read fails', + answers: { version: [failure(404)] }, + reads: ['source', 'deployments', 'version'], + waits: [], + message: `${notReadable('HTTP 404')} Run the command again.`, + }, + { + name: 'the deployment data is malformed', + answers: { deployments: [{ deployments: [{ versions: [{ percentage: 100 }] }] }] }, + reads: ['source', 'deployments'], + waits: [], + message: `platform: Cloudflare returned malformed deployment data for the platform Worker ${names.apiWorker}, so Void cannot find changes made outside Void. No platform Worker changed. This command did not pause application traffic. Run the command again.`, + }, + { + // The deploy refuses this deployment after it paused traffic. + name: 'no version gets all traffic', + answers: { + source: ['dash'], + deployments: [ + { + deployments: [ + { + versions: [ + { version_id: '22222222-2222-4222-8222-222222222222', percentage: 90 }, + { version_id: '33333333-3333-4333-8333-333333333333', percentage: 10 }, + ], + }, + ], + }, + ], + }, + reads: ['source', 'deployments'], + waits: [], + message: `platform: The platform Worker ${names.apiWorker} 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 platform Worker changed. This command did not pause application traffic. In the Cloudflare dashboard, deploy one version of ${names.apiWorker} to 100% of traffic. Then run this command once in a terminal, so that you can approve the dashboard change.`, + }, + ] satisfies ReadonlyArray<{ + name: string; + answers: Partial>>; + reads: ReadonlyArray; + waits: ReadonlyArray; + message: string; + }>)('stops when $name', async ({ answers, reads, waits, message }) => { + const found = find(answers); + + await expect(found.result).rejects.toThrow(message); + expect(found.reads).toEqual(reads); + // Void reads again only after a failure that can pass. + expect(wait.mock.calls).toEqual(waits); + }); + + it('reads again after a rate limit and continues', async () => { + const found = find({ source: [failure(429)] }); + + await expect(found.result).resolves.toEqual([]); + expect(found.reads).toEqual(['source', 'source', 'deployments', 'version']); + expect(wait.mock.calls).toEqual([[250]]); + }); + + it('shows no change for a Worker that does not exist', async () => { + // Cloudflare answers HTTP 404, and the source read then gives null. + const found = find({ source: [null], deployments: [failure(404)] }); + + await expect(found.result).resolves.toEqual([]); + expect(found.reads).toEqual(['source', 'deployments']); + expect(wait).not.toHaveBeenCalled(); + }); + + it('keeps a change made outside Void when the deployments read gives 404', async () => { + const found = find({ source: ['dash'], deployments: [failure(404)] }); + + await expect(found.result).resolves.toEqual([ + { key: 'api', name: names.apiWorker, source: 'dash' }, + ]); + expect(found.reads).toEqual(['source', 'deployments']); + }); + }); + describe('after a run stopped during a Worker cutover', () => { const voidVersion = '22222222-2222-4222-8222-222222222222'; const earlierVersion = '33333333-3333-4333-8333-333333333333'; @@ -496,8 +624,9 @@ describe('platform Workers changed outside Void', () => { `${names.apiWorker}: changed in the Cloudflare dashboard`, `${names.proxyWorker}: has an active version without a Void deployment record`, `${names.tailWorker}: changed in the Cloudflare API`, - // An earlier run can have paused traffic. This one did not. - 'Nothing changed. This command did not pause application traffic.', + // An earlier run can have paused traffic. This one did not. The + // lifecycle lock can have changed the database. + 'No platform Worker changed. This command did not pause application traffic.', 'This command replaces their configuration with the Void configuration. It can ask for your approval only in a terminal.', 'Run this command once in a terminal to review them:', 'void platform upgrade example-a1b2c3d4', @@ -510,7 +639,7 @@ describe('platform Workers changed outside Void', () => { sources.set(names.apiWorker, 'api'); vi.mocked(confirm).mockResolvedValueOnce(false); await expect(review(true)).rejects.toThrow( - 'platform: Cancelled. Nothing changed. This command did not pause application traffic.', + 'platform: Cancelled. No platform Worker changed. This command did not pause application traffic.', ); expect(confirm).toHaveBeenCalledWith({ message: 'Replace the configuration of these Workers?', diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 3a9916e63..67b485287 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -47,6 +47,13 @@ const state = vi.hoisted(() => ({ resume: vi.fn(), }, runtime: undefined as ReturnType | undefined, + wait: vi.fn(), +})); + +// Do not wait before a read is done again. +vi.mock('node:timers/promises', async (importOriginal) => ({ + ...(await importOriginal()), + setTimeout: state.wait, })); vi.mock('../../src/cli/output.ts', async (importOriginal) => ({ @@ -290,6 +297,7 @@ describe('installed platform upgrade', () => { state.listWorkerNames.mockReset().mockResolvedValue([]); state.request.mockReset().mockResolvedValue({ name: 'assets' }); state.getWorkerLastDeployedFrom.mockReset().mockResolvedValue('cf_cli'); + state.wait.mockReset().mockResolvedValue(undefined); state.cfRunner.mockReset(); vi.mocked(readInstallationRecoverySecrets).mockReturnValue({ databaseUrl: 'postgres://database', @@ -1130,6 +1138,7 @@ describe('installed platform upgrade', () => { describe('platform Workers changed outside Void', () => { const apiWorker = () => state.manifest!.deployment.resources.apiWorker; + const activeVersion = '44444444-4444-4444-8444-444444444444'; const withApiWorker = () => { const original = vi.mocked(createPlatformInstallPlan).getMockImplementation()!; vi.mocked(createPlatformInstallPlan).mockImplementationOnce(async (...args) => { @@ -1149,6 +1158,35 @@ describe('installed platform upgrade', () => { ], }; }); + // Void deployed the active version of the API Worker. + state.request.mockImplementation(async (path: string) => { + if (path.endsWith(`/${apiWorker()}/deployments`)) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-09-14T00:00:03.000Z', + versions: [{ version_id: activeVersion, percentage: 100 }], + }, + ], + }; + } + if (path.endsWith(`/versions/${activeVersion}`)) { + return { + annotations: { + 'workers/message': encodeCloudflareVersionMessage([], { + workers_dev: true, + preview_urls: false, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }), + }, + }; + } + return { name: 'assets' }; + }); }; const gateWrites = () => state.events.filter((event) => event.startsWith('put:')); @@ -1162,7 +1200,7 @@ describe('installed platform upgrade', () => { ); expect(error).toBeInstanceOf(Error); expect(error?.message).toContain( - `These platform Workers need your approval before this command replaces their configuration:\n${apiWorker()}: changed in the Cloudflare dashboard\nNothing changed. This command did not pause application traffic.`, + `These platform Workers need your approval before this command replaces their configuration:\n${apiWorker()}: changed in the Cloudflare dashboard\nNo platform Worker changed. This command did not pause application traffic.`, ); expect(error?.message).toContain( `Run this command once in a terminal to review them:\nvoid platform upgrade ${state.manifest!.id}`, @@ -1207,6 +1245,104 @@ describe('installed platform upgrade', () => { expect(state.events).toEqual([]); }); + describe('when Void cannot read the Worker', () => { + const failure = (status: number) => + new PlatformCloudflareApiError('Cloudflare error', status); + /** Answer the reads of one path with these answers, and the other reads as before. */ + const answer = (suffix: () => string, reply: () => Promise) => { + const original = state.request.getMockImplementation()!; + state.request.mockImplementation(async (path: string) => + path.endsWith(suffix()) ? reply() : original(path), + ); + }; + const reads = (suffix: string) => + state.request.mock.calls.filter(([path]) => String(path).endsWith(suffix)).length; + + it.each([ + { + name: 'Cloudflare refuses the token', + fail: () => state.getWorkerLastDeployedFrom.mockRejectedValue(failure(403)), + attempts: () => state.getWorkerLastDeployedFrom.mock.calls.length, + expected: { attempts: 1, message: '(HTTP 403)' }, + }, + { + name: 'Cloudflare fails three times', + fail: () => state.getWorkerLastDeployedFrom.mockRejectedValue(failure(500)), + attempts: () => state.getWorkerLastDeployedFrom.mock.calls.length, + expected: { attempts: 3, message: '(HTTP 500)' }, + }, + { + name: 'the network fails three times', + fail: () => + answer( + () => `/${apiWorker()}/deployments`, + async () => { + throw new TypeError('fetch failed'); + }, + ), + attempts: () => reads(`/${apiWorker()}/deployments`), + expected: { attempts: 3, message: '(no HTTP response: fetch failed)' }, + }, + { + name: 'the deployment data is malformed', + fail: () => + answer( + () => `/${apiWorker()}/deployments`, + async () => ({ deployments: {} }), + ), + attempts: () => reads(`/${apiWorker()}/deployments`), + expected: { attempts: 1, message: 'Cloudflare returned malformed deployment data' }, + }, + { + name: 'the version read fails three times', + fail: () => + answer( + () => `/versions/${activeVersion}`, + async () => { + throw failure(503); + }, + ), + attempts: () => reads(`/versions/${activeVersion}`), + expected: { attempts: 3, message: '(HTTP 503)' }, + }, + ])('stops before the installer when $name', async ({ fail, attempts, expected }) => { + withApiWorker(); + fail(); + + const error = await upgrade().then( + () => null, + (failure: unknown) => failure as Error, + ); + + expect(error?.message).toContain(apiWorker()); + expect(error?.message).toContain(expected.message); + expect(error?.message).toContain( + 'No platform Worker changed. This command did not pause application traffic.', + ); + expect(error?.message).toMatch(/run the command again\.$/i); + expect(attempts()).toBe(expected.attempts); + expect(installPlatform).not.toHaveBeenCalled(); + expect(state.progress.start).not.toHaveBeenCalled(); + expect(gateWrites()).toEqual([]); + expect(state.cfRunner).not.toHaveBeenCalled(); + }); + + it('reads again after a rate limit and runs', async () => { + withApiWorker(); + state.getWorkerLastDeployedFrom + .mockRejectedValueOnce(failure(429)) + .mockResolvedValue('cf_cli'); + + await upgrade(); + + expect(state.getWorkerLastDeployedFrom).toHaveBeenCalledTimes(2); + expect(installPlatform).toHaveBeenCalledOnce(); + expect(vi.mocked(installPlatform).mock.calls[0]![3]).not.toHaveProperty( + 'replaceOutsideChanges', + ); + }); + }); + describe('after a run stopped during a Worker cutover', () => { const voidVersion = '22222222-2222-4222-8222-222222222222'; const earlierVersion = '33333333-3333-4333-8333-333333333333'; @@ -1272,7 +1408,7 @@ describe('installed platform upgrade', () => { ); expect(error?.message).toContain( - `${apiWorker()}: has an active version without a Void deployment record\nNothing changed.`, + `${apiWorker()}: has an active version without a Void deployment record\nNo platform Worker changed.`, ); expect(state.request).toHaveBeenCalledWith( `/accounts/account/workers/scripts/${apiWorker()}/versions/${earlierVersion}`, diff --git a/packages/void/test/unit/platform-setup-flow.test.ts b/packages/void/test/unit/platform-setup-flow.test.ts index 897595b44..533b785b7 100644 --- a/packages/void/test/unit/platform-setup-flow.test.ts +++ b/packages/void/test/unit/platform-setup-flow.test.ts @@ -2005,6 +2005,23 @@ describe('platform credential onboarding', () => { const source = vi .spyOn(PlatformCloudflareClient.prototype, 'getWorkerLastDeployedFrom') .mockResolvedValue('api'); + // The deploy needs one active version, so the check reads the deployments. + const request = vi + .spyOn(PlatformCloudflareClient.prototype, 'request') + .mockImplementation(async (path: string) => { + if (path.endsWith(`/${manifest.deployment.resources.apiWorker}/deployments`)) { + return { + deployments: [ + { + id: 'deployment', + created_on: '2026-10-15T00:00:00.000Z', + versions: [{ version_id: '11111111-1111-4111-8111-111111111111', percentage: 100 }], + }, + ], + }; + } + throw new Error(`unexpected read ${path}`); + }); await expect( runPlatformLifecycleCommand( @@ -2012,10 +2029,11 @@ describe('platform credential onboarding', () => { ), ).rejects.toThrow( new RegExp( - `Nothing changed\\. This command did not pause application traffic\\.[\\s\\S]*platform install --resume --name ${manifest.id}$`, + `No platform Worker changed\\. This command did not pause application traffic\\.[\\s\\S]*platform install --resume --name ${manifest.id}$`, ), ); expect(source).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledOnce(); expect(mocks.install).not.toHaveBeenCalled(); }); it('uploads a resumed install’s Worker changed outside Void when the installer asks', async () => {