Skip to content

Commit 36ef9c6

Browse files
authoredFeb 26, 2025··
Adding wrangler commands for R2 bucket lock rule configuration (#7977)
* Adding wrangler commands for R2 bucket lock rule configuration * R2-2612: addressing feedback and adding tests * R2-2612: updating lock date/days to retention date/days and explicitly making retention required instead of indefinite being implied
1 parent 2b92a65 commit 36ef9c6

File tree

7 files changed

+1045
-3
lines changed

7 files changed

+1045
-3
lines changed
 

‎.changeset/blue-foxes-notice.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Added wrangler r2 commands for bucket lock configuration

‎.changeset/weak-chairs-tap.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
fixing the format of the R2 lifecycle rule date input to be parsed as string instead of number

‎packages/wrangler/src/__tests__/r2.test.ts

+540-2
Large diffs are not rendered by default.

‎packages/wrangler/src/index.ts

+27
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ import {
115115
r2BucketLifecycleRemoveCommand,
116116
r2BucketLifecycleSetCommand,
117117
} from "./r2/lifecycle";
118+
import {
119+
r2BucketLockAddCommand,
120+
r2BucketLockListCommand,
121+
r2BucketLockNamespace,
122+
r2BucketLockRemoveCommand,
123+
r2BucketLockSetCommand,
124+
} from "./r2/lock";
118125
import {
119126
r2BucketNotificationCreateCommand,
120127
r2BucketNotificationDeleteCommand,
@@ -713,6 +720,26 @@ export function createCLIParser(argv: string[]) {
713720
command: "wrangler r2 bucket cors set",
714721
definition: r2BucketCORSSetCommand,
715722
},
723+
{
724+
command: "wrangler r2 bucket lock",
725+
definition: r2BucketLockNamespace,
726+
},
727+
{
728+
command: "wrangler r2 bucket lock list",
729+
definition: r2BucketLockListCommand,
730+
},
731+
{
732+
command: "wrangler r2 bucket lock add",
733+
definition: r2BucketLockAddCommand,
734+
},
735+
{
736+
command: "wrangler r2 bucket lock remove",
737+
definition: r2BucketLockRemoveCommand,
738+
},
739+
{
740+
command: "wrangler r2 bucket lock set",
741+
definition: r2BucketLockSetCommand,
742+
},
716743
]);
717744
registry.registerNamespace("r2");
718745

‎packages/wrangler/src/r2/helpers.ts

+93
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,99 @@ export async function putLifecycleRules(
10671067
});
10681068
}
10691069

1070+
// bucket lock rules
1071+
1072+
export interface BucketLockRule {
1073+
id: string;
1074+
enabled: boolean;
1075+
prefix?: string;
1076+
condition: BucketLockRuleCondition;
1077+
}
1078+
1079+
export interface BucketLockRuleCondition {
1080+
type: "Age" | "Date" | "Indefinite";
1081+
maxAgeSeconds?: number;
1082+
date?: string;
1083+
}
1084+
1085+
export function tableFromBucketLockRulesResponse(rules: BucketLockRule[]): {
1086+
id: string;
1087+
enabled: string;
1088+
prefix: string;
1089+
condition: string;
1090+
}[] {
1091+
const rows = [];
1092+
for (const rule of rules) {
1093+
const conditionString = formatLockCondition(rule.condition);
1094+
rows.push({
1095+
id: rule.id,
1096+
enabled: rule.enabled ? "Yes" : "No",
1097+
prefix: rule.prefix || "(all prefixes)",
1098+
condition: conditionString,
1099+
});
1100+
}
1101+
return rows;
1102+
}
1103+
1104+
function formatLockCondition(condition: BucketLockRuleCondition): string {
1105+
if (condition.type === "Age" && typeof condition.maxAgeSeconds === "number") {
1106+
const days = condition.maxAgeSeconds / 86400; // Convert seconds to days
1107+
if (days == 1) {
1108+
return `after ${days} day`;
1109+
} else {
1110+
return `after ${days} days`;
1111+
}
1112+
} else if (condition.type === "Date" && condition.date) {
1113+
const date = new Date(condition.date);
1114+
const displayDate = date.toISOString().split("T")[0];
1115+
return `on ${displayDate}`;
1116+
}
1117+
1118+
return `indefinitely`;
1119+
}
1120+
1121+
export async function getBucketLockRules(
1122+
accountId: string,
1123+
bucket: string,
1124+
jurisdiction?: string
1125+
): Promise<BucketLockRule[]> {
1126+
const headers: HeadersInit = {};
1127+
if (jurisdiction) {
1128+
headers["cf-r2-jurisdiction"] = jurisdiction;
1129+
}
1130+
1131+
const result = await fetchResult<{ rules: BucketLockRule[] }>(
1132+
`/accounts/${accountId}/r2/buckets/${bucket}/lock`,
1133+
{
1134+
method: "GET",
1135+
headers,
1136+
}
1137+
);
1138+
return result.rules;
1139+
}
1140+
1141+
export async function putBucketLockRules(
1142+
accountId: string,
1143+
bucket: string,
1144+
rules: BucketLockRule[],
1145+
jurisdiction?: string
1146+
): Promise<void> {
1147+
const headers: HeadersInit = {
1148+
"Content-Type": "application/json",
1149+
};
1150+
if (jurisdiction) {
1151+
headers["cf-r2-jurisdiction"] = jurisdiction;
1152+
}
1153+
1154+
await fetchResult(`/accounts/${accountId}/r2/buckets/${bucket}/lock`, {
1155+
method: "PUT",
1156+
headers,
1157+
body: JSON.stringify({ rules: rules }),
1158+
});
1159+
}
1160+
1161+
// bucket lock rules
1162+
10701163
export function formatActionDescription(action: string): string {
10711164
switch (action) {
10721165
case "expire":

‎packages/wrangler/src/r2/lifecycle.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const r2BucketLifecycleAddCommand = createCommand({
9797
},
9898
"expire-date": {
9999
describe: "Date after which objects expire (YYYY-MM-DD)",
100-
type: "number",
100+
type: "string",
101101
requiresArg: true,
102102
},
103103
"ia-transition-days": {

‎packages/wrangler/src/r2/lock.ts

+374
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { createCommand, createNamespace } from "../core/create-command";
2+
import { confirm, prompt } from "../dialogs";
3+
import { UserError } from "../errors";
4+
import { isNonInteractiveOrCI } from "../is-interactive";
5+
import { logger } from "../logger";
6+
import { ParseError, readFileSync } from "../parse";
7+
import { requireAuth } from "../user";
8+
import formatLabelledValues from "../utils/render-labelled-values";
9+
import {
10+
getBucketLockRules,
11+
isValidDate,
12+
putBucketLockRules,
13+
tableFromBucketLockRulesResponse,
14+
} from "./helpers";
15+
import type { BucketLockRule } from "./helpers";
16+
17+
export const r2BucketLockNamespace = createNamespace({
18+
metadata: {
19+
description: "Manage lock rules for an R2 bucket",
20+
status: "stable",
21+
owner: "Product: R2",
22+
},
23+
});
24+
25+
export const r2BucketLockListCommand = createCommand({
26+
metadata: {
27+
description: "List lock rules for an R2 bucket",
28+
status: "stable",
29+
owner: "Product: R2",
30+
},
31+
positionalArgs: ["bucket"],
32+
args: {
33+
bucket: {
34+
describe: "The name of the R2 bucket to list lock rules for",
35+
type: "string",
36+
demandOption: true,
37+
},
38+
jurisdiction: {
39+
describe: "The jurisdiction where the bucket exists",
40+
alias: "J",
41+
requiresArg: true,
42+
type: "string",
43+
},
44+
},
45+
async handler(args, { config }) {
46+
const accountId = await requireAuth(config);
47+
48+
const { bucket, jurisdiction } = args;
49+
50+
logger.log(`Listing lock rules for bucket '${bucket}'...`);
51+
52+
const rules = await getBucketLockRules(accountId, bucket, jurisdiction);
53+
54+
if (rules.length === 0) {
55+
logger.log(`There are no lock rules for bucket '${bucket}'.`);
56+
} else {
57+
const tableOutput = tableFromBucketLockRulesResponse(rules);
58+
logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n"));
59+
}
60+
},
61+
});
62+
63+
export const r2BucketLockAddCommand = createCommand({
64+
metadata: {
65+
description: "Add a lock rule to an R2 bucket",
66+
status: "stable",
67+
owner: "Product: R2",
68+
},
69+
positionalArgs: ["bucket", "id", "prefix"],
70+
args: {
71+
bucket: {
72+
describe: "The name of the R2 bucket to add a bucket lock rule to",
73+
type: "string",
74+
demandOption: true,
75+
},
76+
id: {
77+
describe: "A unique identifier for the bucket lock rule",
78+
type: "string",
79+
requiresArg: true,
80+
},
81+
prefix: {
82+
describe:
83+
'Prefix condition for the bucket lock rule (set to "" for all prefixes)',
84+
type: "string",
85+
requiresArg: true,
86+
},
87+
"retention-days": {
88+
describe: "Number of days which objects will be retained for",
89+
type: "number",
90+
conflicts: ["retention-date", "retention-indefinite"],
91+
},
92+
"retention-date": {
93+
describe: "Date after which objects will be retained until (YYYY-MM-DD)",
94+
type: "string",
95+
conflicts: ["retention-days", "retention-indefinite"],
96+
},
97+
"retention-indefinite": {
98+
describe: "Retain objects indefinitely",
99+
type: "boolean",
100+
conflicts: ["retention-date", "retention-days"],
101+
},
102+
jurisdiction: {
103+
describe: "The jurisdiction where the bucket exists",
104+
alias: "J",
105+
requiresArg: true,
106+
type: "string",
107+
},
108+
force: {
109+
describe: "Skip confirmation",
110+
type: "boolean",
111+
alias: "y",
112+
default: false,
113+
},
114+
},
115+
async handler(
116+
{
117+
bucket,
118+
retentionDays,
119+
retentionDate,
120+
retentionIndefinite,
121+
jurisdiction,
122+
force,
123+
id,
124+
prefix,
125+
},
126+
{ config }
127+
) {
128+
const accountId = await requireAuth(config);
129+
130+
const rules = await getBucketLockRules(accountId, bucket, jurisdiction);
131+
132+
if (!id && !isNonInteractiveOrCI() && !force) {
133+
id = await prompt("Enter a unique identifier for the lock rule");
134+
}
135+
136+
if (!id) {
137+
throw new UserError("Must specify a rule ID.", {
138+
telemetryMessage: true,
139+
});
140+
}
141+
142+
const newRule: BucketLockRule = {
143+
id: id,
144+
enabled: true,
145+
condition: { type: "Indefinite" },
146+
};
147+
if (prefix === undefined && !force) {
148+
prefix = await prompt(
149+
'Enter a prefix for the bucket lock rule (set to "" for all prefixes)',
150+
{ defaultValue: "" }
151+
);
152+
if (prefix === "") {
153+
const confirmedAdd = await confirm(
154+
`Are you sure you want to add lock rule '${id}' to bucket '${bucket}' without a prefix? ` +
155+
`The lock rule will apply to all objects in your bucket.`,
156+
{ defaultValue: false }
157+
);
158+
if (!confirmedAdd) {
159+
logger.log("Add cancelled.");
160+
return;
161+
}
162+
}
163+
}
164+
165+
if (prefix) {
166+
newRule.prefix = prefix;
167+
}
168+
169+
if (
170+
retentionDays === undefined &&
171+
retentionDate === undefined &&
172+
retentionIndefinite === undefined &&
173+
!force
174+
) {
175+
retentionIndefinite = await confirm(
176+
`Are you sure you want to add lock rule '${id}' to bucket '${bucket}' without retention? ` +
177+
`The lock rule will apply to all matching objects indefinitely.`,
178+
{ defaultValue: false }
179+
);
180+
if (retentionIndefinite !== true) {
181+
logger.log("Add cancelled.");
182+
return;
183+
}
184+
}
185+
186+
if (retentionDays !== undefined) {
187+
if (!isNaN(retentionDays)) {
188+
if (retentionDays > 0) {
189+
const conditionDaysValue = Number(retentionDays) * 86400; // Convert days to seconds
190+
newRule.condition = {
191+
type: "Age",
192+
maxAgeSeconds: conditionDaysValue,
193+
};
194+
} else {
195+
throw new UserError(
196+
`Days must be a positive number: ${retentionDays}`,
197+
{
198+
telemetryMessage: "Retention days not a positive number.",
199+
}
200+
);
201+
}
202+
} else {
203+
throw new UserError(`Days must be a number.`, {
204+
telemetryMessage: "Retention days not a positive number.",
205+
});
206+
}
207+
} else if (retentionDate !== undefined) {
208+
if (isValidDate(retentionDate)) {
209+
const date = new Date(`${retentionDate}T00:00:00.000Z`);
210+
const conditionDateValue = date.toISOString();
211+
newRule.condition = {
212+
type: "Date",
213+
date: conditionDateValue,
214+
};
215+
} else {
216+
throw new UserError(
217+
`Date must be a valid date in the YYYY-MM-DD format: ${String(retentionDate)}`,
218+
{
219+
telemetryMessage:
220+
"Retention date not a valid date in the YYYY-MM-DD format.",
221+
}
222+
);
223+
}
224+
} else if (
225+
retentionIndefinite !== undefined &&
226+
retentionIndefinite === true
227+
) {
228+
newRule.condition = {
229+
type: "Indefinite",
230+
};
231+
} else {
232+
throw new UserError(`Retention must be specified.`, {
233+
telemetryMessage: "Lock retention not specified.",
234+
});
235+
}
236+
rules.push(newRule);
237+
logger.log(`Adding lock rule '${id}' to bucket '${bucket}'...`);
238+
await putBucketLockRules(accountId, bucket, rules, jurisdiction);
239+
logger.log(`✨ Added lock rule '${id}' to bucket '${bucket}'.`);
240+
},
241+
});
242+
243+
export const r2BucketLockRemoveCommand = createCommand({
244+
metadata: {
245+
description: "Remove a bucket lock rule from an R2 bucket",
246+
status: "stable",
247+
owner: "Product: R2",
248+
},
249+
positionalArgs: ["bucket"],
250+
args: {
251+
bucket: {
252+
describe: "The name of the R2 bucket to remove a bucket lock rule from",
253+
type: "string",
254+
demandOption: true,
255+
},
256+
id: {
257+
describe: "The unique identifier of the bucket lock rule to remove",
258+
type: "string",
259+
demandOption: true,
260+
requiresArg: true,
261+
},
262+
jurisdiction: {
263+
describe: "The jurisdiction where the bucket exists",
264+
alias: "J",
265+
requiresArg: true,
266+
type: "string",
267+
},
268+
},
269+
async handler(args, { config }) {
270+
const accountId = await requireAuth(config);
271+
272+
const { bucket, id, jurisdiction } = args;
273+
274+
const lockPolicies = await getBucketLockRules(
275+
accountId,
276+
bucket,
277+
jurisdiction
278+
);
279+
280+
const index = lockPolicies.findIndex((policy) => policy.id === id);
281+
282+
if (index === -1) {
283+
throw new UserError(
284+
`Lock rule with ID '${id}' not found in configuration for '${bucket}'.`,
285+
{
286+
telemetryMessage:
287+
"Lock rule with ID not found in configuration for bucket.",
288+
}
289+
);
290+
}
291+
292+
lockPolicies.splice(index, 1);
293+
294+
logger.log(`Removing lock rule '${id}' from bucket '${bucket}'...`);
295+
await putBucketLockRules(accountId, bucket, lockPolicies, jurisdiction);
296+
logger.log(`Lock rule '${id}' removed from bucket '${bucket}'.`);
297+
},
298+
});
299+
300+
export const r2BucketLockSetCommand = createCommand({
301+
metadata: {
302+
description: "Set the lock configuration for an R2 bucket from a JSON file",
303+
status: "stable",
304+
owner: "Product: R2",
305+
},
306+
positionalArgs: ["bucket"],
307+
args: {
308+
bucket: {
309+
describe: "The name of the R2 bucket to set lock configuration for",
310+
type: "string",
311+
demandOption: true,
312+
},
313+
file: {
314+
describe: "Path to the JSON file containing lock configuration",
315+
type: "string",
316+
demandOption: true,
317+
requiresArg: true,
318+
},
319+
jurisdiction: {
320+
describe: "The jurisdiction where the bucket exists",
321+
alias: "J",
322+
requiresArg: true,
323+
type: "string",
324+
},
325+
force: {
326+
describe: "Skip confirmation",
327+
type: "boolean",
328+
alias: "y",
329+
default: false,
330+
},
331+
},
332+
async handler(args, { config }) {
333+
const accountId = await requireAuth(config);
334+
335+
const { bucket, file, jurisdiction, force } = args;
336+
let lockRule: { rules: BucketLockRule[] };
337+
try {
338+
lockRule = JSON.parse(readFileSync(file));
339+
} catch (e) {
340+
if (e instanceof Error) {
341+
throw new ParseError({
342+
text: `Failed to read or parse the lock configuration config file: '${e.message}'`,
343+
telemetryMessage:
344+
"Failed to read or parse the lock configuration config file.",
345+
});
346+
} else {
347+
throw e;
348+
}
349+
}
350+
351+
if (!lockRule.rules || !Array.isArray(lockRule.rules)) {
352+
throw new UserError(
353+
"The lock configuration file must contain a 'rules' array.",
354+
{ telemetryMessage: true }
355+
);
356+
}
357+
358+
if (!force) {
359+
const confirmedRemoval = await confirm(
360+
`Are you sure you want to overwrite all existing lock rules for bucket '${bucket}'?`,
361+
{ defaultValue: true }
362+
);
363+
if (!confirmedRemoval) {
364+
logger.log("Set cancelled.");
365+
return;
366+
}
367+
}
368+
logger.log(
369+
`Setting lock configuration (${lockRule.rules.length} rules) for bucket '${bucket}'...`
370+
);
371+
await putBucketLockRules(accountId, bucket, lockRule.rules, jurisdiction);
372+
logger.log(`✨ Set lock configuration for bucket '${bucket}'.`);
373+
},
374+
});

0 commit comments

Comments
 (0)
Please sign in to comment.