From 477782a0ddacf752a56571d7d588e9baf352a6c0 Mon Sep 17 00:00:00 2001 From: jkoe-cf Date: Thu, 30 Jan 2025 17:17:59 -0800 Subject: [PATCH] Adding wrangler commands for R2 bucket lock rule configuration --- .changeset/blue-foxes-notice.md | 5 + packages/wrangler/src/__tests__/r2.test.ts | 506 ++++++++++++++++++++- packages/wrangler/src/index.ts | 27 ++ packages/wrangler/src/r2/helpers.ts | 90 ++++ packages/wrangler/src/r2/lifecycle.ts | 2 +- packages/wrangler/src/r2/lock-policy.ts | 327 +++++++++++++ 6 files changed, 954 insertions(+), 3 deletions(-) create mode 100644 .changeset/blue-foxes-notice.md create mode 100644 packages/wrangler/src/r2/lock-policy.ts diff --git a/.changeset/blue-foxes-notice.md b/.changeset/blue-foxes-notice.md new file mode 100644 index 000000000000..ca12e9108194 --- /dev/null +++ b/.changeset/blue-foxes-notice.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Added wrangler r2 commands for bucket lock configuration diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index 4385bb97caf3..3867ee8db5d4 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -6,7 +6,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 { mockConfirm, mockPrompt } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; import { createFetchResult, msw, mswR2handlers } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -96,6 +96,7 @@ describe("r2", () => { 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 wrangler r2 bucket cors Manage CORS configuration for an R2 bucket + wrangler r2 bucket lock Manage lock rules for an R2 bucket GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -134,6 +135,7 @@ describe("r2", () => { 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 wrangler r2 bucket cors Manage CORS configuration for an R2 bucket + wrangler r2 bucket lock Manage lock rules for an R2 bucket GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -1941,7 +1943,7 @@ describe("r2", () => { }); }); describe("add", () => { - it("it should add a lifecycle rule using command-line arguments", async () => { + it("it should add an age lifecycle rule using command-line arguments", async () => { const bucketName = "my-bucket"; const ruleId = "my-rule"; const prefix = "images/"; @@ -1998,6 +2000,64 @@ describe("r2", () => { ✨ Added lifecycle rule 'my-rule' to bucket 'my-bucket'." `); }); + + it("it should add a date lifecycle rule using command-line arguments", async () => { + const bucketName = "my-bucket"; + const ruleId = "my-rule"; + const prefix = "images/"; + const conditionType = "Date"; + const conditionValue = "2025-01-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, + date: "2025-01-30T00:00:00.000Z", + }, + }, + }, + ], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler( + `r2 bucket lifecycle add ${bucketName} --id ${ruleId} --prefix ${prefix} --expire-date ${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 () => { @@ -2277,6 +2337,448 @@ describe("r2", () => { }); }); }); + describe("lock", () => { + const { setIsTTY } = useMockIsTTY(); + mockAccountId(); + mockApiToken(); + describe("list", () => { + it("should list lock rules when they exist", async () => { + const bucketName = "my-bucket"; + const lockRules = [ + { + id: "rule-age", + enabled: true, + prefix: "images/age", + condition: { + type: "Age", + maxAgeSeconds: 86400, + }, + }, + { + id: "rule-date", + enabled: true, + prefix: "images/date", + condition: { + type: "Date", + date: 1738277955891, + }, + }, + { + id: "rule-indefinite", + enabled: true, + prefix: "images/indefinite", + condition: { + type: "Indefinite", + }, + }, + ]; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json( + createFetchResult({ + rules: lockRules, + }) + ); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket lock list ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Listing lock rules for bucket 'my-bucket'... + id: rule-age + enabled: Yes + prefix: images/age + condition: after 1 day + + id: rule-date + enabled: Yes + prefix: images/date + condition: on 2025-01-30 + + id: rule-indefinite + enabled: Yes + prefix: images/indefinite + condition: indefinitely" + `); + }); + }); + describe("add", () => { + it("it should add a lock rule without prefix using command-line arguments", async () => { + const bucketName = "my-bucket"; + const ruleId = "rule-no-prefix"; + const conditionTypeAge = "Age"; + const conditionValueAge = 1; + + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + 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/lock", + 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, + condition: { + type: conditionTypeAge, + maxAgeSeconds: 86400, + }, + }, + ], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + + mockPrompt({ + text: "Enter a prefix for the bucket lock rule (leave empty for all prefixes)", + result: "", + }); + mockConfirm({ + text: + `Are you sure you want to add lock rule '${ruleId}' to bucket '${bucketName}' without a prefix? ` + + `The lock rule will apply to all objects in your bucket.`, + result: true, + }); + await runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleId} --expire-days ${conditionValueAge}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Adding lock rule 'rule-no-prefix' to bucket 'my-bucket'... + ✨ Added lock rule 'rule-no-prefix' to bucket 'my-bucket'." + `); + }); + it("it should add an age lock rule using command-line arguments", async () => { + const bucketName = "my-bucket"; + const ruleIdAge = "rule-age"; + const prefixAge = "prefix-age"; + const conditionTypeAge = "Age"; + const conditionValueAge = 1; + + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + 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/lock", + 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: ruleIdAge, + enabled: true, + prefix: prefixAge, + condition: { + type: conditionTypeAge, + maxAgeSeconds: 86400, + }, + }, + ], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + // age + await runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleIdAge} --prefix ${prefixAge} --expire-days ${conditionValueAge}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Adding lock rule 'rule-age' to bucket 'my-bucket'... + ✨ Added lock rule 'rule-age' to bucket 'my-bucket'." + `); + }); + it("it should add a date lock rule using command-line arguments", async () => { + const bucketName = "my-bucket"; + const ruleIdDate = "rule-date"; + const prefixDate = "prefix-date"; + const conditionTypeDate = "Date"; + const conditionValueDate = "2025-01-30"; + + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + 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/lock", + 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: ruleIdDate, + enabled: true, + prefix: prefixDate, + condition: { + type: conditionTypeDate, + date: "2025-01-30T00:00:00.000Z", + }, + }, + ], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + // date + await runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleIdDate} --prefix ${prefixDate} --expire-date ${conditionValueDate}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Adding lock rule 'rule-date' to bucket 'my-bucket'... + ✨ Added lock rule 'rule-date' to bucket 'my-bucket'." + `); + }); + it("it should add an indefinite lock rule using command-line arguments", async () => { + const bucketName = "my-bucket"; + const ruleIdIndefinite = "rule-indefinite"; + const prefixIndefinite = "prefix-indefinite"; + const conditionTypeIndefinite = "Indefinite"; + + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + 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/lock", + 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: ruleIdIndefinite, + enabled: true, + prefix: prefixIndefinite, + condition: { + type: conditionTypeIndefinite, + }, + }, + ], + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + + mockPrompt({ + text: "Enter the number of days or a date (YYYY-MM-DD) after which to remove lock on objects (leave empty for Indefinite)", + result: "", + }); + + await runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleIdIndefinite} --prefix ${prefixIndefinite}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Adding lock rule 'rule-indefinite' to bucket 'my-bucket'... + ✨ Added lock rule 'rule-indefinite' to bucket 'my-bucket'." + `); + }); + }); + describe("remove", () => { + it("should remove a lock rule as expected", async () => { + const bucketName = "my-bucket"; + const ruleId = "my-rule"; + const lockRules = { + rules: [ + { + id: ruleId, + enabled: true, + condition: { + type: "Indefinite", + }, + }, + ], + }; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult(lockRules)); + }, + { once: true } + ), + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + 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 lock remove ${bucketName} --id ${ruleId}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Removing lock rule 'my-rule' from bucket 'my-bucket'... + Lock 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 lockRules = { + rules: [], + }; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult(lockRules)); + }, + { once: true } + ) + ); + await expect(() => + runWrangler(`r2 bucket lock remove ${bucketName} --id ${ruleId}`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + "[Error: Lock rule with ID 'my-rule' not found in configuration for 'my-bucket'.]" + ); + }); + }); + describe("set", () => { + it("should set lock configuration from a JSON file", async () => { + const bucketName = "my-bucket"; + const filePath = "lock-configuration.json"; + const lockRules = { + rules: [ + { + id: "rule-no-prefix-age", + enabled: true, + condition: { + type: "Age", + maxAgeSeconds: 86400, + }, + }, + { + id: "rule-with-prefix-indefinite", + enabled: true, + prefix: "prefix", + condition: { + type: "Indefinite", + }, + }, + ], + }; + + writeFileSync(filePath, JSON.stringify(lockRules)); + + setIsTTY(true); + mockConfirm({ + text: `Are you sure you want to overwrite all existing lock rules for bucket '${bucketName}'?`, + result: true, + }); + + msw.use( + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/lock", + 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({ + ...lockRules, + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + + await runWrangler( + `r2 bucket lock set ${bucketName} --file ${filePath}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Setting lock configuration (2 rules) for bucket 'my-bucket'... + ✨ Set lock configuration for bucket 'my-bucket'." + `); + }); + }); + }); }); describe("r2 object", () => { diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index db04b6db7490..f2d634698417 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -114,6 +114,13 @@ import { r2BucketLifecycleRemoveCommand, r2BucketLifecycleSetCommand, } from "./r2/lifecycle"; +import { + r2BucketLockRuleAddCommand as r2BucketLockAddCommand, + r2BucketLockRuleListCommand as r2BucketLockListCommand, + r2BucketLockRuleNamespace as r2BucketLockNamespace, + r2BucketLockRuleRemoveCommand as r2BucketLockRemoveCommand, + r2BucketLockSetCommand, +} from "./r2/lock-policy"; import { r2BucketNotificationCreateCommand, r2BucketNotificationDeleteCommand, @@ -687,6 +694,26 @@ export function createCLIParser(argv: string[]) { command: "wrangler r2 bucket cors set", definition: r2BucketCORSSetCommand, }, + { + command: "wrangler r2 bucket lock", + definition: r2BucketLockNamespace, + }, + { + command: "wrangler r2 bucket lock list", + definition: r2BucketLockListCommand, + }, + { + command: "wrangler r2 bucket lock add", + definition: r2BucketLockAddCommand, + }, + { + command: "wrangler r2 bucket lock remove", + definition: r2BucketLockRemoveCommand, + }, + { + command: "wrangler r2 bucket lock set", + definition: r2BucketLockSetCommand, + }, ]); registry.registerNamespace("r2"); diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index 4055cf2d547b..2eccba272c9a 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -1067,6 +1067,96 @@ export async function putLifecycleRules( }); } +// bucket lock rules + +export interface BucketLockRule { + id: string; + enabled: boolean; + prefix?: string; + condition: BucketLockRuleCondition; +} + +export interface BucketLockRuleCondition { + type: "Age" | "Date" | "Indefinite"; + maxAgeSeconds?: number; + date?: string; +} + +export function tableFromBucketLockRulesResponse(rules: BucketLockRule[]): { + id: string; + enabled: string; + prefix: string; + condition: BucketLockRuleCondition; +}[] { + const rows = []; + for (const rule of rules) { + const conditionString = formatLockCondition(rule.condition); + rows.push({ + id: rule.id, + enabled: rule.enabled ? "Yes" : "No", + prefix: rule.prefix || "(all prefixes)", + condition: conditionString, + }); + } + return rows; +} + +function formatLockCondition(condition: BucketLockRuleCondition): string { + if (condition.type === "Age" && typeof condition.maxAgeSeconds === "number") { + const days = condition.maxAgeSeconds / 86400; // Convert seconds to days + if (days == 1) return `after ${days} day`; + else 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 `indefinitely`; +} + +export async function getBucketLockRules( + accountId: string, + bucket: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + const result = await fetchResult<{ rules: BucketLockRule[] }>( + `/accounts/${accountId}/r2/buckets/${bucket}/lock`, + { + method: "GET", + headers, + } + ); + return result.rules; +} + +export async function putBucketLockRules( + accountId: string, + bucket: string, + rules: BucketLockRule[], + jurisdiction?: string +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult(`/accounts/${accountId}/r2/buckets/${bucket}/lock`, { + method: "PUT", + headers, + body: JSON.stringify({ rules: rules }), + }); +} + +// bucket lock rules + export function formatActionDescription(action: string): string { switch (action) { case "expire": diff --git a/packages/wrangler/src/r2/lifecycle.ts b/packages/wrangler/src/r2/lifecycle.ts index aa0206455488..0e62471569fa 100644 --- a/packages/wrangler/src/r2/lifecycle.ts +++ b/packages/wrangler/src/r2/lifecycle.ts @@ -97,7 +97,7 @@ export const r2BucketLifecycleAddCommand = createCommand({ }, "expire-date": { describe: "Date after which objects expire (YYYY-MM-DD)", - type: "number", + type: "string", requiresArg: true, }, "ia-transition-days": { diff --git a/packages/wrangler/src/r2/lock-policy.ts b/packages/wrangler/src/r2/lock-policy.ts new file mode 100644 index 000000000000..7c6ce43c992d --- /dev/null +++ b/packages/wrangler/src/r2/lock-policy.ts @@ -0,0 +1,327 @@ +import { createCommand, createNamespace } from "../core/create-command"; +import { confirm, prompt } from "../dialogs"; +import { UserError } from "../errors"; +import isInteractive from "../is-interactive"; +import { logger } from "../logger"; +import { readFileSync } from "../parse"; +import { requireAuth } from "../user"; +import formatLabelledValues from "../utils/render-labelled-values"; +import { + getBucketLockRules, + isNonNegativeNumber, + isValidDate, + putBucketLockRules, + tableFromBucketLockRulesResponse, +} from "./helpers"; +import type { BucketLockRule } from "./helpers"; + +export const r2BucketLockRuleNamespace = createNamespace({ + metadata: { + description: "Manage lock rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +export const r2BucketLockRuleListCommand = createCommand({ + metadata: { + description: "List lock rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the R2 bucket to list lock rules for", + type: "string", + demandOption: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, jurisdiction } = args; + + logger.log(`Listing lock rules for bucket '${bucket}'...`); + + const rules = await getBucketLockRules(accountId, bucket, jurisdiction); + + if (rules.length === 0) { + logger.log(`There are no lock rules for bucket '${bucket}'.`); + } else { + const tableOutput = tableFromBucketLockRulesResponse(rules); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + } + }, +}); + +export const r2BucketLockRuleAddCommand = createCommand({ + metadata: { + description: "Add a lock rule to an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket", "id", "prefix"], + args: { + bucket: { + describe: "The name of the R2 bucket to add a bucket lock rule to", + type: "string", + demandOption: true, + }, + id: { + describe: "A unique identifier for the bucket lock rule", + type: "string", + requiresArg: true, + }, + prefix: { + describe: + "Prefix condition for the bucket lock rule (leave empty for all prefixes)", + type: "string", + requiresArg: true, + }, + "expire-days": { + describe: "Number of days after which objects expire", + type: "number", + requiresArg: true, + }, + "expire-date": { + describe: "Date after which objects expire (YYYY-MM-DD)", + type: "string", + requiresArg: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + force: { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }, + }, + async handler( + { bucket, expireDays, expireDate, jurisdiction, force, id, prefix }, + { config } + ) { + const accountId = await requireAuth(config); + + const rules = await getBucketLockRules(accountId, bucket, jurisdiction); + + if (!id && isInteractive()) { + id = await prompt("Enter a unique identifier for the lock rule"); + } + + if (!id) { + throw new UserError("Must specify a rule ID."); + } + + const newRule: BucketLockRule = { + id: id, + enabled: true, + condition: { type: "Indefinite" }, + }; + if (prefix === undefined) { + prefix = await prompt( + "Enter a prefix for the bucket lock rule (leave empty for all prefixes)" + ); + } + + let conditionType: "Age" | "Date" | "Indefinite"; + let conditionValue: number | string; + + if (expireDays !== undefined) { + conditionType = "Age"; + conditionValue = expireDays; + } else if (expireDate !== undefined) { + conditionType = "Date"; + conditionValue = expireDate; + } else { + conditionValue = await prompt( + `Enter the number of days or a date (YYYY-MM-DD) after which to remove lock on objects (leave empty for Indefinite)` + ); + } + + if ( + String(conditionValue) && + !isNonNegativeNumber(String(conditionValue)) && + !isValidDate(String(conditionValue)) + ) { + throw new UserError( + `Must be a positive number or a valid date in the YYYY-MM-DD format: ${conditionValue}` + ); + } + + if (isNonNegativeNumber(String(conditionValue))) { + conditionType = "Age"; + conditionValue = Number(conditionValue) * 86400; // Convert days to seconds + newRule.condition = { + type: conditionType, + maxAgeSeconds: conditionValue, + }; + } else if (isValidDate(String(conditionValue))) { + conditionType = "Date"; + const date = new Date(`${conditionValue}T00:00:00.000Z`); + conditionValue = date.toISOString(); + newRule.condition = { + type: conditionType, + date: conditionValue, + }; + } else { + conditionType = "Indefinite"; + } + + if (!prefix && !force) { + const confirmedAdd = await confirm( + `Are you sure you want to add lock rule '${id}' to bucket '${bucket}' without a prefix? ` + + `The lock rule will apply to all objects in your bucket.` + ); + if (!confirmedAdd) { + logger.log("Add cancelled."); + return; + } + } + + if (prefix) { + newRule.prefix = prefix; + } + + rules.push(newRule); + logger.log(`Adding lock rule '${id}' to bucket '${bucket}'...`); + await putBucketLockRules(accountId, bucket, rules, jurisdiction); + logger.log(`✨ Added lock rule '${id}' to bucket '${bucket}'.`); + }, +}); + +export const r2BucketLockRuleRemoveCommand = createCommand({ + metadata: { + description: "Remove a bucket lock rule from an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the R2 bucket to remove a bucket lock rule from", + type: "string", + demandOption: true, + }, + id: { + describe: "The unique identifier of the bucket lock rule to remove", + type: "string", + demandOption: true, + requiresArg: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, id, jurisdiction } = args; + + const lockPolicies = await getBucketLockRules( + accountId, + bucket, + jurisdiction + ); + + const index = lockPolicies.findIndex((policy) => policy.id === id); + + if (index === -1) { + throw new UserError( + `Lock rule with ID '${id}' not found in configuration for '${bucket}'.` + ); + } + + lockPolicies.splice(index, 1); + + logger.log(`Removing lock rule '${id}' from bucket '${bucket}'...`); + await putBucketLockRules(accountId, bucket, lockPolicies, jurisdiction); + logger.log(`Lock rule '${id}' removed from bucket '${bucket}'.`); + }, +}); + +export const r2BucketLockSetCommand = createCommand({ + metadata: { + description: "Set the lock configuration for an R2 bucket from a JSON file", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the R2 bucket to set lock configuration for", + type: "string", + demandOption: true, + }, + file: { + describe: "Path to the JSON file containing lock configuration", + type: "string", + demandOption: true, + requiresArg: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + force: { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, file, jurisdiction, force } = args; + let lockRule: { rules: BucketLockRule[] }; + try { + lockRule = JSON.parse(readFileSync(file)); + } catch (e) { + if (e instanceof Error) { + throw new UserError( + `Failed to read or parse the lock configuration config file: '${e.message}'` + ); + } else { + throw e; + } + } + + if (!lockRule.rules || !Array.isArray(lockRule.rules)) { + throw new UserError( + "The lock configuration file must contain a 'rules' array." + ); + } + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to overwrite all existing lock rules for bucket '${bucket}'?` + ); + if (!confirmedRemoval) { + logger.log("Set cancelled."); + return; + } + } + logger.log( + `Setting lock configuration (${lockRule.rules.length} rules) for bucket '${bucket}'...` + ); + await putBucketLockRules(accountId, bucket, lockRule.rules, jurisdiction); + logger.log(`✨ Set lock configuration for bucket '${bucket}'.`); + }, +});