From 0b7a73d44fb6ce6f9e905a5a80a1f1ab9081c385 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 24 Mar 2025 16:31:59 -0700 Subject: [PATCH] Support setting cleanup policy for multiple regions in a single command. --- src/commands/functions-artifacts-setpolicy.ts | 287 ++++++++++++------ src/deploy/functions/prompts.ts | 5 + src/deploy/functions/release/index.ts | 39 ++- src/functions/artifacts.spec.ts | 100 +++--- src/functions/artifacts.ts | 127 +++++--- src/utils.ts | 13 +- 6 files changed, 395 insertions(+), 176 deletions(-) diff --git a/src/commands/functions-artifacts-setpolicy.ts b/src/commands/functions-artifacts-setpolicy.ts index 7476c936861..fd79db349e4 100644 --- a/src/commands/functions-artifacts-setpolicy.ts +++ b/src/commands/functions-artifacts-setpolicy.ts @@ -6,9 +6,10 @@ import { needProjectId } from "../projectUtils"; import { confirm } from "../prompt"; import { requirePermissions } from "../requirePermissions"; import { requireAuth } from "../requireAuth"; -import { logBullet, logSuccess } from "../utils"; +import { logBullet, logSuccess, logWarning } from "../utils"; import * as artifactregistry from "../gcp/artifactregistry"; import * as artifacts from "../functions/artifacts"; +import * as prompts from "../deploy/functions/prompts"; /** * Command to set up a cleanup policy for Cloud Run functions container images in Artifact Registry @@ -46,65 +47,54 @@ export const command = new Command("functions:artifacts:setpolicy") if (options.days && options.none) { throw new FirebaseError("Cannot specify both --days and --none options."); } + const projectId = needProjectId(options); - const location = options.location || "us-central1"; - let daysToKeep = parseInt(options.days || artifacts.DEFAULT_CLEANUP_DAYS, 10); + const locationInput = options.location || "us-central1"; + const locations: string[] = locationInput.split(",").map((loc: string) => loc.trim()); + const uniqueLocations: string[] = [...new Set(locations)]; - const repoPath = artifacts.makeRepoPath(projectId, location); - let repository: artifactregistry.Repository; - try { - repository = await artifactregistry.getRepository(repoPath); - } catch (err: any) { - if (err.status === 404) { - logBullet(`Repository '${repoPath}' does not exist in Artifact Registry.`); - logBullet( - `Please deploy your functions first using: ` + - `${clc.bold(`firebase deploy --only functions`)}`, - ); - return; - } - throw err; + if (uniqueLocations.length === 0) { + throw new FirebaseError("No valid locations specified"); } - if (options.none) { - const existingPolicy = artifacts.findExistingPolicy(repository); + const checkResults = await artifacts.checkCleanupPolicy(projectId, uniqueLocations); - if (artifacts.hasCleanupOptOut(repository) && !existingPolicy) { - logBullet(`Repository '${repoPath}' is already opted out from cleanup policies.`); - logBullet(`No changes needed.`); - return; - } + const statusToLocations = Object.entries(checkResults).reduce< + Record + >( + (acc, [location, status]) => { + acc[status] = acc[status] || []; + acc[status]!.push(location); + return acc; + }, + {} as Record, + ); - logBullet(`You are about to opt-out from cleanup policy for repository '${repoPath}'.`); + const repoNotFound = statusToLocations["notFound"] || []; + if (repoNotFound.length > 0) { + logWarning( + `Repository not found in ${repoNotFound.length > 1 ? "locations" : "location"} ${repoNotFound.join(", ")}`, + ); logBullet( - `This will prevent suggestions to set up cleanup policy during initialization and deployment.`, + `Please deploy your functions first using: ` + + `${clc.bold(`firebase deploy --only functions`)}`, ); + } - if (existingPolicy) { - logBullet(`Note: This will remove the existing cleanup policy from the repository.`); - } - - const confirmOptOut = await confirm({ - ...options, - default: true, - message: "Do you want to continue?", - }); - - if (!confirmOptOut) { - throw new FirebaseError("Command aborted.", { exit: 1 }); - } - - try { - await artifacts.optOutRepository(repository); - logSuccess(`Successfully opted out from cleanup policy for ${clc.bold(repoPath)}`); - return; - } catch (err: unknown) { - throw new FirebaseError("Failed to opt-out from artifact registry cleanup policy", { - original: err as Error, - }); - } + const repoErred = statusToLocations["errored"] || []; + if (repoErred.length > 0) { + logWarning( + `Failed to retrieve state of ${repoErred.length > 1 ? "repositories" : "repository"} ${repoErred.join(", ")}`, + ); + logWarning(`Skipping setting up cleanup policy. Please try again later.`); } + if (options.none) { + return await handleOptOut(projectId, statusToLocations, options); + } + + let daysToKeep = parseInt(options.days || artifacts.DEFAULT_CLEANUP_DAYS, 10); + if (isNaN(daysToKeep) || daysToKeep < 0) { throw new FirebaseError("Days must be a non-negative number"); } @@ -113,43 +103,143 @@ export const command = new Command("functions:artifacts:setpolicy") daysToKeep = 0.003472; // ~5 minutes in days } - if (artifacts.hasSameCleanupPolicy(repository, daysToKeep)) { - logBullet( - `A cleanup policy already exists that deletes images older than ${clc.bold(daysToKeep)} days.`, - ); - logBullet(`No changes needed.`); - return; - } + return await handleSetupPolicies(projectId, statusToLocations, daysToKeep, options); + }); + +async function handleOptOut( + projectId: string, + checkResults: Record, + options: any, +) { + const locationsToOptOut = ( + ["foundPolicy", "noPolicy", "optedOut"] as artifacts.CheckPolicyResult[] + ).flatMap((status) => checkResults[status] || []); + + if (locationsToOptOut.length === 0) { + logBullet("No repositories to opt-out from cleanup policy"); + return; + } + logBullet( + `You are about to opt-out from cleanup policies for ${prompts.formatMany(locationsToOptOut, "repository", "repositories")}`, + ); + logBullet( + `This will prevent suggestions to set up cleanup policy during initialization and deployment.`, + ); + + const reposWithPolicy = checkResults["foundPolicy"] || []; + if (reposWithPolicy.length > 0) { logBullet( - `You are about to set up a cleanup policy for Cloud Run functions container images in location ${clc.bold(location)}`, + `Note: This will remove the existing cleanup policy for ${prompts.formatMany(locationsToOptOut, "repository", "repositories")}.`, ); - logBullet( - `This policy will automatically delete container images that are older than ${clc.bold(daysToKeep)} days`, + } + + const confirmOptOut = await confirm({ + ...options, + default: true, + message: `Do you want to opt-out from cleanup policies for ${locationsToOptOut.length} repositories?`, + }); + + if (!confirmOptOut) { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } + + const results = await artifacts.optOutRepositories(projectId, locationsToOptOut); + + const locationsOptedOutSuccessfully = Object.entries(results) + .filter(([_, result]) => result.status === "success") + .map(([location, _]) => location); + + const locationsWithErrors = Object.entries(results) + .filter(([_, result]) => result.status === "errored") + .map(([location, _]) => location); + + if (locationsOptedOutSuccessfully.length > 0) { + logSuccess( + `Successfully opted out ${prompts.formatMany(locationsOptedOutSuccessfully, "location")} from cleanup policies.`, ); - logBullet( - "This helps reduce storage costs by removing old container images that are no longer needed", + } + + if (locationsWithErrors.length > 0) { + const errs = Object.entries(results) + .filter(([_, result]) => result.status === "errored") + .map(([_, result]) => result.error) + .filter((err) => !!err); + throw new FirebaseError( + `Failed to complete opt-out for all repositories in ${prompts.formatMany(locationsWithErrors, "location")}.`, + { children: errs }, ); + } +} + +async function handleSetupPolicies( + projectId: string, + checkResults: Record, + daysToKeep: number, + options: any, +) { + const locationsNoPolicy = checkResults["noPolicy"] || []; + const locationsWithPolicy = checkResults["foundPolicy"] || []; + const locationsOptedOut = checkResults["optedOut"] || []; + + const locationsToSetup: string[] = []; + const locationsWithSamePolicy: string[] = []; + const locationsNeedingUpdate: string[] = []; - const existingPolicy = artifacts.findExistingPolicy(repository); - - let isUpdate = false; - if (existingPolicy && existingPolicy.condition?.olderThan) { - const existingDays = artifacts.parseDaysFromPolicy(existingPolicy.condition.olderThan); - if (existingDays) { - isUpdate = true; - logBullet( - `Note: This will update an existing policy that currently deletes images older than ${clc.bold(existingDays)} days`, - ); - } + for (const location of locationsWithPolicy) { + const repo = await artifacts.getRepo(projectId, location); + + if (artifacts.hasSameCleanupPolicy(repo, daysToKeep)) { + locationsWithSamePolicy.push(location); + continue; } + locationsNeedingUpdate.push(location); + locationsToSetup.push(location); + } + + locationsToSetup.push(...locationsNoPolicy, ...locationsOptedOut); - if (artifacts.hasCleanupOptOut(repository)) { + if (locationsToSetup.length === 0) { + if (locationsWithSamePolicy.length > 0) { logBullet( - `Note: This repository was previously opted out from cleanup policy. This action will remove the opt-out status.`, + `A cleanup policy already exists that deletes images older than ${daysToKeep} days for ${prompts.formatMany( + locationsWithSamePolicy, + "repository", + "repositories", + )}.`, ); + logBullet(`No changes needed.`); + } else { + logBullet("No repositories need cleanup policy setup."); } + return; + } + logBullet( + `You are about to set up cleanup policies for ${prompts.formatMany(locationsToSetup, "repository", "repositories")}`, + ); + logBullet( + `This will automatically delete container images that are older than ${daysToKeep} days`, + ); + logBullet( + "This helps reduce storage costs by removing old container images that are no longer needed", + ); + + if (locationsNeedingUpdate.length > 0) { + logBullet( + `Note: This will update existing policies for ${prompts.formatMany(locationsNeedingUpdate, "repository", "repositories")}`, + ); + } + + if (locationsOptedOut.length > 0) { + logBullet( + `Note: ${prompts.formatMany(locationsOptedOut, "Repository", "Repositories")} ${ + locationsOptedOut.length === 1 ? "was" : "were" + } previously opted out from cleanup policy. This action will remove the opt-out status.`, + ); + } + + if (!options.force) { const confirmSetup = await confirm({ ...options, default: true, @@ -159,17 +249,40 @@ export const command = new Command("functions:artifacts:setpolicy") if (!confirmSetup) { throw new FirebaseError("Command aborted.", { exit: 1 }); } + } - try { - await artifacts.setCleanupPolicy(repository, daysToKeep); - const successMessage = isUpdate - ? `Successfully updated cleanup policy to delete images older than ${clc.bold(daysToKeep)} days` - : `Successfully set up cleanup policy that deletes images older than ${clc.bold(daysToKeep)} days`; - logSuccess(successMessage); - logBullet(`Cleanup policy has been set for ${clc.bold(repoPath)}`); - } catch (err: unknown) { - throw new FirebaseError("Failed to set up artifact registry cleanup policy", { - original: err as Error, - }); - } - }); + const setPolicyResults = await artifacts.setCleanupPolicies( + projectId, + locationsToSetup, + daysToKeep, + ); + + const locationsConfiguredSuccessfully = Object.entries(setPolicyResults) + .filter(([_, result]) => result.status === "success") + .map(([location, _]) => location); + + const locationsWithSetupErrors = Object.entries(setPolicyResults) + .filter(([_, result]) => result.status === "errored") + .map(([location, _]) => location); + + if (locationsConfiguredSuccessfully.length > 0) { + logSuccess( + `Successfully updated cleanup policy to delete images older than ${daysToKeep} days for ${prompts.formatMany( + locationsConfiguredSuccessfully, + "repository", + "repositories", + )}`, + ); + } + if (locationsWithSetupErrors.length > 0) { + const errs = Object.entries(setPolicyResults) + .filter(([_, result]) => result.status === "errored") + .map(([_, result]) => result.error) + .filter((err) => !!err); + + throw new FirebaseError( + `Failed to set up cleanup policy in ${prompts.formatMany(locationsWithSetupErrors, "location")}. ` + + { children: errs }, + ); + } +} diff --git a/src/deploy/functions/prompts.ts b/src/deploy/functions/prompts.ts index 89fe8e6b802..92ea7ac198d 100644 --- a/src/deploy/functions/prompts.ts +++ b/src/deploy/functions/prompts.ts @@ -313,3 +313,8 @@ export async function promptForCleanupPolicyDays( }); return parseInt(result); } + +export function formatMany(items: string[], singular: string, plural: string = ""): string { + const pluralStr = plural || `${singular}s`; + return `${items.length === 1 ? singular : pluralStr} ${items.join(", ")}`; +} diff --git a/src/deploy/functions/release/index.ts b/src/deploy/functions/release/index.ts index 851a664d673..87993b173d5 100644 --- a/src/deploy/functions/release/index.ts +++ b/src/deploy/functions/release/index.ts @@ -165,8 +165,13 @@ async function setupArtifactCleanupPolicies( return; } - const { locationsToSetup, locationsWithErrors: locationsWithCheckErrors } = - await artifacts.checkCleanupPolicy(projectId, locations); + const checkResults = await artifacts.checkCleanupPolicy(projectId, locations); + const locationsToSetup = Object.entries(checkResults) + .filter(([_, status]) => status === "noPolicy") + .map(([location]) => location); + const locationsWithCheckErrors = Object.entries(checkResults) + .filter(([_, status]) => status === "errored") + .map(([location]) => location); if (locationsToSetup.length === 0) { return; @@ -176,31 +181,47 @@ async function setupArtifactCleanupPolicies( utils.logLabeledBullet( "functions", - `Configuring cleanup policy for ${locationsToSetup.length > 1 ? "repositories" : "repository"} in ${locationsToSetup.join(", ")}. ` + + `Configuring cleanup policy for ${prompts.formatMany(locationsToSetup, "location")}. ` + `Images older than ${daysToKeep} days will be automatically deleted.`, ); - const { locationsWithPolicy, locationsWithErrors: locationsWithSetupErrors } = - await artifacts.setCleanupPolicies(projectId, locationsToSetup, daysToKeep); + const setPolicyResults = await artifacts.setCleanupPolicies( + projectId, + locationsToSetup, + daysToKeep, + ); + + const locationsConfiguredSuccessfully = Object.entries(setPolicyResults) + .filter(([_, result]) => result.status === "success") + .map(([location, _]) => location); + + const locationsWithSetupErrors = Object.entries(setPolicyResults) + .filter(([_, result]) => result.status === "errored") + .map(([location, _]) => location); utils.logLabeledBullet( "functions", - `Configured cleanup policy for ${locationsWithPolicy.length > 1 ? "repositories" : "repository"} in ${locationsToSetup.join(", ")}.`, + `Configured cleanup policy for ${prompts.formatMany(locationsConfiguredSuccessfully, "location")}.`, ); const locationsWithErrors = [...locationsWithCheckErrors, ...locationsWithSetupErrors]; if (locationsWithErrors.length > 0) { + const errs = Object.entries(setPolicyResults) + .filter(([_, result]) => result.status === "errored") + .map(([_, result]) => result.error) + .filter((err) => !!err); + utils.logLabeledWarning( "functions", - `Failed to set up cleanup policy for repositories in ${locationsWithErrors.length > 1 ? "regions" : "region"} ` + - `${locationsWithErrors.join(", ")}.` + + `Failed to set up cleanup policy for repositories in ${prompts.formatMany(locationsWithErrors, "location")} ` + "This could result in a small monthly bill as container images accumulate over time.", ); throw new FirebaseError( `Functions successfully deployed but could not set up cleanup policy in ` + - `${locationsWithErrors.length > 1 ? "regions" : "region"} ${locationsWithErrors.join(", ")}.` + + `${prompts.formatMany(locationsWithErrors, "location")}. ` + `Pass the --force option to automatically set up a cleanup policy or` + "run 'firebase functions:artifacts:setpolicy' to set up a cleanup policy to automatically delete old images.", + { children: errs }, ); } } diff --git a/src/functions/artifacts.spec.ts b/src/functions/artifacts.spec.ts index e6124c17c6d..58f8bdf30c4 100644 --- a/src/functions/artifacts.spec.ts +++ b/src/functions/artifacts.spec.ts @@ -543,12 +543,12 @@ describe("functions artifacts", () => { it("should return empty arrays when no locations are provided", async () => { const result = await artifacts.checkCleanupPolicy(projectId, []); - expect(result).to.deep.equal({ locationsToSetup: [], locationsWithErrors: [] }); + expect(result).to.deep.equal({}); expect(getRepoStub).not.to.have.been.called; }); it("should identify locations that need cleanup policies", async () => { - const locations = ["us-central1", "us-east1", "europe-west1"]; + const locations = ["us-central1", "us-east1", "europe-west1", "asia-northeast1"]; const repos: Record = { "us-central1": { @@ -561,7 +561,7 @@ describe("functions artifacts", () => { "us-east1": { name: artifacts.makeRepoPath(projectId, "us-east1"), format: "DOCKER", - description: "Repo with policy", + description: "Repo with clean up policy", createTime: "", updateTime: "", cleanupPolicies: { @@ -592,6 +592,16 @@ describe("functions artifacts", () => { }, }, }, + "asia-northeast1": { + name: artifacts.makeRepoPath(projectId, "asia-northeast1"), + format: "DOCKER", + description: "Repo with opt-out", + createTime: "", + updateTime: "", + labels: { + [artifacts.OPT_OUT_LABEL_KEY]: "true", + }, + }, }; getRepoStub.callsFake((projectId: string, location: string) => { @@ -600,50 +610,64 @@ describe("functions artifacts", () => { const result = await artifacts.checkCleanupPolicy(projectId, locations); - expect(result.locationsToSetup).to.deep.equal(["us-central1"]); - expect(result.locationsWithErrors).to.deep.equal([]); + expect(result).to.deep.equal({ + "us-central1": "noPolicy", + "us-east1": "foundPolicy", + "europe-west1": "foundPolicy", + "asia-northeast1": "optedOut", + }); }); - it("should identify locations with opt-out", async () => { - const locations = ["us-central1"]; - - const repo = { - name: artifacts.makeRepoPath(projectId, "us-central1"), - format: "DOCKER", - description: "Repo with opt-out", - createTime: "", - updateTime: "", - labels: { [artifacts.OPT_OUT_LABEL_KEY]: "true" }, - }; + it("should handle repository not found", async () => { + const locations = ["us-central1", "non-existent-location"]; - getRepoStub.resolves(repo); + getRepoStub.callsFake((projectId, location) => { + if (location === "non-existent-location") { + const error = new Error("Repository not found"); + (error as any).status = 404; + throw error; + } + return { + name: artifacts.makeRepoPath(projectId, location), + format: "DOCKER", + description: "Test repo", + createTime: "", + updateTime: "", + }; + }); const result = await artifacts.checkCleanupPolicy(projectId, locations); - expect(result.locationsToSetup).to.deep.equal([]); - expect(result.locationsWithErrors).to.deep.equal([]); + expect(result).to.deep.equal({ + "us-central1": "noPolicy", + "non-existent-location": "notFound", + }); }); it("should handle locations with errors", async () => { const locations = ["us-central1", "error-location"]; + const repo = { + name: artifacts.makeRepoPath(projectId, "us-central1"), + format: "DOCKER", + description: "Test repo", + createTime: "", + updateTime: "", + }; getRepoStub.callsFake((projectId, location) => { if (location === "error-location") { - throw new Error("Test error"); + const error = new Error("Test error"); + throw error; } - return { - name: artifacts.makeRepoPath(projectId, location), - format: "DOCKER", - description: "Test repo", - createTime: "", - updateTime: "", - }; + return repo; }); const result = await artifacts.checkCleanupPolicy(projectId, locations); - expect(result.locationsToSetup).to.deep.equal(["us-central1"]); - expect(result.locationsWithErrors).to.deep.equal(["error-location"]); + expect(result).to.deep.equal({ + "us-central1": "noPolicy", + "error-location": "errored", + }); }); }); @@ -666,7 +690,7 @@ describe("functions artifacts", () => { it("should return empty arrays when no locations are provided", async () => { const result = await artifacts.setCleanupPolicies(projectId, [], 1); - expect(result).to.deep.equal({ locationsWithPolicy: [], locationsWithErrors: [] }); + expect(result).to.deep.equal({}); expect(getRepoStub).not.to.have.been.called; }); @@ -698,8 +722,8 @@ describe("functions artifacts", () => { const result = await artifacts.setCleanupPolicies(projectId, locations, daysToKeep); expect(result).to.deep.equal({ - locationsWithPolicy: ["us-central1", "us-east1"], - locationsWithErrors: [], + "us-central1": { status: "success" }, + "us-east1": { status: "success" }, }); expect(setCleanupPolicyStub).to.have.been.calledTwice; @@ -728,10 +752,8 @@ describe("functions artifacts", () => { const result = await artifacts.setCleanupPolicies(projectId, locations, daysToKeep); - expect(result).to.deep.equal({ - locationsWithPolicy: ["us-central1"], - locationsWithErrors: ["error-location"], - }); + expect(result["us-central1"]).to.deep.equal({ status: "success" }); + expect(result["error-location"]).to.include({ status: "errored" }); expect(setCleanupPolicyStub).to.have.been.calledOnce; expect(setCleanupPolicyStub).to.have.been.calledWith(repo, daysToKeep); @@ -771,10 +793,8 @@ describe("functions artifacts", () => { const result = await artifacts.setCleanupPolicies(projectId, locations, daysToKeep); - expect(result).to.deep.equal({ - locationsWithPolicy: ["us-central1"], - locationsWithErrors: ["us-east1"], - }); + expect(result["us-central1"]).to.deep.equal({ status: "success" }); + expect(result["us-east1"]).to.include({ status: "errored" }); expect(getRepoStub).to.have.been.calledTwice; expect(setCleanupPolicyStub).to.have.been.calledTwice; diff --git a/src/functions/artifacts.ts b/src/functions/artifacts.ts index 9981b3b8c1b..d4bfc3ae142 100644 --- a/src/functions/artifacts.ts +++ b/src/functions/artifacts.ts @@ -208,57 +208,67 @@ export function hasCleanupOptOut(repo: artifactregistry.Repository): boolean { return !!(repo.labels && repo.labels[OPT_OUT_LABEL_KEY] === "true"); } +export type CheckPolicyResult = "noPolicy" | "foundPolicy" | "optedOut" | "notFound" | "errored"; + /** * Checks whether a clean up policy is required for Artifact Registry in given locations. + * + * @returns An object mapping each location to its cleanup policy status: + * - "noPolicy": Repository exists but has no cleanup policy and needs one + * - "foundPolicy": Repository exists and has a policy (has our policy or other policies) + * - "optedOut": Repository exists but has opted out + * - "notFound": Repository doesn't exist + * - "errored": Failed to check this location due to other errors */ export async function checkCleanupPolicy( projectId: string, locations: string[], -): Promise<{ locationsToSetup: string[]; locationsWithErrors: string[] }> { +): Promise> { if (locations.length === 0) { - return { locationsToSetup: [], locationsWithErrors: [] }; + return {}; } - - const checkRepos = await Promise.allSettled( + const checkResults = await Promise.allSettled( locations.map(async (location) => { try { const repository = await exports.getRepo(projectId, location); - const hasPolicy = !!findExistingPolicy(repository); - const hasOptOut = hasCleanupOptOut(repository); - const hasOtherPolicies = + if (hasCleanupOptOut(repository)) { + return "optedOut"; + } else if ( repository.cleanupPolicies && - Object.keys(repository.cleanupPolicies).some((key) => key !== CLEANUP_POLICY_ID); - - return { - location, - repository, - hasPolicy, - hasOptOut, - hasOtherPolicies, - }; - } catch (err) { - logger.debug(`Failed to check artifact cleanup policy for region ${location}:`, err); + Object.keys(repository.cleanupPolicies).length > 0 + ) { + return "foundPolicy"; + } else { + return "noPolicy"; + } + } catch (err: any) { + if (err.status === 404) { + return "notFound"; + } throw err; } }), ); - const locationsToSetup = []; - const locationsWithErrors = []; - - for (let i = 0; i < checkRepos.length; i++) { - const result = checkRepos[i]; + const results: Record = {}; + for (let i = 0; i < locations.length; i++) { + const result = checkResults[i]; + const location = locations[i]; if (result.status === "fulfilled") { - if (!(result.value.hasPolicy || result.value.hasOptOut || result.value.hasOtherPolicies)) { - locationsToSetup.push(result.value.location); - } + results[location] = result.value as CheckPolicyResult; } else { - locationsWithErrors.push(locations[i]); + logger.debug(`Failed to check policy for repository in ${location}: ${result.reason}`); + results[location] = "errored"; } } - return { locationsToSetup, locationsWithErrors }; + return results; } +export type SetPolicyResult = { + status: "success" | "errored"; + error?: Error; +}; + /** * Sets Artifact Registry cleanup policies for given locations. */ @@ -266,11 +276,10 @@ export async function setCleanupPolicies( projectId: string, locations: string[], daysToKeep: number, -): Promise<{ locationsWithPolicy: string[]; locationsWithErrors: string[] }> { - if (locations.length === 0) return { locationsWithPolicy: [], locationsWithErrors: [] }; +): Promise> { + if (locations.length === 0) return {}; - const locationsWithPolicy: string[] = []; - const locationsWithErrors: string[] = []; + const results: Record = {}; const setupRepos = await Promise.allSettled( locations.map(async (location) => { @@ -292,17 +301,57 @@ export async function setCleanupPolicies( const result = setupRepos[i]; if (result.status === "rejected") { logger.debug( - `Failed to set up artifact cleanup policy for region ${location}:`, + `Failed to set up artifact cleanup policy for location ${location}:`, result.reason, ); - locationsWithErrors.push(location); + results[location] = { status: "errored", error: result.reason }; } else { - locationsWithPolicy.push(location); + results[location] = { status: "success" }; } } - return { - locationsWithPolicy, - locationsWithErrors, - }; + return results; +} + +export type OptOutResult = { + status: "success" | "errored"; + error?: Error; +}; + +/* + * Opt out repositories in given locations from cleanup policies and delete any firebase-created cleanup policy + */ +export async function optOutRepositories( + projectId: string, + locations: string[], +): Promise> { + if (locations.length === 0) return {}; + const results: Record = {}; + + const optOutPromises = await Promise.allSettled( + locations.map(async (location) => { + try { + logger.debug(`Opting out artifact cleanup policy for repository in ${location}`); + const repo = await exports.getRepo(projectId, location); + await exports.optOutRepository(repo); + return location; + } catch (err: unknown) { + throw new FirebaseError("Failed to opt out artifact cleanup policy", { + original: err as Error, + }); + } + }), + ); + + for (let i = 0; i < locations.length; i++) { + const location = locations[i]; + const result = optOutPromises[i]; + if (result.status === "rejected") { + logger.debug(`Failed to opt out cleanup policy for location ${location}:`, result.reason); + results[location] = { status: "errored", error: result.reason }; + } else { + results[location] = { status: "success" }; + } + } + return results; } diff --git a/src/utils.ts b/src/utils.ts index 5ccf64eaa65..fdde4792bc3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -190,7 +190,7 @@ export function logLabeledBullet( } /** - * Log an info statement with a gray bullet at the start of the line. + * Log an warn statement with a gray bullet at the start of the line. */ export function logWarning( message: string, @@ -200,6 +200,17 @@ export function logWarning( logger[type](clc.yellow(clc.bold(`${WARNING_CHAR} `)), message, data); } +/** + * Log an error statement with a gray bullet at the start of the line. + */ +export function logError( + message: string, + type: LogLevel = "error", + data: LogDataOrUndefined = undefined, +): void { + logger[type](clc.red(clc.bold(`${ERROR_CHAR} `)), message, data); +} + /** * Log an info statement with a gray bullet at the start of the line. */