diff --git a/.changeset/kv-namespace-create-jurisdiction.md b/.changeset/kv-namespace-create-jurisdiction.md new file mode 100644 index 0000000000..bcaaa2d4d0 --- /dev/null +++ b/.changeset/kv-namespace-create-jurisdiction.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Add hidden `--jurisdiction` option to `wrangler kv namespace create` for internal testing + +This option creates a KV namespace within a specific jurisdiction (for example `us`, `eu`, or `fedramp`), backing it with jurisdiction-scoped storage. It is experimental and currently gated to allow-listed accounts, so it is hidden from `--help` until the feature is generally available. diff --git a/.changeset/kv-namespace-provision-jurisdiction.md b/.changeset/kv-namespace-provision-jurisdiction.md new file mode 100644 index 0000000000..f7303516c2 --- /dev/null +++ b/.changeset/kv-namespace-provision-jurisdiction.md @@ -0,0 +1,15 @@ +--- +"wrangler": minor +--- + +Support an optional `jurisdiction` field on `kv_namespaces` bindings + +The field is only read when the namespace is first provisioned; once an `id` exists the namespace is addressed by that `id` and the `jurisdiction` field is ignored. + +This is experimental and currently gated to accounts allowed only. + +```jsonc +{ + "kv_namespaces": [{ "binding": "MY_KV", "jurisdiction": "eu" }], +} +``` diff --git a/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts index ab2a123046..f715ab0ef5 100644 --- a/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts +++ b/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts @@ -576,7 +576,12 @@ class KVHandler extends ProvisionResourceHandler< return undefined; } async create(name: string) { - return await createKVNamespace(this.complianceConfig, this.accountId, name); + return await createKVNamespace( + this.complianceConfig, + this.accountId, + name, + this.binding.jurisdiction + ); } constructor( bindingName: string, @@ -1358,7 +1363,8 @@ function autoProvisionedResourceName( async function createKVNamespace( complianceConfig: ComplianceConfig, accountId: string, - title: string + title: string, + jurisdiction?: string ): Promise { const response = await fetchResult<{ id: string }>( complianceConfig, @@ -1370,6 +1376,7 @@ async function createKVNamespace( }, body: JSON.stringify({ title, + jurisdiction, }), } ); diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index f11298c460..437245be3f 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -937,6 +937,11 @@ export interface EnvironmentNonInheritable { id?: string; /** The ID of the KV namespace used during `wrangler dev` */ preview_id?: string; + /** + * The jurisdiction to create the KV namespace in when provisioning it + * during `wrangler deploy`. Only read when the namespace does not yet exist. Ignored once the namespace has been created. + */ + jurisdiction?: string; /** Whether the KV namespace should be remote or not in local development */ remote?: boolean; }[]; diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index eac1c31443..1e9838eb28 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -3903,6 +3903,14 @@ const validateKVBinding: ValidatorFn = (diagnostics, field, value) => { ); isValid = false; } + if (!isOptionalProperty(value, "jurisdiction", "string")) { + diagnostics.errors.push( + `"${field}" bindings should, optionally, have a string "jurisdiction" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } if (!isRemoteValid(value, field, diagnostics)) { isValid = false; } @@ -3911,6 +3919,7 @@ const validateKVBinding: ValidatorFn = (diagnostics, field, value) => { "binding", "id", "preview_id", + "jurisdiction", "remote", ]); diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 4ec07328f3..d93e63ed4b 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -83,6 +83,7 @@ export interface CfVars { export interface CfKvNamespace { binding: string; id?: string | typeof INHERIT_SYMBOL; + jurisdiction?: string; remote?: boolean; raw?: 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 1dca66f37e..6d194acc5e 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 @@ -4179,6 +4179,37 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasWarnings()).toBe(false); expect(diagnostics.hasErrors()).toBe(false); }); + + it("should allow an optional jurisdiction field", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + kv_namespaces: [{ binding: "VALID", jurisdiction: "eu" }], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("should error if jurisdiction is not a string", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + kv_namespaces: [{ binding: "VALID", jurisdiction: 2000 }], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "kv_namespaces[0]" bindings should, optionally, have a string "jurisdiction" field but got {"binding":"VALID","jurisdiction":2000}." + `); + }); }); it("should error if send_email.bindings are not valid", ({ expect }) => { diff --git a/packages/wrangler/src/__tests__/helpers/mock-kv.ts b/packages/wrangler/src/__tests__/helpers/mock-kv.ts index 01e8b2bc87..f897110908 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-kv.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-kv.ts @@ -68,15 +68,21 @@ export function mockCreateKVNamespace( options: { resultId?: string; assertTitle?: string; + assertJurisdiction?: string; } = {} ) { msw.use( http.post( "*/accounts/:accountId/storage/kv/namespaces", async ({ request }) => { + const requestBody = await request.json(); if (options.assertTitle) { - const requestBody = await request.json(); - expect(requestBody).toEqual({ title: options.assertTitle }); + expect(requestBody).toMatchObject({ title: options.assertTitle }); + } + if (options.assertJurisdiction) { + expect(requestBody).toMatchObject({ + jurisdiction: options.assertJurisdiction, + }); } return HttpResponse.json( diff --git a/packages/wrangler/src/__tests__/kv/namespace.test.ts b/packages/wrangler/src/__tests__/kv/namespace.test.ts index 61da7934bf..880a44cfec 100644 --- a/packages/wrangler/src/__tests__/kv/namespace.test.ts +++ b/packages/wrangler/src/__tests__/kv/namespace.test.ts @@ -165,6 +165,33 @@ describe("kv", () => { `); }); + it("should create a namespace in a jurisdiction", async ({ expect }) => { + msw.use( + http.post( + "*/accounts/:accountId/storage/kv/namespaces", + async ({ request, params }) => { + expect(params.accountId).toEqual("some-account-id"); + const body = (await request.json()) as Record; + expect(body.title).toEqual("UnitTestNamespace"); + expect(body.jurisdiction).toEqual("eu"); + return HttpResponse.json( + createFetchResult({ id: "some-namespace-id" }), + { status: 200 } + ); + }, + { once: true } + ) + ); + + await runWrangler( + "kv namespace create UnitTestNamespace --binding MY_NS --jurisdiction eu" + ); + expect(std.out).toContain( + 'Creating namespace with title "UnitTestNamespace" (jurisdiction: eu)' + ); + expect(std.out).toContain("✨ Success!"); + }); + describe.each(["wrangler.json", "wrangler.toml"])("%s", (configPath) => { it("should create a namespace", async ({ expect }) => { writeWranglerConfig({ name: "worker" }, configPath); diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index e050311a3a..51e63d0ae1 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -826,6 +826,82 @@ describe("resource provisioning", () => { `); }); + it("provisions a KV namespace in the jurisdiction specified in config", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + kv_namespaces: [ + { + binding: "KV", + jurisdiction: "eu", + }, + ], + }); + mockGetSettings(); + mockListKVNamespacesRequest(expect, { + title: "test-kv", + id: "existing-kv-id", + }); + + 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(expect, { + assertTitle: "new-kv", + assertJurisdiction: "eu", + resultId: "new-kv-id", + }); + + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "KV", + type: "kv_namespace", + namespace_id: "new-kv-id", + }, + ], + }); + + await runWrangler("deploy --x-auto-create=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Total Upload: xx KiB / gzip: xx KiB + + The following bindings need to be provisioned: + Binding Resource + env.KV KV Namespace + + + Provisioning KV (KV Namespace)... + 🌀 Creating new KV Namespace "new-kv"... + ✨ KV 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 + + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + it("can provision KV, R2 and D1 bindings with new resources w/ redirected config", async ({ expect, }) => { diff --git a/packages/wrangler/src/kv/helpers.ts b/packages/wrangler/src/kv/helpers.ts index cd01bff4b3..7900b881bc 100644 --- a/packages/wrangler/src/kv/helpers.ts +++ b/packages/wrangler/src/kv/helpers.ts @@ -46,7 +46,8 @@ type KvArgs = { export async function createKVNamespace( complianceConfig: ComplianceConfig, accountId: string, - title: string + title: string, + jurisdiction?: string ): Promise { const response = await fetchResult<{ id: string }>( complianceConfig, @@ -58,6 +59,7 @@ export async function createKVNamespace( }, body: JSON.stringify({ title, + jurisdiction, }), } ); diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index ea31f7babd..404df2d2b1 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -96,6 +96,13 @@ export const kvNamespaceCreateCommand = createCommand({ type: "boolean", describe: "Interact with a preview namespace", }, + jurisdiction: { + type: "string", + describe: + 'The jurisdiction where the new namespace will be created (e.g. "us", "eu", "fedramp")', + requiresArg: true, + hidden: true, + }, ...sharedResourceCreationArgs, }, positionalArgs: ["namespace"], @@ -104,18 +111,27 @@ export const kvNamespaceCreateCommand = createCommand({ const environment = args.env ? `${args.env}-` : ""; const preview = args.preview ? "_preview" : ""; const title = `${environment}${args.namespace}${preview}`; + const { jurisdiction } = args; const accountId = await requireAuth(config); printResourceLocation("remote"); // TODO: generate a binding name stripping non alphanumeric chars - logger.log(`🌀 Creating namespace with title "${title}"`); + logger.log( + `🌀 Creating namespace with title "${title}"${ + jurisdiction ? ` (jurisdiction: ${jurisdiction})` : "" + }` + ); let namespaceId: string; try { - const result = await sdk.kv.namespaces.create({ + const createParams: Cloudflare.KV.Namespaces.NamespaceCreateParams & { + jurisdiction?: string; + } = { account_id: accountId, title, - }); + jurisdiction, + }; + const result = await sdk.kv.namespaces.create(createParams); namespaceId = result.id; } catch (e) { if (