diff --git a/.changeset/hip-cycles-explain.md b/.changeset/hip-cycles-explain.md new file mode 100644 index 000000000000..d5463f4e21ed --- /dev/null +++ b/.changeset/hip-cycles-explain.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Added r2 bucket lifecycle command to Wrangler including list, add, remove, set diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index 8cd3d01e7893..738e05ca0710 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import { writeFileSync } from "node:fs"; import { http, HttpResponse } from "msw"; import { MAX_UPLOAD_SIZE } from "../r2/constants"; import { actionsForEventCategories } from "../r2/helpers"; @@ -100,6 +101,7 @@ describe("r2", () => { wrangler r2 bucket notification Manage event notification rules for an R2 bucket wrangler r2 bucket domain Manage custom domains for an R2 bucket wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket + wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -137,6 +139,7 @@ describe("r2", () => { wrangler r2 bucket notification Manage event notification rules for an R2 bucket wrangler r2 bucket domain Manage custom domains for an R2 bucket wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket + wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -1000,7 +1003,7 @@ binding = \\"testBucket\\"" " wrangler r2 bucket notification list - List event notification rules for a bucket + List event notification rules for an R2 bucket POSITIONALS bucket The name of the R2 bucket to get event notification rules for [string] [required] @@ -1869,6 +1872,240 @@ binding = \\"testBucket\\"" }); }); }); + describe("lifecycle", () => { + const { setIsTTY } = useMockIsTTY(); + mockAccountId(); + mockApiToken(); + describe("list", () => { + it("should list lifecycle rules when they exist", async () => { + const bucketName = "my-bucket"; + const lifecycleRules = [ + { + id: "rule-1", + enabled: true, + conditions: { prefix: "images/" }, + deleteObjectsTransition: { + condition: { + type: "Age", + maxAge: 2592000, + }, + }, + }, + ]; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json( + createFetchResult({ + rules: lifecycleRules, + }) + ); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket lifecycle list ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Listing lifecycle rules for bucket 'my-bucket'... + id: rule-1 + enabled: Yes + prefix: images/ + action: Expire objects after 30 days" + `); + }); + }); + describe("add", () => { + it("it should add a lifecycle rule using command-line arguments", async () => { + const bucketName = "my-bucket"; + const ruleId = "my-rule"; + const prefix = "images/"; + const conditionType = "Age"; + const conditionValue = "30"; + + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json( + createFetchResult({ + rules: [], + }) + ); + }, + { once: true } + ), + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + 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({ + rules: [ + { + id: ruleId, + enabled: true, + conditions: { prefix: prefix }, + deleteObjectsTransition: { + condition: { + type: conditionType, + maxAge: 2592000, + }, + }, + }, + ], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler( + `r2 bucket lifecycle add ${bucketName} --id ${ruleId} --prefix ${prefix} --expire-days ${conditionValue}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Adding lifecycle rule 'my-rule' to bucket 'my-bucket'... + ✨ Added lifecycle rule 'my-rule' to bucket 'my-bucket'." + `); + }); + }); + describe("remove", () => { + it("should remove a lifecycle rule as expected", async () => { + const bucketName = "my-bucket"; + const ruleId = "my-rule"; + const lifecycleRules = { + rules: [ + { + id: ruleId, + enabled: true, + conditions: {}, + }, + ], + }; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult(lifecycleRules)); + }, + { once: true } + ), + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + 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({ + rules: [], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler( + `r2 bucket lifecycle remove ${bucketName} --id ${ruleId}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Removing lifecycle rule 'my-rule' from bucket 'my-bucket'... + Lifecycle rule 'my-rule' removed from bucket 'my-bucket'." + `); + }); + it("should handle removing non-existant rule ID as expected", async () => { + const bucketName = "my-bucket"; + const ruleId = "my-rule"; + const lifecycleRules = { + rules: [], + }; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult(lifecycleRules)); + }, + { once: true } + ) + ); + await expect(() => + runWrangler( + `r2 bucket lifecycle remove ${bucketName} --id ${ruleId}` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + "[Error: Lifecycle rule with ID 'my-rule' not found in configuration for 'my-bucket'.]" + ); + }); + }); + describe("set", () => { + it("should set lifecycle configuration from a JSON file", async () => { + const bucketName = "my-bucket"; + const filePath = "lifecycle-configuration.json"; + const lifecycleRules = { + rules: [ + { + id: "rule-1", + enabled: true, + conditions: {}, + deleteObjectsTransition: { + condition: { + type: "Age", + maxAge: 2592000, + }, + }, + }, + ], + }; + + writeFileSync(filePath, JSON.stringify(lifecycleRules)); + + setIsTTY(true); + mockConfirm({ + text: `Are you sure you want to overwrite all existing lifecycle rules for bucket '${bucketName}'?`, + result: true, + }); + + msw.use( + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/lifecycle", + 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({ + ...lifecycleRules, + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + + await runWrangler( + `r2 bucket lifecycle set ${bucketName} --file ${filePath}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Setting lifecycle configuration (1 rules) for bucket 'my-bucket'... + ✨ Set lifecycle configuration for bucket 'my-bucket'." + `); + }); + }); + }); }); describe("r2 object", () => { diff --git a/packages/wrangler/src/dialogs.ts b/packages/wrangler/src/dialogs.ts index 49caa2c9339e..f8ecba8a4cf2 100644 --- a/packages/wrangler/src/dialogs.ts +++ b/packages/wrangler/src/dialogs.ts @@ -130,3 +130,47 @@ export async function select( }); return value; } + +interface MultiSelectOptions { + choices: SelectOption[]; + defaultOptions?: number[]; +} + +export async function multiselect( + text: string, + options: MultiSelectOptions +): Promise { + if (isNonInteractiveOrCI()) { + if (options?.defaultOptions === undefined) { + throw new NoDefaultValueProvided(); + } + + const defaultTitles = options.defaultOptions.map( + (index) => options.choices[index].title + ); + logger.log(`? ${text}`); + + logger.log( + `🤖 ${chalk.dim( + "Using default value(s) in non-interactive context:" + )} ${chalk.white.bold(defaultTitles.join(", "))}` + ); + return options.defaultOptions.map((index) => options.choices[index].value); + } + const { value } = await prompts({ + type: "multiselect", + name: "value", + message: text, + choices: options.choices, + instructions: false, + hint: "- Space to select. Return to submit", + onState: (state) => { + if (state.aborted) { + process.nextTick(() => { + process.exit(1); + }); + } + }, + }); + return value; +} diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index 5e5a79e76a46..f0594bb228d9 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -924,6 +924,165 @@ export async function updateR2DevDomain( return result; } +export interface LifecycleCondition { + type: "Age" | "Date"; + maxAge?: number; + date?: string; +} + +export interface LifecycleRule { + id: string; + enabled: boolean; + conditions: { + prefix?: string; + }; + deleteObjectsTransition?: { + condition: LifecycleCondition; + }; + storageClassTransitions?: Array<{ + condition: LifecycleCondition; + storageClass: "InfrequentAccess"; + }>; + abortMultipartUploadsTransition?: { + condition: LifecycleCondition; + }; +} + +function formatCondition(condition: LifecycleCondition): string { + if (condition.type === "Age" && typeof condition.maxAge === "number") { + const days = condition.maxAge / 86400; // Convert seconds to days + return `after ${days} days`; + } else if (condition.type === "Date" && condition.date) { + const date = new Date(condition.date); + const displayDate = date.toISOString().split("T")[0]; + return `on ${displayDate}`; + } + + return ""; +} + +export function tableFromLifecycleRulesResponse(rules: LifecycleRule[]): { + id: string; + enabled: string; + prefix: string; + action: string; +}[] { + const rows = []; + for (const rule of rules) { + const actions = []; + + if (rule.deleteObjectsTransition) { + const action = "Expire objects"; + const condition = formatCondition(rule.deleteObjectsTransition.condition); + actions.push(`${action} ${condition}`); + } + if ( + rule.storageClassTransitions && + rule.storageClassTransitions.length > 0 + ) { + for (const transition of rule.storageClassTransitions) { + const action = "Transition to Infrequent Access"; + const condition = formatCondition(transition.condition); + actions.push(`${action} ${condition}`); + } + } + if (rule.abortMultipartUploadsTransition) { + const action = "Abort incomplete multipart uploads"; + const condition = formatCondition( + rule.abortMultipartUploadsTransition.condition + ); + actions.push(`${action} ${condition}`); + } + + rows.push({ + id: rule.id, + enabled: rule.enabled ? "Yes" : "No", + prefix: rule.conditions.prefix || "(all prefixes)", + action: actions.join(", ") || "(none)", + }); + } + return rows; +} + +export async function getLifecycleRules( + accountId: string, + bucket: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + const result = await fetchResult<{ rules: LifecycleRule[] }>( + `/accounts/${accountId}/r2/buckets/${bucket}/lifecycle`, + { + method: "GET", + headers, + } + ); + return result.rules; +} + +export async function putLifecycleRules( + accountId: string, + bucket: string, + rules: LifecycleRule[], + jurisdiction?: string +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult(`/accounts/${accountId}/r2/buckets/${bucket}/lifecycle`, { + method: "PUT", + headers, + body: JSON.stringify({ rules: rules }), + }); +} + +export function formatActionDescription(action: string): string { + switch (action) { + case "expire": + return "expire objects"; + case "transition": + return "transition to Infrequent Access storage class"; + case "abort-multipart": + return "abort incomplete multipart uploads"; + default: + return action; + } +} + +export function isValidDate(dateString: string): boolean { + const regex = /^\d{4}-\d{2}-\d{2}$/; + if (!regex.test(dateString)) { + return false; + } + const date = new Date(`${dateString}T00:00:00.000Z`); + const timestamp = date.getTime(); + if (isNaN(timestamp)) { + return false; + } + const [year, month, day] = dateString.split("-").map(Number); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() + 1 === month && + date.getUTCDate() === day + ); +} + +export function isNonNegativeNumber(str: string): boolean { + if (str === "") { + return false; + } + const num = Number(str); + return num >= 0; +} + /** * 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 4d4398b833df..3e3e7baf476e 100644 --- a/packages/wrangler/src/r2/index.ts +++ b/packages/wrangler/src/r2/index.ts @@ -23,6 +23,7 @@ import { usingLocalBucket, } from "./helpers"; import * as Info from "./info"; +import * as Lifecycle from "./lifecycle"; import * as List from "./list"; import * as Notification from "./notification"; import * as PublicDevUrl from "./public-dev-url"; @@ -576,7 +577,7 @@ export function r2(r2Yargs: CommonYargsArgv, subHelp: SubHelp) { return r2EvNotifyYargs .command( ["list ", "get "], - "List event notification rules for a bucket", + "List event notification rules for an R2 bucket", Notification.ListOptions, Notification.ListHandler ) @@ -651,6 +652,37 @@ export function r2(r2Yargs: CommonYargsArgv, subHelp: SubHelp) { ); } ); + r2BucketYargs.command( + "lifecycle", + "Manage lifecycle rules for an R2 bucket", + (lifecycleYargs) => { + return lifecycleYargs + .command( + "list ", + "List lifecycle rules for an R2 bucket", + Lifecycle.ListOptions, + Lifecycle.ListHandler + ) + .command( + "add ", + "Add a lifecycle rule to an R2 bucket", + Lifecycle.AddOptions, + Lifecycle.AddHandler + ) + .command( + "remove ", + "Remove a lifecycle rule from an R2 bucket", + Lifecycle.RemoveOptions, + Lifecycle.RemoveHandler + ) + .command( + "set ", + "Set the lifecycle configuration for an R2 bucket from a JSON file", + Lifecycle.SetOptions, + Lifecycle.SetHandler + ); + } + ); return r2BucketYargs; }); } diff --git a/packages/wrangler/src/r2/lifecycle.ts b/packages/wrangler/src/r2/lifecycle.ts new file mode 100644 index 000000000000..0daba23fcf57 --- /dev/null +++ b/packages/wrangler/src/r2/lifecycle.ts @@ -0,0 +1,432 @@ +import { readConfig, withConfig } from "../config"; +import { confirm, multiselect, prompt } from "../dialogs"; +import { UserError } from "../errors"; +import isInteractive from "../is-interactive"; +import { logger } from "../logger"; +import { readFileSync } from "../parse"; +import { printWranglerBanner } from "../update-check"; +import { requireAuth } from "../user"; +import formatLabelledValues from "../utils/render-labelled-values"; +import { + formatActionDescription, + getLifecycleRules, + isNonNegativeNumber, + isValidDate, + putLifecycleRules, + tableFromLifecycleRulesResponse, +} from "./helpers"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../yargs-types"; +import type { LifecycleRule } from "./helpers"; + +export function ListOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: "The name of the R2 bucket to list lifecycle rules for", + 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 lifecycle rules for bucket '${bucket}'...`); + + const lifecycleRules = await getLifecycleRules( + accountId, + bucket, + jurisdiction + ); + + if (lifecycleRules.length === 0) { + logger.log(`There are no lifecycle rules for bucket '${bucket}'.`); + } else { + const tableOutput = tableFromLifecycleRulesResponse(lifecycleRules); + 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 add a lifecycle rule to", + type: "string", + demandOption: true, + }) + .positional("id", { + describe: "A unique identifier for the lifecycle rule", + type: "string", + requiresArg: true, + }) + .positional("prefix", { + describe: + "Prefix condition for the lifecycle rule (leave empty for all prefixes)", + type: "string", + requiresArg: true, + }) + .option("expire-days", { + describe: "Number of days after which objects expire", + type: "number", + requiresArg: true, + }) + .option("expire-date", { + describe: "Date after which objects expire (YYYY-MM-DD)", + type: "number", + requiresArg: true, + }) + .option("ia-transition-days", { + describe: + "Number of days after which objects transition to Infrequent Access storage", + type: "number", + requiresArg: true, + }) + .option("ia-transition-date", { + describe: + "Date after which objects transition to Infrequent Access storage (YYYY-MM-DD)", + type: "string", + requiresArg: true, + }) + .option("abort-multipart-days", { + describe: + "Number of days after which incomplete multipart uploads are aborted", + type: "number", + requiresArg: 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 const AddHandler = withConfig< + StrictYargsOptionsToInterface +>( + async ({ + bucket, + expireDays, + expireDate, + iaTransitionDays, + iaTransitionDate, + abortMultipartDays, + jurisdiction, + force, + id, + prefix, + config, + }): Promise => { + await printWranglerBanner(); + const accountId = await requireAuth(config); + + const lifecycleRules = await getLifecycleRules( + accountId, + bucket, + jurisdiction + ); + + if (!id && isInteractive()) { + id = await prompt("Enter a unique identifier for the lifecycle rule"); + } + + if (!id) { + throw new UserError("Must specify a rule ID."); + } + + const newRule: LifecycleRule = { + id: id, + enabled: true, + conditions: {}, + }; + + let selectedActions: string[] = []; + + if (expireDays !== undefined || expireDate !== undefined) { + selectedActions.push("expire"); + } + if (iaTransitionDays !== undefined || iaTransitionDate !== undefined) { + selectedActions.push("transition"); + } + if (abortMultipartDays !== undefined) { + selectedActions.push("abort-multipart"); + } + + if (selectedActions.length === 0 && isInteractive()) { + if (prefix === undefined) { + prefix = await prompt( + "Enter a prefix for the lifecycle rule (leave empty for all prefixes)" + ); + } + const actionChoices = [ + { title: "Expire objects", value: "expire" }, + { + title: "Transition to Infrequent Access storage class", + value: "transition", + }, + { + title: "Abort incomplete multipart uploads", + value: "abort-multipart", + }, + ]; + + selectedActions = await multiselect("Select the actions to apply", { + choices: actionChoices, + }); + } + + if (selectedActions.length === 0) { + throw new UserError("Must specify at least one action."); + } + + for (const action of selectedActions) { + let conditionType: "Age" | "Date"; + let conditionValue: number | string; + + if (action === "abort-multipart") { + if (abortMultipartDays !== undefined) { + conditionValue = abortMultipartDays; + } else { + conditionValue = await prompt( + `Enter the number of days after which to ${formatActionDescription(action)}` + ); + } + if (!isNonNegativeNumber(String(conditionValue))) { + throw new UserError("Must be a positive number."); + } + + conditionType = "Age"; + conditionValue = Number(conditionValue) * 86400; // Convert days to seconds + + newRule.abortMultipartUploadsTransition = { + condition: { + maxAge: conditionValue, + type: conditionType, + }, + }; + } else { + if (expireDays !== undefined) { + conditionType = "Age"; + conditionValue = expireDays; + } else if (iaTransitionDays !== undefined) { + conditionType = "Age"; + conditionValue = iaTransitionDays; + } else if (expireDate !== undefined) { + conditionType = "Date"; + conditionValue = expireDate; + } else if (iaTransitionDate !== undefined) { + conditionType = "Date"; + conditionValue = iaTransitionDate; + } else { + conditionValue = await prompt( + `Enter the number of days or a date (YYYY-MM-DD) after which to ${formatActionDescription(action)}` + ); + if ( + !isNonNegativeNumber(String(conditionValue)) && + !isValidDate(String(conditionValue)) + ) { + throw new UserError( + "Must be a positive number or a valid date in the YYYY-MM-DD format." + ); + } + } + + if (isNonNegativeNumber(String(conditionValue))) { + conditionType = "Age"; + conditionValue = Number(conditionValue) * 86400; // Convert days to seconds + } else if (isValidDate(String(conditionValue))) { + conditionType = "Date"; + const date = new Date(`${conditionValue}T00:00:00.000Z`); + conditionValue = date.toISOString(); + } else { + throw new UserError("Invalid condition input."); + } + + if (action === "expire") { + newRule.deleteObjectsTransition = { + condition: { + [conditionType === "Age" ? "maxAge" : "date"]: conditionValue, + type: conditionType, + }, + }; + } else if (action === "transition") { + newRule.storageClassTransitions = [ + { + condition: { + [conditionType === "Age" ? "maxAge" : "date"]: conditionValue, + type: conditionType, + }, + storageClass: "InfrequentAccess", + }, + ]; + } + } + } + + if (!prefix && !force) { + const confirmedAdd = await confirm( + `Are you sure you want to add lifecycle rule '${id}' to bucket '${bucket}' without a prefix? ` + + `The lifecycle rule will apply to all objects in your bucket.` + ); + if (!confirmedAdd) { + logger.log("Add cancelled."); + return; + } + } + + if (prefix) { + newRule.conditions.prefix = prefix; + } + + lifecycleRules.push(newRule); + logger.log(`Adding lifecycle rule '${id}' to bucket '${bucket}'...`); + await putLifecycleRules(accountId, bucket, lifecycleRules, jurisdiction); + logger.log(`✨ Added lifecycle rule '${id}' to bucket '${bucket}'.`); + } +); + +export function RemoveOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: "The name of the R2 bucket to remove a lifecycle rule from", + type: "string", + demandOption: true, + }) + .option("id", { + describe: "The unique identifier of the lifecycle rule to remove", + type: "string", + demandOption: true, + requiresArg: true, + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }); +} + +export async function RemoveHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, id, jurisdiction } = args; + + const lifecycleRules = await getLifecycleRules( + accountId, + bucket, + jurisdiction + ); + + const index = lifecycleRules.findIndex((rule) => rule.id === id); + + if (index === -1) { + throw new UserError( + `Lifecycle rule with ID '${id}' not found in configuration for '${bucket}'.` + ); + } + + lifecycleRules.splice(index, 1); + + logger.log(`Removing lifecycle rule '${id}' from bucket '${bucket}'...`); + await putLifecycleRules(accountId, bucket, lifecycleRules, jurisdiction); + logger.log(`Lifecycle rule '${id}' removed from bucket '${bucket}'.`); +} + +export function SetOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: "The name of the R2 bucket to set lifecycle configuration for", + type: "string", + demandOption: true, + }) + .option("file", { + describe: "Path to the JSON file containing lifecycle configuration", + type: "string", + demandOption: true, + requiresArg: 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 SetHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, file, jurisdiction, force } = args; + let lifecyclePolicy: { rules: LifecycleRule[] }; + try { + lifecyclePolicy = JSON.parse(readFileSync(file)); + } catch (e) { + if (e instanceof Error) { + throw new UserError( + `Failed to read or parse the lifecycle configuration config file: '${e.message}'` + ); + } else { + throw e; + } + } + + if (!lifecyclePolicy.rules || !Array.isArray(lifecyclePolicy.rules)) { + throw new UserError( + "The lifecycle configuration file must contain a 'rules' array." + ); + } + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to overwrite all existing lifecycle rules for bucket '${bucket}'?` + ); + if (!confirmedRemoval) { + logger.log("Set cancelled."); + return; + } + } + logger.log( + `Setting lifecycle configuration (${lifecyclePolicy.rules.length} rules) for bucket '${bucket}'...` + ); + await putLifecycleRules( + accountId, + bucket, + lifecyclePolicy.rules, + jurisdiction + ); + logger.log(`✨ Set lifecycle configuration for bucket '${bucket}'.`); +}