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
1 change: 1 addition & 0 deletions packages/deploy-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
type CloudflareVersionActivation,
type CloudflareVersionActivationOptions,
type CloudflareVersionMetadata,
type CloudflareWorkersDevSubdomainLookup,
} from './version.ts';
export { createCloudflareMetadataRunner } from './metadata.ts';
export { cloudflareCommandDiagnostic } from './diagnostics.ts';
Expand Down
4 changes: 3 additions & 1 deletion packages/deploy-cloudflare/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ type WorkerTarget = ConfigTarget & { workerName?: string };

export type CloudflareOperation =
| ({ type: 'auth.whoami' | 'auth.login' | 'auth.logout' | 'auth.token' } & ConfigTarget)
| ({ type: 'd1.list' | 'kv.namespace.list' | 'containers.list' } & ConfigTarget)
| ({
type: 'd1.list' | 'kv.namespace.list' | 'containers.list' | 'workers.subdomain.get';
} & ConfigTarget)
| ({ type: 'd1.info'; name: string } & ConfigTarget)
| { type: 'd1.create'; name: string; binding: string; configPath: string }
| { type: 'd1.execute'; database: string; sql: string; configPath: string }
Expand Down
58 changes: 57 additions & 1 deletion packages/deploy-cloudflare/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export type CloudflareTrafficConfig = Pick<
'workers_dev' | 'preview_urls' | 'routes'
>;

/**
* Result of an account workers.dev subdomain lookup. `uploader-changed` means the lookup refused
* to run a changed uploader with the session credentials.
*/
export type CloudflareWorkersDevSubdomainLookup =
| { ok: true; subdomain: string }
| { ok: false; reason: 'unavailable' | 'uploader-changed' };

export type CloudflareVersionActivation =
| {
ok: true;
Expand Down Expand Up @@ -795,6 +803,12 @@ export type CloudflareVersionActivationOptions = {
readinessHeaders?: Readonly<Record<string, string>>;
/** Account workers.dev subdomain used when this Worker cannot expose version preview URLs. */
workersDevSubdomain?: string;
/**
* Look up the account workers.dev subdomain when a first publish reports no URL and
* `workersDevSubdomain` is not set. The subdomain includes the compliance-region label, for
* example `team.fed` for FedRAMP High. A thrown error counts as `unavailable`.
*/
resolveWorkersDevSubdomain?: () => Promise<CloudflareWorkersDevSubdomainLookup>;
/**
* Operator-provided workers.dev subdomain that may receive readiness credentials even when
* Wrangler cannot report a preview URL. Cached/project-controlled values must not be used here.
Expand Down Expand Up @@ -1988,11 +2002,53 @@ async function bootstrapFirstDeployment(
const desiredMetadata = parseCloudflareVersionMessage(options.message);
const desiredTriggerConfig = desiredTriggerConfigFor(desiredMetadata, options.configPath);
const reportedUrl = published?.previewUrl ?? published?.workersDevUrl;
const reportedOrConfiguredUrl =
let reportedOrConfiguredUrl =
reportedUrl ??
(desiredTriggerConfig.workers_dev
? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain)
: customRouteReadinessUrl(desiredTriggerConfig.routes));
if (
versionId &&
!reportedOrConfiguredUrl &&
desiredTriggerConfig.workers_dev &&
!desiredTriggerConfig.preserve &&
!desiredTriggerConfig.preserveWorkersDev &&
!options.preserveTriggers &&
options.resolveWorkersDevSubdomain
) {
// A publish over a Worker with workers.dev disabled, such as the draft that
// `secret put` creates, reports no URL. Trigger synchronization below
// publishes `workers_dev: true` when no trigger is preserved, so the
// readiness URL needs only the account subdomain. The lookup can run the
// uploader to read the session token.
if (!options.verifyUploader()) {
return {
ok: false,
reason: 'uploader-changed',
versionId,
previousVersionId,
rollback: 'unsafe',
};
}
let lookup: CloudflareWorkersDevSubdomainLookup;
try {
lookup = await options.resolveWorkersDevSubdomain();
} catch {
lookup = { ok: false, reason: 'unavailable' };
}
if (!lookup.ok && lookup.reason === 'uploader-changed') {
return {
ok: false,
reason: 'uploader-changed',
versionId,
previousVersionId,
rollback: 'unsafe',
};
}
reportedOrConfiguredUrl = lookup.ok
? workersDevReadinessUrl(options.workerName, lookup.subdomain)
: undefined;
}
if (!versionId || !reportedOrConfiguredUrl) {
return {
ok: false,
Expand Down
232 changes: 232 additions & 0 deletions packages/deploy-cloudflare/test/unit/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ function customDomainTriggers(pattern: string): CloudflareTriggerConfig {
});
}

function noWorkersDevMessage(): string {
return encodeCloudflareVersionMessage(
[],
triggersWith({ workers_dev: false, preview_urls: false }),
);
}

function preservedTriggerMessage(): string {
return encodeCloudflareVersionMessage([], triggersWith({ preserve: true }));
}

function readConfigArg(args: Array<string>): Record<string, unknown> {
return JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8')) as Record<string, unknown>;
}
Expand Down Expand Up @@ -502,6 +513,227 @@ describe('Cloudflare version activation', () => {
expect(fixture.fetcher).toHaveBeenCalledOnce();
});

// A draft Worker from `void secret put` has workers.dev disabled, so its first
// publish reports a version id without a URL (issue #573).
function urlLessDraftPublish(
fixture: ReturnType<typeof sdkDraftActivation>,
publishedTriggers: Array<Record<string, unknown>> = [],
) {
return vi.fn(async (operation: CloudflareOperation): Promise<CloudflareCommandResult> => {
if (operation.type === 'triggers.deploy' && operation.configPath) {
publishedTriggers.push(
JSON.parse(readFileSync(operation.configPath, 'utf8')) as Record<string, unknown>,
);
}
const result = await fixture.runWrangler(operation);
return operation.type === 'worker.deploy' ? { ...result, data: { versionId: NEXT } } : result;
});
}

it.each([true, false])(
'looks up the account subdomain when a draft publish reports no URL (atomic=%s)',
async (atomic) => {
const fixture = sdkDraftActivation();
const publishedTriggers: Array<Record<string, unknown>> = [];
const resolveWorkersDevSubdomain = vi.fn(async () => ({
ok: true as const,
subdomain: 'example',
}));
const result = await activateCloudflareVersion({
...fixture.options,
runWrangler: urlLessDraftPublish(fixture, publishedTriggers),
atomic,
resolveWorkersDevSubdomain,
});
expect(result.ok ? null : result.reason).toBeNull();
expect(result).toMatchObject({
ok: true,
versionId: NEXT,
previousVersionId: CURRENT,
previewUrl: 'https://app.example.workers.dev',
workersDevSubdomain: 'example',
});
expect(resolveWorkersDevSubdomain).toHaveBeenCalledOnce();
expect(fixture.events).toEqual(['source', 'pin', 'publish', 'triggers', 'ready']);
expect(publishedTriggers).toMatchObject([{ workers_dev: true }]);
expect(fixture.fetcher).toHaveBeenCalledOnce();
},
);

// The CLI marks traffic settings that the config leaves unset as preserved.
// Most projects do not set workers_dev, so this is the usual message.
it.each([true, false])(
'looks up the account subdomain for the CLI message that preserves unset traffic (atomic=%s)',
async (atomic) => {
const fixture = sdkDraftActivation();
const publishedTriggers: Array<Record<string, unknown>> = [];
const resolveWorkersDevSubdomain = vi.fn(async () => ({
ok: true as const,
subdomain: 'example',
}));
const result = await activateCloudflareVersion({
...fixture.options,
message: encodeCloudflareVersionMessage(
[],
triggersWith({
preserveWorkersDev: true,
preservePreviewUrls: true,
preserveRoutes: true,
}),
),
runWrangler: urlLessDraftPublish(fixture, publishedTriggers),
atomic,
resolveWorkersDevSubdomain,
});
expect(result.ok ? null : result.reason).toBeNull();
expect(result).toMatchObject({ ok: true, previewUrl: 'https://app.example.workers.dev' });
expect(resolveWorkersDevSubdomain).toHaveBeenCalledOnce();
expect(publishedTriggers).toMatchObject([{ workers_dev: true }]);
},
);

it('sends Access headers only to the FedRAMP High workers.dev host from the lookup', async () => {
const fixture = sdkDraftActivation();
const probes: Array<{ url: string; secret: string | null }> = [];
const result = await activateCloudflareVersion({
...fixture.options,
runWrangler: urlLessDraftPublish(fixture),
atomic: true,
readinessHeaders: {
'CF-Access-Client-Id': 'synthetic.access',
'CF-Access-Client-Secret': 'synthetic-secret',
},
resolveWorkersDevSubdomain: async () => ({ ok: true, subdomain: 'example.fed' }),
fetch: async (url, init) => {
probes.push({
url: String(url),
secret: new Headers(init?.headers).get('cf-access-client-secret'),
});
return Response.json({ ready: true, versionId: NEXT });
},
});
expect(result).toMatchObject({
ok: true,
previewUrl: 'https://app.example.fed.workers.dev',
workersDevSubdomain: 'example.fed',
});
expect(probes).toEqual([
{ url: 'https://app.example.fed.workers.dev/__void/ready', secret: 'synthetic-secret' },
]);
});

it.each([
{ atomic: true, outcome: 'unavailable' },
{ atomic: false, outcome: 'unavailable' },
{ atomic: true, outcome: 'thrown' },
] as const)(
'stops before triggers when no workers.dev URL is known (atomic=$atomic, $outcome)',
async ({ atomic, outcome }) => {
const fixture = sdkDraftActivation();
const resolveWorkersDevSubdomain = vi.fn(async () => {
if (outcome === 'thrown') {
throw new Error('synthetic lookup failure');
}
return { ok: false as const, reason: 'unavailable' as const };
});
const result = await activateCloudflareVersion({
...fixture.options,
runWrangler: urlLessDraftPublish(fixture),
atomic,
resolveWorkersDevSubdomain,
});
expect(result).toEqual({
ok: false,
reason: atomic ? 'atomic-output-invalid' : 'bootstrap-output-invalid',
versionId: NEXT,
previousVersionId: CURRENT,
});
expect(fixture.events).not.toContain('triggers');
expect(fixture.fetcher).not.toHaveBeenCalled();
},
);

it('reports an uploader change that the lookup detects', async () => {
const fixture = sdkDraftActivation();
const result = await activateCloudflareVersion({
...fixture.options,
runWrangler: urlLessDraftPublish(fixture),
atomic: true,
resolveWorkersDevSubdomain: async () => ({ ok: false, reason: 'uploader-changed' }),
});
expect(result).toEqual({
ok: false,
reason: 'uploader-changed',
versionId: NEXT,
previousVersionId: CURRENT,
rollback: 'unsafe',
});
expect(fixture.events).not.toContain('triggers');
expect(fixture.fetcher).not.toHaveBeenCalled();
});

it('does not look up the account subdomain after the uploader changes', async () => {
const fixture = sdkDraftActivation();
const resolveWorkersDevSubdomain = vi.fn(async () => ({
ok: true as const,
subdomain: 'example',
}));
const result = await activateCloudflareVersion({
...fixture.options,
runWrangler: urlLessDraftPublish(fixture),
atomic: true,
verifyUploader: () => !fixture.events.includes('publish'),
resolveWorkersDevSubdomain,
});
expect(result).toEqual({
ok: false,
reason: 'uploader-changed',
versionId: NEXT,
previousVersionId: CURRENT,
rollback: 'unsafe',
});
expect(resolveWorkersDevSubdomain).not.toHaveBeenCalled();
expect(fixture.events).not.toContain('triggers');
});

it.each([
{ label: 'workers.dev is off', options: { message: noWorkersDevMessage() } },
{ label: 'a subdomain is configured', options: { workersDevSubdomain: 'example' } },
{ label: 'triggers are preserved', options: { preserveTriggers: true } },
{ label: 'the snapshot is preserved', options: { message: preservedTriggerMessage() } },
])('does not look up the account subdomain when $label', async ({ options }) => {
const fixture = sdkDraftActivation();
const resolveWorkersDevSubdomain = vi.fn(async () => ({
ok: true as const,
subdomain: 'other',
}));
const result = await activateCloudflareVersion({
...fixture.options,
runWrangler: urlLessDraftPublish(fixture),
atomic: true,
...options,
resolveWorkersDevSubdomain,
});
expect(resolveWorkersDevSubdomain).not.toHaveBeenCalled();
if ('workersDevSubdomain' in options) {
expect(result).toMatchObject({ ok: true, previewUrl: 'https://app.example.workers.dev' });
} else {
expect(result).toMatchObject({ ok: false, reason: 'atomic-output-invalid' });
}
});

it('does not look up the account subdomain when the publish reports a URL', async () => {
const fixture = sdkDraftActivation();
const resolveWorkersDevSubdomain = vi.fn(async () => ({
ok: true as const,
subdomain: 'other',
}));
expect(
await activateCloudflareVersion({ ...fixture.options, resolveWorkersDevSubdomain }),
).toMatchObject({ ok: true, previewUrl: 'https://app.example.workers.dev' });
expect(resolveWorkersDevSubdomain).not.toHaveBeenCalled();
});

it('does not overwrite a deployment that changed during immutable draft source inspection', async () => {
const fixture = sdkDraftActivation();
const entered = deferred<void>(),
Expand Down
11 changes: 11 additions & 0 deletions packages/void/src/cli/cloudflare-operation-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,17 @@ async function readAllQueues(): Promise<Array<Json>> {

async function resources(): Promise<Result> {
switch (operation.type) {
case 'workers.subdomain.get': {
const body = await cloudflareApiGet(
`/accounts/${encodeURIComponent(accountId())}/workers/subdomain`,
'Cloudflare did not return the account workers.dev subdomain.',
);
const { subdomain } = object(body.result);
if (typeof subdomain !== 'string' || !subdomain.trim()) {
throw new Error('Cloudflare returned an invalid account workers.dev subdomain.');
}
return result(0, '', '', { subdomain });
}
case 'd1.execute': {
const database = d1Database(operation.database);
// One argv item, so cf cannot read SQL that starts with "-" (such as a comment) as a flag.
Expand Down
Loading
Loading