diff --git a/.changeset/tender-pens-change.md b/.changeset/tender-pens-change.md new file mode 100644 index 000000000000..f614a5e38529 --- /dev/null +++ b/.changeset/tender-pens-change.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Added location hint option for the Wrangler R2 bucket create command diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index 438bb7b95d82..03b1407d4fbb 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -204,8 +204,9 @@ describe("r2", () => { -v, --version Show version number [boolean] OPTIONS - -J, --jurisdiction The jurisdiction where the new bucket will be created [string] - -s, --storage-class The default storage class for objects uploaded to this bucket [string]" + --location The optional location hint that determines geographic placement of the R2 bucket [string] [choices: \\"weur\\", \\"eeur\\", \\"apac\\", \\"wnam\\", \\"enam\\"] + -s, --storage-class The default storage class for objects uploaded to this bucket [string] + -J, --jurisdiction The jurisdiction where the new bucket will be created [string]" `); expect(std.err).toMatchInlineSnapshot(` "X [ERROR] Not enough non-option arguments: got 0, need at least 1 @@ -221,24 +222,25 @@ describe("r2", () => { `[Error: Unknown arguments: def, ghi]` ); expect(std.out).toMatchInlineSnapshot(` - " - wrangler r2 bucket create + " + wrangler r2 bucket create - Create a new R2 bucket + Create a new R2 bucket - POSITIONALS - name The name of the new bucket [string] [required] + POSITIONALS + name The name of the new bucket [string] [required] - GLOBAL FLAGS - -j, --experimental-json-config Experimental: support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean] + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] - OPTIONS - -J, --jurisdiction The jurisdiction where the new bucket will be created [string] - -s, --storage-class The default storage class for objects uploaded to this bucket [string]" + OPTIONS + --location The optional location hint that determines geographic placement of the R2 bucket [string] [choices: \\"weur\\", \\"eeur\\", \\"apac\\", \\"wnam\\", \\"enam\\"] + -s, --storage-class The default storage class for objects uploaded to this bucket [string] + -J, --jurisdiction The jurisdiction where the new bucket will be created [string]" `); expect(std.err).toMatchInlineSnapshot(` "X [ERROR] Unknown arguments: def, ghi @@ -262,8 +264,8 @@ describe("r2", () => { ); await runWrangler("r2 bucket create testBucket"); expect(std.out).toMatchInlineSnapshot(` - "Creating bucket testBucket with default storage class set to Standard. - Created bucket testBucket with default storage class set to Standard." + "Creating bucket 'testBucket'... + ✅ Created bucket 'testBucket' with default storage class of Standard." `); }); @@ -283,16 +285,16 @@ describe("r2", () => { ); await runWrangler("r2 bucket create testBucket -J eu"); expect(std.out).toMatchInlineSnapshot(` - "Creating bucket testBucket (eu) with default storage class set to Standard. - Created bucket testBucket (eu) with default storage class set to Standard." + "Creating bucket 'testBucket (eu)'... + ✅ Created bucket 'testBucket (eu)' with default storage class of Standard." `); }); it("should create a bucket with the expected default storage class", async () => { await runWrangler("r2 bucket create testBucket -s InfrequentAccess"); expect(std.out).toMatchInlineSnapshot(` - "Creating bucket testBucket with default storage class set to InfrequentAccess. - Created bucket testBucket with default storage class set to InfrequentAccess." + "Creating bucket 'testBucket'... + ✅ Created bucket 'testBucket' with default storage class of InfrequentAccess." `); }); @@ -303,7 +305,7 @@ describe("r2", () => { `[APIError: A request to the Cloudflare API (/accounts/some-account-id/r2/buckets) failed.]` ); expect(std.out).toMatchInlineSnapshot(` - "Creating bucket testBucket with default storage class set to Foo. + "Creating bucket 'testBucket'... X [ERROR] A request to the Cloudflare API (/accounts/some-account-id/r2/buckets) failed. @@ -315,6 +317,28 @@ describe("r2", () => { " `); }); + it("should create a bucket with the expected location hint", async () => { + msw.use( + http.post( + "*/accounts/:accountId/r2/buckets", + async ({ request, params }) => { + const { accountId } = params; + expect(accountId).toEqual("some-account-id"); + expect(await request.json()).toEqual({ + name: "testBucket", + locationHint: "weur", + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler("r2 bucket create testBucket --location weur"); + expect(std.out).toMatchInlineSnapshot(` + "Creating bucket 'testBucket'... + ✅ Created bucket 'testBucket' with location hint weur and default storage class of Standard." + `); + }); }); describe("update", () => { diff --git a/packages/wrangler/src/r2/constants.ts b/packages/wrangler/src/r2/constants.ts index 3f3dc7354fd1..e16674ce20e4 100644 --- a/packages/wrangler/src/r2/constants.ts +++ b/packages/wrangler/src/r2/constants.ts @@ -2,3 +2,4 @@ * The maximum file size we can upload using the V4 API. */ export const MAX_UPLOAD_SIZE = 300 * 1024 * 1024; +export const LOCATION_CHOICES = ["weur", "eeur", "apac", "wnam", "enam"]; diff --git a/packages/wrangler/src/r2/create.ts b/packages/wrangler/src/r2/create.ts new file mode 100644 index 000000000000..18ed120dfe2c --- /dev/null +++ b/packages/wrangler/src/r2/create.ts @@ -0,0 +1,76 @@ +import { printWranglerBanner } from ".."; +import { readConfig } from "../config"; +import { UserError } from "../errors"; +import { logger } from "../logger"; +import * as metrics from "../metrics"; +import { requireAuth } from "../user"; +import { LOCATION_CHOICES } from "./constants"; +import { createR2Bucket, isValidR2BucketName } from "./helpers"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../yargs-types"; + +export function Options(yargs: CommonYargsArgv) { + return yargs + .positional("name", { + describe: "The name of the new bucket", + type: "string", + demandOption: true, + }) + .option("location", { + describe: + "The optional location hint that determines geographic placement of the R2 bucket", + choices: LOCATION_CHOICES, + requiresArg: true, + type: "string", + }) + .option("storage-class", { + describe: "The default storage class for objects uploaded to this bucket", + alias: "s", + requiresArg: false, + type: "string", + }) + .option("jurisdiction", { + describe: "The jurisdiction where the new bucket will be created", + alias: "J", + requiresArg: true, + type: "string", + }); +} + +type HandlerOptions = StrictYargsOptionsToInterface; +export async function Handler(args: HandlerOptions) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + const { name, location, storageClass, jurisdiction } = args; + + if (!isValidR2BucketName(name)) { + throw new UserError( + `The bucket name "${name}" is invalid. Bucket names can only have alphanumeric and - characters.` + ); + } + + if (jurisdiction && location) { + throw new UserError( + "Provide either a jurisdiction or location hint - not both." + ); + } + + let fullBucketName = `${name}`; + if (jurisdiction !== undefined) { + fullBucketName += ` (${jurisdiction})`; + } + + logger.log(`Creating bucket '${fullBucketName}'...`); + await createR2Bucket(accountId, name, location, jurisdiction, storageClass); + logger.log( + `✅ Created bucket '${fullBucketName}' with${ + location ? ` location hint ${location} and` : `` + } default storage class of ${storageClass ? storageClass : `Standard`}.` + ); + await metrics.sendMetricsEvent("create r2 bucket", { + sendMetrics: config.send_metrics, + }); +} diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index 991f60835f87..055f5750b088 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -48,6 +48,7 @@ export async function listR2Buckets( export async function createR2Bucket( accountId: string, bucketName: string, + location?: string, jurisdiction?: string, storageClass?: string ): Promise { @@ -60,6 +61,7 @@ export async function createR2Bucket( body: JSON.stringify({ name: bucketName, ...(storageClass !== undefined && { storageClass }), + ...(location !== undefined && { locationHint: location }), }), headers, }); diff --git a/packages/wrangler/src/r2/index.ts b/packages/wrangler/src/r2/index.ts index faee2f2996a7..5210bea047eb 100644 --- a/packages/wrangler/src/r2/index.ts +++ b/packages/wrangler/src/r2/index.ts @@ -11,13 +11,12 @@ import { logger } from "../logger"; import * as metrics from "../metrics"; import { requireAuth } from "../user"; import { MAX_UPLOAD_SIZE } from "./constants"; +import * as Create from "./create"; import { bucketAndKeyFromObjectPath, - createR2Bucket, deleteR2Bucket, deleteR2Object, getR2Object, - isValidR2BucketName, listR2Buckets, putR2Object, updateR2BucketStorageClass, @@ -434,66 +433,8 @@ export function r2(r2Yargs: CommonYargsArgv, subHelp: SubHelp) { r2BucketYargs.command( "create ", "Create a new R2 bucket", - (yargs) => { - return yargs - .positional("name", { - describe: "The name of the new bucket", - type: "string", - demandOption: true, - }) - .option("jurisdiction", { - describe: "The jurisdiction where the new bucket will be created", - alias: "J", - requiresArg: true, - type: "string", - }) - .option("storage-class", { - describe: - "The default storage class for objects uploaded to this bucket", - alias: "s", - requiresArg: false, - type: "string", - }); - }, - async (args) => { - await printWranglerBanner(); - - if (!isValidR2BucketName(args.name)) { - throw new CommandLineArgsError( - `The bucket name "${args.name}" is invalid. Bucket names can only have alphanumeric and - characters.` - ); - } - - const config = readConfig(args.config, args); - - const accountId = await requireAuth(config); - - let fullBucketName = `${args.name}`; - if (args.jurisdiction !== undefined) { - fullBucketName += ` (${args.jurisdiction})`; - } - - let defaultStorageClass = ` with default storage class set to `; - if (args.storageClass !== undefined) { - defaultStorageClass += args.storageClass; - } else { - defaultStorageClass += "Standard"; - } - - logger.log( - `Creating bucket ${fullBucketName}${defaultStorageClass}.` - ); - await createR2Bucket( - accountId, - args.name, - args.jurisdiction, - args.storageClass - ); - logger.log(`Created bucket ${fullBucketName}${defaultStorageClass}.`); - await metrics.sendMetricsEvent("create r2 bucket", { - sendMetrics: config.send_metrics, - }); - } + Create.Options, + Create.Handler ); r2BucketYargs.command("update", "Update bucket state", (updateYargs) => {