diff --git a/src/apphosting/index.ts b/src/apphosting/index.ts index 80b3ab97f91..97869ba1b7f 100644 --- a/src/apphosting/index.ts +++ b/src/apphosting/index.ts @@ -422,3 +422,41 @@ export async function promptLocation( choices: allowedLocations, })) as string; } + +/** + * Fetches a backend from the server. If there are multiple backends with that name (ie multi-regional backends), + * prompts the user to disambiguate. + */ +export async function getBackendForAmbiguousLocation( + projectId: string, + backendId: string, +): Promise { + let { unreachable, backends } = await apphosting.listBackends(projectId, "-"); + if (unreachable && unreachable.length !== 0) { + logWarning( + `The following locations are currently unreachable: ${unreachable}.\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[0]; + } + + const backendsByLocation = new Map(); + backends.forEach((backend) => + backendsByLocation.set(apphosting.parseBackendName(backend.name).location, backend), + ); + const location = await promptOnce({ + name: "location", + type: "list", + message: "Please select the location of the backend you'd like to delete:", + choices: [...backendsByLocation.keys()], + }); + return backendsByLocation.get(location)!; +} diff --git a/src/commands/apphosting-backends-delete.ts b/src/commands/apphosting-backends-delete.ts index bf4e6194103..de81b4a6e80 100644 --- a/src/commands/apphosting-backends-delete.ts +++ b/src/commands/apphosting-backends-delete.ts @@ -6,36 +6,27 @@ import { promptOnce } from "../prompt"; import * as utils from "../utils"; import * as apphosting from "../gcp/apphosting"; import { printBackendsTable } from "./apphosting-backends-list"; -import { deleteBackendAndPoll, promptLocation } from "../apphosting"; +import { deleteBackendAndPoll, getBackendForAmbiguousLocation } from "../apphosting"; +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", "") + .option("-l, --location ", "specify the location of the backend", "-") .withForce() .before(apphosting.ensureApiEnabled) .action(async (backendId: string, options: Options) => { const projectId = needProjectId(options); let location = options.location as string; - - location = - location || - (await promptLocation( - projectId, - "Please select the location of the backend you'd like to delete:", - )); - let backend: apphosting.Backend; - try { - backend = await apphosting.getBackend(projectId, location, backendId); - } catch (err: any) { - throw new FirebaseError(`No backends found with given parameters. Command aborted.`, { - original: err, - }); + if (location === "-" || location === "") { + backend = await getBackendForAmbiguousLocation(projectId, backendId); + location = apphosting.parseBackendName(backend.name).location; + } else { + backend = await getBackendForLocation(projectId, location, backendId); } - utils.logWarning("You are about to permanently delete the backend:"); - const backends: apphosting.Backend[] = [backend]; - printBackendsTable(backends); + utils.logWarning("You are about to permanently delete this backend:"); + printBackendsTable([backend]); const confirmDeletion = await promptOnce( { @@ -47,18 +38,29 @@ export const command = new Command("apphosting:backends:delete ") options, ); if (!confirmDeletion) { - throw new FirebaseError("Deletion Aborted"); + return; } + const spinner = ora("Deleting backend...").start(); try { await deleteBackendAndPoll(projectId, location, backendId); - utils.logSuccess(`Successfully deleted the backend: ${backendId}`); + spinner.succeed(`Successfully deleted the backend: ${backendId}`); } catch (err: any) { - throw new FirebaseError( - `Failed to delete backend: ${backendId}. Please check the parameters you have provided.`, - { original: err }, - ); + spinner.stop(); + throw new FirebaseError(`Failed to delete backend: ${backendId}.`, { original: err }); } - - return backend; }); + +async function getBackendForLocation( + projectId: string, + location: string, + backendId: string, +): Promise { + try { + return await apphosting.getBackend(projectId, location, backendId); + } catch (err: any) { + throw new FirebaseError(`No backend named "${backendId}" found in ${location}.`, { + original: err, + }); + } +} diff --git a/src/commands/apphosting-backends-list.ts b/src/commands/apphosting-backends-list.ts index 83add9371cb..0b583e2d602 100644 --- a/src/commands/apphosting-backends-list.ts +++ b/src/commands/apphosting-backends-list.ts @@ -42,14 +42,13 @@ export function printBackendsTable(backends: apphosting.Backend[]): void { }); for (const backend of backends) { - // sample backend.name value: "projects//locations/us-central1/backends/" - const [backendLocation, , backendId] = backend.name.split("/").slice(3, 6); + const { location, id } = apphosting.parseBackendName(backend.name); table.push([ - backendId, + id, // sample repository value: "projects//locations/us-central1/connections//repositories/" backend.codebase?.repository?.split("/").pop() ?? "", backend.uri.startsWith("https:") ? backend.uri : "https://" + backend.uri, - backendLocation, + location, datetimeString(new Date(backend.updateTime)), ]); } diff --git a/src/gcp/apphosting.ts b/src/gcp/apphosting.ts index f005d47df2d..871d46f65d4 100644 --- a/src/gcp/apphosting.ts +++ b/src/gcp/apphosting.ts @@ -273,6 +273,17 @@ export function serviceAgentEmail(projectNumber: string): string { return `service-${projectNumber}@${P4SA_DOMAIN}`; } +/** Splits a backend resource name into its parts. */ +export function parseBackendName(backendName: string): { + projectName: string; + location: string; + id: string; +} { + // sample value: "projects//locations/us-central1/backends/" + const [, projectName, , location, , id] = backendName.split("/"); + return { projectName, location, id }; +} + /** * Creates a new Backend in a given project and location. */ diff --git a/src/test/apphosting/index.spec.ts b/src/test/apphosting/index.spec.ts index eb310243036..938181a5fe5 100644 --- a/src/test/apphosting/index.spec.ts +++ b/src/test/apphosting/index.spec.ts @@ -12,6 +12,7 @@ import { promptLocation, setDefaultTrafficPolicy, ensureAppHostingComputeServiceAccount, + getBackendForAmbiguousLocation, } from "../../apphosting/index"; import * as deploymentTool from "../../deploymentTool"; import { FirebaseError } from "../../error"; @@ -24,6 +25,7 @@ describe("apphosting setup functions", () => { let promptOnceStub: sinon.SinonStub; let pollOperationStub: sinon.SinonStub; let createBackendStub: sinon.SinonStub; + let listBackendsStub: sinon.SinonStub; let deleteBackendStub: sinon.SinonStub; let updateTrafficStub: sinon.SinonStub; let listLocationsStub: sinon.SinonStub; @@ -37,6 +39,9 @@ describe("apphosting setup functions", () => { createBackendStub = sinon .stub(apphosting, "createBackend") .throws("Unexpected createBackend call"); + listBackendsStub = sinon + .stub(apphosting, "listBackends") + .throws("Unexpected listBackends call"); deleteBackendStub = sinon .stub(apphosting, "deleteBackend") .throws("Unexpected deleteBackend call"); @@ -250,4 +255,62 @@ describe("apphosting setup functions", () => { }); }); }); + + describe.only("getBackendForAmbiguousLocation", () => { + const backendFoo = { + name: `projects/${projectId}/locations/${location}/backends/foo`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + const backendFooOtherRegion = { + name: `projects/${projectId}/locations/otherRegion/backends/foo`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + const backendBar = { + name: `projects/${projectId}/locations/${location}/backends/bar`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + }; + + it("throws if there are no matching backends", async () => { + listBackendsStub.resolves({ backends: [] }); + + await expect(getBackendForAmbiguousLocation(projectId, "baz")).to.be.rejectedWith( + /No backend named "baz" found./, + ); + }); + + it("returns unambiguous backend", async () => { + listBackendsStub.resolves({ backends: [backendFoo, backendBar] }); + + await expect(getBackendForAmbiguousLocation(projectId, "foo")).to.eventually.equal( + backendFoo, + ); + }); + + it("prompts for location if backend is ambiguous", async () => { + listBackendsStub.resolves({ backends: [backendFoo, backendFooOtherRegion, backendBar] }); + promptOnceStub.resolves(location); + + await expect(getBackendForAmbiguousLocation(projectId, "foo")).to.eventually.equal( + backendFoo, + ); + + expect(promptOnceStub).to.be.calledWith({ + name: "location", + type: "list", + message: "Please select the location of the backend you'd like to delete:", + choices: [location, "otherRegion"], + }); + }); + }); });