diff --git a/.changeset/swift-bulldogs-repeat.md b/.changeset/swift-bulldogs-repeat.md new file mode 100644 index 000000000000..522ecdf328cc --- /dev/null +++ b/.changeset/swift-bulldogs-repeat.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +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`. 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 new file mode 100644 index 000000000000..39bfc0e33d90 --- /dev/null +++ b/packages/wrangler/e2e/provision.test.ts @@ -0,0 +1,195 @@ +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", + }); +}; +const workerName = generateResourceName(); + +describe("provisioning", { timeout: TIMEOUT }, () => { + let deployedUrl: string; + let kvId: string; + let d1Id: string; + const helper = new WranglerE2ETestHelper(); + + it("can run dev without resource ids", async () => { + const worker = helper.runLongLived("wrangler dev --x-provision"); + + 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!"'); + }); + + 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 + // :( + 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`); + 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`); + }, TIMEOUT); +}); diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 6feb4af681ad..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, @@ -10549,58 +10551,6 @@ export default{ }); }); - describe("--x-provision", () => { - it("should accept KV, R2 and D1 bindings without IDs in the configuration file", async () => { - writeWorkerSource(); - writeWranglerConfig({ - main: "index.js", - kv_namespaces: [{ binding: "KV_NAMESPACE" }], - r2_buckets: [{ binding: "R2_BUCKET" }], - d1_databases: [{ binding: "D1_DATABASE" }], - }); - 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", - 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(`""`); - }); - }); - describe("queues", () => { const queueId = "queue-id"; const queueName = "queue1"; @@ -12151,20 +12101,6 @@ function mockPublishCustomDomainsRequest({ ); } -/** Create a mock handler for the request to get a list of all KV namespaces. */ -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 new file mode 100644 index 000000000000..bd4b6531b25f --- /dev/null +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -0,0 +1,573 @@ +import { http, HttpResponse } from "msw"; +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, + mockListKVNamespacesRequest, +} from "./helpers/mock-kv"; +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"; + +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(() => { + clearDialogs(); + }); + + 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", + }, + ], + }) + ); + }) + ); + + 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({ + 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, + }) + ); + }) + ); + + mockSelect({ + text: "Would you like to connect an existing KV Namespace or create a new one?", + result: "__WRANGLER_INTERNAL_SEARCH", + }); + 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: "__WRANGLER_INTERNAL_SEARCH", + }); + 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: "__WRANGLER_INTERNAL_SEARCH", + }); + mockPrompt({ + text: "Enter the name for an existing R2 Bucket", + result: "existing-bucket-1", + }); + + 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", + }, + ], + }) + ); + }) + ); + + mockSelect({ + text: "Would you like to connect an existing KV Namespace or create a new one?", + result: "__WRANGLER_INTERNAL_NEW", + }); + 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: "__WRANGLER_INTERNAL_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: "__WRANGLER_INTERNAL_NEW", + }); + mockPrompt({ + text: "Enter a name for your new R2 Bucket", + result: "new-r2", + }); + 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(`""`); + }); + }); + + 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( + 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 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/config/index.ts b/packages/wrangler/src/config/index.ts index c7da666d4859..6b369bacbe94 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -172,10 +172,9 @@ function addLocalSuffix( id: string | symbol | undefined, local: boolean = false ) { - if (!id || typeof id === "symbol") { - return local ? "(local)" : "(remote)"; + if (id === undefined || typeof id === "symbol") { + id = ""; } - return `${id}${local ? " (local)" : ""}`; } @@ -213,11 +212,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; @@ -618,13 +618,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/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/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 46caf09c2f6d..a5e3c88c25bd 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, @@ -107,6 +107,7 @@ type Props = { projectRoot: string | undefined; dispatchNamespace: string | undefined; experimentalVersions: boolean | undefined; + experimentalAutoCreate: boolean; }; export type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; @@ -787,6 +788,13 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } else { assert(accountId, "Missing accountId"); + await provisionBindings( + bindings, + accountId, + scriptName, + props.experimentalAutoCreate, + props.config + ); 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 bc71b896173e..1773c2041c8b 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,5 +1,22 @@ +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, UserError } from "../errors"; +import { createKVNamespace, listKVNamespaces } from "../kv/helpers"; +import { logger } from "../logger"; +import { createR2Bucket, listR2Buckets } from "../r2/helpers"; import type { Config } from "../config"; -import type { CfWorkerInit } from "./worker"; +import type { WorkerMetadataBinding } from "./create-worker-upload-form"; +import type { + CfD1Database, + CfKvNamespace, + CfR2Bucket, + CfWorkerInit, +} from "./worker"; /** * A symbol to inherit a binding from the deployed worker. @@ -13,10 +30,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 +44,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 +70,270 @@ export function getBindings( }, }; } + +export type Settings = { + bindings: Array; +}; + +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, + autoCreate: boolean, + config: Config +): Promise { + const pendingResources: PendingResources = { + d1_databases: [], + r2_buckets: [], + kv_namespaces: [], + }; + 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 (inBindingSettings(settings, "kv_namespace", kv.binding)) { + kv.id = INHERIT_SYMBOL; + } else { + 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; + }, + }); + } + } + } + + for (const r2 of bindings.r2_buckets ?? []) { + if (!r2.bucket_name) { + if (inBindingSettings(settings, "r2_bucket", r2.binding)) { + r2.bucket_name = INHERIT_SYMBOL; + } else { + pendingResources.r2_buckets?.push({ + binding: r2.binding, + async create(bucketName) { + await createR2Bucket(accountId, bucketName); + r2.bucket_name = bucketName; + return bucketName; + }, + updateId(bucketName) { + r2.bucket_name = bucketName; + }, + }); + } + } + } + + for (const d1 of bindings.d1_databases ?? []) { + if (!d1.database_id) { + if (inBindingSettings(settings, "d1", d1.binding)) { + d1.database_id = INHERIT_SYMBOL; + } else { + pendingResources.d1_databases?.push({ + binding: d1.binding, + async create(name) { + const db = await createD1Database(accountId, name); + d1.database_id = db.uuid; + return db.uuid; + }, + updateId(id) { + d1.database_id = id; + }, + }); + } + } + } + + 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( + pendingResources.kv_namespaces, + preExistingKV.map((ns) => ({ title: ns.title, value: ns.id })), + "KV Namespace", + "title or id", + scriptName, + autoCreate + ); + } + + if (pendingResources.d1_databases?.length) { + const preExisting = await listDatabases(accountId, true); + await runProvisioningFlow( + pendingResources.d1_databases, + preExisting.map((db) => ({ title: db.name, value: db.uuid })), + "D1 Database", + "name or id", + scriptName, + autoCreate + ); + } + if (pendingResources.r2_buckets?.length) { + const preExisting = await listR2Buckets(accountId); + await runProvisioningFlow( + pendingResources.r2_buckets, + preExisting.map((bucket) => ({ + title: bucket.name, + value: bucket.name, + })), + "R2 Bucket", + "name", + scriptName, + autoCreate + ); + } + logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); + } +} + +/** checks whether the binding id can be inherited from a prev deployment */ +function inBindingSettings( + settings: Settings | undefined, + type: Type, + bindingName: string +): Extract | undefined { + return settings?.bindings.find( + (binding): binding is Extract => + binding.type === type && binding.name === bindingName + ); +} + +function getSettings(accountId: string, scriptName: string) { + return fetchResult( + `/accounts/${accountId}/workers/scripts/${scriptName}/settings` + ); +} + +function printDivider() { + logger.log(); + logger.log(chalk.dim("--------------------------------------")); + logger.log(); +} + +type NormalisedResourceInfo = { + /** The name of the resource */ + title: string; + /** The id of the resource */ + value: string; +}; + +async function runProvisioningFlow( + 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.slice(0, MAX_OPTIONS - 1); + if (options.length < preExisting.length) { + options.push({ + title: "Other (too many to list)", + value: SEARCH_OPTION_VALUE, + }); + } + + for (const item of pending) { + logger.log("Provisioning", item.binding, `(${friendlyBindingName})...`); + let name: string = ""; + let selected: string; + + if (options.length === 0 || autoCreate) { + selected = NEW_OPTION_VALUE; + } else { + selected = 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_OPTION_VALUE) { + 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); + } 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.title === input || r.value === input) { + name = r.title; + item.updateId(r.value); + return true; + } else { + return false; + } + }); + if (!searchedResource) { + logger.log( + `No ${friendlyBindingName} with that ${resourceKeyDescriptor} "${input}" found. Please try again.` + ); + } + } + } else { + const selectedResource = preExisting.find((r) => { + if (r.value === selected) { + name = r.title; + item.updateId(selected); + return true; + } else { + return false; + } + }); + // we shouldn't get here + if (!selectedResource) { + throw new FatalError( + `${friendlyBindingName} with id ${selected} not found` + ); + } + } + + logger.log(`✨ ${item.binding} provisioned with ${name}`); + printDivider(); + } + } +} 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; }