From 24e91192e423eccdb39d7a379d15625981f979de Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Tue, 3 Dec 2024 16:23:10 +0000 Subject: [PATCH 01/15] feat(wrangler): identify draft and inherit bindings --- .changeset/swift-bulldogs-repeat.md | 5 + .../wrangler/src/__tests__/deploy.test.ts | 151 ++++++++++++++- packages/wrangler/src/d1/create.ts | 47 +++-- packages/wrangler/src/deploy/deploy.ts | 3 +- .../src/deployment-bundle/bindings.ts | 172 ++++++++++++++++-- 5 files changed, 344 insertions(+), 34 deletions(-) create mode 100644 .changeset/swift-bulldogs-repeat.md diff --git a/.changeset/swift-bulldogs-repeat.md b/.changeset/swift-bulldogs-repeat.md new file mode 100644 index 000000000000..da2e0a754766 --- /dev/null +++ b/.changeset/swift-bulldogs-repeat.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +The `x-provision` experimental flag now identifies draft and inherit bindings by looking up the current binding settings. diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 6feb4af681ad..a687ab4f13dc 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -52,6 +52,7 @@ import { writeWranglerConfig } from "./helpers/write-wrangler-config"; import type { AssetManifest } from "../assets"; import type { Config } from "../config"; import type { CustomDomain, CustomDomainChangeset } from "../deploy/deploy"; +import type { Settings } from "../deployment-bundle/bindings"; import type { KVNamespaceInfo } from "../kv/helpers"; import type { PostQueueBody, @@ -10550,7 +10551,7 @@ export default{ }); describe("--x-provision", () => { - it("should accept KV, R2 and D1 bindings without IDs in the configuration file", async () => { + it("should inherit KV, R2 and D1 bindings if they could be found from the settings", async () => { writeWorkerSource(); writeWranglerConfig({ main: "index.js", @@ -10558,9 +10559,28 @@ export default{ r2_buckets: [{ binding: "R2_BUCKET" }], d1_databases: [{ binding: "D1_DATABASE" }], }); + mockGetSettings({ + result: { + bindings: [ + { + type: "kv_namespace", + name: "KV_NAMESPACE", + namespace_id: "kv-id", + }, + { + type: "r2_bucket", + name: "R2_BUCKET", + bucket_name: "test-bucket", + }, + { + type: "d1", + name: "D1_DATABASE", + id: "d1-id", + }, + ], + }, + }); mockUploadWorkerRequest({ - // We are treating them as inherited bindings temporarily to test the current implementation only - // This will be updated as we implement the actual provision logic expectedBindings: [ { name: "KV_NAMESPACE", @@ -10599,6 +10619,65 @@ export default{ expect(std.err).toMatchInlineSnapshot(`""`); expect(std.warn).toMatchInlineSnapshot(`""`); }); + + it("should provision KV, R2 and D1 bindings if they couldn't be found from the settings", async () => { + writeWorkerSource(); + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [{ binding: "KV_NAMESPACE" }], + // r2_buckets: [{ binding: "R2_BUCKET" }], + // d1_databases: [{ binding: "D1_DATABASE" }], + }); + mockGetSettings(); + mockCreateKvNamespace({ + resultId: "kv-id", + }); + mockConfirm({ + text: "Would you like Wrangler to provision these resources on your behalf and bind them to your project?", + result: true, + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV_NAMESPACE", + type: "kv_namespace", + namespace_id: "kv-id", + }, + // { + // name: "R2_BUCKET", + // type: "inherit", + // }, + // { + // name: "D1_DATABASE", + // type: "inherit", + // }, + ], + }); + mockSubDomainRequest(); + + await expect( + runWrangler("deploy --x-provision") + ).resolves.toBeUndefined(); + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Your worker has access to the following bindings: + - KV Namespaces: + - KV_NAMESPACE: (remote) + + Provisioning resources... + All resources provisioned, continuing deployment... + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV_NAMESPACE: kv-id + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); }); describe("queues", () => { @@ -12407,6 +12486,72 @@ function mockServiceScriptData(options: { } } +function mockGetSettings( + options: { + result?: Settings; + assertAccountId?: string; + assertScriptName?: string; + } = {} +) { + msw.use( + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/settings", + async ({ params }) => { + if (options.assertAccountId) { + expect(params.accountId).toEqual(options.assertAccountId); + } + + if (options.assertScriptName) { + expect(params.scriptName).toEqual(options.assertScriptName); + } + + if (!options.result) { + return new Response(null, { status: 404 }); + } + + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: options.result, + }); + } + ) + ); +} + +function mockCreateKvNamespace( + options: { + resultId?: string; + assertAccountId?: string; + assertTitle?: string; + } = {} +) { + msw.use( + http.post( + "*/accounts/:accountId/storage/kv/namespaces", + async ({ request, params }) => { + if (options.assertAccountId) { + expect(params.accountId).toEqual(options.assertAccountId); + } + + if (options.assertTitle) { + const requestBody = await request.json(); + const title = + typeof requestBody === "object" ? requestBody?.title : null; + expect(title).toEqual(options.assertTitle); + } + + return HttpResponse.json( + createFetchResult({ id: options.resultId ?? "some-namespace-id" }), + { status: 200 } + ); + }, + { once: true } + ) + ); +} + function mockGetQueueByName(queueName: string, queue: QueueResponse | null) { const requests = { count: 0 }; msw.use( diff --git a/packages/wrangler/src/d1/create.ts b/packages/wrangler/src/d1/create.ts index ade04536e395..5efe0a3ee1fb 100644 --- a/packages/wrangler/src/d1/create.ts +++ b/packages/wrangler/src/d1/create.ts @@ -12,6 +12,34 @@ import type { } from "../yargs-types"; import type { DatabaseCreationResult } from "./types"; +export async function createD1Database( + accountId: string, + name: string, + location?: string +) { + try { + return await fetchResult( + `/accounts/${accountId}/d1/database`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + ...(location && { primary_location_hint: location }), + }), + } + ); + } catch (e) { + if ((e as { code: number }).code === 7502) { + throw new UserError("A database with that name already exists"); + } + + throw e; + } +} + export function Options(yargs: CommonYargsArgv) { return yargs .positional("name", { @@ -42,24 +70,7 @@ export const Handler = withConfig( } } - let db: DatabaseCreationResult; - try { - db = await fetchResult(`/accounts/${accountId}/d1/database`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - ...(location && { primary_location_hint: location }), - }), - }); - } catch (e) { - if ((e as { code: number }).code === 7502) { - throw new UserError("A database with that name already exists"); - } - throw e; - } + const db = await createD1Database(accountId, name, location); logger.log( `✅ Successfully created DB '${db.name}'${ diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 46caf09c2f6d..af81f0393327 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -6,7 +6,7 @@ import { cancel } from "@cloudflare/cli"; import { syncAssets } from "../assets"; import { fetchListResult, fetchResult } from "../cfetch"; import { configFileName, formatConfigSnippet, printBindings } from "../config"; -import { getBindings } from "../deployment-bundle/bindings"; +import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; import { bundleWorker } from "../deployment-bundle/bundle"; import { printBundleSize, @@ -787,6 +787,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } else { assert(accountId, "Missing accountId"); + await provisionBindings(bindings, accountId, scriptName); await ensureQueuesExistByConfig(config); let bindingsPrinted = false; diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index bc71b896173e..527f49bf97af 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,4 +1,15 @@ +import { randomUUID } from "node:crypto"; +import { spinner } from "@cloudflare/cli/interactive"; +import { fetchResult } from "../cfetch"; +import { printBindings } from "../config"; +import { createD1Database } from "../d1/create"; +import { confirm } from "../dialogs"; +import { FatalError } from "../errors"; +import { createKVNamespace } from "../kv/helpers"; +import { logger } from "../logger"; +import { createR2Bucket } from "../r2/helpers"; import type { Config } from "../config"; +import type { WorkerMetadataBinding } from "./create-worker-upload-form"; import type { CfWorkerInit } from "./worker"; /** @@ -13,10 +24,7 @@ export function getBindings( } ): CfWorkerInit["bindings"] { return { - kv_namespaces: config?.kv_namespaces?.map((kv) => ({ - ...kv, - id: kv.id ?? INHERIT_SYMBOL, - })), + kv_namespaces: config?.kv_namespaces, send_email: options?.pages ? undefined : config?.send_email, vars: config?.vars, wasm_modules: options?.pages ? undefined : config?.wasm_modules, @@ -30,14 +38,8 @@ export function getBindings( queues: config?.queues.producers?.map((producer) => { return { binding: producer.binding, queue_name: producer.queue }; }), - r2_buckets: config?.r2_buckets?.map((r2) => ({ - ...r2, - bucket_name: r2.bucket_name ?? INHERIT_SYMBOL, - })), - d1_databases: config?.d1_databases.map((d1) => ({ - ...d1, - database_id: d1.database_id ?? INHERIT_SYMBOL, - })), + r2_buckets: config?.r2_buckets, + d1_databases: config?.d1_databases, vectorize: config?.vectorize, hyperdrive: config?.hyperdrive, services: config?.services, @@ -62,3 +64,149 @@ export function getBindings( }, }; } + +export type Settings = { + bindings: Array; +}; + +export type PendingResource = + | { + name: string; + type: "kv"; + create: (title: string) => Promise; + } + | { + name: string; + type: "r2"; + create: ( + bucketName: string, + location?: string, + jurisdiction?: string, + storageClass?: string + ) => Promise; + } + | { + name: string; + type: "d1"; + create: (name: string, location?: string) => Promise; + }; + +export async function provisionBindings( + bindings: CfWorkerInit["bindings"], + accountId: string, + scriptName: string +): Promise { + const pendingResources: Array = []; + let settings: Settings | undefined; + + try { + settings = await getSettings(accountId, scriptName); + } catch (error) { + logger.debug("No settings found"); + } + + for (const kv of bindings.kv_namespaces ?? []) { + if (!kv.id) { + if (hasBindingSettings(settings, "kv_namespace", kv.binding)) { + kv.id = INHERIT_SYMBOL; + } else { + pendingResources.push({ + type: "kv", + name: kv.binding, + async create(title) { + const id = await createKVNamespace(accountId, title); + kv.id = id; + return id; + }, + }); + } + } + } + + for (const r2 of bindings.r2_buckets ?? []) { + if (!r2.bucket_name) { + if (hasBindingSettings(settings, "r2_bucket", r2.binding)) { + r2.bucket_name = INHERIT_SYMBOL; + } else { + pendingResources.push({ + type: "r2", + name: r2.binding, + async create(bucketName, location, jurisdiction, storageClass) { + await createR2Bucket( + accountId, + bucketName, + location, + jurisdiction, + storageClass + ); + r2.bucket_name = bucketName; + return bucketName; + }, + }); + } + } + } + + for (const d1 of bindings.d1_databases ?? []) { + if (!d1.database_id) { + if (hasBindingSettings(settings, "d1", d1.binding)) { + d1.database_id = INHERIT_SYMBOL; + } else { + pendingResources.push({ + type: "d1", + name: d1.binding, + async create(name, location) { + const db = await createD1Database(accountId, name, location); + d1.database_id = db.uuid; + return db.uuid; + }, + }); + } + } + } + + if (pendingResources.length > 0) { + printBindings(bindings); + + // Stylistic newline + logger.log(); + + const ok = await confirm( + "Would you like Wrangler to provision these resources on your behalf and bind them to your project?" + ); + + if (ok) { + logger.log("Provisioning resources..."); + + // After asking the user, create the ones we need to create, mutating `bindings` in the process + for (const binding of pendingResources) { + const s = spinner(); + + s.start(`- Provisioning ${binding.name}...`); + const id = await binding.create(`${binding.name}-${randomUUID()}`); + s.stop(`- ${binding.name} provisioned with ID "${id}"`); + } + } else { + throw new FatalError("Deployment aborted"); + } + + logger.log(`All resources provisioned, continuing deployment...`); + } +} + +function hasBindingSettings( + settings: Settings | undefined, + type: Type, + name: string +): Extract | undefined { + return settings?.bindings.find( + (binding): binding is Extract => + binding.type === type && binding.name === name + ); +} + +function getSettings(accountId: string, scriptName: string) { + return fetchResult( + `/accounts/${accountId}/workers/scripts/${scriptName}/settings` + ); +} From fd1ef165a9c26806ad40981e77a7ad2ff106e34c Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:13:11 +0000 Subject: [PATCH 02/15] add provisioning ui --- packages/wrangler/src/config/index.ts | 37 +- .../src/deployment-bundle/bindings.ts | 321 ++++++++++++++---- 2 files changed, 276 insertions(+), 82 deletions(-) diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index c7da666d4859..4744426aa337 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -168,12 +168,9 @@ export const readRawConfig = (configPath: string | undefined): RawConfig => { return {}; }; -function addLocalSuffix( - id: string | symbol | undefined, - local: boolean = false -) { +function formatValue(id: string | symbol | undefined, local: boolean = false) { if (!id || typeof id === "symbol") { - return local ? "(local)" : "(remote)"; + return local ? "(local)" : ""; } return `${id}${local ? " (local)" : ""}`; @@ -213,11 +210,12 @@ export const friendlyBindingNames: Record< * Print all the bindings a worker using a given config would have access to */ export function printBindings( - bindings: CfWorkerInit["bindings"], + bindings: Partial, context: { registry?: WorkerRegistry | null; local?: boolean; name?: string; + provisioning?: boolean; } = {} ) { let hasConnectionStatus = false; @@ -330,7 +328,7 @@ export function printBindings( entries: kv_namespaces.map(({ binding, id }) => { return { key: binding, - value: addLocalSuffix(id, context.local), + value: formatValue(id, context.local), }; }), }); @@ -359,7 +357,7 @@ export function printBindings( entries: queues.map(({ binding, queue_name }) => { return { key: binding, - value: addLocalSuffix(queue_name, context.local), + value: formatValue(queue_name, context.local), }; }), }); @@ -383,7 +381,7 @@ export function printBindings( } return { key: binding, - value: addLocalSuffix(databaseValue, context.local), + value: formatValue(databaseValue, context.local), }; } ), @@ -396,7 +394,7 @@ export function printBindings( entries: vectorize.map(({ binding, index_name }) => { return { key: binding, - value: addLocalSuffix(index_name, context.local), + value: formatValue(index_name, context.local), }; }), }); @@ -408,7 +406,7 @@ export function printBindings( entries: hyperdrive.map(({ binding, id }) => { return { key: binding, - value: addLocalSuffix(id, context.local), + value: formatValue(id, context.local), }; }), }); @@ -426,7 +424,7 @@ export function printBindings( return { key: binding, - value: addLocalSuffix(name, context.local), + value: formatValue(name, context.local), }; }), }); @@ -618,13 +616,24 @@ export function printBindings( return; } + let title: string; + if (context.provisioning) { + title = "The following bindings need to be provisioned:"; + } else if (context.name && getFlag("MULTIWORKER")) { + title = `${chalk.blue(context.name)} has access to the following bindings:`; + } else { + title = "Your worker has access to the following bindings:"; + } + const message = [ - `${context.name && getFlag("MULTIWORKER") ? chalk.blue(context.name) : "Your worker"} has access to the following bindings:`, + title, ...output .map((bindingGroup) => { return [ `- ${bindingGroup.name}:`, - bindingGroup.entries.map(({ key, value }) => ` - ${key}: ${value}`), + bindingGroup.entries.map( + ({ key, value }) => ` - ${key}${value ? ":" : ""} ${value}` + ), ]; }) .flat(2), diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 527f49bf97af..9de60ad51b77 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,16 +1,25 @@ -import { randomUUID } from "node:crypto"; -import { spinner } from "@cloudflare/cli/interactive"; +import { inputPrompt } from "@cloudflare/cli/interactive"; +import chalk from "chalk"; import { fetchResult } from "../cfetch"; import { printBindings } from "../config"; import { createD1Database } from "../d1/create"; -import { confirm } from "../dialogs"; +import { listDatabases } from "../d1/list"; +import { prompt } from "../dialogs"; import { FatalError } from "../errors"; -import { createKVNamespace } from "../kv/helpers"; +import { createKVNamespace, listKVNamespaces } from "../kv/helpers"; import { logger } from "../logger"; -import { createR2Bucket } from "../r2/helpers"; +import { createR2Bucket, listR2Buckets } from "../r2/helpers"; import type { Config } from "../config"; +import type { Database } from "../d1/types"; +import type { KVNamespaceInfo } from "../kv/helpers"; +import type { R2BucketInfo } from "../r2/helpers"; import type { WorkerMetadataBinding } from "./create-worker-upload-form"; -import type { CfWorkerInit } from "./worker"; +import type { + CfD1Database, + CfKvNamespace, + CfR2Bucket, + CfWorkerInit, +} from "./worker"; /** * A symbol to inherit a binding from the deployed worker. @@ -69,34 +78,25 @@ export type Settings = { bindings: Array; }; -export type PendingResource = - | { - name: string; - type: "kv"; - create: (title: string) => Promise; - } - | { - name: string; - type: "r2"; - create: ( - bucketName: string, - location?: string, - jurisdiction?: string, - storageClass?: string - ) => Promise; - } - | { - name: string; - type: "d1"; - create: (name: string, location?: string) => Promise; - }; - +type PendingResourceOperations = { + create: (name: string) => Promise; + updateId: (id: string) => void; +}; +type PendingResources = { + kv_namespaces: (CfKvNamespace & PendingResourceOperations)[]; + r2_buckets?: (CfR2Bucket & PendingResourceOperations)[]; + d1_databases?: (CfD1Database & PendingResourceOperations)[]; +}; export async function provisionBindings( bindings: CfWorkerInit["bindings"], accountId: string, scriptName: string ): Promise { - const pendingResources: Array = []; + const pendingResources: PendingResources = { + d1_databases: [], + r2_buckets: [], + kv_namespaces: [], + }; let settings: Settings | undefined; try { @@ -107,17 +107,19 @@ export async function provisionBindings( for (const kv of bindings.kv_namespaces ?? []) { if (!kv.id) { - if (hasBindingSettings(settings, "kv_namespace", kv.binding)) { + if (inBindingSettings(settings, "kv_namespace", kv.binding)) { kv.id = INHERIT_SYMBOL; } else { - pendingResources.push({ - type: "kv", - name: kv.binding, + pendingResources.kv_namespaces?.push({ + binding: kv.binding, async create(title) { const id = await createKVNamespace(accountId, title); kv.id = id; return id; }, + updateId(id) { + kv.id = id; + }, }); } } @@ -125,23 +127,25 @@ export async function provisionBindings( for (const r2 of bindings.r2_buckets ?? []) { if (!r2.bucket_name) { - if (hasBindingSettings(settings, "r2_bucket", r2.binding)) { + if (inBindingSettings(settings, "r2_bucket", r2.binding)) { r2.bucket_name = INHERIT_SYMBOL; } else { - pendingResources.push({ - type: "r2", - name: r2.binding, - async create(bucketName, location, jurisdiction, storageClass) { + pendingResources.r2_buckets?.push({ + binding: r2.binding, + async create(bucketName) { await createR2Bucket( accountId, - bucketName, - location, - jurisdiction, - storageClass + bucketName + // location, + // jurisdiction, + // storageClass ); r2.bucket_name = bucketName; return bucketName; }, + updateId(bucketName) { + r2.bucket_name = bucketName; + }, }); } } @@ -149,59 +153,234 @@ export async function provisionBindings( for (const d1 of bindings.d1_databases ?? []) { if (!d1.database_id) { - if (hasBindingSettings(settings, "d1", d1.binding)) { + if (inBindingSettings(settings, "d1", d1.binding)) { d1.database_id = INHERIT_SYMBOL; } else { - pendingResources.push({ - type: "d1", - name: d1.binding, - async create(name, location) { - const db = await createD1Database(accountId, name, location); + pendingResources.d1_databases?.push({ + binding: d1.binding, + async create(name) { + const db = await createD1Database(accountId, name); d1.database_id = db.uuid; + console.log("db", db); return db.uuid; }, + updateId(id) { + // tODO check d1 isn't doing something funny here + d1.database_id = id; + }, }); } } } - if (pendingResources.length > 0) { - printBindings(bindings); + const MAX_OPTIONS = 4; - // Stylistic newline + if (Object.values(pendingResources).some((v) => v && v.length > 0)) { logger.log(); + printBindings(pendingResources, { provisioning: true }); + printDivider(); + if (pendingResources.kv_namespaces?.length) { + const prettyBindingName = "KV Namespace"; + const preExisting = await listKVNamespaces(accountId); + const options = preExisting + .map((resource) => { + return { + label: resource.title, + value: resource.id, + }; + }) + .slice(0, MAX_OPTIONS - 1); + if (options.length < preExisting.length) { + options.push({ + label: "Other (too many to list)", + value: "manual", + }); + } - const ok = await confirm( - "Would you like Wrangler to provision these resources on your behalf and bind them to your project?" - ); + for (const kv of pendingResources.kv_namespaces) { + logger.log("Provisioning", kv.binding, `(${prettyBindingName})...`); + let name: string; + const selected = await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([{ label: "Create new", value: "new" }]), + label: kv.binding, + defaultValue: "new", + }); + if (selected === "new") { + name = await prompt(`Enter a name for the new ${prettyBindingName}`); + logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); + // creates KV and mutates "bindings" to update id + await kv.create(name); + } else if (selected === "manual") { + let searchedResource: KVNamespaceInfo | undefined; + while (searchedResource === undefined) { + const input = await prompt( + `Enter the title or id of an existing ${prettyBindingName}` + ); + searchedResource = preExisting.find( + (r) => r.title === input || r.id === input + ); + if (!searchedResource) { + logger.log( + `No ${prettyBindingName} with the title/id "${input}" found. Please try again.` + ); + } + } + name = searchedResource?.title; + // mutates "bindings" to update id + kv.updateId(searchedResource.id); + } else { + const selectedResource = preExisting.find((r) => r.id === selected); + if (!selectedResource) { + throw new FatalError( + `${prettyBindingName} with id ${selected} not found` + ); + } + name = selectedResource.title; + kv.updateId(selected); + } + logger.log(`✨ ${kv.binding} provisioned with ${name}`); + printDivider(); + } + } - if (ok) { - logger.log("Provisioning resources..."); + if (pendingResources.d1_databases?.length) { + const prettyBindingName = "D1 Database"; + const preExisting = await listDatabases(accountId); + const options = preExisting + .map((resource) => { + return { + label: resource.name, + value: resource.uuid, + }; + }) + .slice(0, MAX_OPTIONS - 1); + if (options.length < preExisting.length) { + options.push({ + label: "Other (too many to list)", + value: "manual", + }); + } - // After asking the user, create the ones we need to create, mutating `bindings` in the process - for (const binding of pendingResources) { - const s = spinner(); + for (const d1 of pendingResources.d1_databases) { + logger.log("Provisioning", d1.binding, `(${prettyBindingName})...`); + let name: string; + const selected = await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([{ label: "Create new", value: "new" }]), + label: d1.binding, + defaultValue: "new", + }); + if (selected === "new") { + name = await prompt(`Enter a name for the new ${prettyBindingName}`); + logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); + // creates KV and mutates "bindings" to update id + await d1.create(name); + } else if (selected === "manual") { + let searchedResource: Database | undefined; + while (searchedResource === undefined) { + const input = await prompt( + `Enter the name or id of an existing ${prettyBindingName}` + ); + searchedResource = preExisting.find( + (r) => r.name === input || r.uuid === input + ); + if (!searchedResource) { + logger.log( + `No ${prettyBindingName} with the name/id "${input}" found. Please try again.` + ); + } + } + name = searchedResource?.name; + // mutates "bindings" to update id + d1.updateId(searchedResource.uuid); + } else { + const selectedResource = preExisting.find((r) => r.uuid === selected); + if (!selectedResource) { + throw new FatalError( + `${prettyBindingName} with id ${selected} not found` + ); + } + name = selectedResource.name; + d1.updateId(selected); + } + logger.log(`✨ ${d1.binding} provisioned with ${name}`); + printDivider(); + } + } + if (pendingResources.r2_buckets?.length) { + const prettyBindingName = "R2 Bucket"; + const preExisting = await listR2Buckets(accountId); + const options = preExisting + .map((resource) => { + return { + label: resource.name, + value: resource.name, + }; + }) + .slice(0, MAX_OPTIONS - 1); + if (options.length < preExisting.length) { + options.push({ + label: "Other (too many to list)", + value: "manual", + }); + } - s.start(`- Provisioning ${binding.name}...`); - const id = await binding.create(`${binding.name}-${randomUUID()}`); - s.stop(`- ${binding.name} provisioned with ID "${id}"`); + for (const r2 of pendingResources.r2_buckets) { + logger.log("Provisioning", r2.binding, `(${prettyBindingName})...`); + let name: string; + const selected = await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([{ label: "Create new", value: "new" }]), + label: r2.binding, + defaultValue: "new", + }); + if (selected === "new") { + name = await prompt(`Enter a name for the new ${prettyBindingName}`); + logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); + // creates R2 bucket and mutates "bindings" to update id + await r2.create(name); + } else if (selected === "manual") { + let searchedResource: R2BucketInfo | undefined; + while (searchedResource === undefined) { + const input = await prompt( + `Enter the name of an existing ${prettyBindingName}` + ); + searchedResource = preExisting.find((r) => r.name === input); + if (!searchedResource) { + logger.log( + `No ${prettyBindingName} with the name "${input}" found. Please try again.` + ); + } + } + name = searchedResource.name; + // mutates "bindings" to update id + r2.updateId(searchedResource.name); + } else { + name = selected; + r2.updateId(selected); + } + logger.log(`✨ ${r2.binding} provisioned with ${name}`); + printDivider(); } - } else { - throw new FatalError("Deployment aborted"); } - logger.log(`All resources provisioned, continuing deployment...`); + logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); } } -function hasBindingSettings( +/** checks whether the binding id can be inherited from a prev deployment */ +function inBindingSettings( settings: Settings | undefined, type: Type, - name: string + bindingName: string ): Extract | undefined { return settings?.bindings.find( (binding): binding is Extract => - binding.type === type && binding.name === name + binding.type === type && binding.name === bindingName ); } @@ -210,3 +389,9 @@ function getSettings(accountId: string, scriptName: string) { `/accounts/${accountId}/workers/scripts/${scriptName}/settings` ); } + +function printDivider() { + logger.log(); + logger.log(chalk.dim("--------------------------------------")); + logger.log(); +} From 38855f705bd1ee4639af95575274a7046b82ec8d Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:52:48 +0000 Subject: [PATCH 03/15] fixup --- packages/wrangler/src/deployment-bundle/bindings.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 9de60ad51b77..592a00888619 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -161,7 +161,6 @@ export async function provisionBindings( async create(name) { const db = await createD1Database(accountId, name); d1.database_id = db.uuid; - console.log("db", db); return db.uuid; }, updateId(id) { From d68882f19ec1aca2cc1a92b449acc283c9253551 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:14:49 +0000 Subject: [PATCH 04/15] skip question if no existing resources --- .../src/deployment-bundle/bindings.ts | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 592a00888619..5ad1a9ed4394 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -199,13 +199,18 @@ export async function provisionBindings( for (const kv of pendingResources.kv_namespaces) { logger.log("Provisioning", kv.binding, `(${prettyBindingName})...`); let name: string; - const selected = await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, - options: options.concat([{ label: "Create new", value: "new" }]), - label: kv.binding, - defaultValue: "new", - }); + const selected = + options.length === 0 + ? "new" + : await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([ + { label: "Create new", value: "new" }, + ]), + label: kv.binding, + defaultValue: "new", + }); if (selected === "new") { name = await prompt(`Enter a name for the new ${prettyBindingName}`); logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); @@ -265,13 +270,18 @@ export async function provisionBindings( for (const d1 of pendingResources.d1_databases) { logger.log("Provisioning", d1.binding, `(${prettyBindingName})...`); let name: string; - const selected = await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, - options: options.concat([{ label: "Create new", value: "new" }]), - label: d1.binding, - defaultValue: "new", - }); + const selected = + options.length === 0 + ? "new" + : await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([ + { label: "Create new", value: "new" }, + ]), + label: d1.binding, + defaultValue: "new", + }); if (selected === "new") { name = await prompt(`Enter a name for the new ${prettyBindingName}`); logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); @@ -330,13 +340,18 @@ export async function provisionBindings( for (const r2 of pendingResources.r2_buckets) { logger.log("Provisioning", r2.binding, `(${prettyBindingName})...`); let name: string; - const selected = await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, - options: options.concat([{ label: "Create new", value: "new" }]), - label: r2.binding, - defaultValue: "new", - }); + const selected = + options.length === 0 + ? "new" + : await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([ + { label: "Create new", value: "new" }, + ]), + label: r2.binding, + defaultValue: "new", + }); if (selected === "new") { name = await prompt(`Enter a name for the new ${prettyBindingName}`); logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); From e2da7a9a663fcbc60e33b6233f09f1b0ab4c5380 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:51:01 +0000 Subject: [PATCH 05/15] consolidate repetitive bits --- .../src/deployment-bundle/bindings.ts | 320 +++++++----------- 1 file changed, 118 insertions(+), 202 deletions(-) diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 5ad1a9ed4394..7f2c7d9fc3e1 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,7 +1,7 @@ import { inputPrompt } from "@cloudflare/cli/interactive"; import chalk from "chalk"; import { fetchResult } from "../cfetch"; -import { printBindings } from "../config"; +import { friendlyBindingNames, printBindings } from "../config"; import { createD1Database } from "../d1/create"; import { listDatabases } from "../d1/list"; import { prompt } from "../dialogs"; @@ -10,9 +10,6 @@ import { createKVNamespace, listKVNamespaces } from "../kv/helpers"; import { logger } from "../logger"; import { createR2Bucket, listR2Buckets } from "../r2/helpers"; import type { Config } from "../config"; -import type { Database } from "../d1/types"; -import type { KVNamespaceInfo } from "../kv/helpers"; -import type { R2BucketInfo } from "../r2/helpers"; import type { WorkerMetadataBinding } from "./create-worker-upload-form"; import type { CfD1Database, @@ -84,8 +81,8 @@ type PendingResourceOperations = { }; type PendingResources = { kv_namespaces: (CfKvNamespace & PendingResourceOperations)[]; - r2_buckets?: (CfR2Bucket & PendingResourceOperations)[]; - d1_databases?: (CfD1Database & PendingResourceOperations)[]; + r2_buckets: (CfR2Bucket & PendingResourceOperations)[]; + d1_databases: (CfD1Database & PendingResourceOperations)[]; }; export async function provisionBindings( bindings: CfWorkerInit["bindings"], @@ -172,216 +169,35 @@ export async function provisionBindings( } } - const MAX_OPTIONS = 4; - if (Object.values(pendingResources).some((v) => v && v.length > 0)) { logger.log(); printBindings(pendingResources, { provisioning: true }); printDivider(); if (pendingResources.kv_namespaces?.length) { - const prettyBindingName = "KV Namespace"; const preExisting = await listKVNamespaces(accountId); - const options = preExisting - .map((resource) => { - return { - label: resource.title, - value: resource.id, - }; - }) - .slice(0, MAX_OPTIONS - 1); - if (options.length < preExisting.length) { - options.push({ - label: "Other (too many to list)", - value: "manual", - }); - } - - for (const kv of pendingResources.kv_namespaces) { - logger.log("Provisioning", kv.binding, `(${prettyBindingName})...`); - let name: string; - const selected = - options.length === 0 - ? "new" - : await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, - options: options.concat([ - { label: "Create new", value: "new" }, - ]), - label: kv.binding, - defaultValue: "new", - }); - if (selected === "new") { - name = await prompt(`Enter a name for the new ${prettyBindingName}`); - logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); - // creates KV and mutates "bindings" to update id - await kv.create(name); - } else if (selected === "manual") { - let searchedResource: KVNamespaceInfo | undefined; - while (searchedResource === undefined) { - const input = await prompt( - `Enter the title or id of an existing ${prettyBindingName}` - ); - searchedResource = preExisting.find( - (r) => r.title === input || r.id === input - ); - if (!searchedResource) { - logger.log( - `No ${prettyBindingName} with the title/id "${input}" found. Please try again.` - ); - } - } - name = searchedResource?.title; - // mutates "bindings" to update id - kv.updateId(searchedResource.id); - } else { - const selectedResource = preExisting.find((r) => r.id === selected); - if (!selectedResource) { - throw new FatalError( - `${prettyBindingName} with id ${selected} not found` - ); - } - name = selectedResource.title; - kv.updateId(selected); - } - logger.log(`✨ ${kv.binding} provisioned with ${name}`); - printDivider(); - } + await runProvisioningFlow( + pendingResources.kv_namespaces, + "kv_namespaces", + preExisting.map((ns) => ({ name: ns.title, id: ns.id })) + ); } if (pendingResources.d1_databases?.length) { - const prettyBindingName = "D1 Database"; const preExisting = await listDatabases(accountId); - const options = preExisting - .map((resource) => { - return { - label: resource.name, - value: resource.uuid, - }; - }) - .slice(0, MAX_OPTIONS - 1); - if (options.length < preExisting.length) { - options.push({ - label: "Other (too many to list)", - value: "manual", - }); - } - - for (const d1 of pendingResources.d1_databases) { - logger.log("Provisioning", d1.binding, `(${prettyBindingName})...`); - let name: string; - const selected = - options.length === 0 - ? "new" - : await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, - options: options.concat([ - { label: "Create new", value: "new" }, - ]), - label: d1.binding, - defaultValue: "new", - }); - if (selected === "new") { - name = await prompt(`Enter a name for the new ${prettyBindingName}`); - logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); - // creates KV and mutates "bindings" to update id - await d1.create(name); - } else if (selected === "manual") { - let searchedResource: Database | undefined; - while (searchedResource === undefined) { - const input = await prompt( - `Enter the name or id of an existing ${prettyBindingName}` - ); - searchedResource = preExisting.find( - (r) => r.name === input || r.uuid === input - ); - if (!searchedResource) { - logger.log( - `No ${prettyBindingName} with the name/id "${input}" found. Please try again.` - ); - } - } - name = searchedResource?.name; - // mutates "bindings" to update id - d1.updateId(searchedResource.uuid); - } else { - const selectedResource = preExisting.find((r) => r.uuid === selected); - if (!selectedResource) { - throw new FatalError( - `${prettyBindingName} with id ${selected} not found` - ); - } - name = selectedResource.name; - d1.updateId(selected); - } - logger.log(`✨ ${d1.binding} provisioned with ${name}`); - printDivider(); - } + await runProvisioningFlow( + pendingResources.d1_databases, + "d1_databases", + preExisting.map((db) => ({ name: db.name, id: db.uuid })) + ); } if (pendingResources.r2_buckets?.length) { - const prettyBindingName = "R2 Bucket"; const preExisting = await listR2Buckets(accountId); - const options = preExisting - .map((resource) => { - return { - label: resource.name, - value: resource.name, - }; - }) - .slice(0, MAX_OPTIONS - 1); - if (options.length < preExisting.length) { - options.push({ - label: "Other (too many to list)", - value: "manual", - }); - } - - for (const r2 of pendingResources.r2_buckets) { - logger.log("Provisioning", r2.binding, `(${prettyBindingName})...`); - let name: string; - const selected = - options.length === 0 - ? "new" - : await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, - options: options.concat([ - { label: "Create new", value: "new" }, - ]), - label: r2.binding, - defaultValue: "new", - }); - if (selected === "new") { - name = await prompt(`Enter a name for the new ${prettyBindingName}`); - logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); - // creates R2 bucket and mutates "bindings" to update id - await r2.create(name); - } else if (selected === "manual") { - let searchedResource: R2BucketInfo | undefined; - while (searchedResource === undefined) { - const input = await prompt( - `Enter the name of an existing ${prettyBindingName}` - ); - searchedResource = preExisting.find((r) => r.name === input); - if (!searchedResource) { - logger.log( - `No ${prettyBindingName} with the name "${input}" found. Please try again.` - ); - } - } - name = searchedResource.name; - // mutates "bindings" to update id - r2.updateId(searchedResource.name); - } else { - name = selected; - r2.updateId(selected); - } - logger.log(`✨ ${r2.binding} provisioned with ${name}`); - printDivider(); - } + await runProvisioningFlow( + pendingResources.r2_buckets, + "r2_buckets", + preExisting.map((bucket) => ({ name: bucket.name, id: bucket.name })) + ); } - logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); } } @@ -409,3 +225,103 @@ function printDivider() { logger.log(chalk.dim("--------------------------------------")); logger.log(); } + +type NormalisedResourceInfo = { + name: string; + id: string; +}; +type ResourceType = "d1_databases" | "r2_buckets" | "kv_namespaces"; +async function runProvisioningFlow( + pending: PendingResources[ResourceType], + key: ResourceType, + preExisting: NormalisedResourceInfo[] +) { + const MAX_OPTIONS = 4; + if (pending.length) { + const prettyBindingName = friendlyBindingNames[key]; + const options = preExisting + .map((resource) => ({ + label: resource.name, + value: resource.id, + })) + .slice(0, MAX_OPTIONS - 1); + if (options.length < preExisting.length) { + options.push({ + label: "Other (too many to list)", + value: "manual", + }); + } + + for (const item of pending) { + logger.log("Provisioning", item.binding, `(${prettyBindingName})...`); + let name: string = ""; + const selected = + options.length === 0 + ? "new" + : await inputPrompt({ + type: "select", + question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + options: options.concat([{ label: "Create new", value: "new" }]), + label: item.binding, + defaultValue: "new", + }); + if (selected === "new") { + name = await prompt(`Enter a name for a new ${prettyBindingName}`); + logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); + // creates new resource and mutates "bindings" to update id + await item.create(name); + } else if (selected === "manual") { + let searchedResource: NormalisedResourceInfo | undefined; + while (searchedResource === undefined) { + let resourceKey: string; + switch (key) { + case "kv_namespaces": + resourceKey = "title or id"; + break; + case "r2_buckets": + resourceKey = "name"; + break; + case "d1_databases": + resourceKey = "name or id"; + break; + } + const input = await prompt( + `Enter the ${resourceKey} of an existing ${prettyBindingName}` + ); + searchedResource = preExisting.find((r) => { + if (r.name === input || r.id === input) { + name = r.name; + item.updateId(r.id); + return true; + } else { + return false; + } + }); + if (!searchedResource) { + logger.log( + `No ${prettyBindingName} with that ${resourceKey} "${input}" found. Please try again.` + ); + } + } + } else { + const selectedResource = preExisting.find((r) => { + if (r.id === selected) { + name = r.name; + item.updateId(selected); + return true; + } else { + return false; + } + }); + // we shouldn't get here + if (!selectedResource) { + throw new FatalError( + `${prettyBindingName} with id ${selected} not found` + ); + } + } + logger.log(`✨ ${item.binding} provisioned with ${name}`); + printDivider(); + } + } +} From eb121e1806e9c6181cb1ef5e81dae5c51c3af277 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:10:21 +0000 Subject: [PATCH 06/15] add tests --- .../wrangler/src/__tests__/deploy.test.ts | 199 +----- .../wrangler/src/__tests__/provision.test.ts | 572 ++++++++++++++++++ .../src/deployment-bundle/bindings.ts | 62 +- 3 files changed, 596 insertions(+), 237 deletions(-) create mode 100644 packages/wrangler/src/__tests__/provision.test.ts diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index a687ab4f13dc..5204d3ba8a6a 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -52,7 +52,6 @@ import { writeWranglerConfig } from "./helpers/write-wrangler-config"; import type { AssetManifest } from "../assets"; import type { Config } from "../config"; import type { CustomDomain, CustomDomainChangeset } from "../deploy/deploy"; -import type { Settings } from "../deployment-bundle/bindings"; import type { KVNamespaceInfo } from "../kv/helpers"; import type { PostQueueBody, @@ -10550,136 +10549,6 @@ export default{ }); }); - describe("--x-provision", () => { - it("should inherit KV, R2 and D1 bindings if they could be found from the settings", async () => { - writeWorkerSource(); - writeWranglerConfig({ - main: "index.js", - kv_namespaces: [{ binding: "KV_NAMESPACE" }], - r2_buckets: [{ binding: "R2_BUCKET" }], - d1_databases: [{ binding: "D1_DATABASE" }], - }); - mockGetSettings({ - result: { - bindings: [ - { - type: "kv_namespace", - name: "KV_NAMESPACE", - namespace_id: "kv-id", - }, - { - type: "r2_bucket", - name: "R2_BUCKET", - bucket_name: "test-bucket", - }, - { - type: "d1", - name: "D1_DATABASE", - id: "d1-id", - }, - ], - }, - }); - mockUploadWorkerRequest({ - expectedBindings: [ - { - name: "KV_NAMESPACE", - type: "inherit", - }, - { - name: "R2_BUCKET", - type: "inherit", - }, - { - name: "D1_DATABASE", - type: "inherit", - }, - ], - }); - mockSubDomainRequest(); - - await expect( - runWrangler("deploy --x-provision") - ).resolves.toBeUndefined(); - expect(std.out).toMatchInlineSnapshot(` - "Total Upload: xx KiB / gzip: xx KiB - Worker Startup Time: 100 ms - Your worker has access to the following bindings: - - KV Namespaces: - - KV_NAMESPACE: (remote) - - D1 Databases: - - D1_DATABASE: (remote) - - R2 Buckets: - - R2_BUCKET: (remote) - Uploaded test-name (TIMINGS) - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); - expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - }); - - it("should provision KV, R2 and D1 bindings if they couldn't be found from the settings", async () => { - writeWorkerSource(); - writeWranglerConfig({ - main: "index.js", - kv_namespaces: [{ binding: "KV_NAMESPACE" }], - // r2_buckets: [{ binding: "R2_BUCKET" }], - // d1_databases: [{ binding: "D1_DATABASE" }], - }); - mockGetSettings(); - mockCreateKvNamespace({ - resultId: "kv-id", - }); - mockConfirm({ - text: "Would you like Wrangler to provision these resources on your behalf and bind them to your project?", - result: true, - }); - mockUploadWorkerRequest({ - expectedBindings: [ - { - name: "KV_NAMESPACE", - type: "kv_namespace", - namespace_id: "kv-id", - }, - // { - // name: "R2_BUCKET", - // type: "inherit", - // }, - // { - // name: "D1_DATABASE", - // type: "inherit", - // }, - ], - }); - mockSubDomainRequest(); - - await expect( - runWrangler("deploy --x-provision") - ).resolves.toBeUndefined(); - expect(std.out).toMatchInlineSnapshot(` - "Total Upload: xx KiB / gzip: xx KiB - Your worker has access to the following bindings: - - KV Namespaces: - - KV_NAMESPACE: (remote) - - Provisioning resources... - All resources provisioned, continuing deployment... - Worker Startup Time: 100 ms - Your worker has access to the following bindings: - - KV Namespaces: - - KV_NAMESPACE: kv-id - Uploaded test-name (TIMINGS) - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); - expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - }); - }); - describe("queues", () => { const queueId = "queue-id"; const queueName = "queue1"; @@ -12231,7 +12100,7 @@ function mockPublishCustomDomainsRequest({ } /** Create a mock handler for the request to get a list of all KV namespaces. */ -function mockListKVNamespacesRequest(...namespaces: KVNamespaceInfo[]) { +export function mockListKVNamespacesRequest(...namespaces: KVNamespaceInfo[]) { msw.use( http.get( "*/accounts/:accountId/storage/kv/namespaces", @@ -12486,72 +12355,6 @@ function mockServiceScriptData(options: { } } -function mockGetSettings( - options: { - result?: Settings; - assertAccountId?: string; - assertScriptName?: string; - } = {} -) { - msw.use( - http.get( - "*/accounts/:accountId/workers/scripts/:scriptName/settings", - async ({ params }) => { - if (options.assertAccountId) { - expect(params.accountId).toEqual(options.assertAccountId); - } - - if (options.assertScriptName) { - expect(params.scriptName).toEqual(options.assertScriptName); - } - - if (!options.result) { - return new Response(null, { status: 404 }); - } - - return HttpResponse.json({ - success: true, - errors: [], - messages: [], - result: options.result, - }); - } - ) - ); -} - -function mockCreateKvNamespace( - options: { - resultId?: string; - assertAccountId?: string; - assertTitle?: string; - } = {} -) { - msw.use( - http.post( - "*/accounts/:accountId/storage/kv/namespaces", - async ({ request, params }) => { - if (options.assertAccountId) { - expect(params.accountId).toEqual(options.assertAccountId); - } - - if (options.assertTitle) { - const requestBody = await request.json(); - const title = - typeof requestBody === "object" ? requestBody?.title : null; - expect(title).toEqual(options.assertTitle); - } - - return HttpResponse.json( - createFetchResult({ id: options.resultId ?? "some-namespace-id" }), - { status: 200 } - ); - }, - { once: true } - ) - ); -} - function mockGetQueueByName(queueName: string, queue: QueueResponse | null) { const requests = { count: 0 }; msw.use( diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts new file mode 100644 index 000000000000..35f2c51e5c64 --- /dev/null +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -0,0 +1,572 @@ +import { inputPrompt } from "@cloudflare/cli/interactive"; +import { http, HttpResponse } from "msw"; +import { prompt } from "../dialogs"; +import { mockListKVNamespacesRequest } from "./deploy.test"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { mockUploadWorkerRequest } from "./helpers/mock-upload-worker"; +import { mockSubDomainRequest } from "./helpers/mock-workers-subdomain"; +import { + createFetchResult, + msw, + mswSuccessDeploymentScriptMetadata, +} from "./helpers/msw"; +import { mswListNewDeploymentsLatestFull } from "./helpers/msw/handlers/versions"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; +import { writeWorkerSource } from "./helpers/write-worker-source"; +import { writeWranglerConfig } from "./helpers/write-wrangler-config"; +import type { Settings } from "../deployment-bundle/bindings"; + +vi.mock("@cloudflare/cli/interactive"); +vi.mock("../dialogs"); + +describe("--x-provision", () => { + const std = mockConsoleMethods(); + mockAccountId(); + mockApiToken(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + + beforeEach(() => { + setIsTTY(true); + msw.use( + ...mswSuccessDeploymentScriptMetadata, + ...mswListNewDeploymentsLatestFull + ); + mockSubDomainRequest(); + writeWorkerSource(); + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [{ binding: "KV" }], + r2_buckets: [{ binding: "R2" }], + d1_databases: [{ binding: "D1" }], + }); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + it("should inherit KV, R2 and D1 bindings if they could be found from the settings", async () => { + mockGetSettings({ + result: { + bindings: [ + { + type: "kv_namespace", + name: "KV", + namespace_id: "kv-id", + }, + { + type: "r2_bucket", + name: "R2", + bucket_name: "test-bucket", + }, + { + type: "d1", + name: "D1", + id: "d1-id", + }, + ], + }, + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV", + type: "inherit", + }, + { + name: "R2", + type: "inherit", + }, + { + name: "D1", + type: "inherit", + }, + ], + }); + + await expect(runWrangler("deploy --x-provision")).resolves.toBeUndefined(); + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV + - D1 Databases: + - D1 + - R2 Buckets: + - R2 + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + describe("provisions KV, R2 and D1 bindings if not found in worker settings", () => { + it("can provision KV, R2 and D1 bindings with existing resources", async () => { + mockGetSettings(); + mockListKVNamespacesRequest({ + title: "test-kv", + id: "existing-kv-id", + }); + msw.use( + http.get("*/accounts/:accountId/d1/database", async () => { + return HttpResponse.json( + createFetchResult([ + { + name: "db-name", + uuid: "existing-d1-id", + }, + ]) + ); + }), + http.get("*/accounts/:accountId/r2/buckets", async () => { + return HttpResponse.json( + createFetchResult({ + buckets: [ + { + name: "existing-bucket-name", + }, + ], + }) + ); + }) + ); + + vi.mocked(inputPrompt).mockImplementation(async (options) => { + if (options.label === "KV") { + return "existing-kv-id"; + } else if (options.label === "R2") { + return "existing-bucket-name"; + } else if (options.label === "D1") { + return "existing-d1-id"; + } + }); + + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV", + type: "kv_namespace", + namespace_id: "existing-kv-id", + }, + { + name: "R2", + type: "r2_bucket", + bucket_name: "existing-bucket-name", + }, + { + name: "D1", + type: "d1", + id: "existing-d1-id", + }, + ], + }); + + await runWrangler("deploy --x-provision"); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + + The following bindings need to be provisioned: + - KV Namespaces: + - KV + - D1 Databases: + - D1 + - R2 Buckets: + - R2 + + Provisioning KV (KV Namespace)... + ✨ KV provisioned with test-kv + + -------------------------------------- + + Provisioning D1 (D1 Database)... + ✨ D1 provisioned with db-name + + -------------------------------------- + + Provisioning R2 (R2 Bucket)... + ✨ R2 provisioned with existing-bucket-name + + -------------------------------------- + + 🎉 All resources provisioned, continuing with deployment... + + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV: existing-kv-id + - D1 Databases: + - D1: existing-d1-id + - R2 Buckets: + - R2: existing-bucket-name + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + it("can provision KV, R2 and D1 bindings with existing resources, and lets you search when there are too many to list", async () => { + mockGetSettings(); + msw.use( + http.get( + "*/accounts/:accountId/storage/kv/namespaces", + async () => { + const result = [1, 2, 3, 4, 5].map((i) => ({ + title: `test-kv-${i}`, + id: `existing-kv-id-${i}`, + })); + return HttpResponse.json(createFetchResult(result)); + }, + { once: true } + ), + http.get("*/accounts/:accountId/d1/database", async () => { + const result = [1, 2, 3, 4, 5].map((i) => ({ + name: `test-d1-${i}`, + uuid: `existing-d1-id-${i}`, + })); + return HttpResponse.json(createFetchResult(result)); + }), + http.get("*/accounts/:accountId/r2/buckets", async () => { + const result = [1, 2, 3, 4, 5].map((i) => ({ + name: `existing-bucket-${i}`, + })); + return HttpResponse.json( + createFetchResult({ + buckets: result, + }) + ); + }) + ); + + vi.mocked(inputPrompt).mockImplementation(async (options) => { + expect(options.type).toBe("select"); + expect(options.type === "select" && options.options[3]).toStrictEqual({ + label: "Other (too many to list)", + value: "manual", + }); + return "manual"; + }); + vi.mocked(prompt).mockImplementation(async (text) => { + switch (text) { + case "Enter the title or id for an existing KV Namespace": + return "existing-kv-id-1"; + case "Enter the name or id for an existing D1 Database": + return "existing-d1-id-1"; + case "Enter the name for an existing R2 Bucket": + return "existing-bucket-1"; + default: + throw new Error(`Unexpected prompt: ${text}`); + } + }); + + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV", + type: "kv_namespace", + namespace_id: "existing-kv-id-1", + }, + { + name: "R2", + type: "r2_bucket", + bucket_name: "existing-bucket-1", + }, + { + name: "D1", + type: "d1", + id: "existing-d1-id-1", + }, + ], + }); + + await runWrangler("deploy --x-provision"); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + + The following bindings need to be provisioned: + - KV Namespaces: + - KV + - D1 Databases: + - D1 + - R2 Buckets: + - R2 + + Provisioning KV (KV Namespace)... + ✨ KV provisioned with test-kv-1 + + -------------------------------------- + + Provisioning D1 (D1 Database)... + ✨ D1 provisioned with test-d1-1 + + -------------------------------------- + + Provisioning R2 (R2 Bucket)... + ✨ R2 provisioned with existing-bucket-1 + + -------------------------------------- + + 🎉 All resources provisioned, continuing with deployment... + + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV: existing-kv-id-1 + - D1 Databases: + - D1: existing-d1-id-1 + - R2 Buckets: + - R2: existing-bucket-1 + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + it("can provision KV, R2 and D1 bindings with new resources", async () => { + mockGetSettings(); + mockListKVNamespacesRequest({ + title: "test-kv", + id: "existing-kv-id", + }); + msw.use( + http.get("*/accounts/:accountId/d1/database", async () => { + return HttpResponse.json( + createFetchResult([ + { + name: "db-name", + uuid: "existing-d1-id", + }, + ]) + ); + }), + http.get("*/accounts/:accountId/r2/buckets", async () => { + return HttpResponse.json( + createFetchResult({ + buckets: [ + { + name: "existing-bucket-name", + }, + ], + }) + ); + }) + ); + + vi.mocked(inputPrompt).mockImplementation(async (options) => { + expect(options.type).toBe("select"); + const labels = + options.type === "select" && options.options.map((o) => o.label); + expect(labels).toContain("Create new"); + expect(labels).not.toContain("Other (too many to list)"); + return "new"; + }); + vi.mocked(prompt).mockImplementation(async (text) => { + switch (text) { + case "Enter a name for your new KV Namespace": + return "new-kv"; + case "Enter a name for your new D1 Database": + return "new-d1"; + case "Enter a name for your new R2 Bucket": + return "new-r2"; + default: + throw new Error(`Unexpected prompt: ${text}`); + } + }); + mockCreateKVNamespace({ + assertTitle: "new-kv", + resultId: "new-kv-id", + }); + mockCreateD1Database({ + assertName: "new-d1", + resultId: "new-d1-id", + }); + mockCreateR2Bucket({ + assertBucketName: "new-r2", + }); + + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV", + type: "kv_namespace", + namespace_id: "new-kv-id", + }, + { + name: "R2", + type: "r2_bucket", + bucket_name: "new-r2", + }, + { + name: "D1", + type: "d1", + id: "new-d1-id", + }, + ], + }); + + await runWrangler("deploy --x-provision"); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + + The following bindings need to be provisioned: + - KV Namespaces: + - KV + - D1 Databases: + - D1 + - R2 Buckets: + - R2 + + Provisioning KV (KV Namespace)... + 🌀 Creating new KV Namespace \\"new-kv\\"... + ✨ KV provisioned with new-kv + + -------------------------------------- + + Provisioning D1 (D1 Database)... + 🌀 Creating new D1 Database \\"new-d1\\"... + ✨ D1 provisioned with new-d1 + + -------------------------------------- + + Provisioning R2 (R2 Bucket)... + 🌀 Creating new R2 Bucket \\"new-r2\\"... + ✨ R2 provisioned with new-r2 + + -------------------------------------- + + 🎉 All resources provisioned, continuing with deployment... + + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV: new-kv-id + - D1 Databases: + - D1: new-d1-id + - R2 Buckets: + - R2: new-r2 + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + }); +}); + +function mockGetSettings( + options: { + result?: Settings; + assertAccountId?: string; + assertScriptName?: string; + } = {} +) { + msw.use( + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/settings", + async ({ params }) => { + if (options.assertAccountId) { + expect(params.accountId).toEqual(options.assertAccountId); + } + + if (options.assertScriptName) { + expect(params.scriptName).toEqual(options.assertScriptName); + } + + if (!options.result) { + return new Response(null, { status: 404 }); + } + + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: options.result, + }); + } + ) + ); +} + +function mockCreateKVNamespace( + options: { + resultId?: string; + assertTitle?: string; + } = {} +) { + msw.use( + http.post( + "*/accounts/:accountId/storage/kv/namespaces", + async ({ request }) => { + if (options.assertTitle) { + const requestBody = await request.json(); + expect(requestBody).toEqual({ title: options.assertTitle }); + } + + return HttpResponse.json( + createFetchResult({ id: options.resultId ?? "some-namespace-id" }) + ); + }, + { once: true } + ) + ); +} + +function mockCreateD1Database( + options: { + resultId?: string; + assertName?: string; + } = {} +) { + msw.use( + http.post( + "*/accounts/:accountId/d1/database", + async ({ request }) => { + if (options.assertName) { + const requestBody = await request.json(); + expect(requestBody).toEqual({ name: options.assertName }); + } + + return HttpResponse.json( + createFetchResult({ uuid: options.resultId ?? "some-d1-id" }) + ); + }, + { once: true } + ) + ); +} + +function mockCreateR2Bucket( + options: { + assertBucketName?: string; + } = {} +) { + msw.use( + http.post( + "*/accounts/:accountId/r2/buckets", + async ({ request }) => { + if (options.assertBucketName) { + const requestBody = await request.json(); + expect(requestBody).toEqual({ name: options.assertBucketName }); + } + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); +} diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 7f2c7d9fc3e1..e0efe67632aa 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -130,13 +130,7 @@ export async function provisionBindings( pendingResources.r2_buckets?.push({ binding: r2.binding, async create(bucketName) { - await createR2Bucket( - accountId, - bucketName - // location, - // jurisdiction, - // storageClass - ); + await createR2Bucket(accountId, bucketName); r2.bucket_name = bucketName; return bucketName; }, @@ -161,7 +155,6 @@ export async function provisionBindings( return db.uuid; }, updateId(id) { - // tODO check d1 isn't doing something funny here d1.database_id = id; }, }); @@ -172,13 +165,14 @@ export async function provisionBindings( if (Object.values(pendingResources).some((v) => v && v.length > 0)) { logger.log(); printBindings(pendingResources, { provisioning: true }); - printDivider(); + logger.log(); if (pendingResources.kv_namespaces?.length) { - const preExisting = await listKVNamespaces(accountId); + const preExistingKV = await listKVNamespaces(accountId); await runProvisioningFlow( pendingResources.kv_namespaces, - "kv_namespaces", - preExisting.map((ns) => ({ name: ns.title, id: ns.id })) + "KV Namespace", + preExistingKV.map((ns) => ({ name: ns.title, id: ns.id })), + "title or id" ); } @@ -186,16 +180,18 @@ export async function provisionBindings( const preExisting = await listDatabases(accountId); await runProvisioningFlow( pendingResources.d1_databases, - "d1_databases", - preExisting.map((db) => ({ name: db.name, id: db.uuid })) + "D1 Database", + preExisting.map((db) => ({ name: db.name, id: db.uuid })), + "name or id" ); } if (pendingResources.r2_buckets?.length) { const preExisting = await listR2Buckets(accountId); await runProvisioningFlow( pendingResources.r2_buckets, - "r2_buckets", - preExisting.map((bucket) => ({ name: bucket.name, id: bucket.name })) + "R2 Bucket", + preExisting.map((bucket) => ({ name: bucket.name, id: bucket.name })), + "name" ); } logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); @@ -233,12 +229,12 @@ type NormalisedResourceInfo = { type ResourceType = "d1_databases" | "r2_buckets" | "kv_namespaces"; async function runProvisioningFlow( pending: PendingResources[ResourceType], - key: ResourceType, - preExisting: NormalisedResourceInfo[] + friendlyBindingName: string, + preExisting: NormalisedResourceInfo[], + resourceKeyDescriptor: string ) { const MAX_OPTIONS = 4; if (pending.length) { - const prettyBindingName = friendlyBindingNames[key]; const options = preExisting .map((resource) => ({ label: resource.name, @@ -253,40 +249,28 @@ async function runProvisioningFlow( } for (const item of pending) { - logger.log("Provisioning", item.binding, `(${prettyBindingName})...`); + logger.log("Provisioning", item.binding, `(${friendlyBindingName})...`); let name: string = ""; const selected = options.length === 0 ? "new" : await inputPrompt({ type: "select", - question: `Would you like to connect an existing ${prettyBindingName} or create a new one?`, + question: `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, options: options.concat([{ label: "Create new", value: "new" }]), label: item.binding, defaultValue: "new", }); if (selected === "new") { - name = await prompt(`Enter a name for a new ${prettyBindingName}`); - logger.log(`🌀 Creating new ${prettyBindingName} "${name}"...`); - // creates new resource and mutates "bindings" to update id + name = await prompt(`Enter a name for your new ${friendlyBindingName}`); + logger.log(`🌀 Creating new ${friendlyBindingName} "${name}"...`); + // creates new resource and mutates `bindings` to update id await item.create(name); } else if (selected === "manual") { let searchedResource: NormalisedResourceInfo | undefined; while (searchedResource === undefined) { - let resourceKey: string; - switch (key) { - case "kv_namespaces": - resourceKey = "title or id"; - break; - case "r2_buckets": - resourceKey = "name"; - break; - case "d1_databases": - resourceKey = "name or id"; - break; - } const input = await prompt( - `Enter the ${resourceKey} of an existing ${prettyBindingName}` + `Enter the ${resourceKeyDescriptor} for an existing ${friendlyBindingName}` ); searchedResource = preExisting.find((r) => { if (r.name === input || r.id === input) { @@ -299,7 +283,7 @@ async function runProvisioningFlow( }); if (!searchedResource) { logger.log( - `No ${prettyBindingName} with that ${resourceKey} "${input}" found. Please try again.` + `No ${friendlyBindingName} with that ${resourceKeyDescriptor} "${input}" found. Please try again.` ); } } @@ -316,7 +300,7 @@ async function runProvisioningFlow( // we shouldn't get here if (!selectedResource) { throw new FatalError( - `${prettyBindingName} with id ${selected} not found` + `${friendlyBindingName} with id ${selected} not found` ); } } From 0d92c3fe29e2aea7dc7171895365dc2ad7448591 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:22:14 +0000 Subject: [PATCH 07/15] add default new resource value --- .../wrangler/src/__tests__/provision.test.ts | 5 ++++- .../src/deployment-bundle/bindings.ts | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index 35f2c51e5c64..529dddc94614 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -373,13 +373,16 @@ describe("--x-provision", () => { expect(labels).not.toContain("Other (too many to list)"); return "new"; }); - vi.mocked(prompt).mockImplementation(async (text) => { + vi.mocked(prompt).mockImplementation(async (text, options) => { switch (text) { case "Enter a name for your new KV Namespace": + expect(options?.defaultValue).toBe("test-name-kv"); return "new-kv"; case "Enter a name for your new D1 Database": + expect(options?.defaultValue).toBe("test-name-d1"); return "new-d1"; case "Enter a name for your new R2 Bucket": + expect(options?.defaultValue).toBe("test-name-r2"); return "new-r2"; default: throw new Error(`Unexpected prompt: ${text}`); diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index e0efe67632aa..d4c92cd5d025 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -172,7 +172,8 @@ export async function provisionBindings( pendingResources.kv_namespaces, "KV Namespace", preExistingKV.map((ns) => ({ name: ns.title, id: ns.id })), - "title or id" + "title or id", + scriptName ); } @@ -182,7 +183,8 @@ export async function provisionBindings( pendingResources.d1_databases, "D1 Database", preExisting.map((db) => ({ name: db.name, id: db.uuid })), - "name or id" + "name or id", + scriptName ); } if (pendingResources.r2_buckets?.length) { @@ -191,7 +193,8 @@ export async function provisionBindings( pendingResources.r2_buckets, "R2 Bucket", preExisting.map((bucket) => ({ name: bucket.name, id: bucket.name })), - "name" + "name", + scriptName ); } logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); @@ -231,7 +234,8 @@ async function runProvisioningFlow( pending: PendingResources[ResourceType], friendlyBindingName: string, preExisting: NormalisedResourceInfo[], - resourceKeyDescriptor: string + resourceKeyDescriptor: string, + scriptName: string ) { const MAX_OPTIONS = 4; if (pending.length) { @@ -262,7 +266,12 @@ async function runProvisioningFlow( defaultValue: "new", }); if (selected === "new") { - name = await prompt(`Enter a name for your new ${friendlyBindingName}`); + name = await prompt( + `Enter a name for your new ${friendlyBindingName}`, + { + defaultValue: `${scriptName}-${item.binding.toLowerCase().replace("_", "-")}`, + } + ); logger.log(`🌀 Creating new ${friendlyBindingName} "${name}"...`); // creates new resource and mutates `bindings` to update id await item.create(name); From 5ca6705145192feb7c59e5a9c26adbe02b773903 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Mon, 9 Dec 2024 20:15:39 +0000 Subject: [PATCH 08/15] update changeset --- .changeset/swift-bulldogs-repeat.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/swift-bulldogs-repeat.md b/.changeset/swift-bulldogs-repeat.md index da2e0a754766..522ecdf328cc 100644 --- a/.changeset/swift-bulldogs-repeat.md +++ b/.changeset/swift-bulldogs-repeat.md @@ -3,3 +3,5 @@ --- The `x-provision` experimental flag now identifies draft and inherit bindings by looking up the current binding settings. + +Draft bindings can then be provisioned (connected to new or existing KV, D1, or R2 resources) during `wrangler deploy`. From 3617a58fc37406bf7db2067ce1ab53636ed53b9a Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Mon, 9 Dec 2024 20:26:04 +0000 Subject: [PATCH 09/15] revert some unnecessary renaming --- packages/wrangler/src/config/index.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index 4744426aa337..6b369bacbe94 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -168,11 +168,13 @@ export const readRawConfig = (configPath: string | undefined): RawConfig => { return {}; }; -function formatValue(id: string | symbol | undefined, local: boolean = false) { - if (!id || typeof id === "symbol") { - return local ? "(local)" : ""; +function addLocalSuffix( + id: string | symbol | undefined, + local: boolean = false +) { + if (id === undefined || typeof id === "symbol") { + id = ""; } - return `${id}${local ? " (local)" : ""}`; } @@ -328,7 +330,7 @@ export function printBindings( entries: kv_namespaces.map(({ binding, id }) => { return { key: binding, - value: formatValue(id, context.local), + value: addLocalSuffix(id, context.local), }; }), }); @@ -357,7 +359,7 @@ export function printBindings( entries: queues.map(({ binding, queue_name }) => { return { key: binding, - value: formatValue(queue_name, context.local), + value: addLocalSuffix(queue_name, context.local), }; }), }); @@ -381,7 +383,7 @@ export function printBindings( } return { key: binding, - value: formatValue(databaseValue, context.local), + value: addLocalSuffix(databaseValue, context.local), }; } ), @@ -394,7 +396,7 @@ export function printBindings( entries: vectorize.map(({ binding, index_name }) => { return { key: binding, - value: formatValue(index_name, context.local), + value: addLocalSuffix(index_name, context.local), }; }), }); @@ -406,7 +408,7 @@ export function printBindings( entries: hyperdrive.map(({ binding, id }) => { return { key: binding, - value: formatValue(id, context.local), + value: addLocalSuffix(id, context.local), }; }), }); @@ -424,7 +426,7 @@ export function printBindings( return { key: binding, - value: formatValue(name, context.local), + value: addLocalSuffix(name, context.local), }; }), }); From 0300c544d1a124cb3c4b52298a2786e83e1c5c08 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:09:54 +0000 Subject: [PATCH 10/15] fix tests --- .../wrangler/src/__tests__/deploy.test.ts | 20 ++-------- .../wrangler/src/__tests__/helpers/mock-kv.ts | 39 ++++++++++++++++++- .../wrangler/src/__tests__/provision.test.ts | 29 ++------------ .../src/deployment-bundle/bindings.ts | 2 +- 4 files changed, 47 insertions(+), 43 deletions(-) diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 5204d3ba8a6a..25baf80ae0a4 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -22,7 +22,10 @@ import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; import { mockGetZoneFromHostRequest } from "./helpers/mock-get-zone-from-host"; import { useMockIsTTY } from "./helpers/mock-istty"; import { mockCollectKnownRoutesRequest } from "./helpers/mock-known-routes"; -import { mockKeyListRequest } from "./helpers/mock-kv"; +import { + mockKeyListRequest, + mockListKVNamespacesRequest, +} from "./helpers/mock-kv"; import { mockExchangeRefreshTokenForAccessToken, mockGetMemberships, @@ -52,7 +55,6 @@ import { writeWranglerConfig } from "./helpers/write-wrangler-config"; import type { AssetManifest } from "../assets"; import type { Config } from "../config"; import type { CustomDomain, CustomDomainChangeset } from "../deploy/deploy"; -import type { KVNamespaceInfo } from "../kv/helpers"; import type { PostQueueBody, PostTypedConsumerBody, @@ -12099,20 +12101,6 @@ function mockPublishCustomDomainsRequest({ ); } -/** Create a mock handler for the request to get a list of all KV namespaces. */ -export function mockListKVNamespacesRequest(...namespaces: KVNamespaceInfo[]) { - msw.use( - http.get( - "*/accounts/:accountId/storage/kv/namespaces", - ({ params }) => { - expect(params.accountId).toEqual("some-account-id"); - return HttpResponse.json(createFetchResult(namespaces)); - }, - { once: true } - ) - ); -} - interface ExpectedAsset { filePath: string; content: string; diff --git a/packages/wrangler/src/__tests__/helpers/mock-kv.ts b/packages/wrangler/src/__tests__/helpers/mock-kv.ts index 447ce7a5869e..6212dbb44f86 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-kv.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-kv.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; import { createFetchResult, msw } from "./msw"; -import type { NamespaceKeyInfo } from "../../kv/helpers"; +import type { KVNamespaceInfo, NamespaceKeyInfo } from "../../kv/helpers"; export function mockKeyListRequest( expectedNamespaceId: string, @@ -44,3 +44,40 @@ export function mockKeyListRequest( ); return requests; } + +export function mockListKVNamespacesRequest(...namespaces: KVNamespaceInfo[]) { + msw.use( + http.get( + "*/accounts/:accountId/storage/kv/namespaces", + ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + return HttpResponse.json(createFetchResult(namespaces)); + }, + { once: true } + ) + ); +} + +export function mockCreateKVNamespace( + options: { + resultId?: string; + assertTitle?: string; + } = {} +) { + msw.use( + http.post( + "*/accounts/:accountId/storage/kv/namespaces", + async ({ request }) => { + if (options.assertTitle) { + const requestBody = await request.json(); + expect(requestBody).toEqual({ title: options.assertTitle }); + } + + return HttpResponse.json( + createFetchResult({ id: options.resultId ?? "some-namespace-id" }) + ); + }, + { once: true } + ) + ); +} diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index 529dddc94614..502e2a4eb2a9 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -1,10 +1,13 @@ import { inputPrompt } from "@cloudflare/cli/interactive"; import { http, HttpResponse } from "msw"; import { prompt } from "../dialogs"; -import { mockListKVNamespacesRequest } from "./deploy.test"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { useMockIsTTY } from "./helpers/mock-istty"; +import { + mockCreateKVNamespace, + mockListKVNamespacesRequest, +} from "./helpers/mock-kv"; import { mockUploadWorkerRequest } from "./helpers/mock-upload-worker"; import { mockSubDomainRequest } from "./helpers/mock-workers-subdomain"; import { @@ -506,30 +509,6 @@ function mockGetSettings( ); } -function mockCreateKVNamespace( - options: { - resultId?: string; - assertTitle?: string; - } = {} -) { - msw.use( - http.post( - "*/accounts/:accountId/storage/kv/namespaces", - async ({ request }) => { - if (options.assertTitle) { - const requestBody = await request.json(); - expect(requestBody).toEqual({ title: options.assertTitle }); - } - - return HttpResponse.json( - createFetchResult({ id: options.resultId ?? "some-namespace-id" }) - ); - }, - { once: true } - ) - ); -} - function mockCreateD1Database( options: { resultId?: string; diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index d4c92cd5d025..cb7df0d526b3 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,7 +1,7 @@ import { inputPrompt } from "@cloudflare/cli/interactive"; import chalk from "chalk"; import { fetchResult } from "../cfetch"; -import { friendlyBindingNames, printBindings } from "../config"; +import { printBindings } from "../config"; import { createD1Database } from "../d1/create"; import { listDatabases } from "../d1/list"; import { prompt } from "../dialogs"; From eb137774891d9cb125f7e79788d84b007f8cb706 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Tue, 10 Dec 2024 12:10:15 +0000 Subject: [PATCH 11/15] use wrangler select instead of cli inputPrompt --- .../wrangler/src/__tests__/provision.test.ts | 113 ++++++++++-------- .../src/deployment-bundle/bindings.ts | 23 ++-- 2 files changed, 72 insertions(+), 64 deletions(-) diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index 502e2a4eb2a9..4830609f64d5 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -1,8 +1,7 @@ -import { inputPrompt } from "@cloudflare/cli/interactive"; import { http, HttpResponse } from "msw"; -import { prompt } from "../dialogs"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; +import { clearDialogs, mockPrompt, mockSelect } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; import { mockCreateKVNamespace, @@ -22,9 +21,6 @@ import { writeWorkerSource } from "./helpers/write-worker-source"; import { writeWranglerConfig } from "./helpers/write-wrangler-config"; import type { Settings } from "../deployment-bundle/bindings"; -vi.mock("@cloudflare/cli/interactive"); -vi.mock("../dialogs"); - describe("--x-provision", () => { const std = mockConsoleMethods(); mockAccountId(); @@ -48,8 +44,9 @@ describe("--x-provision", () => { }); }); afterEach(() => { - vi.clearAllMocks(); + clearDialogs(); }); + it("should inherit KV, R2 and D1 bindings if they could be found from the settings", async () => { mockGetSettings({ result: { @@ -140,14 +137,17 @@ describe("--x-provision", () => { }) ); - vi.mocked(inputPrompt).mockImplementation(async (options) => { - if (options.label === "KV") { - return "existing-kv-id"; - } else if (options.label === "R2") { - return "existing-bucket-name"; - } else if (options.label === "D1") { - return "existing-d1-id"; - } + mockSelect({ + text: "Would you like to connect an existing KV Namespace or create a new one?", + result: "existing-kv-id", + }); + mockSelect({ + text: "Would you like to connect an existing D1 Database or create a new one?", + result: "existing-d1-id", + }); + mockSelect({ + text: "Would you like to connect an existing R2 Bucket or create a new one?", + result: "existing-bucket-name", }); mockUploadWorkerRequest({ @@ -250,25 +250,29 @@ describe("--x-provision", () => { }) ); - vi.mocked(inputPrompt).mockImplementation(async (options) => { - expect(options.type).toBe("select"); - expect(options.type === "select" && options.options[3]).toStrictEqual({ - label: "Other (too many to list)", - value: "manual", - }); - return "manual"; + mockSelect({ + text: "Would you like to connect an existing KV Namespace or create a new one?", + result: "manual", }); - vi.mocked(prompt).mockImplementation(async (text) => { - switch (text) { - case "Enter the title or id for an existing KV Namespace": - return "existing-kv-id-1"; - case "Enter the name or id for an existing D1 Database": - return "existing-d1-id-1"; - case "Enter the name for an existing R2 Bucket": - return "existing-bucket-1"; - default: - throw new Error(`Unexpected prompt: ${text}`); - } + mockPrompt({ + text: "Enter the title or id for an existing KV Namespace", + result: "existing-kv-id-1", + }); + mockSelect({ + text: "Would you like to connect an existing D1 Database or create a new one?", + result: "manual", + }); + mockPrompt({ + text: "Enter the name or id for an existing D1 Database", + result: "existing-d1-id-1", + }); + mockSelect({ + text: "Would you like to connect an existing R2 Bucket or create a new one?", + result: "manual", + }); + mockPrompt({ + text: "Enter the name for an existing R2 Bucket", + result: "existing-bucket-1", }); mockUploadWorkerRequest({ @@ -368,37 +372,40 @@ describe("--x-provision", () => { }) ); - vi.mocked(inputPrompt).mockImplementation(async (options) => { - expect(options.type).toBe("select"); - const labels = - options.type === "select" && options.options.map((o) => o.label); - expect(labels).toContain("Create new"); - expect(labels).not.toContain("Other (too many to list)"); - return "new"; + mockSelect({ + text: "Would you like to connect an existing KV Namespace or create a new one?", + result: "new", }); - vi.mocked(prompt).mockImplementation(async (text, options) => { - switch (text) { - case "Enter a name for your new KV Namespace": - expect(options?.defaultValue).toBe("test-name-kv"); - return "new-kv"; - case "Enter a name for your new D1 Database": - expect(options?.defaultValue).toBe("test-name-d1"); - return "new-d1"; - case "Enter a name for your new R2 Bucket": - expect(options?.defaultValue).toBe("test-name-r2"); - return "new-r2"; - default: - throw new Error(`Unexpected prompt: ${text}`); - } + mockPrompt({ + text: "Enter a name for your new KV Namespace", + result: "new-kv", }); mockCreateKVNamespace({ assertTitle: "new-kv", resultId: "new-kv-id", }); + + mockSelect({ + text: "Would you like to connect an existing D1 Database or create a new one?", + result: "new", + }); + mockPrompt({ + text: "Enter a name for your new D1 Database", + result: "new-d1", + }); mockCreateD1Database({ assertName: "new-d1", resultId: "new-d1-id", }); + + mockSelect({ + text: "Would you like to connect an existing R2 Bucket or create a new one?", + result: "new", + }); + mockPrompt({ + text: "Enter a name for your new R2 Bucket", + result: "new-r2", + }); mockCreateR2Bucket({ assertBucketName: "new-r2", }); diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index cb7df0d526b3..1628dd2dee77 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,10 +1,9 @@ -import { inputPrompt } from "@cloudflare/cli/interactive"; import chalk from "chalk"; import { fetchResult } from "../cfetch"; import { printBindings } from "../config"; import { createD1Database } from "../d1/create"; import { listDatabases } from "../d1/list"; -import { prompt } from "../dialogs"; +import { prompt, select } from "../dialogs"; import { FatalError } from "../errors"; import { createKVNamespace, listKVNamespaces } from "../kv/helpers"; import { logger } from "../logger"; @@ -241,13 +240,13 @@ async function runProvisioningFlow( if (pending.length) { const options = preExisting .map((resource) => ({ - label: resource.name, + title: resource.name, value: resource.id, })) .slice(0, MAX_OPTIONS - 1); if (options.length < preExisting.length) { options.push({ - label: "Other (too many to list)", + title: "Other (too many to list)", value: "manual", }); } @@ -258,13 +257,15 @@ async function runProvisioningFlow( const selected = options.length === 0 ? "new" - : await inputPrompt({ - type: "select", - question: `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, - options: options.concat([{ label: "Create new", value: "new" }]), - label: item.binding, - defaultValue: "new", - }); + : await select( + `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, + { + choices: options.concat([ + { title: "Create new", value: "new" }, + ]), + defaultOption: options.length, + } + ); if (selected === "new") { name = await prompt( `Enter a name for your new ${friendlyBindingName}`, From cad6a8075366386e3bb0e1c4782e62b4233d66ba Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:41:59 +0000 Subject: [PATCH 12/15] add e2e tests --- packages/wrangler/e2e/provision.test.ts | 173 ++++++++++++++++++ packages/wrangler/src/d1/list.ts | 6 +- packages/wrangler/src/deploy/deploy.ts | 8 +- packages/wrangler/src/deploy/index.ts | 8 + .../src/deployment-bundle/bindings.ts | 59 +++--- packages/wrangler/src/kv/helpers.ts | 6 +- 6 files changed, 230 insertions(+), 30 deletions(-) create mode 100644 packages/wrangler/e2e/provision.test.ts diff --git a/packages/wrangler/e2e/provision.test.ts b/packages/wrangler/e2e/provision.test.ts new file mode 100644 index 000000000000..ef5becf8866d --- /dev/null +++ b/packages/wrangler/e2e/provision.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert"; +import dedent from "ts-dedent"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id"; +import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test"; +import { fetchText } from "./helpers/fetch-text"; +import { generateResourceName } from "./helpers/generate-resource-name"; +import { normalizeOutput } from "./helpers/normalize"; +import { retry } from "./helpers/retry"; + +const TIMEOUT = 500_000; +const normalize = (str: string) => { + return normalizeOutput(str, { + [CLOUDFLARE_ACCOUNT_ID]: "CLOUDFLARE_ACCOUNT_ID", + }).replaceAll( + /- KV: ([0-9a-f]{32})/gm, + "- KV: 00000000000000000000000000000000" + ); +}; +const workerName = generateResourceName(); + +describe("provisioning", { timeout: TIMEOUT }, () => { + let deployedUrl: string; + let kvId: string; + let d1Id: string; + const helper = new WranglerE2ETestHelper(); + + it.skip("can run dev without resource ids", async () => { + const worker = helper.runLongLived("wrangler dev --x-provision", { + debug: true, + }); + + const { url } = await worker.waitForReady(); + await fetch(url); + + const text = await fetchText(url); + + expect(text).toMatchInlineSnapshot(`"Hello World!"`); + }); + + beforeAll(async () => { + await helper.seed({ + "wrangler.toml": dedent` + name = "${workerName}" + main = "src/index.ts" + compatibility_date = "2023-01-01" + + [[kv_namespaces]] + binding = "KV" + + [[r2_buckets]] + binding = "R2" + + [[d1_databases]] + binding = "D1" + `, + "src/index.ts": dedent` + export default { + fetch(request) { + return new Response("Hello World!") + } + }`, + "package.json": dedent` + { + "name": "${workerName}", + "version": "0.0.0", + "private": true + } + `, + }); + }); + + it("can provision resources and deploy worker", async () => { + const worker = helper.runLongLived( + `wrangler deploy --x-provision --x-auto-create` + ); + await worker.exitCode; + const output = await worker.output; + expect(normalize(output)).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + The following bindings need to be provisioned: + - KV Namespaces: + - KV + - D1 Databases: + - D1 + - R2 Buckets: + - R2 + Provisioning KV (KV Namespace)... + 🌀 Creating new KV Namespace "tmp-e2e-worker-00000000-0000-0000-0000-000000000000-kv"... + ✨ KV provisioned with tmp-e2e-worker-00000000-0000-0000-0000-000000000000-kv + -------------------------------------- + Provisioning D1 (D1 Database)... + 🌀 Creating new D1 Database "tmp-e2e-worker-00000000-0000-0000-0000-000000000000-d1"... + ✨ D1 provisioned with tmp-e2e-worker-00000000-0000-0000-0000-000000000000-d1 + -------------------------------------- + Provisioning R2 (R2 Bucket)... + 🌀 Creating new R2 Bucket "tmp-e2e-worker-00000000-0000-0000-0000-000000000000-r2"... + ✨ R2 provisioned with tmp-e2e-worker-00000000-0000-0000-0000-000000000000-r2 + -------------------------------------- + 🎉 All resources provisioned, continuing with deployment... + Your worker has access to the following bindings: + - KV Namespaces: + - KV: 00000000000000000000000000000000 + - D1 Databases: + - D1: 00000000-0000-0000-0000-000000000000 + - R2 Buckets: + - R2: tmp-e2e-worker-00000000-0000-0000-0000-000000000000-r2 + Uploaded tmp-e2e-worker-00000000-0000-0000-0000-000000000000 (TIMINGS) + Deployed tmp-e2e-worker-00000000-0000-0000-0000-000000000000 triggers (TIMINGS) + https://tmp-e2e-worker-00000000-0000-0000-0000-000000000000.SUBDOMAIN.workers.dev + Current Version ID: 00000000-0000-0000-0000-000000000000" + `); + const urlMatch = output.match( + /(?https:\/\/tmp-e2e-.+?\..+?\.workers\.dev)/ + ); + assert(urlMatch?.groups); + deployedUrl = urlMatch.groups.url; + + const kvMatch = output.match(/- KV: (?[0-9a-f]{32})/); + assert(kvMatch?.groups); + kvId = kvMatch.groups.kv; + + const d1Match = output.match(/- D1: (?\w{8}-\w{4}-\w{4}-\w{4}-\w{12})/); + assert(d1Match?.groups); + d1Id = d1Match.groups.d1; + + const { text } = await retry( + (s) => s.status !== 200, + async () => { + const r = await fetch(deployedUrl); + return { text: await r.text(), status: r.status }; + } + ); + expect(text).toMatchInlineSnapshot('"Hello World!"'); + }); + + afterAll(async () => { + // we need to add d1 back into the config because otherwise wrangler will + // call the api for all 5000 or so db's the e2e test account has + // :( + await helper.seed({ + "wrangler.toml": dedent` + name = "${workerName}" + main = "src/index.ts" + compatibility_date = "2023-01-01" + + [[d1_databases]] + binding = "D1" + database_name = "${workerName}-d1" + database_id = "${d1Id}" + `, + }); + let output = await helper.run(`wrangler r2 bucket delete ${workerName}-r2`); + expect(output.stdout).toContain(`Deleted bucket`); + output = await helper.run(`wrangler d1 delete ${workerName}-d1 -y`, { + debug: true, + }); + expect(output.stdout).toContain(`Deleted '${workerName}-d1' successfully.`); + output = await helper.run(`wrangler delete`); + expect(output.stdout).toContain("Successfully deleted"); + const status = await retry( + (s) => s === 200 || s === 500, + () => fetch(deployedUrl).then((r) => r.status) + ); + expect(status).toBe(404); + + output = await helper.run( + `wrangler kv namespace delete --namespace-id ${kvId}` + ); + expect(output.stdout).toContain(`Deleted KV namespace`); + }); +}); diff --git a/packages/wrangler/src/d1/list.ts b/packages/wrangler/src/d1/list.ts index 4ede458d3a70..2ab70a35eee9 100644 --- a/packages/wrangler/src/d1/list.ts +++ b/packages/wrangler/src/d1/list.ts @@ -39,7 +39,8 @@ export const Handler = withConfig( ); export const listDatabases = async ( - accountId: string + accountId: string, + limitCalls: boolean = false ): Promise> => { const pageSize = 10; let page = 1; @@ -55,6 +56,9 @@ export const listDatabases = async ( ); page++; results.push(...json); + if (limitCalls) { + break; + } if (json.length < pageSize) { break; } diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index af81f0393327..25f3661d6727 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -107,6 +107,7 @@ type Props = { projectRoot: string | undefined; dispatchNamespace: string | undefined; experimentalVersions: boolean | undefined; + experimentalAutoCreate: boolean; }; export type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; @@ -787,7 +788,12 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } else { assert(accountId, "Missing accountId"); - await provisionBindings(bindings, accountId, scriptName); + await provisionBindings( + bindings, + accountId, + scriptName, + props.experimentalAutoCreate + ); await ensureQueuesExistByConfig(config); let bindingsPrinted = false; diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 431aafb5d96b..fa8a391815d8 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -230,6 +230,13 @@ export function deployOptions(yargs: CommonYargsArgv) { "Name of a dispatch namespace to deploy the Worker to (Workers for Platforms)", type: "string", }) + .option("experimental-auto-create", { + describe: "Automatically provision draft bindings with new resources", + type: "boolean", + default: false, + hidden: true, + alias: "x-auto-create", + }) ); } @@ -380,6 +387,7 @@ async function deployWorker(args: DeployArgs) { projectRoot, dispatchNamespace: args.dispatchNamespace, experimentalVersions: args.experimentalVersions, + experimentalAutoCreate: args.experimentalAutoCreate, }); writeOutput({ diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 1628dd2dee77..ce04ad84efae 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -86,7 +86,8 @@ type PendingResources = { export async function provisionBindings( bindings: CfWorkerInit["bindings"], accountId: string, - scriptName: string + scriptName: string, + autoCreate: boolean ): Promise { const pendingResources: PendingResources = { d1_databases: [], @@ -166,34 +167,37 @@ export async function provisionBindings( printBindings(pendingResources, { provisioning: true }); logger.log(); if (pendingResources.kv_namespaces?.length) { - const preExistingKV = await listKVNamespaces(accountId); + const preExistingKV = await listKVNamespaces(accountId, true); await runProvisioningFlow( pendingResources.kv_namespaces, - "KV Namespace", preExistingKV.map((ns) => ({ name: ns.title, id: ns.id })), + "KV Namespace", "title or id", - scriptName + scriptName, + autoCreate ); } if (pendingResources.d1_databases?.length) { - const preExisting = await listDatabases(accountId); + const preExisting = await listDatabases(accountId, true); await runProvisioningFlow( pendingResources.d1_databases, - "D1 Database", preExisting.map((db) => ({ name: db.name, id: db.uuid })), + "D1 Database", "name or id", - scriptName + scriptName, + autoCreate ); } if (pendingResources.r2_buckets?.length) { const preExisting = await listR2Buckets(accountId); await runProvisioningFlow( pendingResources.r2_buckets, - "R2 Bucket", preExisting.map((bucket) => ({ name: bucket.name, id: bucket.name })), + "R2 Bucket", "name", - scriptName + scriptName, + autoCreate ); } logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); @@ -231,10 +235,11 @@ type NormalisedResourceInfo = { type ResourceType = "d1_databases" | "r2_buckets" | "kv_namespaces"; async function runProvisioningFlow( pending: PendingResources[ResourceType], - friendlyBindingName: string, preExisting: NormalisedResourceInfo[], + friendlyBindingName: string, resourceKeyDescriptor: string, - scriptName: string + scriptName: string, + autoCreate: boolean ) { const MAX_OPTIONS = 4; if (pending.length) { @@ -254,25 +259,25 @@ async function runProvisioningFlow( for (const item of pending) { logger.log("Provisioning", item.binding, `(${friendlyBindingName})...`); let name: string = ""; - const selected = - options.length === 0 - ? "new" - : await select( - `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, - { - choices: options.concat([ - { title: "Create new", value: "new" }, - ]), - defaultOption: options.length, - } - ); - if (selected === "new") { - name = await prompt( - `Enter a name for your new ${friendlyBindingName}`, + let selected: string; + if (options.length === 0 || autoCreate) { + selected = "new"; + } else { + selected = await select( + `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, { - defaultValue: `${scriptName}-${item.binding.toLowerCase().replace("_", "-")}`, + choices: options.concat([{ title: "Create new", value: "new" }]), + defaultOption: options.length, } ); + } + if (selected === "new") { + const defaultValue = `${scriptName}-${item.binding.toLowerCase().replace("_", "-")}`; + name = autoCreate + ? defaultValue + : await prompt(`Enter a name for your new ${friendlyBindingName}`, { + defaultValue, + }); logger.log(`🌀 Creating new ${friendlyBindingName} "${name}"...`); // creates new resource and mutates `bindings` to update id await item.create(name); diff --git a/packages/wrangler/src/kv/helpers.ts b/packages/wrangler/src/kv/helpers.ts index e5d8b0ab005d..61d271dd4374 100644 --- a/packages/wrangler/src/kv/helpers.ts +++ b/packages/wrangler/src/kv/helpers.ts @@ -61,7 +61,8 @@ export interface KVNamespaceInfo { * Fetch a list of all the namespaces under the given `accountId`. */ export async function listKVNamespaces( - accountId: string + accountId: string, + limitCalls: boolean = false ): Promise { const pageSize = 100; let page = 1; @@ -79,6 +80,9 @@ export async function listKVNamespaces( ); page++; results.push(...json); + if (limitCalls) { + break; + } if (json.length < pageSize) { break; } From e0f7b926af139bcaebf8ebefd50f675fc3daf40e Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:30:16 +0000 Subject: [PATCH 13/15] pr feedback --- packages/wrangler/e2e/helpers/normalize.ts | 5 ++ packages/wrangler/e2e/provision.test.ts | 46 ++++++++++++----- .../src/deployment-bundle/bindings.ts | 49 ++++++++++--------- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/packages/wrangler/e2e/helpers/normalize.ts b/packages/wrangler/e2e/helpers/normalize.ts index 429afefad25c..045b3340095c 100644 --- a/packages/wrangler/e2e/helpers/normalize.ts +++ b/packages/wrangler/e2e/helpers/normalize.ts @@ -12,6 +12,7 @@ export function normalizeOutput( removeWorkerPreviewUrl, removeUUID, removeBinding, + removeKVId, normalizeErrorMarkers, replaceByte, stripTrailingWhitespace, @@ -77,6 +78,10 @@ function removeBinding(str: string) { ); } +function removeKVId(str: string) { + return str.replace(/([0-9a-f]{32})/g, "00000000000000000000000000000000"); +} + /** * Remove the Wrangler version/update check header */ diff --git a/packages/wrangler/e2e/provision.test.ts b/packages/wrangler/e2e/provision.test.ts index ef5becf8866d..39bfc0e33d90 100644 --- a/packages/wrangler/e2e/provision.test.ts +++ b/packages/wrangler/e2e/provision.test.ts @@ -13,10 +13,7 @@ const TIMEOUT = 500_000; const normalize = (str: string) => { return normalizeOutput(str, { [CLOUDFLARE_ACCOUNT_ID]: "CLOUDFLARE_ACCOUNT_ID", - }).replaceAll( - /- KV: ([0-9a-f]{32})/gm, - "- KV: 00000000000000000000000000000000" - ); + }); }; const workerName = generateResourceName(); @@ -26,10 +23,8 @@ describe("provisioning", { timeout: TIMEOUT }, () => { let d1Id: string; const helper = new WranglerE2ETestHelper(); - it.skip("can run dev without resource ids", async () => { - const worker = helper.runLongLived("wrangler dev --x-provision", { - debug: true, - }); + it("can run dev without resource ids", async () => { + const worker = helper.runLongLived("wrangler dev --x-provision"); const { url } = await worker.waitForReady(); await fetch(url); @@ -135,6 +130,35 @@ describe("provisioning", { timeout: TIMEOUT }, () => { expect(text).toMatchInlineSnapshot('"Hello World!"'); }); + it("can inherit bindings on re-deploy and won't re-provision", async () => { + const worker = helper.runLongLived(`wrangler deploy --x-provision`); + await worker.exitCode; + const output = await worker.output; + expect(normalize(output)).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Your worker has access to the following bindings: + - KV Namespaces: + - KV + - D1 Databases: + - D1 + - R2 Buckets: + - R2 + Uploaded tmp-e2e-worker-00000000-0000-0000-0000-000000000000 (TIMINGS) + Deployed tmp-e2e-worker-00000000-0000-0000-0000-000000000000 triggers (TIMINGS) + https://tmp-e2e-worker-00000000-0000-0000-0000-000000000000.SUBDOMAIN.workers.dev + Current Version ID: 00000000-0000-0000-0000-000000000000" + `); + + const { text } = await retry( + (s) => s.status !== 200, + async () => { + const r = await fetch(deployedUrl); + return { text: await r.text(), status: r.status }; + } + ); + expect(text).toMatchInlineSnapshot('"Hello World!"'); + }); + afterAll(async () => { // we need to add d1 back into the config because otherwise wrangler will // call the api for all 5000 or so db's the e2e test account has @@ -153,9 +177,7 @@ describe("provisioning", { timeout: TIMEOUT }, () => { }); let output = await helper.run(`wrangler r2 bucket delete ${workerName}-r2`); expect(output.stdout).toContain(`Deleted bucket`); - output = await helper.run(`wrangler d1 delete ${workerName}-d1 -y`, { - debug: true, - }); + output = await helper.run(`wrangler d1 delete ${workerName}-d1 -y`); expect(output.stdout).toContain(`Deleted '${workerName}-d1' successfully.`); output = await helper.run(`wrangler delete`); expect(output.stdout).toContain("Successfully deleted"); @@ -169,5 +191,5 @@ describe("provisioning", { timeout: TIMEOUT }, () => { `wrangler kv namespace delete --namespace-id ${kvId}` ); expect(output.stdout).toContain(`Deleted KV namespace`); - }); + }, TIMEOUT); }); diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index ce04ad84efae..db1726cdff5d 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -170,7 +170,7 @@ export async function provisionBindings( const preExistingKV = await listKVNamespaces(accountId, true); await runProvisioningFlow( pendingResources.kv_namespaces, - preExistingKV.map((ns) => ({ name: ns.title, id: ns.id })), + preExistingKV.map((ns) => ({ title: ns.title, value: ns.id })), "KV Namespace", "title or id", scriptName, @@ -182,7 +182,7 @@ export async function provisionBindings( const preExisting = await listDatabases(accountId, true); await runProvisioningFlow( pendingResources.d1_databases, - preExisting.map((db) => ({ name: db.name, id: db.uuid })), + preExisting.map((db) => ({ title: db.name, value: db.uuid })), "D1 Database", "name or id", scriptName, @@ -193,7 +193,10 @@ export async function provisionBindings( const preExisting = await listR2Buckets(accountId); await runProvisioningFlow( pendingResources.r2_buckets, - preExisting.map((bucket) => ({ name: bucket.name, id: bucket.name })), + preExisting.map((bucket) => ({ + title: bucket.name, + value: bucket.name, + })), "R2 Bucket", "name", scriptName, @@ -229,30 +232,29 @@ function printDivider() { } type NormalisedResourceInfo = { - name: string; - id: string; + /** The name of the resource */ + title: string; + /** The id of the resource */ + value: string; }; -type ResourceType = "d1_databases" | "r2_buckets" | "kv_namespaces"; + async function runProvisioningFlow( - pending: PendingResources[ResourceType], + pending: PendingResources[keyof PendingResources], preExisting: NormalisedResourceInfo[], friendlyBindingName: string, resourceKeyDescriptor: string, scriptName: string, autoCreate: boolean ) { + const NEW_OPTION_VALUE = "__WRANGLER_INTERNAL_NEW"; + const SEARCH_OPTION_VALUE = "__WRANGLER_INTERNAL_SEARCH"; const MAX_OPTIONS = 4; if (pending.length) { - const options = preExisting - .map((resource) => ({ - title: resource.name, - value: resource.id, - })) - .slice(0, MAX_OPTIONS - 1); + const options = preExisting.slice(0, MAX_OPTIONS - 1); if (options.length < preExisting.length) { options.push({ title: "Other (too many to list)", - value: "manual", + value: SEARCH_OPTION_VALUE, }); } @@ -260,8 +262,9 @@ async function runProvisioningFlow( logger.log("Provisioning", item.binding, `(${friendlyBindingName})...`); let name: string = ""; let selected: string; + if (options.length === 0 || autoCreate) { - selected = "new"; + selected = NEW_OPTION_VALUE; } else { selected = await select( `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, @@ -271,7 +274,8 @@ async function runProvisioningFlow( } ); } - if (selected === "new") { + + if (selected === NEW_OPTION_VALUE) { const defaultValue = `${scriptName}-${item.binding.toLowerCase().replace("_", "-")}`; name = autoCreate ? defaultValue @@ -281,16 +285,16 @@ async function runProvisioningFlow( logger.log(`🌀 Creating new ${friendlyBindingName} "${name}"...`); // creates new resource and mutates `bindings` to update id await item.create(name); - } else if (selected === "manual") { + } else if (selected === SEARCH_OPTION_VALUE) { let searchedResource: NormalisedResourceInfo | undefined; while (searchedResource === undefined) { const input = await prompt( `Enter the ${resourceKeyDescriptor} for an existing ${friendlyBindingName}` ); searchedResource = preExisting.find((r) => { - if (r.name === input || r.id === input) { - name = r.name; - item.updateId(r.id); + if (r.title === input || r.value === input) { + name = r.title; + item.updateId(r.value); return true; } else { return false; @@ -304,8 +308,8 @@ async function runProvisioningFlow( } } else { const selectedResource = preExisting.find((r) => { - if (r.id === selected) { - name = r.name; + if (r.value === selected) { + name = r.title; item.updateId(selected); return true; } else { @@ -319,6 +323,7 @@ async function runProvisioningFlow( ); } } + logger.log(`✨ ${item.binding} provisioned with ${name}`); printDivider(); } From bf8f6171fd558892fbec067399b242cfef5e8920 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:45:08 +0000 Subject: [PATCH 14/15] test fixup --- packages/wrangler/src/__tests__/provision.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index 4830609f64d5..bc63f16c358d 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -252,7 +252,7 @@ describe("--x-provision", () => { mockSelect({ text: "Would you like to connect an existing KV Namespace or create a new one?", - result: "manual", + result: "__WRANGLER_INTERNAL_SEARCH", }); mockPrompt({ text: "Enter the title or id for an existing KV Namespace", @@ -260,7 +260,7 @@ describe("--x-provision", () => { }); mockSelect({ text: "Would you like to connect an existing D1 Database or create a new one?", - result: "manual", + result: "__WRANGLER_INTERNAL_SEARCH", }); mockPrompt({ text: "Enter the name or id for an existing D1 Database", @@ -268,7 +268,7 @@ describe("--x-provision", () => { }); mockSelect({ text: "Would you like to connect an existing R2 Bucket or create a new one?", - result: "manual", + result: "__WRANGLER_INTERNAL_SEARCH", }); mockPrompt({ text: "Enter the name for an existing R2 Bucket", @@ -374,7 +374,7 @@ describe("--x-provision", () => { mockSelect({ text: "Would you like to connect an existing KV Namespace or create a new one?", - result: "new", + result: "__WRANGLER_INTERNAL_NEW", }); mockPrompt({ text: "Enter a name for your new KV Namespace", @@ -387,7 +387,7 @@ describe("--x-provision", () => { mockSelect({ text: "Would you like to connect an existing D1 Database or create a new one?", - result: "new", + result: "__WRANGLER_INTERNAL_NEW", }); mockPrompt({ text: "Enter a name for your new D1 Database", @@ -400,7 +400,7 @@ describe("--x-provision", () => { mockSelect({ text: "Would you like to connect an existing R2 Bucket or create a new one?", - result: "new", + result: "__WRANGLER_INTERNAL_NEW", }); mockPrompt({ text: "Enter a name for your new R2 Bucket", From ee0a46d06e4e767bea362478165237ff531d0f69 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:38:22 +0000 Subject: [PATCH 15/15] error on service environments --- packages/wrangler/src/__tests__/provision.test.ts | 12 ++++++++++++ packages/wrangler/src/deploy/deploy.ts | 3 ++- packages/wrangler/src/deployment-bundle/bindings.ts | 12 ++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index bc63f16c358d..bd4b6531b25f 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -480,6 +480,18 @@ describe("--x-provision", () => { expect(std.warn).toMatchInlineSnapshot(`""`); }); }); + + it("should error if used with a service environment", async () => { + writeWorkerSource(); + writeWranglerConfig({ + main: "index.js", + legacy_env: false, + kv_namespaces: [{ binding: "KV" }], + }); + await expect(runWrangler("deploy --x-provision")).rejects.toThrow( + "Provisioning resources is not supported with a service environment" + ); + }); }); function mockGetSettings( diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 25f3661d6727..a5e3c88c25bd 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -792,7 +792,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m bindings, accountId, scriptName, - props.experimentalAutoCreate + props.experimentalAutoCreate, + props.config ); await ensureQueuesExistByConfig(config); let bindingsPrinted = false; diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index db1726cdff5d..1773c2041c8b 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,10 +1,11 @@ import chalk from "chalk"; +import { isLegacyEnv } from ".."; import { fetchResult } from "../cfetch"; import { printBindings } from "../config"; import { createD1Database } from "../d1/create"; import { listDatabases } from "../d1/list"; import { prompt, select } from "../dialogs"; -import { FatalError } from "../errors"; +import { FatalError, UserError } from "../errors"; import { createKVNamespace, listKVNamespaces } from "../kv/helpers"; import { logger } from "../logger"; import { createR2Bucket, listR2Buckets } from "../r2/helpers"; @@ -87,7 +88,8 @@ export async function provisionBindings( bindings: CfWorkerInit["bindings"], accountId: string, scriptName: string, - autoCreate: boolean + autoCreate: boolean, + config: Config ): Promise { const pendingResources: PendingResources = { d1_databases: [], @@ -163,9 +165,15 @@ export async function provisionBindings( } if (Object.values(pendingResources).some((v) => v && v.length > 0)) { + if (!isLegacyEnv(config)) { + throw new UserError( + "Provisioning resources is not supported with a service environment" + ); + } logger.log(); printBindings(pendingResources, { provisioning: true }); logger.log(); + if (pendingResources.kv_namespaces?.length) { const preExistingKV = await listKVNamespaces(accountId, true); await runProvisioningFlow(