Skip to content

Commit da9b9c3

Browse files
committed
Support setting cleanup policy for multiple regions in a single command.
1 parent a6b7a53 commit da9b9c3

File tree

6 files changed

+391
-198
lines changed

6 files changed

+391
-198
lines changed

src/commands/functions-artifacts-setpolicy.ts

Lines changed: 179 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,23 @@ import { needProjectId } from "../projectUtils";
66
import { confirm } from "../prompt";
77
import { requirePermissions } from "../requirePermissions";
88
import { requireAuth } from "../requireAuth";
9-
import { logBullet, logSuccess } from "../utils";
9+
import { logBullet, logError, logSuccess, logWarning } from "../utils";
1010
import * as artifactregistry from "../gcp/artifactregistry";
1111
import * as artifacts from "../functions/artifacts";
12+
import * as prompts from "../deploy/functions/prompts";
1213

1314
/**
1415
* Command to set up a cleanup policy for Cloud Run functions container images in Artifact Registry
1516
*/
1617
export const command = new Command("functions:artifacts:setpolicy")
1718
.description(
1819
"Set up a cleanup policy for Cloud Run functions container images in Artifact Registry. " +
19-
"This policy will automatically delete old container images created during functions deployment.",
20+
"This policy will automatically delete old container images created during functions deployment.",
2021
)
2122
.option(
2223
"--location <location>",
2324
"Specify location to set up the cleanup policy. " +
24-
"If omitted, uses the default functions location.",
25+
"If omitted, uses the default functions location.",
2526
"us-central1",
2627
)
2728
.option(
@@ -46,65 +47,48 @@ export const command = new Command("functions:artifacts:setpolicy")
4647
if (options.days && options.none) {
4748
throw new FirebaseError("Cannot specify both --days and --none options.");
4849
}
50+
4951
const projectId = needProjectId(options);
50-
const location = options.location || "us-central1";
51-
let daysToKeep = parseInt(options.days || artifacts.DEFAULT_CLEANUP_DAYS, 10);
52+
const locationInput = options.location || "us-central1";
53+
const locations: string[] = locationInput.split(',').map((loc: string) => loc.trim());
54+
const uniqueLocations: string[] = [...new Set(locations)];
5255

53-
const repoPath = artifacts.makeRepoPath(projectId, location);
54-
let repository: artifactregistry.Repository;
55-
try {
56-
repository = await artifactregistry.getRepository(repoPath);
57-
} catch (err: any) {
58-
if (err.status === 404) {
59-
logBullet(`Repository '${repoPath}' does not exist in Artifact Registry.`);
60-
logBullet(
61-
`Please deploy your functions first using: ` +
62-
`${clc.bold(`firebase deploy --only functions`)}`,
63-
);
64-
return;
65-
}
66-
throw err;
56+
if (uniqueLocations.length === 0) {
57+
throw new FirebaseError("No valid locations specified");
6758
}
6859

69-
if (options.none) {
70-
const existingPolicy = artifacts.findExistingPolicy(repository);
60+
const checkResults = await artifacts.checkCleanupPolicy(projectId, uniqueLocations);
7161

72-
if (artifacts.hasCleanupOptOut(repository) && !existingPolicy) {
73-
logBullet(`Repository '${repoPath}' is already opted out from cleanup policies.`);
74-
logBullet(`No changes needed.`);
75-
return;
76-
}
62+
const statusToLocations = Object.entries(checkResults).reduce<Record<artifacts.CheckPolicyResult, string[]>>(
63+
(acc, [location, status]) => {
64+
acc[status] = acc[status] || [];
65+
acc[status]!.push(location);
66+
return acc;
67+
},
68+
{} as Record<artifacts.CheckPolicyResult, string[]>
69+
);
7770

78-
logBullet(`You are about to opt-out from cleanup policy for repository '${repoPath}'.`);
71+
const repoNotFound = statusToLocations['notFound'] || [];
72+
if (repoNotFound.length > 0) {
73+
logWarning(`Repository not found in ${repoNotFound.length > 1 ? 'locations' : 'location'} ${repoNotFound.join(", ")}`);
7974
logBullet(
80-
`This will prevent suggestions to set up cleanup policy during initialization and deployment.`,
75+
`Please deploy your functions first using: ` +
76+
`${clc.bold(`firebase deploy --only functions`)}`
8177
);
78+
}
8279

83-
if (existingPolicy) {
84-
logBullet(`Note: This will remove the existing cleanup policy from the repository.`);
85-
}
86-
87-
const confirmOptOut = await confirm({
88-
...options,
89-
default: true,
90-
message: "Do you want to continue?",
91-
});
92-
93-
if (!confirmOptOut) {
94-
throw new FirebaseError("Command aborted.", { exit: 1 });
95-
}
96-
97-
try {
98-
await artifacts.optOutRepository(repository);
99-
logSuccess(`Successfully opted out from cleanup policy for ${clc.bold(repoPath)}`);
100-
return;
101-
} catch (err: unknown) {
102-
throw new FirebaseError("Failed to opt-out from artifact registry cleanup policy", {
103-
original: err as Error,
104-
});
105-
}
80+
const repoErred = statusToLocations['errored'] || [];
81+
if (repoErred.length > 0) {
82+
logWarning(`Failed to retrieve state of ${repoErred.length > 1 ? 'repositories' : 'repository'} ${repoErred.join(", ")}`);
83+
logWarning(`Skipping setting up cleanup policy. Please try again later.`);
10684
}
10785

86+
if (options.none) {
87+
return await handleOptOut(projectId, statusToLocations, options);
88+
}
89+
90+
let daysToKeep = parseInt(options.days || artifacts.DEFAULT_CLEANUP_DAYS, 10);
91+
10892
if (isNaN(daysToKeep) || daysToKeep < 0) {
10993
throw new FirebaseError("Days must be a non-negative number");
11094
}
@@ -113,43 +97,131 @@ export const command = new Command("functions:artifacts:setpolicy")
11397
daysToKeep = 0.003472; // ~5 minutes in days
11498
}
11599

116-
if (artifacts.hasSameCleanupPolicy(repository, daysToKeep)) {
100+
return await handleSetupPolicies(projectId, statusToLocations, daysToKeep, options);
101+
102+
});
103+
104+
async function handleOptOut(projectId: string, checkResults: Record<artifacts.CheckPolicyResult, string[]>, options: any) {
105+
const locationsToOptOut = (['foundPolicy', 'noPolicy', 'optedOut'] as artifacts.CheckPolicyResult[])
106+
.flatMap(status => checkResults[status] || []);
107+
108+
if (locationsToOptOut.length === 0) {
109+
logBullet("No repositories to opt-out from cleanup policy");
110+
return;
111+
}
112+
113+
logBullet(
114+
`You are about to opt-out from cleanup policies for ${prompts.formatMany(locationsToOptOut, "repository", "repositories")}`
115+
);
116+
logBullet(
117+
`This will prevent suggestions to set up cleanup policy during initialization and deployment.`,
118+
);
119+
120+
const reposWithPolicy = checkResults['foundPolicy'] || [];
121+
if (reposWithPolicy.length > 0) {
122+
logBullet(`Note: This will remove the existing cleanup policy for ${prompts.formatMany(locationsToOptOut, "repository", "repositories")}.`);
123+
}
124+
125+
const confirmOptOut = await confirm({
126+
...options,
127+
default: true,
128+
message: `Do you want to opt-out from cleanup policies for ${locationsToOptOut.length} repositories?`,
129+
});
130+
131+
if (!confirmOptOut) {
132+
throw new FirebaseError("Command aborted.", { exit: 1 });
133+
}
134+
135+
const results = await artifacts.optOutRepositories(projectId, locationsToOptOut);
136+
137+
const locationsOptedOutSuccessfully = Object.entries(results)
138+
.filter(([_, result]) => result.status === 'success')
139+
.map(([location, _]) => location);
140+
141+
const locationsWithErrors = Object.entries(results)
142+
.filter(([_, result]) => result.status === 'errored')
143+
.map(([location, _]) => location);
144+
145+
if (locationsOptedOutSuccessfully.length > 0) {
146+
logSuccess(`Successfully opted out ${prompts.formatMany(locationsOptedOutSuccessfully, "location")} from cleanup policies.`);
147+
}
148+
149+
if (locationsWithErrors.length > 0) {
150+
const errs = Object.entries(results)
151+
.filter(([_, result]) => result.status === "errored")
152+
.map(([_, result]) => result.error)
153+
.filter(err => !!err);
154+
throw new FirebaseError(
155+
`Failed to complete opt-out for all repositories in ${prompts.formatMany(locationsWithErrors, "location")}.`,
156+
{ children: errs },
157+
);
158+
}
159+
}
160+
161+
async function handleSetupPolicies(
162+
projectId: string,
163+
checkResults: Record<artifacts.CheckPolicyResult, string[]>,
164+
daysToKeep: number,
165+
options: any
166+
) {
167+
const locationsNoPolicy = checkResults['noPolicy'] || [];
168+
const locationsWithPolicy = checkResults['foundPolicy'] || [];
169+
const locationsOptedOut = checkResults['optedOut'] || [];
170+
171+
const locationsToSetup: string[] = [];
172+
const locationsWithSamePolicy: string[] = [];
173+
const locationsNeedingUpdate: string[] = [];
174+
175+
for (const location of locationsWithPolicy) {
176+
const repo = await artifacts.getRepo(projectId, location);
177+
178+
if (artifacts.hasSameCleanupPolicy(repo, daysToKeep)) {
179+
locationsWithSamePolicy.push(location);
180+
continue;
181+
}
182+
locationsNeedingUpdate.push(location)
183+
locationsToSetup.push(location);
184+
}
185+
186+
locationsToSetup.push(...locationsNoPolicy, ...locationsOptedOut);
187+
188+
if (locationsToSetup.length === 0) {
189+
if (locationsWithSamePolicy.length > 0) {
117190
logBullet(
118-
`A cleanup policy already exists that deletes images older than ${clc.bold(daysToKeep)} days.`,
191+
`A cleanup policy already exists that deletes images older than ${daysToKeep} days for ${prompts.formatMany(locationsWithSamePolicy, "repository", "repositories")
192+
}.`
119193
);
120194
logBullet(`No changes needed.`);
121-
return;
195+
} else {
196+
logBullet("No repositories need cleanup policy setup.");
122197
}
198+
return;
199+
}
123200

201+
logBullet(
202+
`You are about to set up cleanup policies for ${prompts.formatMany(locationsToSetup, "repository", "repositories")}`
203+
);
204+
logBullet(
205+
`This will automatically delete container images that are older than ${daysToKeep} days`
206+
);
207+
logBullet(
208+
"This helps reduce storage costs by removing old container images that are no longer needed"
209+
);
210+
211+
if (locationsNeedingUpdate.length > 0) {
124212
logBullet(
125-
`You are about to set up a cleanup policy for Cloud Run functions container images in location ${clc.bold(location)}`,
126-
);
127-
logBullet(
128-
`This policy will automatically delete container images that are older than ${clc.bold(daysToKeep)} days`,
213+
`Note: This will update existing policies for ${prompts.formatMany(locationsNeedingUpdate, "repository", "repositories")}`
129214
);
215+
}
216+
217+
if (locationsOptedOut.length > 0) {
130218
logBullet(
131-
"This helps reduce storage costs by removing old container images that are no longer needed",
219+
`Note: ${prompts.formatMany(locationsOptedOut, "Repository", "Repositories")} ${locationsOptedOut.length === 1 ? "was" : "were"
220+
} previously opted out from cleanup policy. This action will remove the opt-out status.`
132221
);
222+
}
133223

134-
const existingPolicy = artifacts.findExistingPolicy(repository);
135-
136-
let isUpdate = false;
137-
if (existingPolicy && existingPolicy.condition?.olderThan) {
138-
const existingDays = artifacts.parseDaysFromPolicy(existingPolicy.condition.olderThan);
139-
if (existingDays) {
140-
isUpdate = true;
141-
logBullet(
142-
`Note: This will update an existing policy that currently deletes images older than ${clc.bold(existingDays)} days`,
143-
);
144-
}
145-
}
146-
147-
if (artifacts.hasCleanupOptOut(repository)) {
148-
logBullet(
149-
`Note: This repository was previously opted out from cleanup policy. This action will remove the opt-out status.`,
150-
);
151-
}
152-
224+
if (!options.force) {
153225
const confirmSetup = await confirm({
154226
...options,
155227
default: true,
@@ -159,17 +231,33 @@ export const command = new Command("functions:artifacts:setpolicy")
159231
if (!confirmSetup) {
160232
throw new FirebaseError("Command aborted.", { exit: 1 });
161233
}
234+
}
162235

163-
try {
164-
await artifacts.setCleanupPolicy(repository, daysToKeep);
165-
const successMessage = isUpdate
166-
? `Successfully updated cleanup policy to delete images older than ${clc.bold(daysToKeep)} days`
167-
: `Successfully set up cleanup policy that deletes images older than ${clc.bold(daysToKeep)} days`;
168-
logSuccess(successMessage);
169-
logBullet(`Cleanup policy has been set for ${clc.bold(repoPath)}`);
170-
} catch (err: unknown) {
171-
throw new FirebaseError("Failed to set up artifact registry cleanup policy", {
172-
original: err as Error,
173-
});
174-
}
175-
});
236+
const setPolicyResults = await artifacts.setCleanupPolicies(projectId, locationsToSetup, daysToKeep);
237+
238+
const locationsConfiguredSuccessfully = Object.entries(setPolicyResults)
239+
.filter(([_, result]) => result.status === "success")
240+
.map(([location, _]) => location);
241+
242+
const locationsWithSetupErrors = Object.entries(setPolicyResults)
243+
.filter(([_, result]) => result.status === "errored")
244+
.map(([location, _]) => location);
245+
246+
if (locationsConfiguredSuccessfully.length > 0) {
247+
logSuccess(
248+
`Successfully updated cleanup policy to delete images older than ${daysToKeep} days for ${prompts.formatMany(locationsConfiguredSuccessfully, "repository", "repositories")
249+
}`
250+
);
251+
}
252+
if (locationsWithSetupErrors.length > 0) {
253+
const errs = Object.entries(setPolicyResults)
254+
.filter(([_, result]) => result.status === "errored")
255+
.map(([_, result]) => result.error)
256+
.filter(err => !!err);
257+
258+
throw new FirebaseError(
259+
`Failed to set up cleanup policy in ${prompts.formatMany(locationsWithSetupErrors, "location")}. ` +
260+
{ children: errs }
261+
);
262+
}
263+
}

src/deploy/functions/prompts.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,17 @@ export async function promptForFunctionDeletion(
9797

9898
throw new FirebaseError(
9999
"The following functions are found in your project but do not exist in your local source code:\n" +
100-
deleteList +
101-
"\n\nAborting because deletion cannot proceed in non-interactive mode. To fix, manually delete the functions by running:\n" +
102-
clc.bold(deleteCommands),
100+
deleteList +
101+
"\n\nAborting because deletion cannot proceed in non-interactive mode. To fix, manually delete the functions by running:\n" +
102+
clc.bold(deleteCommands),
103103
);
104104
} else {
105105
logger.info(
106106
"\nThe following functions are found in your project but do not exist in your local source code:\n" +
107-
deleteList +
108-
"\n\nIf you are renaming a function or changing its region, it is recommended that you create the new " +
109-
"function first before deleting the old one to prevent event loss. For more info, visit " +
110-
clc.underline("https://firebase.google.com/docs/functions/manage-functions#modify" + "\n"),
107+
deleteList +
108+
"\n\nIf you are renaming a function or changing its region, it is recommended that you create the new " +
109+
"function first before deleting the old one to prevent event loss. For more info, visit " +
110+
clc.underline("https://firebase.google.com/docs/functions/manage-functions#modify" + "\n"),
111111
);
112112
shouldDeleteFns = await confirm({
113113
default: false,
@@ -282,7 +282,7 @@ export async function promptForCleanupPolicyDays(
282282
utils.logLabeledWarning(
283283
"functions",
284284
`No cleanup policy detected for repositories in ${locations.join(", ")}. ` +
285-
"This may result in a small monthly bill as container images accumulate over time.",
285+
"This may result in a small monthly bill as container images accumulate over time.",
286286
);
287287

288288
if (options.force) {
@@ -292,9 +292,9 @@ export async function promptForCleanupPolicyDays(
292292
if (options.nonInteractive) {
293293
throw new FirebaseError(
294294
`Functions successfully deployed but could not set up cleanup policy in ` +
295-
`${locations.length > 1 ? "locations" : "location"} ${locations.join(", ")}. ` +
296-
`Pass the --force option to automatically set up a cleanup policy or ` +
297-
"run 'firebase functions:artifacts:setpolicy' to manually set up a cleanup policy.",
295+
`${locations.length > 1 ? "locations" : "location"} ${locations.join(", ")}. ` +
296+
`Pass the --force option to automatically set up a cleanup policy or ` +
297+
"run 'firebase functions:artifacts:setpolicy' to manually set up a cleanup policy.",
298298
);
299299
}
300300

@@ -313,3 +313,8 @@ export async function promptForCleanupPolicyDays(
313313
});
314314
return parseInt(result);
315315
}
316+
317+
export function formatMany(items: string[], singular: string, plural: string = ""): string {
318+
const pluralStr = plural || `${singular}s`
319+
return `${items.length === 1 ? singular : pluralStr} ${items.join(", ")}`;
320+
}

0 commit comments

Comments
 (0)