Skip to content

Commit edec415

Browse files
authored
Added r2 bucket lifecycle command (list, add, remove, set) (#7207)
* Added r2 bucket lifecycle command (list, add, remove, set) * Address PR comments: small copy changes, default option for multiselect
1 parent 6508ea2 commit edec415

File tree

6 files changed

+911
-2
lines changed

6 files changed

+911
-2
lines changed

.changeset/hip-cycles-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Added r2 bucket lifecycle command to Wrangler including list, add, remove, set

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

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as fs from "node:fs";
2+
import { writeFileSync } from "node:fs";
23
import { http, HttpResponse } from "msw";
34
import { MAX_UPLOAD_SIZE } from "../r2/constants";
45
import { actionsForEventCategories } from "../r2/helpers";
@@ -100,6 +101,7 @@ describe("r2", () => {
100101
wrangler r2 bucket notification Manage event notification rules for an R2 bucket
101102
wrangler r2 bucket domain Manage custom domains for an R2 bucket
102103
wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket
104+
wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket
103105
104106
GLOBAL FLAGS
105107
-j, --experimental-json-config Experimental: support wrangler.json [boolean]
@@ -137,6 +139,7 @@ describe("r2", () => {
137139
wrangler r2 bucket notification Manage event notification rules for an R2 bucket
138140
wrangler r2 bucket domain Manage custom domains for an R2 bucket
139141
wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket
142+
wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket
140143
141144
GLOBAL FLAGS
142145
-j, --experimental-json-config Experimental: support wrangler.json [boolean]
@@ -1000,7 +1003,7 @@ binding = \\"testBucket\\""
10001003
"
10011004
wrangler r2 bucket notification list <bucket>
10021005
1003-
List event notification rules for a bucket
1006+
List event notification rules for an R2 bucket
10041007
10051008
POSITIONALS
10061009
bucket The name of the R2 bucket to get event notification rules for [string] [required]
@@ -1869,6 +1872,240 @@ binding = \\"testBucket\\""
18691872
});
18701873
});
18711874
});
1875+
describe("lifecycle", () => {
1876+
const { setIsTTY } = useMockIsTTY();
1877+
mockAccountId();
1878+
mockApiToken();
1879+
describe("list", () => {
1880+
it("should list lifecycle rules when they exist", async () => {
1881+
const bucketName = "my-bucket";
1882+
const lifecycleRules = [
1883+
{
1884+
id: "rule-1",
1885+
enabled: true,
1886+
conditions: { prefix: "images/" },
1887+
deleteObjectsTransition: {
1888+
condition: {
1889+
type: "Age",
1890+
maxAge: 2592000,
1891+
},
1892+
},
1893+
},
1894+
];
1895+
msw.use(
1896+
http.get(
1897+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
1898+
async ({ params }) => {
1899+
const { accountId, bucketName: bucketParam } = params;
1900+
expect(accountId).toEqual("some-account-id");
1901+
expect(bucketParam).toEqual(bucketName);
1902+
return HttpResponse.json(
1903+
createFetchResult({
1904+
rules: lifecycleRules,
1905+
})
1906+
);
1907+
},
1908+
{ once: true }
1909+
)
1910+
);
1911+
await runWrangler(`r2 bucket lifecycle list ${bucketName}`);
1912+
expect(std.out).toMatchInlineSnapshot(`
1913+
"Listing lifecycle rules for bucket 'my-bucket'...
1914+
id: rule-1
1915+
enabled: Yes
1916+
prefix: images/
1917+
action: Expire objects after 30 days"
1918+
`);
1919+
});
1920+
});
1921+
describe("add", () => {
1922+
it("it should add a lifecycle rule using command-line arguments", async () => {
1923+
const bucketName = "my-bucket";
1924+
const ruleId = "my-rule";
1925+
const prefix = "images/";
1926+
const conditionType = "Age";
1927+
const conditionValue = "30";
1928+
1929+
msw.use(
1930+
http.get(
1931+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
1932+
async ({ params }) => {
1933+
const { accountId, bucketName: bucketParam } = params;
1934+
expect(accountId).toEqual("some-account-id");
1935+
expect(bucketParam).toEqual(bucketName);
1936+
return HttpResponse.json(
1937+
createFetchResult({
1938+
rules: [],
1939+
})
1940+
);
1941+
},
1942+
{ once: true }
1943+
),
1944+
http.put(
1945+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
1946+
async ({ request, params }) => {
1947+
const { accountId, bucketName: bucketParam } = params;
1948+
expect(accountId).toEqual("some-account-id");
1949+
expect(bucketName).toEqual(bucketParam);
1950+
const requestBody = await request.json();
1951+
expect(requestBody).toEqual({
1952+
rules: [
1953+
{
1954+
id: ruleId,
1955+
enabled: true,
1956+
conditions: { prefix: prefix },
1957+
deleteObjectsTransition: {
1958+
condition: {
1959+
type: conditionType,
1960+
maxAge: 2592000,
1961+
},
1962+
},
1963+
},
1964+
],
1965+
});
1966+
return HttpResponse.json(createFetchResult({}));
1967+
},
1968+
{ once: true }
1969+
)
1970+
);
1971+
await runWrangler(
1972+
`r2 bucket lifecycle add ${bucketName} --id ${ruleId} --prefix ${prefix} --expire-days ${conditionValue}`
1973+
);
1974+
expect(std.out).toMatchInlineSnapshot(`
1975+
"Adding lifecycle rule 'my-rule' to bucket 'my-bucket'...
1976+
✨ Added lifecycle rule 'my-rule' to bucket 'my-bucket'."
1977+
`);
1978+
});
1979+
});
1980+
describe("remove", () => {
1981+
it("should remove a lifecycle rule as expected", async () => {
1982+
const bucketName = "my-bucket";
1983+
const ruleId = "my-rule";
1984+
const lifecycleRules = {
1985+
rules: [
1986+
{
1987+
id: ruleId,
1988+
enabled: true,
1989+
conditions: {},
1990+
},
1991+
],
1992+
};
1993+
msw.use(
1994+
http.get(
1995+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
1996+
async ({ params }) => {
1997+
const { accountId, bucketName: bucketParam } = params;
1998+
expect(accountId).toEqual("some-account-id");
1999+
expect(bucketParam).toEqual(bucketName);
2000+
return HttpResponse.json(createFetchResult(lifecycleRules));
2001+
},
2002+
{ once: true }
2003+
),
2004+
http.put(
2005+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
2006+
async ({ request, params }) => {
2007+
const { accountId, bucketName: bucketParam } = params;
2008+
expect(accountId).toEqual("some-account-id");
2009+
expect(bucketName).toEqual(bucketParam);
2010+
const requestBody = await request.json();
2011+
expect(requestBody).toEqual({
2012+
rules: [],
2013+
});
2014+
return HttpResponse.json(createFetchResult({}));
2015+
},
2016+
{ once: true }
2017+
)
2018+
);
2019+
await runWrangler(
2020+
`r2 bucket lifecycle remove ${bucketName} --id ${ruleId}`
2021+
);
2022+
expect(std.out).toMatchInlineSnapshot(`
2023+
"Removing lifecycle rule 'my-rule' from bucket 'my-bucket'...
2024+
Lifecycle rule 'my-rule' removed from bucket 'my-bucket'."
2025+
`);
2026+
});
2027+
it("should handle removing non-existant rule ID as expected", async () => {
2028+
const bucketName = "my-bucket";
2029+
const ruleId = "my-rule";
2030+
const lifecycleRules = {
2031+
rules: [],
2032+
};
2033+
msw.use(
2034+
http.get(
2035+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
2036+
async ({ params }) => {
2037+
const { accountId, bucketName: bucketParam } = params;
2038+
expect(accountId).toEqual("some-account-id");
2039+
expect(bucketParam).toEqual(bucketName);
2040+
return HttpResponse.json(createFetchResult(lifecycleRules));
2041+
},
2042+
{ once: true }
2043+
)
2044+
);
2045+
await expect(() =>
2046+
runWrangler(
2047+
`r2 bucket lifecycle remove ${bucketName} --id ${ruleId}`
2048+
)
2049+
).rejects.toThrowErrorMatchingInlineSnapshot(
2050+
"[Error: Lifecycle rule with ID 'my-rule' not found in configuration for 'my-bucket'.]"
2051+
);
2052+
});
2053+
});
2054+
describe("set", () => {
2055+
it("should set lifecycle configuration from a JSON file", async () => {
2056+
const bucketName = "my-bucket";
2057+
const filePath = "lifecycle-configuration.json";
2058+
const lifecycleRules = {
2059+
rules: [
2060+
{
2061+
id: "rule-1",
2062+
enabled: true,
2063+
conditions: {},
2064+
deleteObjectsTransition: {
2065+
condition: {
2066+
type: "Age",
2067+
maxAge: 2592000,
2068+
},
2069+
},
2070+
},
2071+
],
2072+
};
2073+
2074+
writeFileSync(filePath, JSON.stringify(lifecycleRules));
2075+
2076+
setIsTTY(true);
2077+
mockConfirm({
2078+
text: `Are you sure you want to overwrite all existing lifecycle rules for bucket '${bucketName}'?`,
2079+
result: true,
2080+
});
2081+
2082+
msw.use(
2083+
http.put(
2084+
"*/accounts/:accountId/r2/buckets/:bucketName/lifecycle",
2085+
async ({ request, params }) => {
2086+
const { accountId, bucketName: bucketParam } = params;
2087+
expect(accountId).toEqual("some-account-id");
2088+
expect(bucketName).toEqual(bucketParam);
2089+
const requestBody = await request.json();
2090+
expect(requestBody).toEqual({
2091+
...lifecycleRules,
2092+
});
2093+
return HttpResponse.json(createFetchResult({}));
2094+
},
2095+
{ once: true }
2096+
)
2097+
);
2098+
2099+
await runWrangler(
2100+
`r2 bucket lifecycle set ${bucketName} --file ${filePath}`
2101+
);
2102+
expect(std.out).toMatchInlineSnapshot(`
2103+
"Setting lifecycle configuration (1 rules) for bucket 'my-bucket'...
2104+
✨ Set lifecycle configuration for bucket 'my-bucket'."
2105+
`);
2106+
});
2107+
});
2108+
});
18722109
});
18732110

18742111
describe("r2 object", () => {

packages/wrangler/src/dialogs.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,47 @@ export async function select<Values extends string>(
130130
});
131131
return value;
132132
}
133+
134+
interface MultiSelectOptions<Values> {
135+
choices: SelectOption<Values>[];
136+
defaultOptions?: number[];
137+
}
138+
139+
export async function multiselect<Values extends string>(
140+
text: string,
141+
options: MultiSelectOptions<Values>
142+
): Promise<Values[]> {
143+
if (isNonInteractiveOrCI()) {
144+
if (options?.defaultOptions === undefined) {
145+
throw new NoDefaultValueProvided();
146+
}
147+
148+
const defaultTitles = options.defaultOptions.map(
149+
(index) => options.choices[index].title
150+
);
151+
logger.log(`? ${text}`);
152+
153+
logger.log(
154+
`🤖 ${chalk.dim(
155+
"Using default value(s) in non-interactive context:"
156+
)} ${chalk.white.bold(defaultTitles.join(", "))}`
157+
);
158+
return options.defaultOptions.map((index) => options.choices[index].value);
159+
}
160+
const { value } = await prompts({
161+
type: "multiselect",
162+
name: "value",
163+
message: text,
164+
choices: options.choices,
165+
instructions: false,
166+
hint: "- Space to select. Return to submit",
167+
onState: (state) => {
168+
if (state.aborted) {
169+
process.nextTick(() => {
170+
process.exit(1);
171+
});
172+
}
173+
},
174+
});
175+
return value;
176+
}

0 commit comments

Comments
 (0)