diff --git a/src/apphosting/backend.spec.ts b/src/apphosting/backend.spec.ts index eba297efd97..c5554e33fb4 100644 --- a/src/apphosting/backend.spec.ts +++ b/src/apphosting/backend.spec.ts @@ -12,6 +12,7 @@ import { promptLocation, setDefaultTrafficPolicy, ensureAppHostingComputeServiceAccount, + chooseBackends, getBackendForAmbiguousLocation, } from "./backend"; import * as deploymentTool from "../deploymentTool"; @@ -266,6 +267,85 @@ describe("apphosting setup functions", () => { }); }); + describe("chooseBackends", () => { + const backendChickenAsia = { + name: `projects/${projectId}/locations/asia-east1/backends/chicken`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + const backendChickenEurope = { + name: `projects/${projectId}/locations/europe-west4/backends/chicken`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + const backendChickenUS = { + name: `projects/${projectId}/locations/us-central1/backends/chicken`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + const backendCow = { + name: `projects/${projectId}/locations/asia-east1/backends/cow`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + const allBackends = [backendChickenAsia, backendChickenEurope, backendChickenUS, backendCow]; + + it("returns backend if only one is found", async () => { + listBackendsStub.resolves({ + backends: allBackends, + }); + + await expect(chooseBackends(projectId, "cow", /* prompt= */ "")).to.eventually.deep.equal([ + backendCow, + ]); + }); + + it("throws if --force is used when multiple backends are found", async () => { + listBackendsStub.resolves({ + backends: allBackends, + }); + + await expect( + chooseBackends(projectId, "chicken", /* prompt= */ "", /* force= */ true), + ).to.be.rejectedWith( + "Force cannot be used because multiple backends were found with ID chicken.", + ); + }); + + it("throws if no backend is found", async () => { + listBackendsStub.resolves({ + backends: allBackends, + }); + + await expect(chooseBackends(projectId, "farmer", /* prompt= */ "")).to.be.rejectedWith( + 'No backend named "farmer" found.', + ); + }); + + it("lets user choose backends when more than one share a name", async () => { + listBackendsStub.resolves({ + backends: allBackends, + }); + promptOnceStub.resolves(["chicken(asia-east1)", "chicken(europe-west4)"]); + + await expect(chooseBackends(projectId, "chicken", /* prompt= */ "")).to.eventually.deep.equal( + [backendChickenAsia, backendChickenEurope], + ); + }); + }); + describe("getBackendForAmbiguousLocation", () => { const backendFoo = { name: `projects/${projectId}/locations/${location}/backends/foo`, diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts index 8a5aa579901..33343c4030e 100644 --- a/src/apphosting/backend.ts +++ b/src/apphosting/backend.ts @@ -429,6 +429,64 @@ export async function getBackendForLocation( } } +/** + * Fetches backends of the given backendId and lets the user choose if more than one is found. + */ +export async function chooseBackends( + projectId: string, + backendId: string, + chooseBackendPrompt: string, + force?: boolean, +): Promise { + let { unreachable, backends } = await apphosting.listBackends(projectId, "-"); + if (unreachable && unreachable.length !== 0) { + logWarning( + `The following locations are currently unreachable: ${unreachable.join(",")}.\n` + + "If your backend is in one of these regions, please try again later.", + ); + } + backends = backends.filter( + (backend) => apphosting.parseBackendName(backend.name).id === backendId, + ); + if (backends.length === 0) { + throw new FirebaseError(`No backend named "${backendId}" found.`); + } + if (backends.length === 1) { + return backends; + } + + if (force) { + throw new FirebaseError( + `Force cannot be used because multiple backends were found with ID ${backendId}.`, + ); + } + const backendsByDisplay = new Map(); + backends.forEach((backend) => { + const { location, id } = apphosting.parseBackendName(backend.name); + backendsByDisplay.set(`${id}(${location})`, backend); + }); + const chosenBackendDisplays = await promptOnce({ + name: "backend", + type: "checkbox", + message: chooseBackendPrompt, + choices: Array.from(backendsByDisplay.keys(), (name) => { + return { + checked: false, + name: name, + value: name, + }; + }), + }); + const chosenBackends: apphosting.Backend[] = []; + chosenBackendDisplays.forEach((backendDisplay) => { + const backend = backendsByDisplay.get(backendDisplay); + if (backend !== undefined) { + chosenBackends.push(backend); + } + }); + return chosenBackends; +} + /** * Fetches a backend from the server. If there are multiple backends with that name (ie multi-regional backends), * prompts the user to disambiguate. If the force option is specified and multiple backends have the same name, diff --git a/src/commands/apphosting-backends-delete.ts b/src/commands/apphosting-backends-delete.ts index 624427ec468..6d1fa73c5bc 100644 --- a/src/commands/apphosting-backends-delete.ts +++ b/src/commands/apphosting-backends-delete.ts @@ -6,38 +6,25 @@ import { promptOnce } from "../prompt"; import * as utils from "../utils"; import * as apphosting from "../gcp/apphosting"; import { printBackendsTable } from "./apphosting-backends-list"; -import { - deleteBackendAndPoll, - getBackendForAmbiguousLocation, - getBackendForLocation, -} from "../apphosting/backend"; +import { deleteBackendAndPoll, chooseBackends } from "../apphosting/backend"; import * as ora from "ora"; export const command = new Command("apphosting:backends:delete ") .description("delete a Firebase App Hosting backend") - .option("-l, --location ", "specify the location of the backend") .withForce() .before(apphosting.ensureApiEnabled) .action(async (backendId: string, options: Options) => { const projectId = needProjectId(options); - if (options.location !== undefined) { - utils.logWarning("--location is being removed in the next major release."); - } - let location = (options.location as string) ?? "-"; - let backend: apphosting.Backend; - if (location === "-" || location === "") { - backend = await getBackendForAmbiguousLocation( - projectId, - backendId, - "Please select the location of the backend you'd like to delete:", - ); - location = apphosting.parseBackendName(backend.name).location; - } else { - backend = await getBackendForLocation(projectId, location, backendId); - } - utils.logWarning("You are about to permanently delete this backend:"); - printBackendsTable([backend]); + const backends = await chooseBackends( + projectId, + backendId, + "Please select the backends you'd like to delete:", + options.force, + ); + + utils.logWarning("You are about to permanently delete these backend(s):"); + printBackendsTable(backends); const confirmDeletion = await promptOnce( { @@ -52,14 +39,17 @@ export const command = new Command("apphosting:backends:delete ") return; } - const spinner = ora("Deleting backend...").start(); - try { - await deleteBackendAndPoll(projectId, location, backendId); - spinner.succeed(`Successfully deleted the backend: ${backendId}`); - } catch (err: unknown) { - spinner.stop(); - throw new FirebaseError(`Failed to delete backend: ${backendId}.`, { - original: getError(err), - }); + for (const b of backends) { + const { location, id } = apphosting.parseBackendName(b.name); + const spinner = ora(`Deleting backend ${id}(${location})...`).start(); + try { + await deleteBackendAndPoll(projectId, location, id); + spinner.succeed(`Successfully deleted the backend: ${id}(${location})`); + } catch (err: unknown) { + spinner.stop(); + throw new FirebaseError(`Failed to delete backend: ${id}(${location}). Please retry.`, { + original: getError(err), + }); + } } });