Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/kv-namespace-create-jurisdiction.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions .changeset/kv-namespace-provision-jurisdiction.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
oOPa marked this conversation as resolved.

This is experimental and currently gated to accounts allowed only.

```jsonc
{
"kv_namespaces": [{ "binding": "MY_KV", "jurisdiction": "eu" }],
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1358,7 +1363,8 @@ function autoProvisionedResourceName(
async function createKVNamespace(
complianceConfig: ComplianceConfig,
accountId: string,
title: string
title: string,
jurisdiction?: string
): Promise<string> {
const response = await fetchResult<{ id: string }>(
complianceConfig,
Expand All @@ -1370,6 +1376,7 @@ async function createKVNamespace(
},
body: JSON.stringify({
title,
jurisdiction,
}),
}
);
Expand Down
5 changes: 5 additions & 0 deletions packages/workers-utils/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}[];
Expand Down
9 changes: 9 additions & 0 deletions packages/workers-utils/src/config/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -3911,6 +3919,7 @@ const validateKVBinding: ValidatorFn = (diagnostics, field, value) => {
"binding",
"id",
"preview_id",
"jurisdiction",
"remote",
]);

Expand Down
1 change: 1 addition & 0 deletions packages/workers-utils/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface CfVars {
export interface CfKvNamespace {
binding: string;
id?: string | typeof INHERIT_SYMBOL;
jurisdiction?: string;
remote?: boolean;
raw?: boolean;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
10 changes: 8 additions & 2 deletions packages/wrangler/src/__tests__/helpers/mock-kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions packages/wrangler/src/__tests__/kv/namespace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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)'
);
Comment thread
oOPa marked this conversation as resolved.
expect(std.out).toContain("✨ Success!");
});

describe.each(["wrangler.json", "wrangler.toml"])("%s", (configPath) => {
it("should create a namespace", async ({ expect }) => {
writeWranglerConfig({ name: "worker" }, configPath);
Expand Down
76 changes: 76 additions & 0 deletions packages/wrangler/src/__tests__/provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/wrangler/src/kv/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ type KvArgs = {
export async function createKVNamespace(
complianceConfig: ComplianceConfig,
accountId: string,
title: string
title: string,
jurisdiction?: string
): Promise<string> {
const response = await fetchResult<{ id: string }>(
complianceConfig,
Expand All @@ -58,6 +59,7 @@ export async function createKVNamespace(
},
body: JSON.stringify({
title,
jurisdiction,
}),
}
);
Expand Down
22 changes: 19 additions & 3 deletions packages/wrangler/src/kv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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);
Comment thread
oOPa marked this conversation as resolved.
namespaceId = result.id;
} catch (e) {
if (
Expand Down
Loading