diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index cc3ed4b365..f2a1aa4054 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -848,8 +848,8 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the Queue in the Worker. */ binding: string; - /** The name of this Queue. */ - queue: string; + /** The name of this Queue. Omit to auto-provision. */ + queue?: string; /** The number of seconds to wait before delivering a message */ delivery_delay?: number; @@ -958,7 +958,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the Vectorize index in the Worker. */ binding: string; /** The name of the index. */ - index_name: string; + index_name?: string; /** Whether the Vectorize index should be remote or not in local development */ remote?: boolean; }[]; @@ -1016,7 +1016,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the project in the Worker. */ binding: string; /** The id of the database. */ - id: string; + id?: string; /** The local database connection string for `wrangler dev` */ localConnectionString?: string; }[]; @@ -1232,7 +1232,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the certificate in the Worker */ binding: string; /** The uuid of the uploaded mTLS certificate */ - certificate_id: string; + certificate_id?: string; /** Whether the mtls fetcher should be remote or not in local development */ remote?: boolean; }[]; @@ -1274,7 +1274,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the bound service. */ binding: string; /** The namespace to bind to. */ - namespace: string; + namespace?: string; /** Details about the outbound Worker which will handle outbound requests from your namespace */ outbound?: DispatchNamespaceOutbound; /** Whether the Dispatch Namespace should be remote or not in local development */ @@ -1294,7 +1294,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the bound service. */ binding: string; /** Name of the Pipeline to bind */ - pipeline: string; + pipeline?: string; /** Whether the pipeline should be remote or not in local development */ remote?: boolean; }[]; @@ -1386,7 +1386,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the VPC service in the Worker. */ binding: string; /** The service ID of the VPC connectivity service. */ - service_id: string; + service_id?: string; /** Whether the VPC service is remote or not */ remote?: boolean; }[]; diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index c0e95b9f8f..f03e7271ce 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -3751,11 +3751,12 @@ const validateQueueBinding: ValidatorFn = (diagnostics, field, value) => { } if ( - !isRequiredProperty(value, "queue", "string") || - (value as { queue: string }).queue.length === 0 + !isOptionalProperty(value, "queue", "string") || + (isRequiredProperty(value, "queue", "string") && + (value as { queue: string }).queue.length === 0) ) { diagnostics.errors.push( - `"${field}" bindings should have a string "queue" field but got ${JSON.stringify( + `"${field}" bindings should, optionally, have a string "queue" field but got ${JSON.stringify( value )}.` ); @@ -3940,7 +3941,7 @@ const validateVectorizeBinding: ValidatorFn = (diagnostics, field, value) => { ); isValid = false; } - if (!isRequiredProperty(value, "index_name", "string")) { + if (!isOptionalProperty(value, "index_name", "string")) { diagnostics.errors.push( `"${field}" bindings must have an "index_name" field but got ${JSON.stringify( value @@ -4052,7 +4053,7 @@ const validateHyperdriveBinding: ValidatorFn = (diagnostics, field, value) => { ); isValid = false; } - if (!isRequiredProperty(value, "id", "string")) { + if (!isOptionalProperty(value, "id", "string")) { diagnostics.errors.push( `"${field}" bindings must have a "id" field but got ${JSON.stringify( value @@ -4089,7 +4090,7 @@ const validateVpcServiceBinding: ValidatorFn = (diagnostics, field, value) => { ); isValid = false; } - if (!isRequiredProperty(value, "service_id", "string")) { + if (!isOptionalProperty(value, "service_id", "string")) { diagnostics.errors.push( `"${field}" bindings must have a "service_id" field but got ${JSON.stringify( value @@ -4381,7 +4382,7 @@ const validateWorkerNamespaceBinding: ValidatorFn = ( ); isValid = false; } - if (!isRequiredProperty(value, "namespace", "string")) { + if (!isOptionalProperty(value, "namespace", "string")) { diagnostics.errors.push( `"${field}" should have a string "namespace" field but got ${JSON.stringify( value @@ -4477,8 +4478,9 @@ const validateMTlsCertificateBinding: ValidatorFn = ( isValid = false; } if ( - !isRequiredProperty(value, "certificate_id", "string") || - (value as { certificate_id: string }).certificate_id.length === 0 + !isOptionalProperty(value, "certificate_id", "string") || + ((value as { certificate_id?: string }).certificate_id !== undefined && + (value as { certificate_id: string }).certificate_id.length === 0) ) { diagnostics.errors.push( `"${field}" bindings should have a string "certificate_id" field but got ${JSON.stringify( @@ -4666,7 +4668,7 @@ const validatePipelineBinding: ValidatorFn = (diagnostics, field, value) => { ); isValid = false; } - if (!isRequiredProperty(value, "pipeline", "string")) { + if (!isOptionalProperty(value, "pipeline", "string")) { diagnostics.errors.push( `"${field}" bindings must have a string "pipeline" field but got ${JSON.stringify( value diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index f7cc525212..ff07163504 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -196,7 +196,7 @@ export interface CfWorkflow { export interface CfQueue { binding: string; - queue_name: string; + queue_name?: string | typeof INHERIT_SYMBOL; delivery_delay?: number; remote?: boolean; raw?: boolean; @@ -225,7 +225,7 @@ export interface CfD1Database { export interface CfVectorize { binding: string; - index_name: string; + index_name?: string | typeof INHERIT_SYMBOL; raw?: boolean; remote?: boolean; } @@ -268,7 +268,7 @@ export interface CfRateLimit { export interface CfHyperdrive { binding: string; - id: string; + id?: string | typeof INHERIT_SYMBOL; localConnectionString?: string; } @@ -284,7 +284,7 @@ export interface CfService { export interface CfVpcService { binding: string; - service_id: string; + service_id?: string | typeof INHERIT_SYMBOL; remote?: boolean; } @@ -302,7 +302,7 @@ export interface CfAnalyticsEngineDataset { export interface CfDispatchNamespace { binding: string; - namespace: string; + namespace?: string | typeof INHERIT_SYMBOL; outbound?: { service: string; environment?: string; @@ -313,7 +313,7 @@ export interface CfDispatchNamespace { export interface CfMTlsCertificate { binding: string; - certificate_id: string; + certificate_id?: string | typeof INHERIT_SYMBOL; remote?: boolean; } @@ -332,7 +332,7 @@ export interface CfAssetsBinding { export interface CfPipeline { binding: string; - pipeline: string; + pipeline?: string | typeof INHERIT_SYMBOL; remote?: boolean; } diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index c60f5e6d8c..3c0827c46a 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -2038,7 +2038,6 @@ describe("normalizeAndValidateConfig()", () => { { vectorize: [ {}, - { binding: "VALID" }, { binding: 2000, index_name: 2111 }, { binding: "BINDING_2", @@ -2056,10 +2055,8 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "vectorize[0]" bindings should have a string "binding" field but got {}. - - "vectorize[0]" bindings must have an "index_name" field but got {}. - - "vectorize[1]" bindings must have an "index_name" field but got {"binding":"VALID"}. - - "vectorize[2]" bindings should have a string "binding" field but got {"binding":2000,"index_name":2111}. - - "vectorize[2]" bindings must have an "index_name" field but got {"binding":2000,"index_name":2111}." + - "vectorize[1]" bindings should have a string "binding" field but got {"binding":2000,"index_name":2111}. + - "vectorize[1]" bindings must have an "index_name" field but got {"binding":2000,"index_name":2111}." `); }); @@ -3537,9 +3534,7 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "hyperdrive[0]" bindings should have a string "binding" field but got {}. - - "hyperdrive[0]" bindings must have a "id" field but got {}. - - "hyperdrive[2]" bindings should have a string "binding" field but got {"binding":2000,"project":2111}. - - "hyperdrive[2]" bindings must have a "id" field but got {"binding":2000,"project":2111}." + - "hyperdrive[2]" bindings should have a string "binding" field but got {"binding":2000,"project":2111}." `); }); }); @@ -3597,11 +3592,9 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "queues.producers[0]" bindings should have a string "binding" field but got {}. - - "queues.producers[0]" bindings should have a string "queue" field but got {}. - - "queues.producers[1]" bindings should have a string "queue" field but got {"binding":"QUEUE_BINDING_1"}. - "queues.producers[2]" bindings should have a string "binding" field but got {"binding":2333,"queue":2444}. - - "queues.producers[2]" bindings should have a string "queue" field but got {"binding":2333,"queue":2444}. - - "queues.producers[3]" bindings should have a string "queue" field but got {"binding":"QUEUE_BINDING_3","queue":""}." + - "queues.producers[2]" bindings should, optionally, have a string "queue" field but got {"binding":2333,"queue":2444}. + - "queues.producers[3]" bindings should, optionally, have a string "queue" field but got {"binding":"QUEUE_BINDING_3","queue":""}." `); }); @@ -4122,8 +4115,7 @@ describe("normalizeAndValidateConfig()", () => { - "dispatch_namespaces[2]" should have a string "namespace" field but got {"binding":123,"namespace":456}. - "dispatch_namespaces[3]" should have a string "namespace" field but got {"binding":"DISPATCH_NAMESPACE_BINDING_1","namespace":456}. - "dispatch_namespaces[5]" should have a string "binding" field but got {"binding":123,"namespace":"DISPATCH_NAMESPACE_BINDING_SERVICE_1"}. - - "dispatch_namespaces[6]" should have a string "binding" field but got {"binding":123,"service":456}. - - "dispatch_namespaces[6]" should have a string "namespace" field but got {"binding":123,"service":456}." + - "dispatch_namespaces[6]" should have a string "binding" field but got {"binding":123,"service":456}." `); }); @@ -4306,11 +4298,8 @@ describe("normalizeAndValidateConfig()", () => { - "mtls_certificates" bindings should be objects, but got 123 - "mtls_certificates" bindings should be objects, but got false - "mtls_certificates[3]" bindings should have a string "binding" field but got {"binding":123,"namespace":123}. - - "mtls_certificates[3]" bindings should have a string "certificate_id" field but got {"binding":123,"namespace":123}. - - "mtls_certificates[4]" bindings should have a string "certificate_id" field but got {"binding":"CERT_ONE","id":"1234"}. - "mtls_certificates[5]" bindings should have a string "certificate_id" field but got {"binding":"CERT_TWO","certificate_id":1234}. - - "mtls_certificates[7]" bindings should have a string "binding" field but got {"binding":true,"service":"1234"}. - - "mtls_certificates[7]" bindings should have a string "certificate_id" field but got {"binding":true,"service":"1234"}." + - "mtls_certificates[7]" bindings should have a string "binding" field but got {"binding":true,"service":"1234"}." `); }); }); @@ -4422,9 +4411,7 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "pipelines[0]" bindings must have a string "binding" field but got {}. - - "pipelines[0]" bindings must have a string "pipeline" field but got {}. - - "pipelines[2]" bindings must have a string "binding" field but got {"binding":2000,"project":2111}. - - "pipelines[2]" bindings must have a string "pipeline" field but got {"binding":2000,"project":2111}." + - "pipelines[2]" bindings must have a string "binding" field but got {"binding":2000,"project":2111}." `); }); }); @@ -4861,7 +4848,6 @@ describe("normalizeAndValidateConfig()", () => { service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", }, { binding: null, service_id: 123, invalid: true }, - { binding: "MISSING_SERVICE_ID" }, ], } as unknown as RawConfig, undefined, @@ -4873,10 +4859,8 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "vpc_services[0]" bindings should have a string "binding" field but got {}. - - "vpc_services[0]" bindings must have a "service_id" field but got {}. - "vpc_services[2]" bindings should have a string "binding" field but got {"binding":null,"service_id":123,"invalid":true}. - - "vpc_services[2]" bindings must have a "service_id" field but got {"binding":null,"service_id":123,"invalid":true}. - - "vpc_services[3]" bindings must have a "service_id" field but got {"binding":"MISSING_SERVICE_ID"}." + - "vpc_services[2]" bindings must have a "service_id" field but got {"binding":null,"service_id":123,"invalid":true}." `); }); }); diff --git a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts index 6278406a4b..c997eff182 100644 --- a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts +++ b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts @@ -30,13 +30,6 @@ describe("experimental_getWranglerCommands", () => { "requiresArg": true, "type": "string", }, - "experimental-auto-create": { - "alias": "x-auto-create", - "default": true, - "describe": "Automatically provision draft bindings with new resources", - "hidden": true, - "type": "boolean", - }, "experimental-provision": { "alias": [ "x-provision", diff --git a/packages/wrangler/src/__tests__/helpers/mock-dialogs.ts b/packages/wrangler/src/__tests__/helpers/mock-dialogs.ts index 8bc56dd5fe..f420bea62d 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-dialogs.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-dialogs.ts @@ -134,6 +134,35 @@ export function mockSelect( } } +/** + * The expected values for a search (autocomplete) request. + */ +export interface SearchExpectation { + /** The text expected to be seen in the search dialog (without the chalk-dimmed hint). */ + text: string; + /** The mock response sent back from the search dialog. */ + result: string; +} + +/** + * Mock the implementation of `search()` (autocomplete prompt) that will respond + * with configured results for configured search text messages. + */ +export function mockSearch(...expectations: SearchExpectation[]) { + for (const expectation of expectations) { + (prompts as unknown as Mock).mockImplementationOnce( + ({ type, name, message }) => { + expect(type).toStrictEqual("autocomplete"); + expect(name).toStrictEqual("value"); + // The message includes a chalk-dimmed "(type to filter)" suffix, + // so we check with a `toContain` rather than exact match. + expect(message).toContain(expectation.text); + return Promise.resolve({ value: expectation.result }); + } + ); + } +} + export function clearDialogs() { // No dialog mocks should be left after each test, and so calling the dialog methods should throw expect(() => prompts({ type: "select", name: "unknown" })).toThrow( diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index a7f8d3d0df..e0f8d7c180 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -1,14 +1,11 @@ -import { rmSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { - writeRedirectedWranglerConfig, - writeWranglerConfig, -} from "@cloudflare/workers-utils/test-helpers"; +import crypto from "node:crypto"; +import { writeWranglerConfig } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; -import { afterEach, beforeEach, describe, it, vi } from "vitest"; +// eslint-disable-next-line no-restricted-imports +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; -import { clearDialogs, mockPrompt, mockSelect } from "./helpers/mock-dialogs"; +import { clearDialogs, mockPrompt, mockSearch } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; import { mockCreateKVNamespace, @@ -26,13 +23,18 @@ 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 type { DatabaseInfo } from "../d1/types"; -import type { ExpectStatic } from "vitest"; vi.mock("../utils/fetch-secrets", () => ({ fetchSecrets: async () => [], })); +// Fix the random suffix so auto-generated names are deterministic in tests. +vi.spyOn(crypto, "randomBytes").mockReturnValue( + Buffer.from("deadbeef", "hex") as unknown as ReturnType< + typeof crypto.randomBytes + > +); + describe("resource provisioning", () => { const std = mockConsoleMethods(); mockAccountId(); @@ -48,380 +50,152 @@ describe("resource provisioning", () => { ); 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 ({ - expect, - }) => { - 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 runWrangler("deploy --x-auto-create=false"); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.KV (inherited) KV Namespace - env.D1 (inherited) D1 Database - env.R2 (inherited) R2 Bucket - - 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 ({ - expect, - }) => { - 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", + describe("inheriting bindings from deployed worker settings", () => { + it("should inherit KV, R2 and D1 bindings if they are found in settings", async () => { + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [{ binding: "KV" }], + r2_buckets: [{ binding: "R2" }], + d1_databases: [{ binding: "D1" }], }); - mockSelect({ - text: "Would you like to connect an existing R2 Bucket or create a new one?", - result: "existing-bucket-name", + 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: "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", - }, + { name: "KV", type: "inherit" }, + { name: "R2", type: "inherit" }, + { name: "D1", type: "inherit" }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.KV KV Namespace - env.D1 D1 Database - env.R2 R2 Bucket - - - Provisioning KV (KV Namespace)... - ✨ KV provisioned 🎉 - - Provisioning D1 (D1 Database)... - ✨ D1 provisioned 🎉 - - Provisioning R2 (R2 Bucket)... - ✨ R2 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.KV (existing-kv-id) KV Namespace - env.D1 (existing-d1-id) D1 Database - env.R2 (existing-bucket-name) R2 Bucket - - Uploaded test-name (TIMINGS) - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); + await runWrangler("deploy"); + expect(std.out).toContain("env.KV (inherited)"); + expect(std.out).toContain("env.R2 (inherited)"); + expect(std.out).toContain("env.D1 (inherited)"); 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 ({ - expect, - }) => { - 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", + it("can inherit D1 binding when the database name matches", async () => { + writeWranglerConfig({ + main: "index.js", + d1_databases: [{ binding: "D1", database_name: "prefilled-d1-name" }], }); - mockPrompt({ - text: "Enter the title or id for an existing KV Namespace", - result: "existing-kv-id-1", + mockGetSettings({ + result: { + bindings: [{ type: "d1", name: "D1", id: "d1-id" }], + }, }); - mockSelect({ - text: "Would you like to connect an existing D1 Database or create a new one?", - result: "__WRANGLER_INTERNAL_SEARCH", + mockGetD1Database("d1-id", { name: "prefilled-d1-name" }); + mockUploadWorkerRequest({ + expectedBindings: [{ name: "D1", type: "inherit" }], }); - mockPrompt({ - text: "Enter the name or id for an existing D1 Database", - result: "existing-d1-id-1", + + await runWrangler("deploy"); + expect(std.out).toContain("env.D1 (inherited)"); + }); + + it("will not inherit D1 binding when the database name has changed", async () => { + writeWranglerConfig({ + main: "index.js", + d1_databases: [{ binding: "D1", database_name: "new-d1-name" }], }); - mockSelect({ - text: "Would you like to connect an existing R2 Bucket or create a new one?", - result: "__WRANGLER_INTERNAL_SEARCH", + mockGetSettings({ + result: { + bindings: [{ type: "d1", name: "D1", id: "old-d1-id" }], + }, }); - mockPrompt({ - text: "Enter the name for an existing R2 Bucket", - result: "existing-bucket-1", + // Handle all D1 database lookups by ID/name + msw.use( + http.get( + "*/accounts/:accountId/d1/database/:databaseId", + ({ params }) => { + if (params.databaseId === "old-d1-id") { + return HttpResponse.json( + createFetchResult({ + name: "old-d1-name", + uuid: "old-d1-id", + }) + ); + } + // new-d1-name doesn't exist yet + return HttpResponse.json( + createFetchResult(null, false, [ + { code: 7404, message: "database not found" }, + ]) + ); + } + ), + http.get("*/accounts/:accountId/d1/database", async () => + HttpResponse.json( + createFetchResult([{ name: "old-d1-name", uuid: "old-d1-id" }]) + ) + ) + ); + mockCreateD1Database({ + assertName: "new-d1-name", + resultId: "new-d1-id", }); - 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", - }, - ], + expectedBindings: [{ name: "D1", type: "d1", id: "new-d1-id" }], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.KV KV Namespace - env.D1 D1 Database - env.R2 R2 Bucket - - - Provisioning KV (KV Namespace)... - ✨ KV provisioned 🎉 - - Provisioning D1 (D1 Database)... - ✨ D1 provisioned 🎉 - - Provisioning R2 (R2 Bucket)... - ✨ R2 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.KV (existing-kv-id-1) KV Namespace - env.D1 (existing-d1-id-1) D1 Database - env.R2 (existing-bucket-1) R2 Bucket - - 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(`""`); + await runWrangler("deploy"); + expect(std.out).toContain("Provisioning D1 (D1 Database)"); + expect(std.out).toContain('Creating new D1 Database "new-d1-name"'); }); + }); - it("can provision KV, R2 and D1 bindings with new resources", async ({ - expect, - }) => { + describe("interactive provisioning", () => { + it("can connect existing resources via search", async () => { + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [{ binding: "KV" }], + r2_buckets: [{ binding: "R2" }], + d1_databases: [{ binding: "D1" }], + }); 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( + http.get("*/accounts/:accountId/d1/database", async () => + HttpResponse.json( + createFetchResult([{ name: "db-name", uuid: "existing-d1-id" }]) + ) + ), + http.get("*/accounts/:accountId/r2/buckets", async () => + HttpResponse.json( createFetchResult({ - buckets: [ - { - name: "existing-bucket-name", - }, - ], + buckets: [{ name: "existing-bucket" }], }) - ); - }) + ) + ) ); - 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(expect, { - 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", + mockSearch({ + text: "Select an existing KV Namespace or create a new one", + result: "existing-kv-id", }); - mockPrompt({ - text: "Enter a name for your new R2 Bucket", - result: "new-r2", + mockSearch({ + text: "Select an existing D1 Database or create a new one", + result: "existing-d1-id", }); - mockCreateR2Bucket(expect, { - assertBucketName: "new-r2", + mockSearch({ + text: "Select an existing R2 Bucket or create a new one", + result: "existing-bucket", }); mockUploadWorkerRequest({ @@ -429,93 +203,28 @@ describe("resource provisioning", () => { { name: "KV", type: "kv_namespace", - namespace_id: "new-kv-id", + namespace_id: "existing-kv-id", }, { name: "R2", type: "r2_bucket", - bucket_name: "new-r2", - }, - { - name: "D1", - type: "d1", - id: "new-d1-id", + bucket_name: "existing-bucket", }, + { name: "D1", type: "d1", id: "existing-d1-id" }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.KV KV Namespace - env.D1 D1 Database - env.R2 R2 Bucket - - - Provisioning KV (KV Namespace)... - 🌀 Creating new KV Namespace "new-kv"... - ✨ KV provisioned 🎉 - - Provisioning D1 (D1 Database)... - 🌀 Creating new D1 Database "new-d1"... - ✨ D1 provisioned 🎉 - - Provisioning R2 (R2 Bucket)... - 🌀 Creating new R2 Bucket "new-r2"... - ✨ R2 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.KV (new-kv-id) KV Namespace - env.D1 (new-d1-id) D1 Database - env.R2 (new-r2) R2 Bucket - - Uploaded test-name (TIMINGS) - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); + await runWrangler("deploy"); + expect(std.out).toContain("✨ KV provisioned"); + expect(std.out).toContain("✨ D1 provisioned"); + expect(std.out).toContain("✨ R2 provisioned"); + expect(std.out).toContain("All resources provisioned"); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - - // IDs should be written back to the config file - expect(await readFile("wrangler.toml", "utf-8")).toMatchInlineSnapshot(` - "compatibility_date = "2022-01-12" - name = "test-name" - main = "index.js" - - [[kv_namespaces]] - binding = "KV" - id = "new-kv-id" - - [[r2_buckets]] - binding = "R2" - bucket_name = "new-r2" - - [[d1_databases]] - binding = "D1" - database_id = "new-d1-id" - " - `); }); - it("can provision KV, R2 and D1 bindings with new resources w/ redirected config", async ({ - expect, - }) => { - writeRedirectedWranglerConfig({ - main: "../index.js", - compatibility_flags: ["nodejs_compat"], + it("can create new resources via search", async () => { + writeWranglerConfig({ + main: "index.js", kv_namespaces: [{ binding: "KV" }], r2_buckets: [{ binding: "R2" }], d1_databases: [{ binding: "D1" }], @@ -526,31 +235,23 @@ describe("resource provisioning", () => { 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( + http.get("*/accounts/:accountId/d1/database", async () => + HttpResponse.json( + createFetchResult([{ name: "db-name", uuid: "existing-d1-id" }]) + ) + ), + http.get("*/accounts/:accountId/r2/buckets", async () => + HttpResponse.json( createFetchResult({ - buckets: [ - { - name: "existing-bucket-name", - }, - ], + buckets: [{ name: "existing-bucket" }], }) - ); - }) + ) + ) ); - mockSelect({ - text: "Would you like to connect an existing KV Namespace or create a new one?", + // Select "Create new" for all three + mockSearch({ + text: "Select an existing KV Namespace or create a new one", result: "__WRANGLER_INTERNAL_NEW", }); mockPrompt({ @@ -562,30 +263,28 @@ describe("resource provisioning", () => { resultId: "new-kv-id", }); - mockSelect({ - text: "Would you like to connect an existing D1 Database or create a new one?", + mockSearch({ + text: "Select 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(expect, { + mockCreateD1Database({ assertName: "new-d1", resultId: "new-d1-id", }); - mockSelect({ - text: "Would you like to connect an existing R2 Bucket or create a new one?", + mockSearch({ + text: "Select 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(expect, { - assertBucketName: "new-r2", - }); + mockCreateR2Bucket({ assertBucketName: "new-r2" }); mockUploadWorkerRequest({ expectedBindings: [ @@ -599,757 +298,416 @@ describe("resource provisioning", () => { type: "r2_bucket", bucket_name: "new-r2", }, - { - name: "D1", - type: "d1", - id: "new-d1-id", - }, + { name: "D1", type: "d1", id: "new-d1-id" }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.KV KV Namespace - env.D1 D1 Database - env.R2 R2 Bucket - - - Provisioning KV (KV Namespace)... - 🌀 Creating new KV Namespace "new-kv"... - ✨ KV provisioned 🎉 - - Provisioning D1 (D1 Database)... - 🌀 Creating new D1 Database "new-d1"... - ✨ D1 provisioned 🎉 - - Provisioning R2 (R2 Bucket)... - 🌀 Creating new R2 Bucket "new-r2"... - ✨ R2 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.KV (new-kv-id) KV Namespace - env.D1 (new-d1-id) D1 Database - env.R2 (new-r2) R2 Bucket - - Uploaded test-name (TIMINGS) - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); + await runWrangler("deploy"); + expect(std.out).toContain('Creating new KV Namespace "new-kv"'); + expect(std.out).toContain('Creating new D1 Database "new-d1"'); + expect(std.out).toContain('Creating new R2 Bucket "new-r2"'); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - - // IDs should be written back to the user config file - expect(await readFile("wrangler.toml", "utf-8")).toMatchInlineSnapshot(` - "compatibility_date = "2022-01-12" - name = "test-name" - main = "index.js" - - [[kv_namespaces]] - binding = "KV" - id = "new-kv-id" - - [[r2_buckets]] - binding = "R2" - bucket_name = "new-r2" - - [[d1_databases]] - binding = "D1" - database_id = "new-d1-id" - " - `); - - rmSync(".wrangler/deploy/config.json"); }); + }); - it("can inject additional bindings in redirected config that aren't written back to disk", async ({ - expect, - }) => { - writeRedirectedWranglerConfig({ - main: "../index.js", - compatibility_flags: ["nodejs_compat"], - kv_namespaces: [{ binding: "KV" }, { binding: "PLATFORM_KV" }], - r2_buckets: [{ binding: "R2" }], - d1_databases: [{ binding: "D1" }], + describe("name from config", () => { + it("uses database_name from config to create D1 without prompting", async () => { + writeWranglerConfig({ + main: "index.js", + d1_databases: [{ binding: "D1", database_name: "my-database" }], }); 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", - }, - ], - }) - ); - }) + http.get("*/accounts/:accountId/d1/database", async () => + HttpResponse.json(createFetchResult([])) + ) ); - mockCreateKVNamespace({ - assertTitle: "test-name-platform-kv", - resultId: "test-name-platform-kv-id", - }); - - mockCreateKVNamespace({ - assertTitle: "test-name-kv", - resultId: "test-name-kv-id", - }); - - mockCreateD1Database(expect, { - assertName: "test-name-d1", - resultId: "test-name-d1-id", - }); - - mockCreateR2Bucket(expect, { - assertBucketName: "test-name-r2", + mockGetD1Database("my-database", {}, true); + mockCreateD1Database({ + assertName: "my-database", + resultId: "new-d1-id", }); - mockUploadWorkerRequest({ - expectedBindings: [ - { - name: "KV", - type: "kv_namespace", - namespace_id: "test-name-kv-id", - }, - { - name: "PLATFORM_KV", - type: "kv_namespace", - namespace_id: "test-name-platform-kv-id", - }, - { - name: "R2", - type: "r2_bucket", - bucket_name: "test-name-r2", - }, - { - name: "D1", - type: "d1", - id: "test-name-d1-id", - }, - ], + expectedBindings: [{ name: "D1", type: "d1", id: "new-d1-id" }], }); await runWrangler("deploy"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.KV KV Namespace - env.PLATFORM_KV KV Namespace - env.D1 D1 Database - env.R2 R2 Bucket - - - Provisioning KV (KV Namespace)... - 🌀 Creating new KV Namespace "test-name-kv"... - ✨ KV provisioned 🎉 - - Provisioning PLATFORM_KV (KV Namespace)... - 🌀 Creating new KV Namespace "test-name-platform-kv"... - ✨ PLATFORM_KV provisioned 🎉 - - Provisioning D1 (D1 Database)... - 🌀 Creating new D1 Database "test-name-d1"... - ✨ D1 provisioned 🎉 - - Provisioning R2 (R2 Bucket)... - 🌀 Creating new R2 Bucket "test-name-r2"... - ✨ R2 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.KV (test-name-kv-id) KV Namespace - env.PLATFORM_KV (test-name-platform-kv-id) KV Namespace - env.D1 (test-name-d1-id) D1 Database - env.R2 (test-name-r2) R2 Bucket - - 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(`""`); - - // IDs should be written back to the user config file, except the injected PLATFORM_KV one - expect(await readFile("wrangler.toml", "utf-8")).toMatchInlineSnapshot(` - "compatibility_date = "2022-01-12" - name = "test-name" - main = "index.js" - - [[kv_namespaces]] - binding = "KV" - id = "test-name-kv-id" - - [[r2_buckets]] - binding = "R2" - bucket_name = "test-name-r2" - - [[d1_databases]] - binding = "D1" - database_id = "test-name-d1-id" - " - `); - - rmSync(".wrangler/deploy/config.json"); + expect(std.out).toContain("Resource name found in config: my-database"); + expect(std.out).toContain('Creating new D1 Database "my-database"'); }); - it("can prefill d1 database name from config file if provided", async ({ - expect, - }) => { + it("uses bucket_name from config to create R2 with jurisdiction", async () => { writeWranglerConfig({ main: "index.js", - d1_databases: [{ binding: "D1", database_name: "prefilled-d1-name" }], + r2_buckets: [ + { + binding: "BUCKET", + bucket_name: "my-bucket", + jurisdiction: "eu", + }, + ], }); mockGetSettings(); 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 () => + HttpResponse.json(createFetchResult({ buckets: [] })) + ) ); - mockGetD1Database(expect, "prefilled-d1-name", {}, true); - - // no name prompt - mockCreateD1Database(expect, { - assertName: "prefilled-d1-name", - resultId: "new-d1-id", + mockGetR2Bucket("my-bucket", true); + mockCreateR2Bucket({ + assertBucketName: "my-bucket", + assertJurisdiction: "eu", }); - mockUploadWorkerRequest({ expectedBindings: [ { - name: "D1", - type: "d1", - id: "new-d1-id", + name: "BUCKET", + type: "r2_bucket", + bucket_name: "my-bucket", + jurisdiction: "eu", }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.D1 D1 Database - - - Provisioning D1 (D1 Database)... - Resource name found in config: prefilled-d1-name - 🌀 Creating new D1 Database "prefilled-d1-name"... - ✨ D1 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.D1 (prefilled-d1-name) D1 Database - - 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(`""`); + await runWrangler("deploy"); + expect(std.out).toContain("Resource name found in config: my-bucket"); }); - it("can inherit d1 binding when the database name is provided", async ({ - expect, - }) => { + it("skips provisioning if D1 database_name matches an existing database", async () => { writeWranglerConfig({ main: "index.js", - d1_databases: [{ binding: "D1", database_name: "prefilled-d1-name" }], + d1_databases: [{ binding: "DB", database_name: "existing-db" }], }); - mockGetSettings({ - result: { - bindings: [ - { - type: "d1", - name: "D1", - id: "d1-id", - }, - ], - }, + mockGetSettings(); + mockGetD1Database("existing-db", { + name: "existing-db", + uuid: "existing-d1-id", }); - mockGetD1Database(expect, "d1-id", { name: "prefilled-d1-name" }); mockUploadWorkerRequest({ - expectedBindings: [ - { - name: "D1", - type: "inherit", - }, - ], + expectedBindings: [{ name: "DB", type: "d1", id: "existing-d1-id" }], }); - await runWrangler("deploy --x-auto-create=false"); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.D1 (inherited) D1 Database - - Uploaded test-name (TIMINGS) - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); + await runWrangler("deploy"); + expect(std.out).not.toContain("Provisioning"); }); - it("will not inherit d1 binding when the database name is provided but has changed", async ({ - expect, - }) => { - // first deploy used old-d1-name/old-d1-id - // now we provide a different database_name that doesn't match + it("skips provisioning if R2 bucket_name matches an existing bucket", async () => { writeWranglerConfig({ main: "index.js", - d1_databases: [{ binding: "D1", database_name: "new-d1-name" }], - }); - mockGetSettings({ - result: { - bindings: [ - { - type: "d1", - name: "D1", - id: "old-d1-id", - }, - ], - }, - }); - msw.use( - http.get("*/accounts/:accountId/d1/database", async () => { - return HttpResponse.json( - createFetchResult([ - { - name: "old-d1-name", - uuid: "old-d1-id", - }, - ]) - ); - }) - ); - mockGetD1Database(expect, "new-d1-name", {}, true); - - mockGetD1Database(expect, "old-d1-id", { name: "old-d1-name" }); - - // no name prompt - mockCreateD1Database(expect, { - assertName: "new-d1-name", - resultId: "new-d1-id", + r2_buckets: [ + { + binding: "BUCKET", + bucket_name: "existing-bucket", + jurisdiction: "eu", + }, + ], }); - + mockGetSettings(); + mockGetR2Bucket("existing-bucket", false); mockUploadWorkerRequest({ expectedBindings: [ { - name: "D1", - type: "d1", - id: "new-d1-id", + name: "BUCKET", + type: "r2_bucket", + bucket_name: "existing-bucket", + jurisdiction: "eu", }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.D1 D1 Database - - - Provisioning D1 (D1 Database)... - Resource name found in config: new-d1-name - 🌀 Creating new D1 Database "new-d1-name"... - ✨ D1 provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.D1 (new-d1-name) D1 Database - - 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(`""`); + await runWrangler("deploy"); + expect(std.out).not.toContain("Provisioning"); }); - it("can prefill r2 bucket name from config file if provided", async ({ - expect, - }) => { + it("will provision if R2 jurisdiction changes", async () => { writeWranglerConfig({ main: "index.js", r2_buckets: [ { binding: "BUCKET", - bucket_name: "prefilled-r2-name", - // note it will also respect jurisdiction if provided, but wont prompt for it + bucket_name: "existing-bucket", jurisdiction: "eu", }, ], }); - mockGetSettings(); + mockGetSettings({ + result: { + bindings: [ + { + type: "r2_bucket", + name: "BUCKET", + bucket_name: "existing-bucket", + jurisdiction: "fedramp", + }, + ], + }, + }); msw.use( - http.get("*/accounts/:accountId/r2/buckets", async () => { - return HttpResponse.json( - createFetchResult({ - buckets: [ - { - name: "existing-bucket-name", - }, - ], - }) - ); - }) + http.get("*/accounts/:accountId/r2/buckets", async () => + HttpResponse.json(createFetchResult({ buckets: [] })) + ) ); - mockGetR2Bucket(expect, "prefilled-r2-name", true); - // no name prompt - mockCreateR2Bucket(expect, { - assertBucketName: "prefilled-r2-name", + mockGetR2Bucket("existing-bucket", true); + mockCreateR2Bucket({ + assertBucketName: "existing-bucket", assertJurisdiction: "eu", }); - mockUploadWorkerRequest({ expectedBindings: [ { name: "BUCKET", type: "r2_bucket", - bucket_name: "prefilled-r2-name", + bucket_name: "existing-bucket", jurisdiction: "eu", }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - - Experimental: The following bindings need to be provisioned: - Binding Resource - env.BUCKET R2 Bucket - - - Provisioning BUCKET (R2 Bucket)... - Resource name found in config: prefilled-r2-name - 🌀 Creating new R2 Bucket "prefilled-r2-name"... - ✨ BUCKET provisioned 🎉 - - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... - - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.BUCKET (prefilled-r2-name (eu)) R2 Bucket + await runWrangler("deploy"); + expect(std.out).toContain("Provisioning BUCKET (R2 Bucket)"); + }); + }); - 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("CI auto-provisioning", () => { + beforeEach(() => { + setIsTTY(false); }); - it("won't prompt to provision if an r2 bucket name belongs to an existing bucket", async ({ - expect, - }) => { + it("auto-creates ciSafe bindings with generated names", async () => { writeWranglerConfig({ main: "index.js", - r2_buckets: [ - { - binding: "BUCKET", - bucket_name: "existing-bucket-name", - jurisdiction: "eu", - }, - ], + kv_namespaces: [{ binding: "KV" }], + d1_databases: [{ binding: "D1" }], + r2_buckets: [{ binding: "R2" }], }); mockGetSettings(); + mockListKVNamespacesRequest(); msw.use( - http.get("*/accounts/:accountId/r2/buckets", async () => { - return HttpResponse.json( - createFetchResult({ - buckets: [ - { - name: "existing-bucket-name", - }, - ], - }) - ); - }) + http.get("*/accounts/:accountId/d1/database", async () => + HttpResponse.json(createFetchResult([])) + ), + http.get("*/accounts/:accountId/r2/buckets", async () => + HttpResponse.json(createFetchResult({ buckets: [] })) + ) ); - mockGetR2Bucket(expect, "existing-bucket-name", false); + + // The random suffix is mocked to "deadbeef" + mockCreateKVNamespace({ + assertTitle: "test-name-kv-deadbeef", + resultId: "auto-kv-id", + }); + mockCreateD1Database({ + assertName: "test-name-d1-deadbeef", + resultId: "auto-d1-id", + }); + mockCreateR2Bucket({ + assertBucketName: "test-name-r2-deadbeef", + }); + mockUploadWorkerRequest({ expectedBindings: [ { - name: "BUCKET", + name: "KV", + type: "kv_namespace", + namespace_id: "auto-kv-id", + }, + { + name: "R2", type: "r2_bucket", - bucket_name: "existing-bucket-name", - jurisdiction: "eu", + bucket_name: "test-name-r2-deadbeef", }, + { name: "D1", type: "d1", id: "auto-d1-id" }, ], }); - await runWrangler("deploy --x-auto-create=false"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.BUCKET (existing-bucket-name (eu)) R2 Bucket - - 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(`""`); + await runWrangler("deploy"); + expect(std.out).toContain( + 'Creating new KV Namespace "test-name-kv-deadbeef"' + ); + expect(std.out).toContain( + 'Creating new D1 Database "test-name-d1-deadbeef"' + ); + expect(std.out).toContain( + 'Creating new R2 Bucket "test-name-r2-deadbeef"' + ); + expect(std.out).toContain("All resources provisioned"); }); - it("won't prompt to provision if a D1 database name belongs to an existing database", async ({ - expect, - }) => { + it("skips provisioning for queue bindings with queue name set", async () => { writeWranglerConfig({ main: "index.js", - d1_databases: [ - { - binding: "DB_NAME", - database_name: "existing-db-name", - }, - ], + queues: { + producers: [{ binding: "QUEUE", queue: "my-queue" }], + }, }); mockGetSettings(); - - mockGetD1Database(expect, "existing-db-name", { - name: "existing-db-name", - uuid: "existing-d1-id", - }); - mockUploadWorkerRequest({ expectedBindings: [ { - name: "DB_NAME", - type: "d1", - id: "existing-d1-id", + name: "QUEUE", + type: "queue", + queue_name: "my-queue", }, ], }); await runWrangler("deploy"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.DB_NAME (existing-db-name) D1 Database - - 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(`""`); + expect(std.out).not.toContain("Provisioning"); }); - // because buckets with the same name can exist in different jurisdictions - it("will provision if the jurisdiction changes", async ({ expect }) => { + it("auto-creates queue bindings without a name in CI", async () => { writeWranglerConfig({ main: "index.js", - r2_buckets: [ - { - binding: "BUCKET", - bucket_name: "existing-bucket-name", - jurisdiction: "eu", - }, - ], - }); - mockGetSettings({ - result: { - bindings: [ - { - type: "r2_bucket", - name: "BUCKET", - bucket_name: "existing-bucket-name", - jurisdiction: "fedramp", - }, - ], + queues: { + producers: [{ binding: "QUEUE" }], }, }); - // list r2 buckets + mockGetSettings(); + mockListKVNamespacesRequest(); // for the KV load in HANDLERS msw.use( - http.get("*/accounts/:accountId/r2/buckets", async () => { - return HttpResponse.json( - createFetchResult({ - buckets: [ - { - name: "existing-bucket-name", - }, - ], - }) - ); - }) + http.get("*/accounts/:accountId/queues", async () => + HttpResponse.json(createFetchResult([])) + ), + http.post( + "*/accounts/:accountId/queues", + async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ + queue_name: "test-name-queue-deadbeef", + }); + return HttpResponse.json( + createFetchResult({ + queue_name: "test-name-queue-deadbeef", + }) + ); + }, + { once: true } + ) ); - // since the jurisdiction doesn't match, it should return not found - mockGetR2Bucket(expect, "existing-bucket-name", true); mockUploadWorkerRequest({ expectedBindings: [ { - name: "BUCKET", - type: "r2_bucket", - bucket_name: "existing-bucket-name", - jurisdiction: "eu", + name: "QUEUE", + type: "queue", + queue_name: "test-name-queue-deadbeef", }, ], }); - mockCreateR2Bucket(expect, { - assertJurisdiction: "eu", - assertBucketName: "existing-bucket-name", - }); await runWrangler("deploy"); + expect(std.out).toContain( + 'Creating new Queue "test-name-queue-deadbeef"' + ); + }); + }); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB + describe("pre-flight check", () => { + beforeEach(() => { + setIsTTY(false); + }); - Experimental: The following bindings need to be provisioned: - Binding Resource - env.BUCKET R2 Bucket + it("blocks all provisioning if any non-ciSafe binding cannot be resolved", async () => { + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [{ binding: "KV" }], + vectorize: [{ binding: "EMBEDDINGS" }], + }); + mockGetSettings(); + // No KV or Vectorize should be created — pre-flight blocks everything + await expect(runWrangler("deploy")).rejects.toThrow( + "Could not auto-provision the following bindings" + ); + expect(std.err).toContain("EMBEDDINGS (Vectorize Index)"); + expect(std.err).toContain("wrangler vectorize create"); + // The message should mention what WOULD work once resolved + expect(std.err).toContain("KV (KV Namespace)"); + expect(std.err).toContain( + "run wrangler deploy interactively in a terminal" + ); + }); - Provisioning BUCKET (R2 Bucket)... - Resource name found in config: existing-bucket-name - 🌀 Creating new R2 Bucket "existing-bucket-name"... - ✨ BUCKET provisioned 🎉 + it("blocks with multiple non-ciSafe bindings and lists all of them", async () => { + writeWranglerConfig({ + main: "index.js", + vectorize: [{ binding: "INDEX" }], + hyperdrive: [{ binding: "DB" }], + }); + mockGetSettings(); - Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work. - 🎉 All resources provisioned, continuing with deployment... + await expect(runWrangler("deploy")).rejects.toThrow( + "Could not auto-provision the following bindings" + ); + expect(std.err).toContain("INDEX (Vectorize Index)"); + expect(std.err).toContain("DB (Hyperdrive Config)"); + expect(std.err).toContain("wrangler vectorize create"); + expect(std.err).toContain("wrangler hyperdrive create"); + }); - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.BUCKET (existing-bucket-name (eu)) R2 Bucket + it("does not block if non-ciSafe binding is fully specified", async () => { + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [{ binding: "KV" }], + hyperdrive: [{ binding: "DB", id: "existing-hyperdrive-id" }], + }); + mockGetSettings(); + mockListKVNamespacesRequest(); - 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(`""`); + mockCreateKVNamespace({ + assertTitle: "test-name-kv-deadbeef", + resultId: "auto-kv-id", + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV", + type: "kv_namespace", + namespace_id: "auto-kv-id", + }, + { + name: "DB", + type: "hyperdrive", + id: "existing-hyperdrive-id", + }, + ], + }); + + await runWrangler("deploy"); + expect(std.out).toContain("✨ KV provisioned"); + expect(std.out).not.toContain("Provisioning DB"); }); }); - it("should error if used with a service environment", async ({ expect }) => { - writeWorkerSource(); + it("should error if used with a service environment", async () => { writeWranglerConfig({ main: "index.js", legacy_env: false, kv_namespaces: [{ binding: "KV" }], }); - await expect(runWrangler("deploy --x-auto-create=false")).rejects.toThrow( + mockGetSettings(); + await expect(runWrangler("deploy")).rejects.toThrow( "Provisioning resources is not supported with a service environment" ); }); }); +// ---- MSW mock helpers ---- + function mockCreateD1Database( - expect: ExpectStatic, - options: { - resultId?: string; - assertName?: string; - } = {} + 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 }); + const body = await request.json(); + expect(body).toEqual({ name: options.assertName }); } - return HttpResponse.json( - createFetchResult({ uuid: options.resultId ?? "some-d1-id" }) + createFetchResult({ + uuid: options.resultId ?? "some-d1-id", + }) ); }, { once: true } @@ -1358,7 +716,6 @@ function mockCreateD1Database( } function mockCreateR2Bucket( - expect: ExpectStatic, options: { assertBucketName?: string; assertJurisdiction?: string; @@ -1369,8 +726,10 @@ function mockCreateR2Bucket( "*/accounts/:accountId/r2/buckets", async ({ request }) => { if (options.assertBucketName) { - const requestBody = await request.json(); - expect(requestBody).toMatchObject({ name: options.assertBucketName }); + const body = await request.json(); + expect(body).toMatchObject({ + name: options.assertBucketName, + }); } if (options.assertJurisdiction) { expect(request.headers.get("cf-r2-jurisdiction")).toEqual( @@ -1384,17 +743,12 @@ function mockCreateR2Bucket( ); } -function mockGetR2Bucket( - expect: ExpectStatic, - bucketName: string, - missing: boolean = false -) { +function mockGetR2Bucket(bucketName: string, missing = false) { msw.use( http.get( "*/accounts/:accountId/r2/buckets/:bucketName", async ({ params }) => { - const { bucketName: bucketParam } = params; - expect(bucketParam).toEqual(bucketName); + expect(params.bucketName).toEqual(bucketName); if (missing) { return HttpResponse.json( createFetchResult(null, false, [ @@ -1410,14 +764,13 @@ function mockGetR2Bucket( } function mockGetD1Database( - expect: ExpectStatic, databaseIdOrName: string, - databaseInfo: Partial, - missing: boolean = false + databaseInfo: Record, + missing = false ) { msw.use( http.get( - `*/accounts/:accountId/d1/database/:database_id`, + "*/accounts/:accountId/d1/database/:database_id", ({ params }) => { expect(params.database_id).toEqual(databaseIdOrName); if (missing) { diff --git a/packages/wrangler/src/api/dev.ts b/packages/wrangler/src/api/dev.ts index ed289e02bb..b38a9c5e77 100644 --- a/packages/wrangler/src/api/dev.ts +++ b/packages/wrangler/src/api/dev.ts @@ -216,7 +216,6 @@ export async function unstable_dev( logLevel: options?.logLevel ?? defaultLogLevel, port: options?.port ?? 0, experimentalProvision: undefined, - experimentalAutoCreate: false, enableIpc: options?.experimental?.enableIpc, nodeCompat: undefined, enableContainers: options?.experimental?.enableContainers ?? false, @@ -231,7 +230,6 @@ export async function unstable_dev( // TODO: can we make this work? MULTIWORKER: false, RESOURCES_PROVISION: false, - AUTOCREATE_RESOURCES: false, }, () => startDev(devOptions) ); diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index f80fec2ce2..4f7147b526 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -178,7 +178,6 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { : { MULTIWORKER: false, RESOURCES_PROVISION: args.experimentalProvision ?? false, - AUTOCREATE_RESOURCES: args.experimentalAutoCreate, }; await run(experimentalFlags, async () => { diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index bf578c9966..24a05b199f 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -134,7 +134,6 @@ type Props = { oldAssetTtl: number | undefined; projectRoot: string | undefined; dispatchNamespace: string | undefined; - experimentalAutoCreate: boolean; metafile: string | boolean | undefined; containersRollout: "immediate" | "gradual" | undefined; strict: boolean | undefined; @@ -998,7 +997,6 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m bindings ?? {}, accountId, scriptName, - props.experimentalAutoCreate, props.config ); } @@ -1016,7 +1014,11 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } ); - await ensureQueuesExistByConfig(config); + // When provisioning is enabled, queues are created as part of provisioning + // so we don't need to verify they exist separately. + if (!getFlag("RESOURCES_PROVISION")) { + await ensureQueuesExistByConfig(config); + } let bindingsPrinted = false; // Upload the script so it has time to propagate. diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 5f9b202a93..dea79e95c8 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -270,7 +270,6 @@ export const deployCommand = createCommand({ overrideExperimentalFlags: (args) => ({ MULTIWORKER: false, RESOURCES_PROVISION: args.experimentalProvision ?? false, - AUTOCREATE_RESOURCES: args.experimentalAutoCreate, }), warnIfMultipleEnvsConfiguredButNoneSpecified: true, printMetricsBanner: true, @@ -507,7 +506,6 @@ export const deployCommand = createCommand({ oldAssetTtl: args.oldAssetTtl, projectRoot, dispatchNamespace: args.dispatchNamespace, - experimentalAutoCreate: args.experimentalAutoCreate, containersRollout: args.containersRollout, strict: args.strict, tag: args.tag, diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index db9d8ef36f..1dd362b917 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -1,41 +1,38 @@ import assert from "node:assert"; -import { - APIError, - experimental_patchConfig, - experimental_readRawConfig, - INHERIT_SYMBOL, - PatchConfigError, - UserError, -} from "@cloudflare/workers-utils"; -import { createAISearchNamespace, getAISearchNamespace } from "../ai-search"; +import { UserError } from "@cloudflare/workers-utils"; import { convertConfigToBindings } from "../api/startDevWorker/utils"; import { fetchResult } from "../cfetch"; -import { createD1Database } from "../d1/create"; -import { listDatabases } from "../d1/list"; -import { getDatabaseInfoFromIdOrName } from "../d1/utils"; -import { prompt, select } from "../dialogs"; +import { prompt, search } from "../dialogs"; import { isNonInteractiveOrCI } from "../is-interactive"; -import { createKVNamespace, listKVNamespaces } from "../kv/helpers"; import { logger } from "../logger"; import * as metrics from "../metrics"; -import { - createR2Bucket, - getR2Bucket, - listR2Buckets, -} from "../r2/helpers/bucket"; import { printBindings } from "../utils/print-bindings"; import { useServiceEnvironments } from "../utils/useServiceEnvironments"; +import { AISearchNamespaceHandler } from "./provision/ai-search"; +import { D1Handler } from "./provision/d1"; +import { DispatchNamespaceHandler } from "./provision/dispatch-namespace"; +import { HyperdriveHandler } from "./provision/hyperdrive"; +import { + generateDefaultName, + type NormalisedResourceInfo, + type ProvisionableBinding, + type Settings, +} from "./provision/index"; +import { KVHandler } from "./provision/kv"; +import { MtlsCertificateHandler } from "./provision/mtls-certificate"; +import { PipelineHandler } from "./provision/pipeline"; +import { QueueHandler } from "./provision/queue"; +import { R2Handler } from "./provision/r2"; +import { VectorizeHandler } from "./provision/vectorize"; +import { VpcServiceHandler } from "./provision/vpc-service"; import type { Binding, StartDevWorkerInput } from "../api/startDevWorker/types"; import type { - CfAISearchNamespace, - CfD1Database, - CfKvNamespace, - CfR2Bucket, - ComplianceConfig, - Config, - RawConfig, - WorkerMetadataBinding, -} from "@cloudflare/workers-utils"; + HandlerStatics, + ProvisionResourceHandler, +} from "./provision/index"; +import type { Config, WorkerMetadataBinding } from "@cloudflare/workers-utils"; + +export type { Settings } from "./provision/index"; export function getBindings( config: Config | undefined, @@ -52,462 +49,27 @@ export function getBindings( }); } -export type Settings = { - bindings: Array; -}; - -abstract class ProvisionResourceHandler< - T extends WorkerMetadataBinding["type"], - B extends ProvisionableBinding, -> { - constructor( - public type: T, - public bindingName: string, - public binding: B, - public idField: keyof B, - public complianceConfig: ComplianceConfig, - public accountId: string - ) {} - - // Does this resource already exist in the currently deployed version of the Worker? - // If it does, that means we can inherit from it. - abstract canInherit( - settings: Settings | undefined - ): boolean | Promise; - - inherit(): void { - // @ts-expect-error idField is a key of this.binding - this.binding[this.idField] = INHERIT_SYMBOL; - } - connect(id: string): void { - // @ts-expect-error idField is a key of this.binding - this.binding[this.idField] = id; - } - - abstract create(name: string): Promise; - - abstract get name(): string | undefined; - - async provision(name: string): Promise { - const id = await this.create(name); - this.connect(id); - } - - // This binding is fully specified and can't/shouldn't be provisioned - // This is usually when it has an id (e.g. D1 `database_id`) - isFullySpecified(): boolean { - return false; - } - - // Does this binding need to be provisioned? - // Some bindings are not fully specified, but don't need provisioning - // (e.g. R2 binding, with a bucket_name that already exists) - async isConnectedToExistingResource(): Promise { - return false; - } - - // Should this resource be provisioned? - async shouldProvision(settings: Settings | undefined) { - // If the resource is fully specified, don't provision - if (!this.isFullySpecified()) { - // If we can inherit, do that and don't provision - if (await this.canInherit(settings)) { - this.inherit(); - } else { - // If the resource is connected to a remote resource that _exists_ - // (see comments on the individual functions for why this is different to isFullySpecified()) - const connected = await this.isConnectedToExistingResource(); - if (connected) { - if (typeof connected === "string") { - // Basically a special case for D1: the resource is specified by name in config - // and exists, but needs to be specified by ID for the first deploy to work - this.connect(connected); - } - return false; - } - return true; - } - } - return false; - } -} - -class R2Handler extends ProvisionResourceHandler< - "r2_bucket", - Extract -> { - get name(): string | undefined { - return this.binding.bucket_name as string; - } - - async create(name: string) { - await createR2Bucket( - this.complianceConfig, - this.accountId, - name, - undefined, - this.binding.jurisdiction - ); - return name; - } - constructor( - bindingName: string, - binding: Extract, - complianceConfig: ComplianceConfig, - accountId: string - ) { - super( - "r2_bucket", - bindingName, - binding, - "bucket_name", - complianceConfig, - accountId - ); - } - - /** - * Inheriting an R2 binding replaces the id property (bucket_name for R2) with the inheritance symbol. - * This works when deploying (and is appropriate for all other binding types), but it means that the - * bucket_name for an R2 bucket is not displayed when deploying. As such, only use the inheritance symbol - * if the R2 binding has no `bucket_name`. - */ - override inherit(): void { - this.binding.bucket_name ??= INHERIT_SYMBOL; - } - - /** - * R2 bindings can be inherited if the binding name and jurisdiction match. - * Additionally, if the user has specified a bucket_name in config, make sure that matches - */ - canInherit(settings: Settings | undefined): boolean { - return !!settings?.bindings.find( - (existing) => - existing.type === this.type && - existing.name === this.bindingName && - existing.jurisdiction === this.binding.jurisdiction && - (this.binding.bucket_name - ? this.binding.bucket_name === existing.bucket_name - : true) - ); - } - async isConnectedToExistingResource(): Promise { - assert(typeof this.binding.bucket_name !== "symbol"); - - // If the user hasn't specified a bucket_name in config, we always provision - if (!this.binding.bucket_name) { - return false; - } - try { - await getR2Bucket( - this.complianceConfig, - this.accountId, - this.binding.bucket_name, - this.binding.jurisdiction - ); - // This bucket_name exists! We don't need to provision it - return true; - } catch (e) { - if (!(e instanceof APIError && e.code === 10006)) { - // this is an error that is not "bucket not found", so we do want to throw - throw e; - } - - // This bucket_name doesn't exist—let's provision - return false; - } - } -} - -class AISearchNamespaceHandler extends ProvisionResourceHandler< - "ai_search_namespace", - Extract -> { - get name(): string | undefined { - return this.binding.namespace as string; - } - - async create(name: string) { - await createAISearchNamespace(this.complianceConfig, this.accountId, name); - return name; - } - - constructor( - bindingName: string, - binding: Extract, - complianceConfig: ComplianceConfig, - accountId: string - ) { - super( - "ai_search_namespace", - bindingName, - binding, - "namespace", - complianceConfig, - accountId - ); - } - - canInherit(settings: Settings | undefined): boolean { - return !!settings?.bindings.find( - (existing) => - existing.type === this.type && - existing.name === this.bindingName && - (this.binding.namespace - ? this.binding.namespace === existing.namespace - : true) - ); - } - - async isConnectedToExistingResource(): Promise { - assert(typeof this.binding.namespace !== "symbol"); - - if (!this.binding.namespace) { - return false; - } - - const namespace = await getAISearchNamespace( - this.complianceConfig, - this.accountId, - this.binding.namespace - ); - - return namespace !== null; - } -} - -class KVHandler extends ProvisionResourceHandler< - "kv_namespace", - Extract -> { - get name(): string | undefined { - return undefined; - } - async create(name: string) { - return await createKVNamespace(this.complianceConfig, this.accountId, name); - } - constructor( - bindingName: string, - binding: Extract, - complianceConfig: ComplianceConfig, - accountId: string - ) { - super( - "kv_namespace", - bindingName, - binding, - "id", - complianceConfig, - accountId - ); - } - canInherit(settings: Settings | undefined): boolean { - return !!settings?.bindings.find( - (existing) => - existing.type === this.type && existing.name === this.bindingName - ); - } - isFullySpecified(): boolean { - return !!this.binding.id; - } -} - -class D1Handler extends ProvisionResourceHandler< - "d1", - Extract -> { - get name(): string | undefined { - return this.binding.database_name as string; - } - async create(name: string) { - const db = await createD1Database( - this.complianceConfig, - this.accountId, - name - ); - return db.uuid; - } - constructor( - bindingName: string, - binding: Extract, - complianceConfig: ComplianceConfig, - accountId: string - ) { - super( - "d1", - bindingName, - binding, - "database_id", - complianceConfig, - accountId - ); - } - async canInherit(settings: Settings | undefined): Promise { - const maybeInherited = settings?.bindings.find( - (existing) => - existing.type === this.type && existing.name === this.bindingName - ) as Extract | undefined; - // A D1 binding with the same binding name exists is already present on the worker... - if (maybeInherited) { - // ...and the user hasn't specified a name in their config, so we don't need to check if the database_name matches - if (!this.binding.database_name) { - return true; - } - - // ...and the user HAS specified a name in their config, so we need to check if the database_name they provided - // matches the database_name of the existing binding (which isn't present in settings, so we'll need to make an API call to check) - const dbFromId = await getDatabaseInfoFromIdOrName( - this.complianceConfig, - this.accountId, - maybeInherited.id - ); - if (this.binding.database_name === dbFromId.name) { - return true; - } - } - return false; - } - async isConnectedToExistingResource(): Promise { - assert(typeof this.binding.database_name !== "symbol"); - - // If the user hasn't specified a database_name in config, we always provision - if (!this.binding.database_name) { - return false; - } - try { - const db = await getDatabaseInfoFromIdOrName( - this.complianceConfig, - this.accountId, - this.binding.database_name - ); - - // This database_name exists! We don't need to provision it - return db.uuid; - } catch (e) { - if (!(e instanceof APIError && e.code === 7404)) { - // this is an error that is not "database not found", so we do want to throw - throw e; - } - - // This database_name doesn't exist—let's provision - return false; - } - } - isFullySpecified(): boolean { - return !!this.binding.database_id; - } -} - -type ProvisionableBinding = - | Extract - | Extract - | Extract - | Extract; - -const HANDLERS = { - kv_namespace: { - Handler: KVHandler, - sort: 0, - name: "KV Namespace", - keyDescription: "title or id", - configField: "kv_namespaces" as const, - load: async (complianceConfig: ComplianceConfig, accountId: string) => { - const preExistingKV = await listKVNamespaces( - complianceConfig, - accountId, - true - ); - return preExistingKV.map((ns) => ({ title: ns.title, value: ns.id })); - }, - toConfig: ( - bindingName: string, - binding: Extract - ): CfKvNamespace => { - const { type: _, ...rest } = binding; - return { - ...rest, - binding: bindingName, - }; - }, - }, - d1: { - Handler: D1Handler, - sort: 1, - name: "D1 Database", - keyDescription: "name or id", - configField: "d1_databases" as const, - load: async (complianceConfig: ComplianceConfig, accountId: string) => { - const preExisting = await listDatabases( - complianceConfig, - accountId, - true, - 1000 - ); - return preExisting.map((db) => ({ title: db.name, value: db.uuid })); - }, - toConfig: ( - bindingName: string, - binding: Extract - ): CfD1Database => { - const { type: _, ...rest } = binding; - return { - ...rest, - binding: bindingName, - }; - }, - }, - r2_bucket: { - Handler: R2Handler, - sort: 2, - name: "R2 Bucket", - keyDescription: "name", - configField: "r2_buckets" as const, - load: async (complianceConfig: ComplianceConfig, accountId: string) => { - const preExisting = await listR2Buckets(complianceConfig, accountId); - return preExisting.map((bucket) => ({ - title: bucket.name, - value: bucket.name, - })); - }, - toConfig: ( - bindingName: string, - binding: Extract - ): CfR2Bucket => { - const { type: _, ...rest } = binding; - return { - ...rest, - binding: bindingName, - }; - }, - }, - ai_search_namespace: { - Handler: AISearchNamespaceHandler, - sort: 3, - name: "AI Search Namespace", - keyDescription: "namespace name", - configField: "ai_search_namespaces" as const, - load: async (_complianceConfig: ComplianceConfig, _accountId: string) => { - // AI Search namespaces don't have a general list API in this context. - // The provisioning system will create them if they don't exist. - return []; - }, - toConfig: ( - bindingName: string, - binding: Extract - ): CfAISearchNamespace => { - const { type: _, ...rest } = binding; - return { - ...rest, - binding: bindingName, - }; - }, - }, +const HANDLERS: Record = { + [KVHandler.bindingType]: KVHandler, + [D1Handler.bindingType]: D1Handler, + [R2Handler.bindingType]: R2Handler, + [AISearchNamespaceHandler.bindingType]: AISearchNamespaceHandler, + [QueueHandler.bindingType]: QueueHandler, + [DispatchNamespaceHandler.bindingType]: DispatchNamespaceHandler, + [VectorizeHandler.bindingType]: VectorizeHandler, + [HyperdriveHandler.bindingType]: HyperdriveHandler, + [PipelineHandler.bindingType]: PipelineHandler, + [VpcServiceHandler.bindingType]: VpcServiceHandler, + [MtlsCertificateHandler.bindingType]: MtlsCertificateHandler, }; type PendingResource = { binding: string; - resourceType: "kv_namespace" | "d1" | "r2_bucket" | "ai_search_namespace"; - handler: KVHandler | D1Handler | R2Handler | AISearchNamespaceHandler; + resourceType: string; + handler: ProvisionResourceHandler< + WorkerMetadataBinding["type"], + ProvisionableBinding + >; }; function isProvisionableBinding( @@ -519,44 +81,17 @@ function isProvisionableBinding( function createHandler( bindingName: string, binding: ProvisionableBinding, - complianceConfig: ComplianceConfig, + config: Config, accountId: string -): KVHandler | D1Handler | R2Handler | AISearchNamespaceHandler { - switch (binding.type) { - case "kv_namespace": - return new KVHandler(bindingName, binding, complianceConfig, accountId); - case "d1": - return new D1Handler(bindingName, binding, complianceConfig, accountId); - case "r2_bucket": - return new R2Handler(bindingName, binding, complianceConfig, accountId); - case "ai_search_namespace": - return new AISearchNamespaceHandler( - bindingName, - binding, - complianceConfig, - accountId - ); - } -} - -function toConfigBinding( - bindingName: string, - binding: ProvisionableBinding -): CfKvNamespace | CfR2Bucket | CfD1Database | CfAISearchNamespace { - switch (binding.type) { - case "kv_namespace": - return HANDLERS.kv_namespace.toConfig(bindingName, binding); - case "d1": - return HANDLERS.d1.toConfig(bindingName, binding); - case "r2_bucket": - return HANDLERS.r2_bucket.toConfig(bindingName, binding); - case "ai_search_namespace": - return HANDLERS.ai_search_namespace.toConfig(bindingName, binding); - } +): ProvisionResourceHandler< + WorkerMetadataBinding["type"], + ProvisionableBinding +> { + return HANDLERS[binding.type].create(bindingName, binding, config, accountId); } async function collectPendingResources( - complianceConfig: ComplianceConfig, + config: Config, accountId: string, scriptName: string, bindings: StartDevWorkerInput["bindings"], @@ -565,13 +100,16 @@ async function collectPendingResources( let settings: Settings | undefined; try { - settings = await getSettings(complianceConfig, accountId, scriptName); + settings = await getSettings(config, accountId, scriptName); } catch { logger.debug("No settings found"); } const pendingResources: PendingResource[] = []; + // Track provisioned queue names for de-duplication with consumers + const provisionedQueueNames = new Set(); + for (const [bindingName, binding] of Object.entries(bindings ?? {})) { if (!isProvisionableBinding(binding)) { continue; @@ -581,7 +119,7 @@ async function collectPendingResources( continue; } - const h = createHandler(bindingName, binding, complianceConfig, accountId); + const h = createHandler(bindingName, binding, config, accountId); if (await h.shouldProvision(settings)) { pendingResources.push({ @@ -590,10 +128,55 @@ async function collectPendingResources( handler: h, }); } + + // Track queue names (both provisioned and already-existing) for de-duplication + if (binding.type === "queue" && typeof binding.queue_name === "string") { + provisionedQueueNames.add(binding.queue_name); + } + } + + // Also provision queues referenced by consumers that aren't already + // covered by a producer binding. Consumers are triggers, not bindings, + // so they don't appear in the bindings map above. + for (const consumer of config.queues?.consumers ?? []) { + if (!consumer.queue || provisionedQueueNames.has(consumer.queue)) { + continue; + } + provisionedQueueNames.add(consumer.queue); + + const syntheticBinding: ProvisionableBinding = { + type: "queue", + queue_name: consumer.queue, + }; + const syntheticName = `__consumer_${consumer.queue}`; + const h = createHandler(syntheticName, syntheticBinding, config, accountId); + + if (await h.shouldProvision(settings)) { + pendingResources.push({ + binding: syntheticName, + resourceType: "queue", + handler: h, + }); + } } + // Sort by the order handlers appear in the HANDLERS registry + const handlerOrder = Object.keys(HANDLERS); return pendingResources.sort( - (a, b) => HANDLERS[a.resourceType].sort - HANDLERS[b.resourceType].sort + (a, b) => + handlerOrder.indexOf(a.resourceType) - + handlerOrder.indexOf(b.resourceType) + ); +} + +export function getSettings( + config: Config, + accountId: string, + scriptName: string +) { + return fetchResult( + config, + `/accounts/${accountId}/workers/scripts/${scriptName}/settings` ); } @@ -601,7 +184,6 @@ export async function provisionBindings( bindings: StartDevWorkerInput["bindings"], accountId: string, scriptName: string, - autoCreate: boolean, config: Config, requireRemote = false ): Promise { @@ -614,202 +196,132 @@ export async function provisionBindings( requireRemote ); - if (pendingResources.length > 0) { - assert( - configPath, - "Provisioning resources is not possible without a config file" - ); + if (pendingResources.length === 0) { + return; + } - if (useServiceEnvironments(config)) { - throw new UserError( - "Provisioning resources is not supported with a service environment" - ); - } - logger.log(); - - printBindings( - Object.fromEntries( - pendingResources.map((r) => [r.binding, { type: r.resourceType }]) - ) as Record, - config.tail_consumers, - config.streaming_tail_consumers, - config.containers, - { provisioning: true } - ); - logger.log(); + assert( + configPath, + "Provisioning resources is not possible without a config file" + ); - const existingResources: Record = {}; + if (useServiceEnvironments(config)) { + throw new UserError( + "Provisioning resources is not supported with a service environment" + ); + } + logger.log(); - for (const resource of pendingResources) { - existingResources[resource.resourceType] ??= await HANDLERS[ - resource.resourceType - ].load(config, accountId); + printBindings( + Object.fromEntries( + pendingResources.map((r) => [r.binding, { type: r.resourceType }]) + ) as Record, + config.tail_consumers, + config.streaming_tail_consumers, + config.containers, + { provisioning: true } + ); + logger.log(); - await runProvisioningFlow( - resource, - existingResources[resource.resourceType], - HANDLERS[resource.resourceType].name, - scriptName, - autoCreate + // Pre-flight: identify any bindings that will fail BEFORE creating anything. + // This avoids orphaned resources when some bindings can auto-provision but others can't. + if (isNonInteractiveOrCI()) { + const blocked = pendingResources.filter( + (r) => !r.handler.name && !r.handler.ciSafe + ); + if (blocked.length > 0) { + const provisionable = pendingResources.filter( + (r) => r.handler.name || r.handler.ciSafe ); - } - - const patch: RawConfig = {}; - - const existingBindingNames = new Set(); - - const isUsingRedirectedConfig = - config.userConfigPath && config.userConfigPath !== config.configPath; - - // If we're using a redirected config, then the redirected config potentially has injected - // bindings that weren't originally in the user config. These can be provisioned, but we - // should not write the IDs back to the user config file (because the bindings weren't there in the first place) - if (isUsingRedirectedConfig) { - const { rawConfig: unredirectedConfig } = - await experimental_readRawConfig( - { config: config.userConfigPath }, - { useRedirectIfAvailable: false } - ); - for (const resourceType of Object.keys( - HANDLERS - ) as (keyof typeof HANDLERS)[]) { - const configField = HANDLERS[resourceType].configField; - for (const binding of unredirectedConfig[configField] ?? []) { - existingBindingNames.add(binding.binding); - } - } - } - - for (const [bindingName, binding] of Object.entries(bindings ?? {})) { - if (!isProvisionableBinding(binding)) { - continue; - } - - // See above for why we skip writing back some bindings to the config file - if (isUsingRedirectedConfig && !existingBindingNames.has(bindingName)) { - continue; - } - - const resourceType = HANDLERS[binding.type].configField; - - patch[resourceType] ??= []; - - const bindingToWrite = toConfigBinding(bindingName, binding); - - (patch[resourceType] as unknown as Array>).push( - Object.fromEntries( - Object.entries(bindingToWrite).filter( - // Make sure all the values are JSON serialisable. - // Otherwise we end up with "undefined" in the config - ([_, value]) => typeof value === "string" - ) - ) + reportProvisioningFailures( + blocked.map((r) => ({ + binding: r.binding, + type: r.resourceType, + friendlyName: HANDLERS[r.resourceType].friendlyName, + hint: + r.handler.provisioningHint ?? + "Provide the resource ID in your configuration file.", + })), + provisionable.map((r) => ({ + binding: r.binding, + friendlyName: HANDLERS[r.resourceType].friendlyName, + })) ); } + } - // If the user is performing an interactive deploy, write the provisioned IDs back to the config file. - // This is not necessary, as future deploys can use inherited resources, but it can help with - // portability of the config file, and adds robustness to bindings being renamed. - if (!isNonInteractiveOrCI()) { - try { - await experimental_patchConfig(configPath, patch, false); - logger.log( - "Your Worker was deployed with provisioned resources. We've written the IDs of these resources to your config file, which you can choose to save or discard. Either way future deploys will continue to work." - ); - } catch (e) { - // no-op — if the user is using TOML config we can't update it. - if (!(e instanceof PatchConfigError)) { - throw e; - } - } - } + const existingResources: Record = {}; - const resourceCount = pendingResources.reduce( - (acc, resource) => { - acc[resource.resourceType] ??= 0; - acc[resource.resourceType]++; - return acc; - }, - {} as Record - ); - logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); + for (const resource of pendingResources) { + existingResources[resource.resourceType] ??= await HANDLERS[ + resource.resourceType + ].load(config, accountId); - metrics.sendMetricsEvent("provision resources", resourceCount, { - sendMetrics: config.send_metrics, - }); + await runProvisioningFlow( + resource, + existingResources[resource.resourceType], + HANDLERS[resource.resourceType].friendlyName, + scriptName + ); } -} -export function getSettings( - complianceConfig: ComplianceConfig, - accountId: string, - scriptName: string -) { - return fetchResult( - complianceConfig, - `/accounts/${accountId}/workers/scripts/${scriptName}/settings` + const resourceCount = pendingResources.reduce( + (acc, resource) => { + acc[resource.resourceType] ??= 0; + acc[resource.resourceType]++; + return acc; + }, + {} as Record ); -} + logger.log(`🎉 All resources provisioned, continuing with deployment...\n`); -function printDivider() { - logger.log(); + metrics.sendMetricsEvent("provision resources", resourceCount, { + sendMetrics: config.send_metrics, + }); } -type NormalisedResourceInfo = { - /** The name of the resource */ - title: string; - /** The id of the resource */ - value: string; +type ProvisioningFailure = { + binding: string; + type: string; + friendlyName: string; + hint: string; }; +/** + * Provision a single resource. In non-interactive mode, the pre-flight + * in provisionBindings guarantees this is only called for resources + * that can succeed (ciSafe or name provided). + */ async function runProvisioningFlow( item: PendingResource, preExisting: NormalisedResourceInfo[], friendlyBindingName: string, - scriptName: string, - autoCreate: boolean -) { + scriptName: string +): Promise { const NEW_OPTION_VALUE = "__WRANGLER_INTERNAL_NEW"; - const SEARCH_OPTION_VALUE = "__WRANGLER_INTERNAL_SEARCH"; - const MAX_OPTIONS = 4; - // NB preExisting does not actually contain all resources on the account - we max out at ~30 d1 databases, ~100 kv, and ~20 r2. - 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, - }); - } - - const defaultName = `${scriptName}-${item.binding.toLowerCase().replaceAll("_", "-")}`; + const defaultName = generateDefaultName(scriptName, item.binding); logger.log("Provisioning", item.binding, `(${friendlyBindingName})...`); + // Path 1: Name found in config -- create non-interactively if (item.handler.name) { logger.log("Resource name found in config:", item.handler.name); logger.log( `🌀 Creating new ${friendlyBindingName} "${item.handler.name}"...` ); await item.handler.provision(item.handler.name); - } else if (autoCreate) { - logger.log(`🌀 Creating new ${friendlyBindingName} "${defaultName}"...`); - await item.handler.provision(defaultName); - } else { - let action: string = NEW_OPTION_VALUE; - - if (options.length > 0) { - action = await select( - `Would you like to connect an existing ${friendlyBindingName} or create a new one?`, - { - choices: options.concat([ - { title: "Create new", value: NEW_OPTION_VALUE }, - ]), - defaultOption: options.length, - fallbackOption: options.length, - } - ); - } + } else if (!isNonInteractiveOrCI()) { + // Path 2: Interactive terminal -- searchable list of existing + create new + const choices = [ + { title: "Create new", value: NEW_OPTION_VALUE }, + ...preExisting, + ]; + + const action = await search( + `Select an existing ${friendlyBindingName} or create a new one`, + { choices } + ); - if (action === NEW_OPTION_VALUE) { + if (!action || action === NEW_OPTION_VALUE) { const name = await prompt( `Enter a name for your new ${friendlyBindingName}`, { @@ -817,30 +329,47 @@ async function runProvisioningFlow( } ); logger.log(`🌀 Creating new ${friendlyBindingName} "${name}"...`); - await item.handler.provision(name); - } else if (action === SEARCH_OPTION_VALUE) { - // search through pre-existing resources that weren't listed - let foundResource: NormalisedResourceInfo | undefined; - while (foundResource === undefined) { - const input = await prompt( - `Enter the ${HANDLERS[item.resourceType].keyDescription} for an existing ${friendlyBindingName}` - ); - foundResource = preExisting.find( - (r) => r.title === input || r.value === input - ); - if (foundResource) { - item.handler.connect(foundResource.value); - } else { - logger.log( - `No ${friendlyBindingName} with that ${HANDLERS[item.resourceType].keyDescription} "${input}" found. Please try again.` - ); - } - } + await item.handler.interactiveCreate(name); } else { item.handler.connect(action); } + } else if (item.handler.ciSafe) { + // Path 3: CI + safe binding -- auto-create with generated name + logger.log(`🌀 Creating new ${friendlyBindingName} "${defaultName}"...`); + await item.handler.provision(defaultName); + } else { + // Safety net — pre-flight should have caught this + throw new UserError( + `Cannot auto-provision ${friendlyBindingName} "${item.binding}" in non-interactive mode.` + ); + } + + logger.log(`✨ ${item.binding} provisioned`); + logger.log(); +} + +/** + * Report all provisioning failures at once. + * + * Nothing is created before this is called — the check is a pre-flight + * so we never orphan resources. + */ +function reportProvisioningFailures( + failures: ProvisioningFailure[], + wouldSucceed: { binding: string; friendlyName: string }[] = [] +): never { + const lines = failures.map( + (f) => ` - ${f.binding} (${f.friendlyName}): ${f.hint}` + ); + + let msg = `Could not auto-provision the following bindings:\n${lines.join("\n")}`; + msg += `\n\nAlternatively, run wrangler deploy interactively in a terminal to provision these resources via a guided wizard.`; + if (wouldSucceed.length > 0) { + const names = wouldSucceed + .map((r) => `${r.binding} (${r.friendlyName})`) + .join(", "); + msg += `\n\nOnce resolved, these bindings will be auto-provisioned on deploy: ${names}`; } - logger.log(`✨ ${item.binding} provisioned 🎉`); - printDivider(); + throw new UserError(msg); } diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index ae704c8708..e12fbd02ee 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -257,13 +257,23 @@ export function createWorkerUploadForm( }); queues.forEach(({ binding, queue_name, delivery_delay, raw }) => { - metadataBindings.push({ - type: "queue", - name: binding, - queue_name, - delivery_delay, - raw, - }); + if (options?.dryRun) { + queue_name ??= INHERIT_SYMBOL; + } + if (queue_name === undefined) { + throw new UserError(`${binding} bindings must have a "queue_name" field`); + } + if (queue_name === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + type: "queue", + name: binding, + queue_name, + delivery_delay, + raw, + }); + } }); r2_buckets.forEach(({ binding, bucket_name, jurisdiction, raw }) => { @@ -321,12 +331,24 @@ export function createWorkerUploadForm( ); vectorize.forEach(({ binding, index_name, raw }) => { - metadataBindings.push({ - name: binding, - type: "vectorize", - index_name: index_name, - raw, - }); + if (options?.dryRun) { + index_name ??= INHERIT_SYMBOL; + } + if (index_name === undefined) { + throw new UserError( + `${binding} bindings must have an "index_name" field` + ); + } + if (index_name === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "vectorize", + index_name, + raw, + }); + } }); ai_search_namespaces.forEach(({ binding, namespace }) => { @@ -360,11 +382,21 @@ export function createWorkerUploadForm( }); hyperdrive.forEach(({ binding, id }) => { - metadataBindings.push({ - name: binding, - type: "hyperdrive", - id: id, - }); + if (options?.dryRun) { + id ??= INHERIT_SYMBOL; + } + if (id === undefined) { + throw new UserError(`${binding} bindings must have an "id" field`); + } + if (id === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "hyperdrive", + id, + }); + } }); secrets_store_secrets.forEach(({ binding, store_id, secret_name }) => { @@ -394,11 +426,21 @@ export function createWorkerUploadForm( }); vpc_services.forEach(({ binding, service_id }) => { - metadataBindings.push({ - name: binding, - type: "vpc_service", - service_id, - }); + if (options?.dryRun) { + service_id ??= INHERIT_SYMBOL; + } + if (service_id === undefined) { + throw new UserError(`${binding} bindings must have a "service_id" field`); + } + if (service_id === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "vpc_service", + service_id, + }); + } }); vpc_networks.forEach(({ binding, tunnel_id, network_id }) => { @@ -439,36 +481,68 @@ export function createWorkerUploadForm( }); dispatch_namespaces.forEach(({ binding, namespace, outbound }) => { - metadataBindings.push({ - name: binding, - type: "dispatch_namespace", - namespace, - ...(outbound && { - outbound: { - worker: { - service: outbound.service, - environment: outbound.environment, + if (options?.dryRun) { + namespace ??= INHERIT_SYMBOL; + } + if (namespace === undefined) { + throw new UserError(`${binding} bindings must have a "namespace" field`); + } + if (namespace === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "dispatch_namespace", + namespace, + ...(outbound && { + outbound: { + worker: { + service: outbound.service, + environment: outbound.environment, + }, + params: outbound.parameters?.map((p) => ({ name: p })), }, - params: outbound.parameters?.map((p) => ({ name: p })), - }, - }), - }); + }), + }); + } }); mtls_certificates.forEach(({ binding, certificate_id }) => { - metadataBindings.push({ - name: binding, - type: "mtls_certificate", - certificate_id, - }); + if (options?.dryRun) { + certificate_id ??= INHERIT_SYMBOL; + } + if (certificate_id === undefined) { + throw new UserError( + `${binding} bindings must have a "certificate_id" field` + ); + } + if (certificate_id === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "mtls_certificate", + certificate_id, + }); + } }); pipelines.forEach(({ binding, pipeline }) => { - metadataBindings.push({ - name: binding, - type: "pipelines", - pipeline: pipeline, - }); + if (options?.dryRun) { + pipeline ??= INHERIT_SYMBOL; + } + if (pipeline === undefined) { + throw new UserError(`${binding} bindings must have a "pipeline" field`); + } + if (pipeline === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "pipelines", + pipeline, + }); + } }); worker_loaders.forEach(({ binding }) => { diff --git a/packages/wrangler/src/deployment-bundle/provision/ai-search.ts b/packages/wrangler/src/deployment-bundle/provision/ai-search.ts new file mode 100644 index 0000000000..98d8f1de29 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/ai-search.ts @@ -0,0 +1,108 @@ +import assert from "node:assert"; +import { createAISearchNamespace, getAISearchNamespace } from "../../ai-search"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type AISearchBinding = Extract; + +export class AISearchNamespaceHandler extends ProvisionResourceHandler< + "ai_search_namespace", + AISearchBinding +> { + static readonly bindingType = "ai_search_namespace"; + static readonly friendlyName = "AI Search Namespace"; + + static async load( + _config: Config, + _accountId: string + ): Promise { + return []; + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new AISearchNamespaceHandler( + bindingName, + binding as AISearchBinding, + config, + accountId + ); + } + + get name(): string | undefined { + return typeof this.binding.namespace === "string" + ? this.binding.namespace + : undefined; + } + + async create(name: string) { + await createAISearchNamespace(this.config, this.accountId, name); + return name; + } + + constructor( + bindingName: string, + binding: AISearchBinding, + config: Config, + accountId: string + ) { + super( + "ai_search_namespace", + bindingName, + binding, + "namespace", + config, + accountId + ); + } + + isFullySpecified(): boolean { + return ( + typeof this.binding.namespace === "string" && + this.binding.namespace.length > 0 + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && + existing.name === this.bindingName && + (this.binding.namespace + ? this.binding.namespace === existing.namespace + : true) + ); + } + + async isConnectedToExistingResource(): Promise { + assert(typeof this.binding.namespace !== "symbol"); + + if (!this.binding.namespace) { + return false; + } + + const namespace = await getAISearchNamespace( + this.config, + this.accountId, + this.binding.namespace + ); + + return namespace !== null; + } + get ciSafe(): boolean { + return true; + } + get provisioningHint(): undefined { + return undefined; + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/d1.ts b/packages/wrangler/src/deployment-bundle/provision/d1.ts new file mode 100644 index 0000000000..e69da117c5 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/d1.ts @@ -0,0 +1,115 @@ +import assert from "node:assert"; +import { APIError } from "@cloudflare/workers-utils"; +import { createD1Database } from "../../d1/create"; +import { listDatabases } from "../../d1/list"; +import { getDatabaseInfoFromIdOrName } from "../../d1/utils"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config, WorkerMetadataBinding } from "@cloudflare/workers-utils"; + +type D1Binding = Extract; + +export class D1Handler extends ProvisionResourceHandler<"d1", D1Binding> { + static readonly bindingType = "d1"; + static readonly friendlyName = "D1 Database"; + + static async load( + config: Config, + accountId: string + ): Promise { + const preExisting = await listDatabases(config, accountId, true, 1000); + return preExisting.map((db) => ({ title: db.name, value: db.uuid })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new D1Handler(bindingName, binding as D1Binding, config, accountId); + } + + get name(): string | undefined { + return typeof this.binding.database_name === "string" + ? this.binding.database_name + : undefined; + } + async create(name: string) { + const db = await createD1Database(this.config, this.accountId, name); + return db.uuid; + } + constructor( + bindingName: string, + binding: D1Binding, + config: Config, + accountId: string + ) { + super("d1", bindingName, binding, "database_id", config, accountId); + } + async canInherit(settings: Settings | undefined): Promise { + const maybeInherited = settings?.bindings.find( + (existing) => + existing.type === this.type && existing.name === this.bindingName + ) as Extract | undefined; + // A D1 binding with the same binding name exists is already present on the worker... + if (maybeInherited) { + // ...and the user hasn't specified a name in their config, so we don't need to check if the database_name matches + if (!this.binding.database_name) { + return true; + } + + // ...and the user HAS specified a name in their config, so we need to check if the database_name they provided + // matches the database_name of the existing binding (which isn't present in settings, so we'll need to make an API call to check) + const dbFromId = await getDatabaseInfoFromIdOrName( + this.config, + this.accountId, + maybeInherited.id + ); + if (this.binding.database_name === dbFromId.name) { + return true; + } + } + return false; + } + async isConnectedToExistingResource(): Promise { + assert(typeof this.binding.database_name !== "symbol"); + + // If the user hasn't specified a database_name in config, we always provision + if (!this.binding.database_name) { + return false; + } + try { + const db = await getDatabaseInfoFromIdOrName( + this.config, + this.accountId, + this.binding.database_name + ); + + // This database_name exists! We don't need to provision it + return db.uuid; + } catch (e) { + if (!(e instanceof APIError && e.code === 7404)) { + // this is an error that is not "database not found", so we do want to throw + throw e; + } + + // This database_name doesn't exist—let's provision + return false; + } + } + isFullySpecified(): boolean { + return !!this.binding.database_id; + } + get ciSafe(): boolean { + return true; + } + get provisioningHint(): undefined { + return undefined; + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/dispatch-namespace.ts b/packages/wrangler/src/deployment-bundle/provision/dispatch-namespace.ts new file mode 100644 index 0000000000..f35076b3e3 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/dispatch-namespace.ts @@ -0,0 +1,114 @@ +import { + createWorkerNamespace, + listWorkerNamespaces, +} from "../../dispatch-namespace"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type DispatchNamespaceBinding = Extract< + Binding, + { type: "dispatch_namespace" } +>; + +export class DispatchNamespaceHandler extends ProvisionResourceHandler< + "dispatch_namespace", + DispatchNamespaceBinding +> { + static readonly bindingType = "dispatch_namespace"; + static readonly friendlyName = "Dispatch Namespace"; + + static async load( + config: Config, + accountId: string + ): Promise { + const preExisting = await listWorkerNamespaces(config, accountId); + return preExisting.map((ns) => ({ + title: ns.namespace_name, + value: ns.namespace_name, + })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new DispatchNamespaceHandler( + bindingName, + binding as DispatchNamespaceBinding, + config, + accountId + ); + } + + get name(): string | undefined { + return typeof this.binding.namespace === "string" + ? this.binding.namespace + : undefined; + } + async create(name: string) { + const result = await createWorkerNamespace( + this.config, + this.accountId, + name + ); + return result.namespace_name; + } + constructor( + bindingName: string, + binding: DispatchNamespaceBinding, + config: Config, + accountId: string + ) { + super( + "dispatch_namespace", + bindingName, + binding, + "namespace", + config, + accountId + ); + } + + isFullySpecified(): boolean { + return ( + typeof this.binding.namespace === "string" && + this.binding.namespace.length > 0 + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && + existing.name === this.bindingName && + (this.binding.namespace + ? this.binding.namespace === existing.namespace + : true) + ); + } + + async isConnectedToExistingResource(): Promise { + if (typeof this.binding.namespace === "symbol" || !this.binding.namespace) { + return false; + } + const namespaces = await listWorkerNamespaces(this.config, this.accountId); + return namespaces.some( + (ns) => ns.namespace_name === this.binding.namespace + ); + } + + get ciSafe(): boolean { + return true; + } + get provisioningHint(): undefined { + return undefined; + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/hyperdrive.ts b/packages/wrangler/src/deployment-bundle/provision/hyperdrive.ts new file mode 100644 index 0000000000..e41f84a870 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/hyperdrive.ts @@ -0,0 +1,274 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { prompt, select } from "../../dialogs"; +import { createConfig, listConfigs } from "../../hyperdrive/client"; +import { logger } from "../../logger"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { OriginWithSecrets } from "../../hyperdrive/client"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type HyperdriveBinding = Extract; + +export class HyperdriveHandler extends ProvisionResourceHandler< + "hyperdrive", + HyperdriveBinding +> { + static readonly bindingType = "hyperdrive"; + static readonly friendlyName = "Hyperdrive Config"; + + static async load( + config: Config, + _accountId: string + ): Promise { + const preExisting = await listConfigs(config); + return preExisting.map((hd) => ({ title: hd.name, value: hd.id })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new HyperdriveHandler( + bindingName, + binding as HyperdriveBinding, + config, + accountId + ); + } + + private origin?: OriginWithSecrets; + + get name(): string | undefined { + return undefined; + } + async create(name: string) { + if (!this.origin) { + throw new UserError( + "Cannot create Hyperdrive config without connection details. Use interactive mode." + ); + } + const config = await createConfig(this.config, { + name, + origin: this.origin, + }); + return config.id; + } + constructor( + bindingName: string, + binding: HyperdriveBinding, + config: Config, + accountId: string + ) { + super("hyperdrive", bindingName, binding, "id", config, accountId); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && existing.name === this.bindingName + ); + } + + isFullySpecified(): boolean { + return !!this.binding.id; + } + + get ciSafe(): boolean { + return false; + } + get provisioningHint(): string { + return "Run `wrangler hyperdrive create --connection-string ` and set the returned id in your config. Or set id to an existing Hyperdrive config."; + } + + override async interactiveCreate(name: string): Promise { + const connectionMethod = await select( + `How does your database connect for Hyperdrive config "${name}"?`, + { + choices: [ + { + title: "Connection string", + value: "connection-string", + }, + { + title: "Host and port", + value: "host-port", + }, + { + title: "Hyperdrive over Access", + value: "access", + }, + { + title: "VPC Service", + value: "vpc", + }, + ], + defaultOption: 0, + } + ); + + if (connectionMethod === "connection-string") { + const connStr = await prompt( + "Enter your database connection string (e.g. postgres://user:password@host:port/database):", + {} + ); + this.origin = parseConnectionString(connStr); + } else if (connectionMethod === "host-port") { + this.origin = await promptHostPort(); + } else if (connectionMethod === "access") { + this.origin = await promptAccess(); + } else { + this.origin = await promptVpc(); + } + + logger.log(`🌀 Creating Hyperdrive config "${name}"...`); + await this.provision(name); + } +} + +function parseConnectionString(connStr: string): OriginWithSecrets { + let url: URL; + try { + url = new URL(connStr); + } catch { + throw new UserError( + `Invalid connection string: "${connStr}". Expected format: protocol://user:password@host:port/database` + ); + } + + const protocol = url.protocol.toLowerCase(); + if ( + !protocol.startsWith("postgresql") && + !protocol.startsWith("postgres") && + !protocol.startsWith("mysql") + ) { + throw new UserError( + `Unsupported protocol "${protocol}". Must be postgresql, postgres, or mysql.` + ); + } + + if (!url.hostname) { + throw new UserError("Connection string must include a hostname."); + } + if (!url.username) { + throw new UserError("Connection string must include a username."); + } + if (!url.password) { + throw new UserError("Connection string must include a password."); + } + + let port = url.port; + if (!port) { + if (protocol.startsWith("postgres")) { + port = "5432"; + } else if (protocol.startsWith("mysql")) { + port = "3306"; + } + } + + if (!port) { + throw new UserError("Connection string must include a port."); + } + + const database = decodeURIComponent(url.pathname).replace(/^\//, ""); + if (!database) { + throw new UserError("Connection string must include a database name."); + } + + return { + scheme: url.protocol.replace(/:$/, ""), + host: url.hostname, + port: parseInt(port, 10), + database, + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + }; +} + +async function promptHostPort(): Promise { + const scheme = await select("Select database protocol:", { + choices: [ + { title: "PostgreSQL", value: "postgresql" }, + { title: "MySQL", value: "mysql" }, + ], + defaultOption: 0, + }); + const host = await prompt("Enter database host:", {}); + const portStr = await prompt("Enter database port:", { + defaultValue: scheme === "postgresql" ? "5432" : "3306", + }); + const database = await prompt("Enter database name:", {}); + const user = await prompt("Enter database user:", {}); + const password = await prompt("Enter database password:", { + isSecret: true, + }); + + return { + scheme, + host, + port: parseInt(portStr, 10), + database, + user, + password, + }; +} + +async function promptAccess(): Promise { + const scheme = await select("Select database protocol:", { + choices: [ + { title: "PostgreSQL", value: "postgresql" }, + { title: "MySQL", value: "mysql" }, + ], + defaultOption: 0, + }); + const host = await prompt("Enter database host:", {}); + const accessClientId = await prompt("Enter Access Client ID:", {}); + const accessClientSecret = await prompt("Enter Access Client Secret:", { + isSecret: true, + }); + const database = await prompt("Enter database name:", {}); + const user = await prompt("Enter database user:", {}); + const password = await prompt("Enter database password:", { + isSecret: true, + }); + + return { + scheme, + host, + access_client_id: accessClientId, + access_client_secret: accessClientSecret, + database, + user, + password, + }; +} + +async function promptVpc(): Promise { + const scheme = await select("Select database protocol:", { + choices: [ + { title: "PostgreSQL", value: "postgresql" }, + { title: "MySQL", value: "mysql" }, + ], + defaultOption: 0, + }); + const serviceId = await prompt("Enter VPC Service ID:", {}); + const database = await prompt("Enter database name:", {}); + const user = await prompt("Enter database user:", {}); + const password = await prompt("Enter database password:", { + isSecret: true, + }); + + return { + scheme, + service_id: serviceId, + database, + user, + password, + }; +} diff --git a/packages/wrangler/src/deployment-bundle/provision/index.ts b/packages/wrangler/src/deployment-bundle/provision/index.ts new file mode 100644 index 0000000000..7b3a55853e --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/index.ts @@ -0,0 +1,162 @@ +import crypto from "node:crypto"; +import { INHERIT_SYMBOL } from "@cloudflare/workers-utils"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { Config, WorkerMetadataBinding } from "@cloudflare/workers-utils"; + +export type ProvisionableBinding = + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract; + +export type Settings = { + bindings: Array; +}; + +export abstract class ProvisionResourceHandler< + T extends WorkerMetadataBinding["type"], + B extends ProvisionableBinding, +> { + constructor( + public type: T, + public bindingName: string, + public binding: B, + public idField: string, + public config: Config, + public accountId: string + ) {} + + // Does this resource already exist in the currently deployed version of the Worker? + // If it does, that means we can inherit from it. + abstract canInherit( + settings: Settings | undefined + ): boolean | Promise; + + inherit(): void { + // @ts-expect-error idField is a key of this.binding + this.binding[this.idField] = INHERIT_SYMBOL; + } + connect(id: string): void { + // @ts-expect-error idField is a key of this.binding + this.binding[this.idField] = id; + } + + abstract create(name: string): Promise; + + abstract get name(): string | undefined; + + async provision(name: string): Promise { + const id = await this.create(name); + this.connect(id); + } + + // This binding is fully specified and can't/shouldn't be provisioned + // This is usually when it has an id (e.g. D1 `database_id`) + isFullySpecified(): boolean { + return false; + } + + // Does this binding need to be provisioned? + // Some bindings are not fully specified, but don't need provisioning + // (e.g. R2 binding, with a bucket_name that already exists) + async isConnectedToExistingResource(): Promise { + return false; + } + + // Should this resource be provisioned? + async shouldProvision(settings: Settings | undefined) { + // If the resource is fully specified, don't provision + if (!this.isFullySpecified()) { + // If we can inherit, do that and don't provision + if (await this.canInherit(settings)) { + this.inherit(); + } else { + // If the resource is connected to a remote resource that _exists_ + // (see comments on the individual functions for why this is different to isFullySpecified()) + const connected = await this.isConnectedToExistingResource(); + if (connected) { + if (typeof connected === "string") { + // Basically a special case for D1: the resource is specified by name in config + // and exists, but needs to be specified by ID for the first deploy to work + this.connect(connected); + } + return false; + } + return true; + } + } + return false; + } + + /** + * Whether this binding type is safe to auto-create in CI without interactive prompts. + * Safe bindings only need a name (KV, R2, D1, AI Search, Queues, Dispatch Namespaces). + * Unsafe bindings need additional configuration (Vectorize, Hyperdrive, Pipelines, VPC, mTLS). + */ + abstract get ciSafe(): boolean; + + /** + * Instructions shown to users and AI agents when this binding can't be + * auto-provisioned in non-interactive mode. Should describe the wrangler + * command to create the resource and which config field to set. + * ciSafe handlers return undefined (they'll just be auto-created). + */ + abstract get provisioningHint(): string | undefined; + + /** + * Perform interactive creation, prompting the user for any additional + * parameters beyond just a name. The default implementation works for + * simple handlers that only need a name. Handlers that need additional + * config (e.g. Vectorize, Hyperdrive) should override this. + */ + async interactiveCreate(name: string): Promise { + await this.provision(name); + } +} + +export type NormalisedResourceInfo = { + title: string; + value: string; +}; + +export interface HandlerStatics { + readonly bindingType: string; + readonly friendlyName: string; + load(config: Config, accountId: string): Promise; + create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ): ProvisionResourceHandler< + WorkerMetadataBinding["type"], + ProvisionableBinding + >; +} + +/** + * Generate a short random suffix for auto-created resource names + * to reduce the chance of name collisions. + */ +export function generateRandomSuffix(): string { + return crypto.randomBytes(4).toString("hex"); +} + +/** + * Generate a default resource name from the script name and binding name, + * with a random suffix to avoid collisions. + */ +export function generateDefaultName( + scriptName: string, + bindingName: string +): string { + const base = `${scriptName}-${bindingName.toLowerCase().replaceAll("_", "-")}`; + return `${base}-${generateRandomSuffix()}`; +} diff --git a/packages/wrangler/src/deployment-bundle/provision/kv.ts b/packages/wrangler/src/deployment-bundle/provision/kv.ts new file mode 100644 index 0000000000..5fa99ac6fd --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/kv.ts @@ -0,0 +1,66 @@ +import { createKVNamespace, listKVNamespaces } from "../../kv/helpers"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type KVBinding = Extract; + +export class KVHandler extends ProvisionResourceHandler< + "kv_namespace", + KVBinding +> { + static readonly bindingType = "kv_namespace"; + static readonly friendlyName = "KV Namespace"; + + static async load( + config: Config, + accountId: string + ): Promise { + const preExisting = await listKVNamespaces(config, accountId, true); + return preExisting.map((ns) => ({ title: ns.title, value: ns.id })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new KVHandler(bindingName, binding as KVBinding, config, accountId); + } + + get name(): string | undefined { + return undefined; + } + async create(name: string) { + return await createKVNamespace(this.config, this.accountId, name); + } + constructor( + bindingName: string, + binding: KVBinding, + config: Config, + accountId: string + ) { + super("kv_namespace", bindingName, binding, "id", config, accountId); + } + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === "kv_namespace" && existing.name === this.bindingName + ); + } + isFullySpecified(): boolean { + return !!this.binding.id; + } + get ciSafe(): boolean { + return true; + } + get provisioningHint(): undefined { + return undefined; + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/mtls-certificate.ts b/packages/wrangler/src/deployment-bundle/provision/mtls-certificate.ts new file mode 100644 index 0000000000..c752195c3b --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/mtls-certificate.ts @@ -0,0 +1,114 @@ +import { + listMTlsCertificates, + uploadMTlsCertificateFromFs, +} from "../../api/mtls-certificate"; +import { prompt } from "../../dialogs"; +import { logger } from "../../logger"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type MtlsCertificateBinding = Extract; + +export class MtlsCertificateHandler extends ProvisionResourceHandler< + "mtls_certificate", + MtlsCertificateBinding +> { + static readonly bindingType = "mtls_certificate"; + static readonly friendlyName = "mTLS Certificate"; + + static async load( + config: Config, + accountId: string + ): Promise { + const preExisting = await listMTlsCertificates(config, accountId, {}); + return preExisting.map((c) => ({ title: c.name ?? c.id, value: c.id })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new MtlsCertificateHandler( + bindingName, + binding as MtlsCertificateBinding, + config, + accountId + ); + } + + private certPath?: string; + private keyPath?: string; + + get name(): string | undefined { + return undefined; + } + async create(name: string) { + if (!this.certPath || !this.keyPath) { + throw new Error( + "Cannot upload mTLS certificate without cert and key file paths. Use interactive mode." + ); + } + const result = await uploadMTlsCertificateFromFs( + this.config, + this.accountId, + { + certificateChainFilename: this.certPath, + privateKeyFilename: this.keyPath, + name, + } + ); + return result.id; + } + constructor( + bindingName: string, + binding: MtlsCertificateBinding, + config: Config, + accountId: string + ) { + super( + "mtls_certificate", + bindingName, + binding, + "certificate_id", + config, + accountId + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && existing.name === this.bindingName + ); + } + + isFullySpecified(): boolean { + return !!this.binding.certificate_id; + } + + get ciSafe(): boolean { + return false; + } + get provisioningHint(): string { + return "Run `wrangler mtls-certificate upload --cert --key ` and set certificate_id in your config. Or set certificate_id to an existing certificate."; + } + + override async interactiveCreate(name: string): Promise { + this.certPath = await prompt( + "Enter path to the certificate chain (.pem) file:", + {} + ); + this.keyPath = await prompt("Enter path to the private key file:", {}); + + logger.log(`🌀 Uploading mTLS certificate "${name}"...`); + await this.provision(name); + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/pipeline.ts b/packages/wrangler/src/deployment-bundle/provision/pipeline.ts new file mode 100644 index 0000000000..6b07c4347c --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/pipeline.ts @@ -0,0 +1,115 @@ +import { prompt } from "../../dialogs"; +import { logger } from "../../logger"; +import { createPipeline, listPipelines } from "../../pipelines/client"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type PipelineBinding = Extract; + +export class PipelineHandler extends ProvisionResourceHandler< + "pipelines", + PipelineBinding +> { + static readonly bindingType = "pipeline"; + static readonly friendlyName = "Pipeline"; + + static async load( + config: Config, + _accountId: string + ): Promise { + const preExisting = await listPipelines(config); + return preExisting.map((p) => ({ title: p.name, value: p.name })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new PipelineHandler( + bindingName, + binding as PipelineBinding, + config, + accountId + ); + } + + private sql?: string; + + // Pipeline creation requires a SQL query, so we can't auto-create from + // just a name in config. Return undefined to force the interactive path. + // The name-in-config is still used by isConnectedToExistingResource. + get name(): string | undefined { + return undefined; + } + async create(name: string) { + if (!this.sql) { + throw new Error( + "Cannot create Pipeline without a SQL query. Use interactive mode." + ); + } + const pipeline = await createPipeline(this.config, { + name, + sql: this.sql, + }); + return pipeline.name; + } + constructor( + bindingName: string, + binding: PipelineBinding, + config: Config, + accountId: string + ) { + super("pipelines", bindingName, binding, "pipeline", config, accountId); + } + + isFullySpecified(): boolean { + return ( + typeof this.binding.pipeline === "string" && + this.binding.pipeline.length > 0 + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === "pipelines" && + existing.name === this.bindingName && + (this.binding.pipeline + ? this.binding.pipeline === existing.pipeline + : true) + ); + } + + async isConnectedToExistingResource(): Promise { + if (typeof this.binding.pipeline === "symbol" || !this.binding.pipeline) { + return false; + } + const pipelines = await listPipelines(this.config); + return pipelines.some((p) => p.name === this.binding.pipeline); + } + + get ciSafe(): boolean { + return false; + } + get provisioningHint(): string { + return "Run `wrangler pipelines create --sql ` and set pipeline in your config. Or set pipeline to an existing pipeline name."; + } + + override async interactiveCreate(name: string): Promise { + this.sql = await prompt( + `Enter the SQL query for Pipeline "${name}" (e.g. SELECT * FROM stream):`, + {} + ); + + logger.log(`🌀 Creating Pipeline "${name}"...`); + await this.provision(name); + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/queue.ts b/packages/wrangler/src/deployment-bundle/provision/queue.ts new file mode 100644 index 0000000000..5db8361041 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/queue.ts @@ -0,0 +1,102 @@ +import { createQueue, listQueues } from "../../queues/client"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type QueueBinding = Extract; + +export class QueueHandler extends ProvisionResourceHandler< + "queue", + QueueBinding +> { + static readonly bindingType = "queue"; + static readonly friendlyName = "Queue"; + + static async load( + config: Config, + _accountId: string + ): Promise { + const preExisting = await listQueues(config); + return preExisting.map((q) => ({ + title: q.queue_name, + value: q.queue_name, + })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new QueueHandler( + bindingName, + binding as QueueBinding, + config, + accountId + ); + } + + get name(): string | undefined { + return typeof this.binding.queue_name === "string" + ? this.binding.queue_name + : undefined; + } + async create(name: string) { + const result = await createQueue(this.config, { queue_name: name }); + return result.queue_name; + } + constructor( + bindingName: string, + binding: QueueBinding, + config: Config, + accountId: string + ) { + super("queue", bindingName, binding, "queue_name", config, accountId); + } + + isFullySpecified(): boolean { + return ( + typeof this.binding.queue_name === "string" && + this.binding.queue_name.length > 0 + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && + existing.name === this.bindingName && + (this.binding.queue_name + ? this.binding.queue_name === existing.queue_name + : true) + ); + } + + async isConnectedToExistingResource(): Promise { + if ( + typeof this.binding.queue_name === "symbol" || + !this.binding.queue_name + ) { + return false; + } + const queues = await listQueues( + this.config, + undefined, + this.binding.queue_name + ); + return queues.some((q) => q.queue_name === this.binding.queue_name); + } + + get ciSafe(): boolean { + return true; + } + get provisioningHint(): undefined { + return undefined; + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/r2.ts b/packages/wrangler/src/deployment-bundle/provision/r2.ts new file mode 100644 index 0000000000..eb573a4668 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/r2.ts @@ -0,0 +1,128 @@ +import assert from "node:assert"; +import { APIError, INHERIT_SYMBOL } from "@cloudflare/workers-utils"; +import { + createR2Bucket, + getR2Bucket, + listR2Buckets, +} from "../../r2/helpers/bucket"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type R2Binding = Extract; + +export class R2Handler extends ProvisionResourceHandler< + "r2_bucket", + R2Binding +> { + static readonly bindingType = "r2_bucket"; + static readonly friendlyName = "R2 Bucket"; + + static async load( + config: Config, + accountId: string + ): Promise { + const preExisting = await listR2Buckets(config, accountId); + return preExisting.map((bucket) => ({ + title: bucket.name, + value: bucket.name, + })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new R2Handler(bindingName, binding as R2Binding, config, accountId); + } + + get name(): string | undefined { + return typeof this.binding.bucket_name === "string" + ? this.binding.bucket_name + : undefined; + } + + async create(name: string) { + await createR2Bucket( + this.config, + this.accountId, + name, + undefined, + this.binding.jurisdiction + ); + return name; + } + constructor( + bindingName: string, + binding: R2Binding, + config: Config, + accountId: string + ) { + super("r2_bucket", bindingName, binding, "bucket_name", config, accountId); + } + + /** + * Inheriting an R2 binding replaces the id property (bucket_name for R2) with the inheritance symbol. + * This works when deploying (and is appropriate for all other binding types), but it means that the + * bucket_name for an R2 bucket is not displayed when deploying. As such, only use the inheritance symbol + * if the R2 binding has no `bucket_name`. + */ + override inherit(): void { + this.binding.bucket_name ??= INHERIT_SYMBOL; + } + + /** + * R2 bindings can be inherited if the binding name and jurisdiction match. + * Additionally, if the user has specified a bucket_name in config, make sure that matches + */ + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && + existing.name === this.bindingName && + existing.jurisdiction === this.binding.jurisdiction && + (this.binding.bucket_name + ? this.binding.bucket_name === existing.bucket_name + : true) + ); + } + async isConnectedToExistingResource(): Promise { + assert(typeof this.binding.bucket_name !== "symbol"); + + // If the user hasn't specified a bucket_name in config, we always provision + if (!this.binding.bucket_name) { + return false; + } + try { + await getR2Bucket( + this.config, + this.accountId, + this.binding.bucket_name, + this.binding.jurisdiction + ); + // This bucket_name exists! We don't need to provision it + return true; + } catch (e) { + if (!(e instanceof APIError && e.code === 10006)) { + // this is an error that is not "bucket not found", so we do want to throw + throw e; + } + + // This bucket_name doesn't exist—let's provision + return false; + } + } + get ciSafe(): boolean { + return true; + } + get provisioningHint(): undefined { + return undefined; + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/vectorize.ts b/packages/wrangler/src/deployment-bundle/provision/vectorize.ts new file mode 100644 index 0000000000..940f4f7e2c --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/vectorize.ts @@ -0,0 +1,137 @@ +import { prompt, select } from "../../dialogs"; +import { logger } from "../../logger"; +import { createIndex, listIndexes } from "../../vectorize/client"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type VectorizeBinding = Extract; + +export class VectorizeHandler extends ProvisionResourceHandler< + "vectorize", + VectorizeBinding +> { + static readonly bindingType = "vectorize"; + static readonly friendlyName = "Vectorize Index"; + + static async load( + config: Config, + _accountId: string + ): Promise { + const preExisting = await listIndexes(config, false); + return preExisting.map((idx) => ({ title: idx.name, value: idx.name })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new VectorizeHandler( + bindingName, + binding as VectorizeBinding, + config, + accountId + ); + } + + private dimensions?: number; + private metric?: string; + + // Vectorize creation requires dimensions + metric, so we can't auto-create + // from just a name in config. Return undefined to force the interactive path. + // The name-in-config is still used by isConnectedToExistingResource. + get name(): string | undefined { + return undefined; + } + async create(name: string) { + const body: Record = { name }; + if (this.dimensions !== undefined && this.metric !== undefined) { + body.config = { + dimensions: this.dimensions, + metric: this.metric, + }; + } + const index = await createIndex(this.config, body, false); + return index.name; + } + constructor( + bindingName: string, + binding: VectorizeBinding, + config: Config, + accountId: string + ) { + super("vectorize", bindingName, binding, "index_name", config, accountId); + } + + isFullySpecified(): boolean { + return ( + typeof this.binding.index_name === "string" && + this.binding.index_name.length > 0 + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && + existing.name === this.bindingName && + (this.binding.index_name + ? this.binding.index_name === existing.index_name + : true) + ); + } + + async isConnectedToExistingResource(): Promise { + if ( + typeof this.binding.index_name === "symbol" || + !this.binding.index_name + ) { + return false; + } + const indexes = await listIndexes(this.config, false); + return indexes.some((idx) => idx.name === this.binding.index_name); + } + + get ciSafe(): boolean { + return false; + } + get provisioningHint(): string { + return "Run `wrangler vectorize create --dimensions --metric ` and set index_name in your config. Or set index_name to an existing index."; + } + + override async interactiveCreate(name: string): Promise { + this.metric = await select( + `Select a distance metric for Vectorize index "${name}":`, + { + choices: [ + { title: "cosine", value: "cosine" }, + { title: "euclidean", value: "euclidean" }, + { title: "dot-product", value: "dot-product" }, + ], + defaultOption: 0, + } + ); + const dimensionsStr = await prompt( + `Enter the vector dimensions for Vectorize index "${name}" (e.g. 768, 1536):`, + {} + ); + this.dimensions = parseInt(dimensionsStr, 10); + if (isNaN(this.dimensions) || this.dimensions <= 0) { + throw new Error( + `Invalid dimensions "${dimensionsStr}". Must be a positive integer.` + ); + } + + logger.log( + `🌀 Creating Vectorize index "${name}" (metric: ${this.metric}, dimensions: ${this.dimensions})...` + ); + await this.provision(name); + } +} diff --git a/packages/wrangler/src/deployment-bundle/provision/vpc-service.ts b/packages/wrangler/src/deployment-bundle/provision/vpc-service.ts new file mode 100644 index 0000000000..60fb263a41 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/provision/vpc-service.ts @@ -0,0 +1,164 @@ +import { prompt, select } from "../../dialogs"; +import { logger } from "../../logger"; +import { createService, listServices } from "../../vpc/client"; +import { ServiceType } from "../../vpc/index"; +import { ProvisionResourceHandler } from "./index"; +import type { Binding } from "../../api/startDevWorker/types"; +import type { ConnectivityServiceRequest } from "../../vpc/index"; +import type { + NormalisedResourceInfo, + ProvisionableBinding, + Settings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +type VpcServiceBinding = Extract; + +export class VpcServiceHandler extends ProvisionResourceHandler< + "vpc_service", + VpcServiceBinding +> { + static readonly bindingType = "vpc_service"; + static readonly friendlyName = "VPC Service"; + + static async load( + config: Config, + _accountId: string + ): Promise { + const preExisting = await listServices(config); + return preExisting.map((s) => ({ title: s.name, value: s.service_id })); + } + + static create( + bindingName: string, + binding: ProvisionableBinding, + config: Config, + accountId: string + ) { + return new VpcServiceHandler( + bindingName, + binding as VpcServiceBinding, + config, + accountId + ); + } + + private serviceRequest?: ConnectivityServiceRequest; + + get name(): string | undefined { + return undefined; + } + async create(name: string) { + if (!this.serviceRequest) { + throw new Error( + "Cannot create VPC Service without configuration. Use interactive mode." + ); + } + const service = await createService(this.config, { + ...this.serviceRequest, + name, + }); + return service.service_id; + } + constructor( + bindingName: string, + binding: VpcServiceBinding, + config: Config, + accountId: string + ) { + super("vpc_service", bindingName, binding, "service_id", config, accountId); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && existing.name === this.bindingName + ); + } + + isFullySpecified(): boolean { + return !!this.binding.service_id; + } + + get ciSafe(): boolean { + return false; + } + get provisioningHint(): string { + return "Run `wrangler vpc service create --type --tunnel-id ...` and set service_id in your config. Or set service_id to an existing VPC service."; + } + + override async interactiveCreate(name: string): Promise { + const serviceType: ServiceType = (await select( + `Select the service type for VPC Service "${name}":`, + { + choices: [ + { title: "TCP", value: ServiceType.Tcp }, + { title: "HTTP", value: ServiceType.Http }, + ], + defaultOption: 0, + } + )) as ServiceType; + + const tunnelId = await prompt("Enter the Cloudflare Tunnel ID:", {}); + + const hostType = await select("How is the origin identified?", { + choices: [ + { title: "Hostname", value: "hostname" }, + { title: "IPv4 address", value: "ipv4" }, + { title: "IPv6 address", value: "ipv6" }, + ], + defaultOption: 0, + }); + + let host: ConnectivityServiceRequest["host"]; + if (hostType === "hostname") { + const hostname = await prompt("Enter the origin hostname:", {}); + host = { + hostname, + network: { tunnel_id: tunnelId }, + }; + } else if (hostType === "ipv4") { + const ipv4 = await prompt("Enter the origin IPv4 address:", {}); + host = { + ipv4, + network: { tunnel_id: tunnelId }, + }; + } else { + const ipv6 = await prompt("Enter the origin IPv6 address:", {}); + host = { + ipv6, + network: { tunnel_id: tunnelId }, + }; + } + + const request: ConnectivityServiceRequest = { + name, + type: serviceType, + host, + }; + + if (serviceType === ServiceType.Tcp) { + const portStr = await prompt("Enter the TCP port:", {}); + request.tcp_port = parseInt(portStr, 10); + } else { + const httpPortStr = await prompt( + "Enter the HTTP port (leave empty for none):", + { defaultValue: "" } + ); + if (httpPortStr) { + request.http_port = parseInt(httpPortStr, 10); + } + const httpsPortStr = await prompt( + "Enter the HTTPS port (leave empty for none):", + { defaultValue: "" } + ); + if (httpsPortStr) { + request.https_port = parseInt(httpsPortStr, 10); + } + } + + this.serviceRequest = request; + logger.log(`🌀 Creating VPC Service "${name}"...`); + await this.provision(name); + } +} diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 44647340f6..efdedd0227 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -33,7 +33,6 @@ export const dev = createCommand({ overrideExperimentalFlags: (args) => ({ MULTIWORKER: Array.isArray(args.config), RESOURCES_PROVISION: args.experimentalProvision ?? false, - AUTOCREATE_RESOURCES: args.experimentalAutoCreate, }), printMetricsBanner: true, }, diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index d0d9e8d4b7..d50169b60a 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -272,10 +272,17 @@ function queueProducerEntry( }, ] { if (!remoteProxyConnectionString || !remote) { - return [binding, { queueName, deliveryDelay }]; + return [binding, { queueName: queueName as string, deliveryDelay }]; } - return [binding, { queueName, deliveryDelay, remoteProxyConnectionString }]; + return [ + binding, + { + queueName: queueName as string, + deliveryDelay, + remoteProxyConnectionString, + }, + ]; } function pipelineEntry( pipeline: CfPipeline, @@ -288,11 +295,11 @@ function pipelineEntry( }, ] { if (!remoteProxyConnectionString || !pipeline.remote) { - return [pipeline.binding, { pipeline: pipeline.pipeline }]; + return [pipeline.binding, { pipeline: pipeline.pipeline as string }]; } return [ pipeline.binding, - { pipeline: pipeline.pipeline, remoteProxyConnectionString }, + { pipeline: pipeline.pipeline as string, remoteProxyConnectionString }, ]; } function hyperdriveEntry(hyperdrive: CfHyperdrive): [string, string] { @@ -369,9 +376,9 @@ function dispatchNamespaceEntry( }, ] { if (!remoteProxyConnectionString || !remote) { - return [binding, { namespace }]; + return [binding, { namespace: namespace as string }]; } - return [binding, { namespace, remoteProxyConnectionString }]; + return [binding, { namespace: namespace as string, remoteProxyConnectionString }]; } function ratelimitEntry(ratelimit: T): [string, T] { return [ratelimit.name, ratelimit]; @@ -841,7 +848,7 @@ export function buildMiniflareBindingOptions( return [ vectorize.binding, { - index_name: vectorize.index_name, + index_name: vectorize.index_name as string, remoteProxyConnectionString: vectorize.remote && remoteProxyConnectionString ? remoteProxyConnectionString @@ -856,7 +863,7 @@ export function buildMiniflareBindingOptions( return [ vpc.binding, { - service_id: vpc.service_id, + service_id: vpc.service_id as string, remoteProxyConnectionString, }, ]; @@ -920,7 +927,7 @@ export function buildMiniflareBindingOptions( mtlsCertificate.remote && remoteProxyConnectionString ? remoteProxyConnectionString : undefined, - certificate_id: mtlsCertificate.certificate_id, + certificate_id: mtlsCertificate.certificate_id as string, }, ]; }) diff --git a/packages/wrangler/src/dialogs.ts b/packages/wrangler/src/dialogs.ts index 316f98bdb8..a659903532 100644 --- a/packages/wrangler/src/dialogs.ts +++ b/packages/wrangler/src/dialogs.ts @@ -141,6 +141,49 @@ interface MultiSelectOptions { defaultOptions?: number[]; } +interface SearchOptions { + choices: SelectOption[]; + fallbackValue?: Values; +} + +export async function search( + text: string, + options: SearchOptions +): Promise { + if (isNonInteractiveOrCI()) { + if (options.fallbackValue === undefined) { + throw new NoDefaultValueProvided(); + } + logger.log(`? ${text}`); + logger.log( + `🤖 ${chalk.dim( + "Using fallback value in non-interactive context:" + )} ${chalk.white.bold(String(options.fallbackValue))}` + ); + return options.fallbackValue; + } + const { value } = await prompts({ + type: "autocomplete", + name: "value", + message: `${text} ${chalk.dim("(type to filter)")}`, + choices: options.choices, + suggest: (input: string, choices: prompts.Choice[]) => + Promise.resolve( + choices.filter((c) => + c.title.toLowerCase().includes(input.toLowerCase()) + ) + ), + onState: (state) => { + if (state.aborted) { + process.nextTick(() => { + process.exit(1); + }); + } + }, + }); + return value; +} + export async function multiselect( text: string, options: MultiSelectOptions diff --git a/packages/wrangler/src/dispatch-namespace.ts b/packages/wrangler/src/dispatch-namespace.ts index 5d55a818c7..d33d7bc95f 100644 --- a/packages/wrangler/src/dispatch-namespace.ts +++ b/packages/wrangler/src/dispatch-namespace.ts @@ -5,7 +5,7 @@ import * as metrics from "./metrics"; import { requireAuth } from "./user"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; -type Namespace = { +export type Namespace = { namespace_id: string; namespace_name: string; created_on: string; @@ -20,11 +20,11 @@ type Namespace = { /** * Create a dynamic dispatch namespace. */ -async function createWorkerNamespace( +export async function createWorkerNamespace( complianceConfig: ComplianceConfig, accountId: string, name: string -) { +): Promise { const namespace = await fetchResult( complianceConfig, `/accounts/${accountId}/workers/dispatch/namespaces`, @@ -39,6 +39,7 @@ async function createWorkerNamespace( logger.log( `Created dispatch namespace "${name}" with ID "${namespace.namespace_id}"` ); + return namespace; } /** @@ -60,21 +61,20 @@ async function deleteWorkerNamespace( /** * List all created dynamic dispatch namespaces for an account */ -async function listWorkerNamespaces( +export async function listWorkerNamespaces( complianceConfig: ComplianceConfig, accountId: string -) { - logger.log( - await fetchResult( - complianceConfig, - `/accounts/${accountId}/workers/dispatch/namespaces`, - { - headers: { - "Content-Type": "application/json", - }, - } - ) +): Promise { + const namespaces = await fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/dispatch/namespaces`, + { + headers: { + "Content-Type": "application/json", + }, + } ); + return namespaces; } /** @@ -138,7 +138,8 @@ export const dispatchNamespaceListCommand = createCommand({ }, async handler(_, { config }) { const accountId = await requireAuth(config); - await listWorkerNamespaces(config, accountId); + const namespaces = await listWorkerNamespaces(config, accountId); + logger.log(namespaces); metrics.sendMetricsEvent("list dispatch namespaces", { sendMetrics: config.send_metrics, }); diff --git a/packages/wrangler/src/experimental-flags.ts b/packages/wrangler/src/experimental-flags.ts index 4a88fb489f..8058f8f7b6 100644 --- a/packages/wrangler/src/experimental-flags.ts +++ b/packages/wrangler/src/experimental-flags.ts @@ -4,7 +4,6 @@ import { logger } from "./logger"; export type ExperimentalFlags = { MULTIWORKER: boolean; RESOURCES_PROVISION: boolean; - AUTOCREATE_RESOURCES: boolean; }; const flags = new AsyncLocalStorage(); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 0e5820f5bc..7804c2cde9 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -490,13 +490,6 @@ export function createCLIParser(argv: string[]) { hidden: true, alias: ["x-provision"], }, - "experimental-auto-create": { - describe: "Automatically provision draft bindings with new resources", - type: "boolean", - default: true, - hidden: true, - alias: "x-auto-create", - }, } as const; // Type check result against CommonYargsOptions to make sure we've included // all common options diff --git a/packages/wrangler/src/pages/dev.ts b/packages/wrangler/src/pages/dev.ts index 8c8ae20380..5c0ee546cc 100644 --- a/packages/wrangler/src/pages/dev.ts +++ b/packages/wrangler/src/pages/dev.ts @@ -946,7 +946,6 @@ export const pagesDevCommand = createCommand({ { MULTIWORKER: Array.isArray(args.config), RESOURCES_PROVISION: false, - AUTOCREATE_RESOURCES: false, }, () => startDev({ @@ -1016,7 +1015,6 @@ export const pagesDevCommand = createCommand({ persistTo: args.persistTo, logLevel: args.logLevel ?? "log", experimentalProvision: undefined, - experimentalAutoCreate: false, enableIpc: true, config: Array.isArray(args.config) ? args.config : undefined, site: undefined, diff --git a/packages/wrangler/src/queues/client.ts b/packages/wrangler/src/queues/client.ts index 332e16bd88..55116485b0 100644 --- a/packages/wrangler/src/queues/client.ts +++ b/packages/wrangler/src/queues/client.ts @@ -201,9 +201,9 @@ export async function getQueue( } export async function ensureQueuesExistByConfig(config: Config) { - const producers = (config.queues.producers || []).map( - (producer) => producer.queue - ); + const producers = (config.queues.producers || []) + .map((producer) => producer.queue) + .filter((q): q is string => typeof q === "string"); const consumers = (config.queues.consumers || []).map( (consumer) => consumer.queue ); diff --git a/packages/wrangler/src/triggers/deploy.ts b/packages/wrangler/src/triggers/deploy.ts index d13a593891..ce968fb764 100644 --- a/packages/wrangler/src/triggers/deploy.ts +++ b/packages/wrangler/src/triggers/deploy.ts @@ -13,6 +13,7 @@ import { updateQueueConsumers, validateRoutes, } from "../deploy/deploy"; +import { getFlag } from "../experimental-flags"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { ensureQueuesExistByConfig } from "../queues/client"; @@ -75,7 +76,7 @@ export default async function triggersDeploy( ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${envName}` : `/accounts/${accountId}/workers/scripts/${scriptName}`; - if (!props.dryRun) { + if (!props.dryRun && !getFlag("RESOURCES_PROVISION")) { await ensureQueuesExistByConfig(config); } @@ -239,9 +240,9 @@ export default async function triggersDeploy( if (config.queues.producers && config.queues.producers.length) { deployments.push( - ...config.queues.producers.map((producer) => - Promise.resolve([`Producer for ${producer.queue}`]) - ) + ...config.queues.producers + .filter((producer) => producer.queue) + .map((producer) => Promise.resolve([`Producer for ${producer.queue}`])) ); } diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 71d09edd04..97ad559a7d 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -2383,7 +2383,8 @@ function collectAllPipelines( pipelinesMap.set(pipeline.binding, { binding: pipeline.binding, - pipeline: pipeline.pipeline, + // pipeline.pipeline is guaranteed to be a string after the !pipeline.pipeline check above + pipeline: pipeline.pipeline as string, }); } } @@ -3491,7 +3492,8 @@ function collectPipelinesPerEnvironment( pipelines.push({ binding: pipeline.binding, - pipeline: pipeline.pipeline, + // pipeline.pipeline is guaranteed to be a string after the !pipeline.pipeline check above + pipeline: pipeline.pipeline as string, }); } diff --git a/packages/wrangler/src/utils/print-bindings.ts b/packages/wrangler/src/utils/print-bindings.ts index 659cbbd877..3d4efab38a 100644 --- a/packages/wrangler/src/utils/print-bindings.ts +++ b/packages/wrangler/src/utils/print-bindings.ts @@ -716,7 +716,7 @@ export function printBindings( name: binding, type: getBindingTypeFriendlyName("dispatch_namespace"), value: outbound - ? `${namespace} (outbound -> ${outbound.service})` + ? `${String(namespace)} (outbound -> ${outbound.service})` : namespace, mode: getMode({ isSimulatedLocally: diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 3f9d4c1f83..62e7cc14ec 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -104,8 +104,6 @@ type Props = { noBundle: boolean | undefined; keepVars: boolean | undefined; projectRoot: string | undefined; - experimentalAutoCreate: boolean; - tag: string | undefined; message: string | undefined; previewAlias: string | undefined; @@ -265,13 +263,6 @@ export const versionsUploadCommand = createCommand({ describe: "Compile a project without actually uploading the version.", type: "boolean", }, - "experimental-auto-create": { - describe: "Automatically provision draft bindings with new resources", - type: "boolean", - default: true, - hidden: true, - alias: "x-auto-create", - }, "secrets-file": { describe: "Path to a file containing secrets to upload with the version (JSON or .env format). Secrets from previous deployments will not be deleted - see `--keep-secrets`", @@ -284,7 +275,6 @@ export const versionsUploadCommand = createCommand({ overrideExperimentalFlags: (args) => ({ MULTIWORKER: false, RESOURCES_PROVISION: args.experimentalProvision ?? false, - AUTOCREATE_RESOURCES: args.experimentalAutoCreate, }), warnIfMultipleEnvsConfiguredButNoneSpecified: true, }, @@ -401,7 +391,6 @@ export const versionsUploadCommand = createCommand({ tag: args.tag, message: args.message, previewAlias: previewAlias, - experimentalAutoCreate: args.experimentalAutoCreate, outFile: args.outfile, secretsFile: args.secretsFile, }); @@ -792,19 +781,15 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } else { assert(accountId, "Missing accountId"); if (getFlag("RESOURCES_PROVISION")) { - await provisionBindings( - bindings, - accountId, - scriptName, - props.experimentalAutoCreate, - props.config - ); + await provisionBindings(bindings, accountId, scriptName, props.config); } workerBundle = createWorkerUploadForm(worker, bindings, { unsafe: config.unsafe, }); - await ensureQueuesExistByConfig(config); + if (!getFlag("RESOURCES_PROVISION")) { + await ensureQueuesExistByConfig(config); + } let bindingsPrinted = false; // Upload the version. diff --git a/packages/wrangler/src/yargs-types.ts b/packages/wrangler/src/yargs-types.ts index e3fe55c59f..87374e0f7b 100644 --- a/packages/wrangler/src/yargs-types.ts +++ b/packages/wrangler/src/yargs-types.ts @@ -11,7 +11,6 @@ export interface CommonYargsOptions { env: string | undefined; "env-file": string[] | undefined; "experimental-provision": boolean | undefined; - "experimental-auto-create": boolean; } export type CommonYargsArgvSanitized

= OnlyCamelCase<