Skip to content

Commit

Permalink
Added r2 bucket lifecycle command (list, add, remove, set) (#7207)
Browse files Browse the repository at this point in the history
* Added r2 bucket lifecycle command (list, add, remove, set)

* Address PR comments: small copy changes, default option for multiselect
  • Loading branch information
jonesphillip authored Nov 19, 2024
1 parent 6508ea2 commit edec415
Show file tree
Hide file tree
Showing 6 changed files with 911 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/hip-cycles-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wrangler": minor
---

Added r2 bucket lifecycle command to Wrangler including list, add, remove, set
239 changes: 238 additions & 1 deletion packages/wrangler/src/__tests__/r2.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -1000,7 +1003,7 @@ binding = \\"testBucket\\""
"
wrangler r2 bucket notification list <bucket>
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]
Expand Down Expand Up @@ -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", () => {
Expand Down
44 changes: 44 additions & 0 deletions packages/wrangler/src/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,47 @@ export async function select<Values extends string>(
});
return value;
}

interface MultiSelectOptions<Values> {
choices: SelectOption<Values>[];
defaultOptions?: number[];
}

export async function multiselect<Values extends string>(
text: string,
options: MultiSelectOptions<Values>
): Promise<Values[]> {
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;
}
Loading

0 comments on commit edec415

Please sign in to comment.