Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,23 @@ jobs:
# workflow cannot exfiltrate it. The `if` also keeps the job off everything except a direct push to
# the `main` branch: tag pushes and `workflow_call` (release) carry a non-main ref, so they are
# skipped — which both avoids a branch-policy failure and matches where the secret is allowed, and
# it is scoped to the canonical repo so forks don't run it. It deploys a UNIQUELY-named worker
# (VOID_E2E_RUN_ID) to a throwaway account and tears it down. A preflight step fails the job when a
# credential is empty (unset / deleted / not-yet-rotated token), so a missing secret can never turn
# it is scoped to the canonical repo so forks don't run it. It updates a reusable test Worker
# and retains its resources; job concurrency serializes that shared target. A preflight step fails
# the job when a credential is empty (unset / deleted / not-yet-rotated token), so a missing secret can never turn
# the live-deploy gate into a silent green no-op (the `test` job's uncredentialed run still skips).
live-e2e:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'voidzero-dev/void' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
environment: cloudflare-e2e
concurrency:
group: cloudflare-live-e2e
cancel-in-progress: false
env:
VOID_E2E_CF_TOKEN: ${{ secrets.VOID_E2E_CF_TOKEN }}
VOID_E2E_CF_ACCOUNT_ID: ${{ secrets.VOID_E2E_CF_ACCOUNT_ID }}
VOID_E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
VOID_E2E_NAMESPACE: ci
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
13 changes: 9 additions & 4 deletions docs/guide/platform/installation/prerequisites.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,27 @@ To run a Void platform, you need:

Void creates the Workers, storage, queues, routing, and database tables through the CLI.

Workers for Platforms requires a paid plan. Your database and Cloudflare usage are billed separately.
Workers for Platforms requires its own paid plan. Analytics Engine is available on Workers Free, but you must enable it for the account. Review [Analytics Engine pricing and allowances](https://developers.cloudflare.com/analytics/analytics-engine/pricing/). Your database and Cloudflare usage are billed separately.

## Prepare Your Cloudflare Account and Domain

In the [Cloudflare dashboard](https://dash.cloudflare.com/), select the account where you want the platform to live:

1. Open **Workers & Pages** once to let Cloudflare provision Workers for the account.
2. Open **Workers for Platforms** and enable its plan. Review [Workers for Platforms pricing](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/pricing/) before confirming.
2. Open **Workers for Platforms** and enable its plan. Review [Workers for Platforms pricing](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/reference/pricing/) before confirming.
3. Open **R2 Object Storage** and complete its activation. Void creates the bucket later.
4. For a domain installation, choose a domain you own, such as `example.app`. If you need one, register it with your preferred registrar. Use a spare domain's root: apps will be served at `my-app.example.app`. For workers.dev testing, skip this step and the DNS setup below.
4. Open **Analytics Engine** and select **Enable** if prompted. You do not need to create a dataset manually. If the page has no **Enable** action and installation reports code 10089, follow the [steps below](#if-cloudflare-rejects-analytics-engine).
5. For a domain installation, choose a domain you own, such as `example.app`. If you need one, register it with your preferred registrar. Use a spare domain's root: apps will be served at `my-app.example.app`. For workers.dev testing, skip this step and the DNS setup below.

If that domain is already in this Cloudflare account, use its existing zone. Otherwise, [add the domain to Cloudflare](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/) and follow the nameserver instructions until the zone is active. A zone is Cloudflare's DNS configuration for a domain; creating one does not buy the domain.

You can also let Void create the zone during installation. Setting it up now lets you scope the API tokens to that zone and finish installation without a DNS pause. The platform API uses `workers.dev` by default, so it needs no additional domain.

### If Cloudflare rejects Analytics Engine

The platform stores request metrics and logs in Analytics Engine. If installation stops with "You need to enable Analytics Engine" (code 10089), add an Analytics Engine binding to a Worker that is not part of the platform under **Settings** > **Bindings**, and deploy it once from the dashboard. A new Hello World Worker works. Do not edit the platform's own Workers. Then run the printed `void platform install --resume` command.
The platform stores request metrics and logs in Analytics Engine. If installation stops with "You need to enable Analytics Engine" (code 10089), open the Analytics Engine dashboard link in the error and select **Enable** for that account. Activation is available on Workers Free; this error does not require a Workers plan upgrade or a replacement API token. Then run the printed `void platform install --resume` command.

If your account's dashboard has no **Enable** action, create a disposable Hello World Worker, add an Analytics Engine binding under **Settings** > **Bindings**, and deploy it once from the dashboard. Then resume installation. Use a separate Worker for this step; leave the platform's own Workers unchanged.

## Install the CLI and Preview

Expand Down Expand Up @@ -82,6 +85,8 @@ Use any PostgreSQL provider that accepts connections from your computer and Clou
| [PlanetScale Postgres](https://planetscale.com/docs/postgres/connecting) | Choose **Postgres**, not Vitess/MySQL. Open **Connect**, create role credentials, and use the direct primary connection on port `5432`, not PgBouncer on `6432`. |
| [Neon](https://neon.com/docs/get-started-with-neon/connect-neon) | Open **Connect**, select the database and owner role, and turn **connection pooling off**. |

When sizing your database, include the connection pools for every platform installation and application sharing that server. Adjust each Hyperdrive's [origin connection limit](https://developers.cloudflare.com/hyperdrive/configuration/tune-connection-pool/) to fit the server's capacity, leaving room for migrations, administration, provider-reserved connections, and temporary bursts.

Once installation claims the database, continue using that same database for resume and maintenance commands. If installation stops because it cannot use the database, for example because the database is not empty, set `VOID_PLATFORM_DATABASE_URL` to another database's URL before you resume. Uninstall never deletes external PostgreSQL.

### Use a provider CLI
Expand Down
54 changes: 51 additions & 3 deletions packages/void/src/cli/platform-cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto';
import { formatUrl } from './output.ts';
import { resolvePinnedApiBaseUrl } from './provision.ts';

type CloudflareEnvelope<T> = Readonly<{
Expand All @@ -25,6 +26,16 @@ export class PlatformCloudflareApiError extends Error {
}
}

export class PlatformCloudflarePrerequisiteError extends PlatformCloudflareApiError {
override readonly cause: PlatformCloudflareApiError;

constructor(message: string, cause: PlatformCloudflareApiError) {
super(message, cause.status, cause.requestPath);
this.name = 'PlatformCloudflarePrerequisiteError';
this.cause = cause;
}
}

export class PlatformWorkerCustomDomainsChangedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
Expand Down Expand Up @@ -326,11 +337,17 @@ export class PlatformCloudflareClient {
}
if (!response.ok || envelope.success !== true || envelope.result === undefined) {
const details = cloudflareErrorDetails(envelope, response.status);
throw new PlatformCloudflareApiError(
`Cloudflare API error: ${details} (HTTP ${response.status}; ${init?.method ?? 'GET'} ${path.split('?')[0]}).`,
const requestPath = path.split('?')[0];
const error = new PlatformCloudflareApiError(
`Cloudflare API error: ${details} (HTTP ${response.status}; ${init?.method ?? 'GET'} ${requestPath}).`,
response.status,
path.split('?')[0],
requestPath,
);
const prerequisite = cloudflarePrerequisiteHelp(envelope, response.status, requestPath);
if (prerequisite) {
throw new PlatformCloudflarePrerequisiteError(prerequisite, error);
}
throw error;
}
return { result: envelope.result, resultInfo: envelope.result_info };
}
Expand Down Expand Up @@ -1427,6 +1444,37 @@ function deletionPath(
}
}

function cloudflarePrerequisiteHelp(
envelope: CloudflareEnvelope<unknown>,
status: number,
path: string,
): string | undefined {
if (status !== 403) {
return;
}
const [, scope, accountId, service, resource] = path.split('/');
if (scope !== 'accounts' || !accountId) {
return;
}
if (service === 'r2' && envelope.errors?.some((error) => error.code === 10042)) {
return (
'Cloudflare R2 is not enabled for this account (code 10042). ' +
`Complete R2 activation at ${formatUrl(`https://dash.cloudflare.com/${accountId}/r2/overview`)}, then retry the same command.`
);
}
if (
service === 'workers' &&
resource === 'dispatch' &&
envelope.errors?.some((error) => error.code === 10121)
) {
return (
'Cloudflare Workers for Platforms requires a paid subscription for this account (code 10121). ' +
`Enable it at ${formatUrl(`https://dash.cloudflare.com/${accountId}/workers-for-platforms`)}. ` +
'Enterprise customers can contact their account team. Then retry the same command.'
);
}
}

function cloudflareErrorDetails(envelope: CloudflareEnvelope<unknown>, status: number): string {
return (
envelope.errors
Expand Down
7 changes: 7 additions & 0 deletions packages/void/src/cli/platform-installer-cutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export function recordWorkerSecretUpdateState(
);
}

export function workerCutoverUpdatesSecrets(
manifest: PlatformInstallationManifest,
generation: number,
): boolean {
return manifest.steps.some((step) => step.key === `worker-secret-updates:${generation}:pending`);
}

export function recordWorkerCutoverStage(
manifest: PlatformInstallationManifest,
generation: number,
Expand Down
26 changes: 18 additions & 8 deletions packages/void/src/cli/platform-installer-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import {
stabilizeCloudflareVersionTriggers,
type CloudflareCommandResult,
} from '@void/deploy-cloudflare';
import { formatUrl } from './output.ts';

// Cloudflare refuses a Worker with an Analytics Engine binding (code 10089)
// until the account has used Analytics Engine once. No API turns it on, and
// the dashboard page may offer only "Create dataset"; deploying a binding from
// the dashboard once enables it (cloudflare/workers-sdk#9312).
// until Analytics Engine is enabled for the account, including on Workers Free.
const ANALYTICS_ENGINE_NOT_ENABLED = 10089;

// Without a terminal, the Cloudflare CLI stops an upload when it must ask
Expand Down Expand Up @@ -67,6 +66,7 @@ function uploadConflictMessage(result: CloudflareCommandResult, operation: strin
export function assertCleanPlatformWrangler(
result: CloudflareCommandResult,
operation: string,
accountId?: string,
): void {
if (result.status !== 0 || result.signal || result.error) {
const conflict = uploadConflictMessage(result, operation);
Expand All @@ -80,12 +80,22 @@ export function assertCleanPlatformWrangler(
const analyticsEngineDisabled =
result.errorCode === ANALYTICS_ENGINE_NOT_ENABLED ||
result.errors?.some((error) => error.code === ANALYTICS_ENGINE_NOT_ENABLED) ||
/\b10089\b/.test(diagnostic ?? '');
(result.errorCode === undefined &&
!result.errors?.some((error) => error.code !== undefined) &&
(diagnostic?.includes('[code: 10089]') || diagnostic?.includes('[10089]')));
if (analyticsEngineDisabled) {
const dashboard = accountId
? `https://dash.cloudflare.com/${accountId}/workers/analytics-engine`
: 'https://dash.cloudflare.com/';
throw new Error(
`platform: Cloudflare could not ${operation}: Analytics Engine is not enabled for this account (code 10089). ` +
`Open ${formatUrl(dashboard)}${accountId ? '' : ', select your account, and open Analytics Engine'}, then select Enable. ` +
'Analytics Engine is available on Workers Free. After enabling it, use the printed recovery command if one is shown; otherwise retry this command. ' +
`If Enable is unavailable, see ${formatUrl('https://void.cloud/guide/platform/installation/prerequisites#if-cloudflare-rejects-analytics-engine')}.`,
);
}
throw new Error(
`platform: Wrangler failed to ${operation} (${diagnostic || `exit ${result.status}`}).` +
(analyticsEngineDisabled
? ' If the Analytics Engine page offers no way to enable it, follow https://void.cloud/guide/platform/installation/prerequisites#if-cloudflare-rejects-analytics-engine, then retry.'
: ''),
`platform: Cloudflare failed to ${operation} (${diagnostic || `exit ${result.status}`}).`,
);
}
}
Expand Down
33 changes: 23 additions & 10 deletions packages/void/src/cli/platform-installer-health.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setTimeout as delay } from 'node:timers/promises';
import { normalizeApplicationDomain, PLATFORM_HEALTH_HOSTNAME_LABEL } from '@void/platform';
import { cfAccessHeaders } from '../shared/cf-access.ts';
import { type PlatformHealthCheckContext } from './platform-installer-types.ts';
Expand Down Expand Up @@ -72,16 +73,28 @@ export async function verifyPlatformHealth(
dispatchOrigin?: string,
context?: PlatformHealthCheckContext,
): Promise<void> {
const request = (url: string) =>
fetcher(url, {
headers: {
'X-Void-Platform-Probe': healthProbeToken,
...cfAccessHeaders(process.env, url),
},
// A login redirect must not forward the installation's probe credential.
redirect: 'manual',
signal: AbortSignal.timeout(30_000),
});
const request = async (url: string): Promise<Response> => {
for (let attempt = 0; ; attempt++) {
try {
return await fetcher(url, {
headers: {
'X-Void-Platform-Probe': healthProbeToken,
...cfAccessHeaders(process.env, url),
},
// A login redirect must not forward the installation's probe credential.
redirect: 'manual',
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
if (attempt === 2) {
throw new Error(`platform: Health check could not reach ${url} after 3 attempts.`, {
cause: error,
});
}
await delay(250 * (attempt + 1));
}
}
};
// Project Zero Trust leaves only this application-domain hostname outside Access.
const dispatchUrl =
dispatchOrigin ?? `https://${PLATFORM_HEALTH_HOSTNAME_LABEL}.${applicationBaseDomain}`;
Expand Down
6 changes: 3 additions & 3 deletions packages/void/src/cli/platform-installer-inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function readActivePlatformWorkerVersion(
true,
);
if (result.status !== 0) {
assertCleanPlatformWrangler(result, `read active version for ${workerName}`);
assertCleanPlatformWrangler(result, `read active version for ${workerName}`, accountId);
}
const value = result.data;
if (
Expand Down Expand Up @@ -136,7 +136,7 @@ export async function readPlatformWorkerDeployments(
true,
);
if (result.status !== 0) {
assertCleanPlatformWrangler(result, `read deployment lineage for ${workerName}`);
assertCleanPlatformWrangler(result, `read deployment lineage for ${workerName}`, accountId);
}
const value = result.data;
if (!Array.isArray(value)) {
Expand Down Expand Up @@ -342,7 +342,7 @@ export async function readPlatformWorkerVersionSnapshot(
process.cwd(),
true,
);
assertCleanPlatformWrangler(result, `read rollback metadata for ${workerName}`);
assertCleanPlatformWrangler(result, `read rollback metadata for ${workerName}`, accountId);
const value = result.data;
if (!value || typeof value !== 'object') {
throw new Error(`platform: Wrangler returned malformed version metadata for ${workerName}.`);
Expand Down
12 changes: 8 additions & 4 deletions packages/void/src/cli/platform-installer-outside-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
PlatformCloudflareApiError,
type PlatformCloudflareClient,
} from './platform-cloudflare.ts';
import { interruptedWorkerCutover } from './platform-installer-cutover.ts';
import {
interruptedWorkerCutover,
workerCutoverUpdatesSecrets,
} from './platform-installer-cutover.ts';
import {
platformWorkerVersionHasTriggerRecord,
readActivePlatformWorkerDeployment,
Expand Down Expand Up @@ -116,9 +119,9 @@ function needsReviewUpload(change: PlatformWorkerOutsideChange): boolean {
* 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.
* installer, retain rotated credentials or a configurable sign-in transition
* that already requires sign-in. Every Worker that this check reads exists,
* so the installation had Workers before.
*/
async function restoredPlatformWorkerVersions(
installation: PlatformOutsideChangeInstallation,
Expand All @@ -127,6 +130,7 @@ async function restoredPlatformWorkerVersions(
const cutover = interruptedWorkerCutover(installation.manifest);
if (
!cutover ||
workerCutoverUpdatesSecrets(installation.manifest, cutover.generation) ||
(installation.manifest.deployment.authentication.configurable !== true &&
runtime.manifest.authenticationProtocolVersion === 1 &&
(await installation.readAuthenticationState?.())?.sessionsRequired === true)
Expand Down
Loading
Loading