diff --git a/.changeset/blue-foxes-notice.md b/.changeset/blue-foxes-notice.md new file mode 100644 index 0000000000000..ca12e91081942 --- /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/.changeset/weak-chairs-tap.md b/.changeset/weak-chairs-tap.md new file mode 100644 index 0000000000000..3e10b62ce681a --- /dev/null +++ b/.changeset/weak-chairs-tap.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +fixing the format of the R2 lifecycle rule date input to be parsed as string instead of number diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index 4385bb97caf35..64e564a6e6239 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,575 @@ 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 () => { + setIsTTY(false); + 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)", + options: { defaultValue: "" }, + 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} --lock-days ${conditionValueAge}` + ); + expect(std.out).toMatchInlineSnapshot(` + "? Are you sure you want to add lock rule 'rule-no-prefix' to bucket 'my-bucket' without a prefix? The lock rule will apply to all objects in your bucket. + 🤖 Using fallback value in non-interactive context: yes + 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 () => { + setIsTTY(true); + 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} --lock-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 fail an age lock rule using command-line arguments with invalid age", async () => { + setIsTTY(true); + const bucketName = "my-bucket"; + const ruleIdAge = "rule-age"; + const prefixAge = "prefix-age"; + const conditionTypeAge = "Age"; + const conditionValueAge = "one"; + + 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 expect(() => + runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleIdAge} --prefix ${prefixAge} --lock-days ${conditionValueAge}` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Must be a positive number: ${conditionValueAge}]` + ); + }); + it("it should add a date lock rule using command-line arguments", async () => { + setIsTTY(true); + 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} --lock-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 a date lock rule using command-line arguments", async () => { + setIsTTY(true); + const bucketName = "my-bucket"; + const ruleIdDate = "rule-date"; + const prefixDate = "prefix-date"; + const conditionTypeDate = "Date"; + const conditionValueDate = "January 30, 2025"; + + 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 expect(() => + runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleIdDate} --prefix ${prefixDate} --lock-date '${conditionValueDate}'` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Must be a valid date in the YYYY-MM-DD format: ${conditionValueDate}]` + ); + }); + it("it should add an indefinite lock rule using command-line arguments", async () => { + setIsTTY(false); + 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 } + ) + ); + + mockConfirm({ + text: + `Are you sure you want to add lock rule '${ruleIdIndefinite}' to bucket '${bucketName}' without an expiration? ` + + `The lock rule will apply to all matching objects indefinitely.`, + result: true, + }); + + await runWrangler( + `r2 bucket lock add ${bucketName} --id ${ruleIdIndefinite} --prefix ${prefixIndefinite}` + ); + expect(std.out).toMatchInlineSnapshot(` + "? Are you sure you want to add lock rule 'rule-indefinite' to bucket 'my-bucket' without an expiration? The lock rule will apply to all matching objects indefinitely. + 🤖 Using fallback value in non-interactive context: yes + 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 () => { + setIsTTY(false); + 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)); + mockConfirm({ + text: `Are you sure you want to overwrite all existing lock rules for bucket '${bucketName}'?`, + options: { defaultValue: true }, + 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(` + "? Are you sure you want to overwrite all existing lock rules for bucket 'my-bucket'? + 🤖 Using fallback value in non-interactive context: yes + 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 cad27fed2c202..8fd3918162d44 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -114,6 +114,13 @@ import { r2BucketLifecycleRemoveCommand, r2BucketLifecycleSetCommand, } from "./r2/lifecycle"; +import { + r2BucketLockAddCommand, + r2BucketLockListCommand, + r2BucketLockNamespace, + r2BucketLockRemoveCommand, + r2BucketLockSetCommand, +} from "./r2/lock"; import { r2BucketNotificationCreateCommand, r2BucketNotificationDeleteCommand, @@ -696,6 +703,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 4055cf2d547b9..33ed455ef74eb 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -1067,6 +1067,99 @@ 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: string; +}[] { + 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 aa0206455488b..0e62471569fa3 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.ts b/packages/wrangler/src/r2/lock.ts new file mode 100644 index 0000000000000..4911f0b092ce9 --- /dev/null +++ b/packages/wrangler/src/r2/lock.ts @@ -0,0 +1,334 @@ +import { createCommand, createNamespace } from "../core/create-command"; +import { confirm, prompt } from "../dialogs"; +import { UserError } from "../errors"; +import isInteractive, { isNonInteractiveOrCI } 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 r2BucketLockNamespace = createNamespace({ + metadata: { + description: "Manage lock rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +export const r2BucketLockListCommand = 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 r2BucketLockAddCommand = 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, + }, + "lock-days": { + describe: "Number of days after which objects expire", + type: "string", + conflicts: "lock-date", + }, + "lock-date": { + describe: "Date after which objects expire (YYYY-MM-DD)", + type: "string", + conflicts: "lock-days", + }, + 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, lockDays, lockDate, jurisdiction, force, id, prefix }, + { config } + ) { + const accountId = await requireAuth(config); + + const rules = await getBucketLockRules(accountId, bucket, jurisdiction); + + if (!id && !isNonInteractiveOrCI() && !force) { + id = await prompt("Enter a unique identifier for the lock rule"); + } + + if (!id) { + throw new UserError("Must specify a rule ID.", { + telemetryMessage: true, + }); + } + + const newRule: BucketLockRule = { + id: id, + enabled: true, + condition: { type: "Indefinite" }, + }; + if (prefix === undefined) + if (force) { + prefix = await prompt( + "Enter a prefix for the bucket lock rule (leave empty for all prefixes)", + { defaultValue: "" } + ); + } else { + 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 (lockDays === undefined && lockDate === undefined) { + const confirmIndefinite = confirm( + `Are you sure you want to add lock rule '${id}' to bucket '${bucket}' without an expiration? ` + + `The lock rule will apply to all matching objects indefinitely.`, + { defaultValue: true } + ); + if (!confirmIndefinite) { + logger.log("Add cancelled."); + return; + } else { + newRule.condition = { + type: "Indefinite", + }; + } + } else { + if (lockDays !== undefined) { + if (isNonNegativeNumber(String(lockDays))) { + let conditionDaysValue = Number(lockDays) * 86400; // Convert days to seconds + newRule.condition = { + type: "Age", + maxAgeSeconds: conditionDaysValue, + }; + } else { + throw new UserError( + `Must be a positive number: ${String(lockDays)}`, + { telemetryMessage: true } + ); + } + } else if (lockDate !== undefined) { + if (isValidDate(String(lockDate))) { + const date = new Date(`${lockDate}T00:00:00.000Z`); + let conditionDateValue = date.toISOString(); + newRule.condition = { + type: "Date", + date: conditionDateValue, + }; + } else { + throw new UserError( + `Must be a valid date in the YYYY-MM-DD format: ${String(lockDate)}`, + { telemetryMessage: true } + ); + } + } + } + + 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 r2BucketLockRemoveCommand = 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}'.`, + { telemetryMessage: true } + ); + } + + 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}'`, + { telemetryMessage: true } + ); + } else { + throw e; + } + } + + if (!lockRule.rules || !Array.isArray(lockRule.rules)) { + throw new UserError( + "The lock configuration file must contain a 'rules' array.", + { telemetryMessage: true } + ); + } + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to overwrite all existing lock rules for bucket '${bucket}'?`, + { defaultValue: true } + ); + 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}'.`); + }, +});