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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/platform/installation/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,6 @@ Resume restores your selected Hyperdrive. If that selection is missing from the

If Cloudflare rejects a saved runtime token, Void opens the token page and asks for a replacement. For a missing [email permission](/guide/platform/installation/credentials#enable-email), update the existing token's permissions and resume. If you supplied a token through an environment variable, update that variable.

To replace the runtime token, for example after revoking it, resume with the new token in `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Void checks it, saves it, and updates the platform Workers that the interrupted run already deployed.

:::
45 changes: 33 additions & 12 deletions packages/void/src/cli/platform-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,8 +933,11 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr
writePendingInstallationSecrets(setupManifest, values);
}
};
// After the draft stage, a resume keeps the saved values. The runtime token
// is the exception: an explicit replacement wins, and is sent to Workers
// that are already deployed (see `updateExistingWorkers` below).
const configured = (key: string, environment: string) =>
credentialSetup
credentialSetup || key === 'cloudflareApiToken'
? process.env[environment] || values[key] || ''
: values[key] || process.env[environment] || '';
const collect = async (
Expand Down Expand Up @@ -967,10 +970,9 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr
openPlatformSetupPage(pages.runtime);
}

const savedRuntimeToken = checkpoint?.cloudflareApiToken ?? '';
const usingSavedRuntimeToken =
credentialSetup &&
!process.env.VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN &&
Boolean(values.cloudflareApiToken);
!process.env.VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN && Boolean(values.cloudflareApiToken);
let runtimeCloudflareApiToken = await collect(
'cloudflareApiToken',
'VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN',
Expand Down Expand Up @@ -1004,7 +1006,6 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr
}
} catch (error) {
if (
credentialSetup &&
token === values.cloudflareApiToken &&
// The operator adds a missing email permission to the same token.
!(error instanceof PlatformRuntimeTokenEmailPermissionError) &&
Expand All @@ -1014,7 +1015,11 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr
)
) {
delete values.cloudflareApiToken;
writePendingInstallationSecrets(setupManifest, values);
// After the draft stage the installer saves the replacement, so a
// cancelled prompt keeps the token the deployed Workers still hold.
if (credentialSetup) {
writePendingInstallationSecrets(setupManifest, values);
}
rejectedSavedRuntimeToken = true;
}
throw error;
Expand Down Expand Up @@ -1151,22 +1156,38 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr
: 'project encryption key',
].filter(Boolean)
: [];
// A replaced runtime token must also reach the Workers already deployed,
// and a saved update must finish after a crash. Only the token is sent as
// an update: every other value comes from the checkpoint. Without a saved
// token (another machine) there is nothing to compare, so nothing is sent.
const replacingRuntimeToken =
!credentialSetup && !!savedRuntimeToken && runtimeCloudflareApiToken !== savedRuntimeToken;
const updateExistingWorkers =
replacingRuntimeToken || (!credentialSetup && Boolean(checkpoint?.workerSecretUpdatesJson));
const checkpointValue = (value: string) => (updateExistingWorkers ? '' : value);
const secrets = createPlatformInstallationSecrets({
configurableAuthentication: !!authenticationSelection.authentication.configurable,
...authenticationSecrets,
bootstrapGitHubId,
bootstrapGitHubLogin,
cloudflareApiToken: runtimeCloudflareApiToken,
cloudflareApiToken: replacingRuntimeToken
? runtimeCloudflareApiToken
: checkpointValue(runtimeCloudflareApiToken),
checkpoint: values,
databaseUrl: installationDatabaseUrl,
emailEnabled: plan.deployment.capabilities.email,
emailSigningSecret: process.env.VOID_PLATFORM_EMAIL_SIGNING_SECRET || '',
// A saved email key wins on resume, so the variable would only add an update.
emailSigningSecret:
updateExistingWorkers && values.emailSigningSecret
? ''
: process.env.VOID_PLATFORM_EMAIL_SIGNING_SECRET || '',
existingEmailEnabled: existing.deployment.capabilities.email,
githubClientId,
githubClientSecret,
r2AccessKeyId,
r2SecretAccessKey,
githubClientId: checkpointValue(githubClientId),
githubClientSecret: checkpointValue(githubClientSecret),
r2AccessKeyId: checkpointValue(r2AccessKeyId),
r2SecretAccessKey: checkpointValue(r2SecretAccessKey),
existingInstallation: !credentialSetup,
updateExistingWorkers,
generateIdentityKeys,
jwtSecret: generateIdentityKeys
? configured('jwtSecret', 'VOID_PLATFORM_JWT_SECRET')
Expand Down
61 changes: 61 additions & 0 deletions packages/void/test/unit/platform-install-cutover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createPlatformInstallPlan } from '../../src/cli/platform-plan.ts';
import {
listInstallationManifests,
readInstallationRecoverySecrets,
readPendingInstallationSecrets,
} from '../../src/cli/platform-registry.ts';
import type { RunWrangler } from '../../src/cli/provision.ts';

Expand Down Expand Up @@ -99,6 +100,7 @@ async function createFakeCloudflare() {
const r2 = new Map<string, string>();
const named = new Map<string, Map<string, string>>();
const commands: Array<{ args: CloudflareOperation; worker: string | null }> = [];
const secretUploads: Array<{ secrets: Record<string, string>; worker: string | null }> = [];
let clock = Date.parse('2026-10-01T00:00:00.000Z');
let failBlockedCreate: string | null = null;
let failUpload: string | null = null;
Expand Down Expand Up @@ -147,6 +149,12 @@ async function createFakeCloudflare() {
wrangler.run = async (args, opts) => {
const worker = workerOf(args);
commands.push({ args, worker });
if ('secretsFilePath' in args && args.secretsFilePath) {
secretUploads.push({
secrets: JSON.parse(readFileSync(args.secretsFilePath, 'utf8')),
worker,
});
}
if (args.type === 'worker.deploy') {
const base = `${opts.apiBaseUrl}/accounts/${opts.accountId}/workers/scripts/${worker}`;
if (failBlockedCreate === worker) {
Expand Down Expand Up @@ -309,6 +317,7 @@ async function createFakeCloudflare() {
},
kv,
planningClient,
secretUploads,
replaceActiveVersion: (name: string) => {
const worker = workers.get(name)!;
const active = worker.versions.get(worker.deployments[0]!.versions[0]!.version_id)!;
Expand Down Expand Up @@ -727,6 +736,58 @@ it('enables traffic when resuming after the first Worker create was refused', as
expect(workerStepKeys(resumed.manifest)).toContain('worker-cutover:2:committed');
});

it('sends a replaced runtime token to Workers an interrupted install already deployed', async () => {
const cloud = await createFakeCloudflare();
await expect(
runInstall(cloud, null, {
verifyHealth: async () => {
throw new Error('network unavailable');
},
}),
).rejects.toThrow('network unavailable');
const interrupted = currentManifest();
expect(interrupted.status).toBe('failed');
const { apiWorker, proxyWorker } = interrupted.deployment.resources;
// What `install --resume` builds: only an explicit runtime token is an update.
const resumeSecrets = (manifest: PlatformInstallationManifest, cloudflareApiToken: string) =>
createPlatformInstallationSecrets({
...installationSecrets(),
checkpoint: readPendingInstallationSecrets(manifest),
cloudflareApiToken,
githubClientId: '',
githubClientSecret: '',
r2AccessKeyId: '',
r2SecretAccessKey: '',
existingInstallation: true,
updateExistingWorkers: true,
});

// The first resume stops after the API Worker received the replacement.
cloud.failUpload(proxyWorker);
const firstResume = cloud.secretUploads.length;
await expect(
runInstall(cloud, interrupted, { secrets: resumeSecrets(interrupted, 'replacement-token') }),
).rejects.toThrow('upload failed');
expect(
cloud.secretUploads.slice(firstResume).find((upload) => upload.worker === apiWorker)?.secrets,
).toMatchObject({ CF_API_TOKEN: 'replacement-token' });
const stopped = currentManifest();
expect(readPendingInstallationSecrets(stopped)?.cloudflareApiToken).toBe('replacement-token');

// The next resume has no new token, yet still finishes the proxy update.
const secondResume = cloud.secretUploads.length;
const result = await runInstall(cloud, stopped, { secrets: resumeSecrets(stopped, '') });
expect(result.manifest.status).toBe('installed');
expect(
cloud.secretUploads.slice(secondResume).find((upload) => upload.worker === proxyWorker)
?.secrets,
).toEqual({ CF_API_TOKEN: 'replacement-token' });
expect(readInstallationRecoverySecrets(result.manifest)).toMatchObject({
cloudflareApiToken: 'replacement-token',
});
expect(readInstallationRecoverySecrets(result.manifest)?.workerSecretUpdatesJson).toBeUndefined();
});

it('retries a restored enabling generation with every Worker restaged', async () => {
const cloud = await createFakeCloudflare();
const plan = await planInstallation(cloud, null);
Expand Down
149 changes: 148 additions & 1 deletion packages/void/test/unit/platform-setup-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,10 @@ describe('platform credential onboarding', () => {
...readPendingInstallationSecrets(manifest),
managementApiToken: 'saved-management-secret',
});
for (const [key, value] of Object.entries(environment)) {
// An explicit runtime token would replace the saved one.
const { VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN: _runtime, ...resumeEnvironment } =
environment;
for (const [key, value] of Object.entries(resumeEnvironment)) {
vi.stubEnv(key, value);
}
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ success: true, result: {} }));
Expand Down Expand Up @@ -1417,6 +1420,150 @@ describe('platform credential onboarding', () => {
projectSecretKey: environment.VOID_PLATFORM_PROJECT_SECRET_KEY,
});
});
describe('runtime token on an interrupted install resume', () => {
const replacement = 'replacement-runtime-token';
const resume = (manifest: { id: string }) =>
runPlatformLifecycleCommand(
parsePlatformArgs(['install', '--resume', '--name', manifest.id]),
);
const stubEnvironment = (runtimeToken: string) => {
for (const [key, value] of Object.entries(environment)) {
vi.stubEnv(key, value);
}
vi.stubEnv('VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN', runtimeToken);
};

it('uses an explicit replacement and sends it to deployed Workers', async () => {
const manifest = await interruptedInstall();
stubEnvironment(replacement);
await resume(manifest);
expect(mocks.password).not.toHaveBeenCalled();
expect(mocks.install.mock.calls[0]![1]).toMatchObject({
cloudflareApiToken: replacement,
githubClientId: environment.VOID_PLATFORM_GITHUB_CLIENT_ID,
r2SecretAccessKey: environment.VOID_PLATFORM_R2_SECRET_ACCESS_KEY,
});
// Only the token is an update; the other values are not replacements.
expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({
CF_API_TOKEN: replacement,
});
});

it.each(['', 'saved-runtime-token'])(
'keeps deployed Workers as they are without a different token (env %j)',
async (runtimeToken) => {
const manifest = await interruptedInstall();
stubEnvironment(runtimeToken);
await resume(manifest);
expect(mocks.install.mock.calls[0]![1].cloudflareApiToken).toBe('saved-runtime-token');
expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({});
expect(mocks.install.mock.calls[0]![1].pendingWorkerSecretUpdateIntent).toBeUndefined();
},
);

it('sends no update when no token was saved to compare with', async () => {
const manifest = await interruptedInstall();
// Another machine: the pending file holds no runtime token.
const { cloudflareApiToken: _saved, ...pending } = readPendingInstallationSecrets(manifest)!;
writePendingInstallationSecrets(manifest, pending);
stubEnvironment(replacement);
await resume(manifest);
expect(mocks.install.mock.calls[0]![1].cloudflareApiToken).toBe(replacement);
expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({});
});

it('finishes a saved token update after a crash', async () => {
const manifest = await interruptedInstall();
const intent = {
baselineGeneration: 0,
generation: null,
id: 'intent-1',
schema: 1,
updates: { CF_API_TOKEN: replacement },
};
// The installer saved the replacement and its update before it stopped.
writePendingInstallationSecrets(manifest, {
...readPendingInstallationSecrets(manifest),
cloudflareApiToken: replacement,
workerSecretUpdatesJson: JSON.stringify(intent),
});
stubEnvironment('');
await resume(manifest);
expect(mocks.install.mock.calls[0]![1]).toMatchObject({
cloudflareApiToken: replacement,
pendingWorkerSecretUpdateIntent: intent,
});
expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({});
});

it('does not turn a saved email key into an update', async () => {
const manifest = await interruptedInstall();
const emailManifest = {
...manifest,
deployment: {
...manifest.deployment,
capabilities: { ...manifest.deployment.capabilities, email: true },
},
};
writeInstallationManifest(emailManifest);
const emailSigningSecret = 'e'.repeat(32);
writePendingInstallationSecrets(emailManifest, {
...readPendingInstallationSecrets(manifest),
emailSigningSecret,
});
stubEnvironment(replacement);
vi.stubEnv('VOID_PLATFORM_EMAIL_SIGNING_SECRET', emailSigningSecret);
await resume(emailManifest);
expect(mocks.install.mock.calls[0]![1].emailSigningSecret).toBe(emailSigningSecret);
expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({
CF_API_TOKEN: replacement,
});
});

it('asks for a replacement when Cloudflare rejects the saved token', async () => {
const manifest = await interruptedInstall();
vi.spyOn(log, 'warn').mockImplementation(() => {});
stubEnvironment('');
mocks.preflight.mockRejectedValueOnce(
new PlatformCloudflareApiError('Authentication error', 403),
);
mocks.password.mockResolvedValueOnce(replacement);
await resume(manifest);
expect(mocks.preflight).toHaveBeenCalledTimes(2);
expect(mocks.install.mock.calls[0]![1].cloudflareApiToken).toBe(replacement);
expect(mocks.install.mock.calls[0]![1].workerSecretUpdates).toEqual({
CF_API_TOKEN: replacement,
});
});

it('keeps the saved token when the replacement prompt is cancelled', async () => {
const manifest = await interruptedInstall();
vi.spyOn(log, 'warn').mockImplementation(() => {});
stubEnvironment('');
mocks.preflight.mockRejectedValueOnce(
new PlatformCloudflareApiError('Authentication error', 401),
);
mocks.password.mockResolvedValueOnce(Symbol('cancel'));
await expect(resume(manifest)).rejects.toThrow('Cancelled');
expect(readPendingInstallationSecrets(manifest)?.cloudflareApiToken).toBe(
'saved-runtime-token',
);
expect(mocks.install).not.toHaveBeenCalled();
});

it('fails without a prompt when Cloudflare rejects an explicit token', async () => {
const manifest = await interruptedInstall();
stubEnvironment(replacement);
mocks.preflight.mockRejectedValueOnce(
new PlatformCloudflareApiError('Authentication error', 403),
);
await expect(resume(manifest)).rejects.toThrow('Authentication error');
expect(mocks.password).not.toHaveBeenCalled();
expect(readPendingInstallationSecrets(manifest)?.cloudflareApiToken).toBe(
'saved-runtime-token',
);
});
});
it('requires identity keys from the environment on a non-interactive install', async () => {
for (const [key, value] of Object.entries(environment)) {
if (key !== 'VOID_PLATFORM_JWT_SECRET' && key !== 'VOID_PLATFORM_PROJECT_SECRET_KEY') {
Expand Down
Loading