diff --git a/.changeset/tame-dryers-end.md b/.changeset/tame-dryers-end.md new file mode 100644 index 000000000000..e08daf8d78db --- /dev/null +++ b/.changeset/tame-dryers-end.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Added the ability to list, add, remove, and update R2 bucket custom domains. diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index 03b1407d4fbb..b0dd139c2716 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -5,6 +5,7 @@ import { actionsForEventCategories } from "../r2/helpers"; import { endEventLoop } from "./helpers/end-event-loop"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; +import { mockConfirm } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; import { createFetchResult, msw, mswR2handlers } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -97,6 +98,7 @@ describe("r2", () => { wrangler r2 bucket delete Delete an R2 bucket wrangler r2 bucket sippy Manage Sippy incremental migration on an R2 bucket wrangler r2 bucket notification Manage event notification rules for an R2 bucket + wrangler r2 bucket domain Manage custom domains for an R2 bucket GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -131,6 +133,7 @@ describe("r2", () => { wrangler r2 bucket delete Delete an R2 bucket wrangler r2 bucket sippy Manage Sippy incremental migration on an R2 bucket wrangler r2 bucket notification Manage event notification rules for an R2 bucket + wrangler r2 bucket domain Manage custom domains for an R2 bucket GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -1467,6 +1470,202 @@ describe("r2", () => { }); }); }); + describe("domain", () => { + const { setIsTTY } = useMockIsTTY(); + mockAccountId(); + mockApiToken(); + describe("add", () => { + it("should add custom domain to the bucket as expected", async () => { + const bucketName = "my-bucket"; + const domainName = "example.com"; + const zoneId = "zone-id-123"; + + setIsTTY(true); + mockConfirm({ + text: + `Are you sure you want to add the custom domain '${domainName}' to bucket '${bucketName}'? ` + + `The contents of your bucket will be made publicly available at 'https://${domainName}'`, + result: true, + }); + msw.use( + http.post( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/custom", + async ({ request, params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketName).toEqual(bucketParam); + const requestBody = await request.json(); + expect(requestBody).toEqual({ + domain: domainName, + zoneId: zoneId, + enabled: true, + minTLS: "1.0", + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler( + `r2 bucket domain add ${bucketName} --domain ${domainName} --zone-id ${zoneId}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Connecting custom domain 'example.com' to bucket 'my-bucket'... + ✨ Custom domain 'example.com' connected successfully." + `); + }); + + it("should error if domain and zone-id are not provided", async () => { + const bucketName = "my-bucket"; + await expect( + runWrangler(`r2 bucket domain add ${bucketName}`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing required arguments: domain, zone-id]` + ); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Missing required arguments: domain, zone-id + + " + `); + }); + }); + describe("list", () => { + it("should list custom domains for a bucket as expected", async () => { + const bucketName = "my-bucket"; + const mockDomains = [ + { + domain: "example.com", + enabled: true, + status: { + ownership: "verified", + ssl: "active", + }, + minTLS: "1.2", + zoneId: "zone-id-123", + zoneName: "example-zone", + }, + { + domain: "test.com", + enabled: false, + status: { + ownership: "pending", + ssl: "pending", + }, + minTLS: "1.0", + zoneId: "zone-id-456", + zoneName: "test-zone", + }, + ]; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/custom", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json( + createFetchResult({ + domains: mockDomains, + }) + ); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket domain list ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Listing custom domains connected to bucket 'my-bucket'... + domain: example.com + enabled: Yes + ownership_status: verified + ssl_status: active + min_tls_version: 1.2 + zone_id: zone-id-123 + zone_name: example-zone + + domain: test.com + enabled: No + ownership_status: pending + ssl_status: pending + min_tls_version: 1.0 + zone_id: zone-id-456 + zone_name: test-zone" + `); + }); + }); + describe("remove", () => { + it("should remove a custom domain as expected", async () => { + const bucketName = "my-bucket"; + const domainName = "example.com"; + setIsTTY(true); + mockConfirm({ + text: + `Are you sure you want to remove the custom domain '${domainName}' from bucket '${bucketName}'? ` + + `Your bucket will no longer be available from 'https://${domainName}'`, + result: true, + }); + msw.use( + http.delete( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/custom/:domainName", + async ({ params }) => { + const { + accountId, + bucketName: bucketParam, + domainName: domainParam, + } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + expect(domainParam).toEqual(domainName); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler( + `r2 bucket domain remove ${bucketName} --domain ${domainName}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Removing custom domain 'example.com' from bucket 'my-bucket'... + Custom domain 'example.com' removed successfully." + `); + }); + }); + describe("update", () => { + it("should update a custom domain as expected", async () => { + const bucketName = "my-bucket"; + const domainName = "example.com"; + msw.use( + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/custom/:domainName", + async ({ request, params }) => { + const { + accountId, + bucketName: bucketParam, + domainName: domainParam, + } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + expect(domainParam).toEqual(domainName); + const requestBody = await request.json(); + expect(requestBody).toEqual({ + domain: domainName, + minTLS: "1.3", + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler( + `r2 bucket domain update ${bucketName} --domain ${domainName} --min-tls 1.3` + ); + expect(std.out).toMatchInlineSnapshot(` + "Updating custom domain 'example.com' for bucket 'my-bucket'... + ✨ Custom domain 'example.com' updated successfully." + `); + }); + }); + }); }); describe("r2 object", () => { diff --git a/packages/wrangler/src/__tests__/r2/helpers.test.ts b/packages/wrangler/src/__tests__/r2/helpers.test.ts index 1437b7f7145d..85ced6165ab0 100644 --- a/packages/wrangler/src/__tests__/r2/helpers.test.ts +++ b/packages/wrangler/src/__tests__/r2/helpers.test.ts @@ -13,7 +13,6 @@ describe("event notifications", () => { test("tableFromNotificationsGetResponse", async () => { const bucketName = "my-bucket"; - const config = { account_id: "my-account" }; const response: GetNotificationConfigResponse = { bucketName, queues: [ @@ -48,10 +47,7 @@ describe("event notifications", () => { }, ], }; - const tableOutput = await tableFromNotificationGetResponse( - config, - response - ); + const tableOutput = tableFromNotificationGetResponse(response); logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); await expect(std.out).toMatchInlineSnapshot(` diff --git a/packages/wrangler/src/r2/domain.ts b/packages/wrangler/src/r2/domain.ts new file mode 100644 index 000000000000..15e60f55c258 --- /dev/null +++ b/packages/wrangler/src/r2/domain.ts @@ -0,0 +1,234 @@ +import { readConfig } from "../config"; +import { confirm } from "../dialogs"; +import { logger } from "../logger"; +import { printWranglerBanner } from "../update-check"; +import { requireAuth } from "../user"; +import formatLabelledValues from "../utils/render-labelled-values"; +import { + attachCustomDomainToBucket, + configureCustomDomainSettings, + listCustomDomainsOfBucket, + removeCustomDomainFromBucket, + tableFromCustomDomainListResponse, +} from "./helpers"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../yargs-types"; + +export function ListOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: + "The name of the R2 bucket whose connected custom domains will be listed", + type: "string", + demandOption: true, + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }); +} + +export async function ListHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, jurisdiction } = args; + + logger.log(`Listing custom domains connected to bucket '${bucket}'...`); + + const domains = await listCustomDomainsOfBucket( + accountId, + bucket, + jurisdiction + ); + + if (domains.length === 0) { + logger.log("There are no custom domains connected to this bucket."); + } else { + const tableOutput = tableFromCustomDomainListResponse(domains); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + } +} + +export function AddOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: "The name of the R2 bucket to connect a custom domain to", + type: "string", + demandOption: true, + }) + .option("domain", { + describe: "The custom domain to connect to the R2 bucket", + type: "string", + demandOption: true, + }) + .option("zone-id", { + describe: "The zone ID associated with the custom domain", + type: "string", + demandOption: true, + }) + .option("min-tls", { + describe: + "Set the minimum TLS version for the custom domain (defaults to 1.0 if not set)", + choices: ["1.0", "1.1", "1.2", "1.3"], + type: "string", + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }) + .option("force", { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }); +} + +export async function AddHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, domain, zoneId, minTls = "1.0", jurisdiction, force } = args; + + if (!force) { + const confirmedAdd = await confirm( + `Are you sure you want to add the custom domain '${domain}' to bucket '${bucket}'? ` + + `The contents of your bucket will be made publicly available at 'https://${domain}'` + ); + if (!confirmedAdd) { + logger.log("Add cancelled."); + return; + } + } + + logger.log(`Connecting custom domain '${domain}' to bucket '${bucket}'...`); + + await attachCustomDomainToBucket( + accountId, + bucket, + { + domain, + zoneId, + minTLS: minTls, + }, + jurisdiction + ); + + logger.log(`✨ Custom domain '${domain}' connected successfully.`); +} + +export function RemoveOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: "The name of the R2 bucket to remove the custom domain from", + type: "string", + demandOption: true, + }) + .option("domain", { + describe: "The custom domain to remove from the R2 bucket", + type: "string", + demandOption: true, + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }) + .option("force", { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }); +} + +export async function RemoveHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, domain, jurisdiction, force } = args; + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to remove the custom domain '${domain}' from bucket '${bucket}'? ` + + `Your bucket will no longer be available from 'https://${domain}'` + ); + if (!confirmedRemoval) { + logger.log("Removal cancelled."); + return; + } + } + logger.log(`Removing custom domain '${domain}' from bucket '${bucket}'...`); + + await removeCustomDomainFromBucket(accountId, bucket, domain, jurisdiction); + + logger.log(`Custom domain '${domain}' removed successfully.`); +} + +export function UpdateOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: + "The name of the R2 bucket associated with the custom domain to update", + type: "string", + demandOption: true, + }) + .option("domain", { + describe: "The custom domain whose settings will be updated", + type: "string", + demandOption: true, + }) + .option("min-tls", { + describe: "Update the minimum TLS version for the custom domain", + choices: ["1.0", "1.1", "1.2", "1.3"], + type: "string", + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }); +} + +export async function UpdateHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, domain, minTls, jurisdiction } = args; + + logger.log(`Updating custom domain '${domain}' for bucket '${bucket}'...`); + + await configureCustomDomainSettings( + accountId, + bucket, + domain, + { + domain, + minTLS: minTls, + }, + jurisdiction + ); + + logger.log(`✨ Custom domain '${domain}' updated successfully.`); +} diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index 055f5750b088..ac0b31960346 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -445,19 +445,18 @@ export function eventNotificationHeaders( return headers; } -export async function tableFromNotificationGetResponse( - config: Pick, +export function tableFromNotificationGetResponse( response: GetNotificationConfigResponse -): Promise< - { - queue_name: string; - prefix: string; - suffix: string; - event_type: string; - }[] -> { - const reducer = async (entry: GetQueueDetail) => { - const rows = []; +): { + rule_id: string; + created_at: string; + queue_name: string; + prefix: string; + suffix: string; + event_type: string; +}[] { + const rows = []; + for (const entry of response.queues) { for (const { prefix = "", suffix = "", @@ -474,20 +473,8 @@ export async function tableFromNotificationGetResponse( event_type: actions.join(","), }); } - return rows; - }; - - let tableOutput: { - queue_name: string; - prefix: string; - suffix: string; - event_type: string; - }[] = []; - for (const entry of response.queues) { - const result = await reducer(entry); - tableOutput = tableOutput.concat(...result); } - return tableOutput; + return rows; } export async function listEventNotificationConfig( @@ -621,6 +608,140 @@ export async function deleteEventNotificationConfig( } } +export interface CustomDomainConfig { + domain: string; + minTLS?: string; + zoneId?: string; +} + +export interface CustomDomainInfo { + domain: string; + enabled: boolean; + status: { + ownership: string; + ssl: string; + }; + minTLS: string; + zoneId: string; + zoneName: string; +} + +export async function attachCustomDomainToBucket( + accountId: string, + bucketName: string, + config: CustomDomainConfig, + jurisdiction?: string +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult( + `/accounts/${accountId}/r2/buckets/${bucketName}/domains/custom`, + { + method: "POST", + headers, + body: JSON.stringify({ + ...config, + enabled: true, + }), + } + ); +} + +export async function removeCustomDomainFromBucket( + accountId: string, + bucketName: string, + domainName: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult( + `/accounts/${accountId}/r2/buckets/${bucketName}/domains/custom/${domainName}`, + { + method: "DELETE", + headers, + } + ); +} + +export function tableFromCustomDomainListResponse( + domains: CustomDomainInfo[] +): { + domain: string; + enabled: string; + ownership_status: string; + ssl_status: string; + min_tls_version: string; + zone_id: string; + zone_name: string; +}[] { + const rows = []; + for (const domainInfo of domains) { + rows.push({ + domain: domainInfo.domain, + enabled: domainInfo.enabled ? "Yes" : "No", + ownership_status: domainInfo.status.ownership || "(unknown)", + ssl_status: domainInfo.status.ssl || "(unknown)", + min_tls_version: domainInfo.minTLS || "1.0", + zone_id: domainInfo.zoneId || "(none)", + zone_name: domainInfo.zoneName || "(none)", + }); + } + return rows; +} + +export async function listCustomDomainsOfBucket( + accountId: string, + bucketName: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + const result = await fetchResult<{ + domains: CustomDomainInfo[]; + }>(`/accounts/${accountId}/r2/buckets/${bucketName}/domains/custom`, { + method: "GET", + headers, + }); + + return result.domains; +} + +export async function configureCustomDomainSettings( + accountId: string, + bucketName: string, + domainName: string, + config: CustomDomainConfig, + jurisdiction?: string +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult( + `/accounts/${accountId}/r2/buckets/${bucketName}/domains/custom/${domainName}`, + { + method: "PUT", + headers, + body: JSON.stringify(config), + } + ); +} + /** * R2 bucket names must only contain alphanumeric and - characters. */ diff --git a/packages/wrangler/src/r2/index.ts b/packages/wrangler/src/r2/index.ts index 5210bea047eb..525464007e51 100644 --- a/packages/wrangler/src/r2/index.ts +++ b/packages/wrangler/src/r2/index.ts @@ -12,6 +12,7 @@ import * as metrics from "../metrics"; import { requireAuth } from "../user"; import { MAX_UPLOAD_SIZE } from "./constants"; import * as Create from "./create"; +import * as Domain from "./domain"; import { bucketAndKeyFromObjectPath, deleteR2Bucket, @@ -607,6 +608,38 @@ export function r2(r2Yargs: CommonYargsArgv, subHelp: SubHelp) { ); } ); + + r2BucketYargs.command( + "domain", + "Manage custom domains for an R2 bucket", + (domainYargs) => { + return domainYargs + .command( + "list ", + "List custom domains for an R2 bucket", + Domain.ListOptions, + Domain.ListHandler + ) + .command( + "add ", + "Connect a custom domain to an R2 bucket", + Domain.AddOptions, + Domain.AddHandler + ) + .command( + "remove ", + "Remove a custom domain from an R2 bucket", + Domain.RemoveOptions, + Domain.RemoveHandler + ) + .command( + "update ", + "Update settings for a custom domain connected to an R2 bucket", + Domain.UpdateOptions, + Domain.UpdateHandler + ); + } + ); return r2BucketYargs; }); } diff --git a/packages/wrangler/src/r2/notification.ts b/packages/wrangler/src/r2/notification.ts index e808575d2f32..ec66d500953c 100644 --- a/packages/wrangler/src/r2/notification.ts +++ b/packages/wrangler/src/r2/notification.ts @@ -51,7 +51,7 @@ export async function ListHandler( bucket, jurisdiction ); - const tableOutput = await tableFromNotificationGetResponse(config, resp); + const tableOutput = tableFromNotificationGetResponse(resp); logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); }