From 12b92577bf91771777f150aed7d16a3cf6a4cc5b Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Fri, 31 Oct 2025 22:20:48 +0000 Subject: [PATCH 01/25] adds listServices fn and adds trigger to endpointFromService fn --- src/gcp/runv2.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index ce4c0b7e2ed..84675a93dab 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -200,6 +200,34 @@ export async function updateService(service: Omit) return svc; } +export async function listServices(projectId: string): Promise { + let allServices: Service[] = []; + let pageToken: string | undefined = undefined; + + do { + const queryParams: Record = {}; + if (pageToken) { + queryParams["pageToken"] = pageToken; + } + + const res = await client.get<{ services?: Service[]; nextPageToken?: string }>( + `/projects/${projectId}/locations/-/services`, + { queryParams }, + ); + + if (res.status !== 200) { + throw new FirebaseError(`Failed to list services: ${res.status} ${res.body}`); + } + + if (res.body.services) { + allServices = allServices.concat(res.body.services); + } + pageToken = res.body.nextPageToken; + } while (pageToken); + + return allServices; +} + // TODO: Replace with real version: function functionNameToServiceName(id: string): string { return id.toLowerCase().replace(/_/g, "-"); @@ -486,10 +514,11 @@ export function endpointFromService(service: Omit) env.find((e) => e.name === FUNCTION_TARGET_ENV)?.value || service.annotations?.[FUNCTION_TARGET_ANNOTATION] || service.annotations?.[FUNCTION_ID_ANNOTATION] || + id, - - // TODO: trigger types. - httpsTrigger: {}, + ...(service.annotations?.[TRIGGER_TYPE_ANNOTATION] === "HTTP_TRIGGER" + ? { httpsTrigger: {} } + : { eventTrigger: { eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown", retry: false } }), }; proto.renameIfPresent(endpoint, service.template, "concurrency", "containerConcurrency"); proto.renameIfPresent(endpoint, service.labels || {}, "codebase", CODEBASE_LABEL); From 0a307225db8452dbb8382f5bc5b19391ec7bb37e Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Fri, 31 Oct 2025 22:21:34 +0000 Subject: [PATCH 02/25] updates functions-list to use listServices to use cloud run api instead --- src/commands/functions-list.ts | 37 +++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index ad7ed657533..a1178889499 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -1,31 +1,50 @@ import { Command } from "../command"; -import * as args from "../deploy/functions/args"; import { needProjectId } from "../projectUtils"; import { Options } from "../options"; import { requirePermissions } from "../requirePermissions"; import * as backend from "../deploy/functions/backend"; import { logger } from "../logger"; import * as Table from "cli-table3"; +import { listServices, endpointFromService } from "../gcp/runv2"; export const command = new Command("functions:list") .description("list all deployed functions in your Firebase project") - .before(requirePermissions, ["cloudfunctions.functions.list"]) + .before(requirePermissions, ["run.services.list"]) .action(async (options: Options) => { - const context = { - projectId: needProjectId(options), - } as args.Context; - const existing = await backend.existingBackend(context); - const endpointsList = backend.allEndpoints(existing).sort(backend.compareFunctions); + const projectId = needProjectId(options); + + let services: any[] = []; + try { + logger.info(`Listing functions in project ${projectId}...`); + services = await listServices(projectId); + } catch (err: any) { + logger.debug(`Failed to list services:`, err); + logger.error( + `Failed to list functions. Ensure you have the Cloud Run Admin API enabled and the necessary permissions.`, + ); + return []; + } + + if (services.length === 0) { + logger.info(`No functions found in project ${projectId}.`); + return []; + } + + const endpointsList = services + .map((service) => endpointFromService(service)) + .sort(backend.compareFunctions); + const table = new Table({ - head: ["Function", "Version", "Trigger", "Location", "Memory", "Runtime"], + head: ["Function", "Platform", "Trigger", "Location", "Memory", "Runtime"], style: { head: ["yellow"] }, }); + for (const endpoint of endpointsList) { const trigger = backend.endpointTriggerType(endpoint); const availableMemoryMb = endpoint.availableMemoryMb || "---"; const entry = [ endpoint.id, - endpoint.platform === "gcfv2" ? "v2" : "v1", + endpoint.platform, trigger, endpoint.region, availableMemoryMb, From df189eead7f4dd6e21b1d39788b134856f664869 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Fri, 31 Oct 2025 22:26:12 +0000 Subject: [PATCH 03/25] adds listServices tests --- src/gcp/runv2.spec.ts | 79 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 4d5f0b200f6..334c2c42ce1 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -1,9 +1,12 @@ import { expect } from "chai"; +import * as sinon from "sinon"; import * as runv2 from "./runv2"; import * as backend from "../deploy/functions/backend"; import { latest } from "../deploy/functions/runtimes/supported"; import { CODEBASE_LABEL } from "../functions/constants"; +import { Client } from "../apiv2"; +import { FirebaseError } from "../error"; describe("runv2", () => { const PROJECT_ID = "project-id"; @@ -201,13 +204,11 @@ describe("runv2", () => { const service: Omit = { ...BASE_RUN_SERVICE, name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, - labels: { - [runv2.RUNTIME_LABEL]: latest("nodejs"), - }, annotations: { ...BASE_RUN_SERVICE.annotations, [runv2.FUNCTION_ID_ANNOTATION]: FUNCTION_ID, // Using FUNCTION_ID_ANNOTATION as primary source for id [runv2.FUNCTION_TARGET_ANNOTATION]: "customEntryPoint", + [runv2.TRIGGER_TYPE_ANNOTATION]: "HTTP_TRIGGER", }, template: { containers: [ @@ -239,6 +240,7 @@ describe("runv2", () => { httpsTrigger: {}, labels: { [runv2.RUNTIME_LABEL]: latest("nodejs"), + [runv2.CLIENT_NAME_LABEL]: "firebase-functions", }, environmentVariables: {}, secretEnvironmentVariables: [], @@ -259,6 +261,7 @@ describe("runv2", () => { ...BASE_RUN_SERVICE.annotations, [runv2.FUNCTION_ID_ANNOTATION]: FUNCTION_ID, // Using FUNCTION_ID_ANNOTATION as primary source for id [runv2.FUNCTION_TARGET_ANNOTATION]: "customEntryPoint", + [runv2.TRIGGER_TYPE_ANNOTATION]: "HTTP_TRIGGER", }, template: { containers: [ @@ -379,7 +382,7 @@ describe("runv2", () => { const service: runv2.Service = JSON.parse(JSON.stringify(BASE_RUN_SERVICE)); service.template.containers![0].env = [ { name: "FOO", value: "bar" }, - { + { name: "MY_SECRET", valueSource: { secretKeyRef: { @@ -442,7 +445,7 @@ describe("runv2", () => { entryPoint: SERVICE_ID, // No FUNCTION_TARGET_ANNOTATION availableMemoryMb: 128, cpu: 0.5, - httpsTrigger: {}, + eventTrigger: { eventType: "unknown", retry: false }, labels: {}, environmentVariables: {}, secretEnvironmentVariables: [], @@ -452,4 +455,70 @@ describe("runv2", () => { expect(runv2.endpointFromService(service)).to.deep.equal(expectedEndpoint); }); }); + + describe("listServices", () => { + let sandbox: sinon.SinonSandbox; + let getStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getStub = sandbox.stub(Client.prototype, "get"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should return a list of services", async () => { + const mockServices = [ + { name: "service1" }, + { name: "service2" }, + ]; + getStub.resolves({ status: 200, body: { services: mockServices } }); + + const services = await runv2.listServices(PROJECT_ID); + + expect(services).to.deep.equal(mockServices); + expect(getStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/-/services`, + { queryParams: {} }, + ); + }); + + it("should handle pagination", async () => { + const mockServices1 = [{ name: "service1" }]; + const mockServices2 = [{ name: "service2" }]; + getStub + .onFirstCall() + .resolves({ status: 200, body: { services: mockServices1, nextPageToken: "nextPage" } }); + getStub + .onSecondCall() + .resolves({ status: 200, body: { services: mockServices2 } }); + + const services = await runv2.listServices(PROJECT_ID); + + expect(services).to.deep.equal([...mockServices1, ...mockServices2]); + expect(getStub).to.have.been.calledTwice; + expect(getStub.firstCall).to.have.been.calledWithExactly( + `/projects/${PROJECT_ID}/locations/-/services`, + { queryParams: {} }, + ); + expect(getStub.secondCall).to.have.been.calledWithExactly( + `/projects/${PROJECT_ID}/locations/-/services`, + { queryParams: { pageToken: "nextPage" } }, + ); + }); + + it("should throw an error if the API call fails", async () => { + getStub.resolves({ status: 500, body: "Internal Server Error" }); + + try { + await runv2.listServices(PROJECT_ID); + expect.fail("Should have thrown an error"); + } catch (err: any) { + expect(err).to.be.instanceOf(FirebaseError); + expect(err.message).to.contain("Failed to list services: 500 Internal Server Error"); + } + }); + }); }); From 66cf473c7211955aaa43311849b7a7a5b865a18b Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Fri, 31 Oct 2025 22:36:31 +0000 Subject: [PATCH 04/25] fix linting errors --- CHANGELOG.md | 1 + src/gcp/runv2.spec.ts | 11 +++-------- src/gcp/runv2.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c32eaf81d2..a396efd800d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Upgraded functions::list command to use cloud run api (#9425) - Fixed an issue where the emulator would fail to start when using `firebase-functions` v7+ (#9401). - Added `functions.list_functions` as a MCP tool (#9369) - Added AI Logic to `firebase init` CLI command and `firebase_init` MCP tool. (#9185) diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 334c2c42ce1..0a814b4b355 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -382,7 +382,7 @@ describe("runv2", () => { const service: runv2.Service = JSON.parse(JSON.stringify(BASE_RUN_SERVICE)); service.template.containers![0].env = [ { name: "FOO", value: "bar" }, - { + { name: "MY_SECRET", valueSource: { secretKeyRef: { @@ -470,10 +470,7 @@ describe("runv2", () => { }); it("should return a list of services", async () => { - const mockServices = [ - { name: "service1" }, - { name: "service2" }, - ]; + const mockServices = [{ name: "service1" }, { name: "service2" }]; getStub.resolves({ status: 200, body: { services: mockServices } }); const services = await runv2.listServices(PROJECT_ID); @@ -491,9 +488,7 @@ describe("runv2", () => { getStub .onFirstCall() .resolves({ status: 200, body: { services: mockServices1, nextPageToken: "nextPage" } }); - getStub - .onSecondCall() - .resolves({ status: 200, body: { services: mockServices2 } }); + getStub.onSecondCall().resolves({ status: 200, body: { services: mockServices2 } }); const services = await runv2.listServices(PROJECT_ID); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 84675a93dab..11cf772bd3c 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -514,11 +514,15 @@ export function endpointFromService(service: Omit) env.find((e) => e.name === FUNCTION_TARGET_ENV)?.value || service.annotations?.[FUNCTION_TARGET_ANNOTATION] || service.annotations?.[FUNCTION_ID_ANNOTATION] || - id, ...(service.annotations?.[TRIGGER_TYPE_ANNOTATION] === "HTTP_TRIGGER" ? { httpsTrigger: {} } - : { eventTrigger: { eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown", retry: false } }), + : { + eventTrigger: { + eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown", + retry: false, + }, + }), }; proto.renameIfPresent(endpoint, service.template, "concurrency", "containerConcurrency"); proto.renameIfPresent(endpoint, service.labels || {}, "codebase", CODEBASE_LABEL); From f6ef8c0fccb2d1db885ae59eae96cc398449cc77 Mon Sep 17 00:00:00 2001 From: brittanycho Date: Mon, 3 Nov 2025 14:16:09 -0800 Subject: [PATCH 05/25] Update src/commands/functions-list.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/commands/functions-list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index a1178889499..1357930e009 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -13,7 +13,7 @@ export const command = new Command("functions:list") .action(async (options: Options) => { const projectId = needProjectId(options); - let services: any[] = []; + let services: Service[] = []; try { logger.info(`Listing functions in project ${projectId}...`); services = await listServices(projectId); From d9fd843c659a729feebd735beab3b9e844d040ab Mon Sep 17 00:00:00 2001 From: brittanycho Date: Mon, 3 Nov 2025 14:16:37 -0800 Subject: [PATCH 06/25] Update src/gcp/runv2.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/gcp/runv2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 11cf772bd3c..caf26e16b13 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -216,7 +216,7 @@ export async function listServices(projectId: string): Promise { ); if (res.status !== 200) { - throw new FirebaseError(`Failed to list services: ${res.status} ${res.body}`); + throw new FirebaseError(`Failed to list services: ${res.status} ${JSON.stringify(res.body)}`); } if (res.body.services) { From 84725cf08354b2bb09d137f95dda3825b81bc049 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Mon, 3 Nov 2025 23:41:15 +0000 Subject: [PATCH 07/25] corrects import error --- src/commands/functions-list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 1357930e009..ef6c59a7d8b 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -5,7 +5,7 @@ import { requirePermissions } from "../requirePermissions"; import * as backend from "../deploy/functions/backend"; import { logger } from "../logger"; import * as Table from "cli-table3"; -import { listServices, endpointFromService } from "../gcp/runv2"; +import { listServices, endpointFromService, Service } from "../gcp/runv2"; export const command = new Command("functions:list") .description("list all deployed functions in your Firebase project") From 6a4545747bac2b2f7d1898c6bd4b48c27a6f17b7 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Tue, 4 Nov 2025 01:05:42 +0000 Subject: [PATCH 08/25] add relevant labelSelectors --- src/commands/functions-list.ts | 4 +++- src/gcp/runv2.spec.ts | 2 +- src/gcp/runv2.ts | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index ef6c59a7d8b..0585c2c0494 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -16,7 +16,9 @@ export const command = new Command("functions:list") let services: Service[] = []; try { logger.info(`Listing functions in project ${projectId}...`); - services = await listServices(projectId); + const v2Services = await listServices(projectId, "goog-managed-by=cloudfunctions"); + const runServices = await listServices(projectId, "goog-managed-by=firebase-functions"); + services = [...v2Services, ...runServices]; } catch (err: any) { logger.debug(`Failed to list services:`, err); logger.error( diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 0a814b4b355..683495469d6 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -512,7 +512,7 @@ describe("runv2", () => { expect.fail("Should have thrown an error"); } catch (err: any) { expect(err).to.be.instanceOf(FirebaseError); - expect(err.message).to.contain("Failed to list services: 500 Internal Server Error"); + expect(err.message).to.contain('Failed to list services: 500 "Internal Server Error"'); } }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index caf26e16b13..1d1ecddc2c9 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -200,7 +200,7 @@ export async function updateService(service: Omit) return svc; } -export async function listServices(projectId: string): Promise { +export async function listServices(projectId: string, filter?: string): Promise { let allServices: Service[] = []; let pageToken: string | undefined = undefined; @@ -209,6 +209,9 @@ export async function listServices(projectId: string): Promise { if (pageToken) { queryParams["pageToken"] = pageToken; } + if (filter) { + queryParams["labelSelector"] = filter; + } const res = await client.get<{ services?: Service[]; nextPageToken?: string }>( `/projects/${projectId}/locations/-/services`, From 9cedf12175bef898335aa95be2751e5de26134e9 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Wed, 5 Nov 2025 22:49:43 +0000 Subject: [PATCH 09/25] adding v1 fns in --- src/commands/functions-list.ts | 46 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 0585c2c0494..6aa10dcc216 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -1,43 +1,53 @@ import { Command } from "../command"; +import * as args from "../deploy/functions/args"; import { needProjectId } from "../projectUtils"; import { Options } from "../options"; import { requirePermissions } from "../requirePermissions"; import * as backend from "../deploy/functions/backend"; import { logger } from "../logger"; import * as Table from "cli-table3"; -import { listServices, endpointFromService, Service } from "../gcp/runv2"; +import { listServices, endpointFromService } from "../gcp/runv2"; export const command = new Command("functions:list") .description("list all deployed functions in your Firebase project") - .before(requirePermissions, ["run.services.list"]) + .before(requirePermissions, ["cloudfunctions.functions.list", "run.services.list"]) .action(async (options: Options) => { const projectId = needProjectId(options); + const context = { + projectId, + } as args.Context; - let services: Service[] = []; + let v1Endpoints: backend.Endpoint[] = []; try { - logger.info(`Listing functions in project ${projectId}...`); - const v2Services = await listServices(projectId, "goog-managed-by=cloudfunctions"); - const runServices = await listServices(projectId, "goog-managed-by=firebase-functions"); - services = [...v2Services, ...runServices]; + const existing = await backend.existingBackend(context); + v1Endpoints = backend.allEndpoints(existing); } catch (err: any) { - logger.debug(`Failed to list services:`, err); - logger.error( - `Failed to list functions. Ensure you have the Cloud Run Admin API enabled and the necessary permissions.`, + logger.debug(`Failed to list v1 functions:`, err); + logger.warn( + `Failed to list v1 functions. Ensure you have the Cloud Functions API enabled and the necessary permissions.`, + ); + } + + let v2Endpoints: backend.Endpoint[] = []; + try { + const services = await listServices(projectId); + v2Endpoints = services.map((service) => endpointFromService(service)); + } catch (err: any) { + logger.debug(`Failed to list v2 functions:`, err); + logger.warn( + `Failed to list v2 functions. Ensure you have the Cloud Run Admin API enabled and the necessary permissions.`, ); - return []; } - if (services.length === 0) { + const endpointsList = [...v1Endpoints, ...v2Endpoints].sort(backend.compareFunctions); + + if (endpointsList.length === 0) { logger.info(`No functions found in project ${projectId}.`); return []; } - const endpointsList = services - .map((service) => endpointFromService(service)) - .sort(backend.compareFunctions); - const table = new Table({ - head: ["Function", "Platform", "Trigger", "Location", "Memory", "Runtime"], + head: ["Function", "Version", "Trigger", "Location", "Memory", "Runtime"], style: { head: ["yellow"] }, }); @@ -46,7 +56,7 @@ export const command = new Command("functions:list") const availableMemoryMb = endpoint.availableMemoryMb || "---"; const entry = [ endpoint.id, - endpoint.platform, + endpoint.platform === "gcfv2" ? "v2" : endpoint.platform === "run" ? "run" : "v1", trigger, endpoint.region, availableMemoryMb, From 481d02fa7d154d8c40950a9bcbe93168b5290e79 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Wed, 5 Nov 2025 23:06:06 +0000 Subject: [PATCH 10/25] adding in deduplication --- src/commands/functions-list.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 6aa10dcc216..711a1506fc2 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -39,7 +39,17 @@ export const command = new Command("functions:list") ); } - const endpointsList = [...v1Endpoints, ...v2Endpoints].sort(backend.compareFunctions); + const endpointMap = new Map(); + for (const endpoint of v1Endpoints) { + const key = `${endpoint.region}/${endpoint.id}`; + endpointMap.set(key, endpoint); + } + for (const endpoint of v2Endpoints) { + const key = `${endpoint.region}/${endpoint.id}`; + endpointMap.set(key, endpoint); + } + + const endpointsList = Array.from(endpointMap.values()).sort(backend.compareFunctions); if (endpointsList.length === 0) { logger.info(`No functions found in project ${projectId}.`); From d4ed0f9e2bcb47d51139833b87a8711dc0aa3615 Mon Sep 17 00:00:00 2001 From: brittanycho Date: Wed, 5 Nov 2025 15:10:53 -0800 Subject: [PATCH 11/25] Update src/gcp/runv2.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/gcp/runv2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 1d1ecddc2c9..8cafe73790c 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -223,7 +223,7 @@ export async function listServices(projectId: string, filter?: string): Promise< } if (res.body.services) { - allServices = allServices.concat(res.body.services); + allServices.push(...res.body.services); } pageToken = res.body.nextPageToken; } while (pageToken); From 2de426f30233e707b1d880b7cc739ab0aa3c4aaf Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Wed, 5 Nov 2025 23:32:20 +0000 Subject: [PATCH 12/25] linting --- src/gcp/runv2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 8cafe73790c..eecddb8cb93 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -201,7 +201,7 @@ export async function updateService(service: Omit) } export async function listServices(projectId: string, filter?: string): Promise { - let allServices: Service[] = []; + const allServices: Service[] = []; let pageToken: string | undefined = undefined; do { From d1986ac8b2feb105429a63b4f95e9e342ca529a2 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Tue, 11 Nov 2025 01:09:40 +0000 Subject: [PATCH 13/25] follow-up on feedback: move parts of implementation to backend.ts, add relevant tests for backend.ts, add tests to runv2.spec.ts, call listService with label for v2 functions --- src/commands/functions-list.ts | 39 ++++++++++++---------------- src/deploy/functions/backend.spec.ts | 19 ++++++++++++++ src/deploy/functions/backend.ts | 16 ++++++++++++ src/gcp/runv2.spec.ts | 13 ++++++++++ src/gcp/runv2.ts | 3 ++- 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 711a1506fc2..7ffe28cf32b 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -1,12 +1,19 @@ import { Command } from "../command"; import * as args from "../deploy/functions/args"; import { needProjectId } from "../projectUtils"; -import { Options } from "../options"; import { requirePermissions } from "../requirePermissions"; import * as backend from "../deploy/functions/backend"; import { logger } from "../logger"; import * as Table from "cli-table3"; -import { listServices, endpointFromService } from "../gcp/runv2"; +import { Options } from "../options"; +import { FunctionsPlatform } from "../deploy/functions/backend"; + +type PLATFORM_DISPLAY_NAME = "v1" | "v2" | "run"; +const PLATFORM_TO_DISPLAY_NAME: Record = { + gcfv1: "v1", + gcfv2: "v2", + run: "run", +}; export const command = new Command("functions:list") .description("list all deployed functions in your Firebase project") @@ -28,30 +35,16 @@ export const command = new Command("functions:list") ); } - let v2Endpoints: backend.Endpoint[] = []; - try { - const services = await listServices(projectId); - v2Endpoints = services.map((service) => endpointFromService(service)); - } catch (err: any) { - logger.debug(`Failed to list v2 functions:`, err); - logger.warn( - `Failed to list v2 functions. Ensure you have the Cloud Run Admin API enabled and the necessary permissions.`, - ); - } - const endpointMap = new Map(); for (const endpoint of v1Endpoints) { const key = `${endpoint.region}/${endpoint.id}`; endpointMap.set(key, endpoint); } - for (const endpoint of v2Endpoints) { - const key = `${endpoint.region}/${endpoint.id}`; - endpointMap.set(key, endpoint); - } - const endpointsList = Array.from(endpointMap.values()).sort(backend.compareFunctions); + const endpoints = Array.from(endpointMap.values()); + endpoints.sort(backend.compareFunctions); - if (endpointsList.length === 0) { + if (endpoints.length === 0) { logger.info(`No functions found in project ${projectId}.`); return []; } @@ -59,14 +52,14 @@ export const command = new Command("functions:list") const table = new Table({ head: ["Function", "Version", "Trigger", "Location", "Memory", "Runtime"], style: { head: ["yellow"] }, - }); + }) as any; - for (const endpoint of endpointsList) { + for (const endpoint of endpoints) { const trigger = backend.endpointTriggerType(endpoint); const availableMemoryMb = endpoint.availableMemoryMb || "---"; const entry = [ endpoint.id, - endpoint.platform === "gcfv2" ? "v2" : endpoint.platform === "run" ? "run" : "v1", + PLATFORM_TO_DISPLAY_NAME[endpoint.platform] || "v1", trigger, endpoint.region, availableMemoryMb, @@ -75,5 +68,5 @@ export const command = new Command("functions:list") table.push(entry); } logger.info(table.toString()); - return endpointsList; + return endpoints; }); diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index c3bd12d536b..7b8a42fced0 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -6,6 +6,7 @@ import * as args from "./args"; import * as backend from "./backend"; import * as gcf from "../../gcp/cloudfunctions"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; +import * as runv2 from "../../gcp/runv2"; import * as utils from "../../utils"; import * as projectConfig from "../../functions/projectConfig"; @@ -126,17 +127,20 @@ describe("Backend", () => { describe("existing backend", () => { let listAllFunctions: sinon.SinonStub; let listAllFunctionsV2: sinon.SinonStub; + let listServices: sinon.SinonStub; let logLabeledWarning: sinon.SinonSpy; beforeEach(() => { listAllFunctions = sinon.stub(gcf, "listAllFunctions").rejects("Unexpected call"); listAllFunctionsV2 = sinon.stub(gcfV2, "listAllFunctions").rejects("Unexpected v2 call"); + listServices = sinon.stub(runv2, "listServices").resolves([]); logLabeledWarning = sinon.spy(utils, "logLabeledWarning"); }); afterEach(() => { listAllFunctions.restore(); listAllFunctionsV2.restore(); + listServices.restore(); logLabeledWarning.restore(); }); @@ -318,6 +322,21 @@ describe("Backend", () => { expect(have).to.deep.equal(want); }); + + it("should list services with correct filter", async () => { + listAllFunctions.onFirstCall().resolves({ + functions: [], + unreachable: [], + }); + listAllFunctionsV2.onFirstCall().resolves({ + functions: [], + unreachable: [], + }); + + await backend.existingBackend(newContext()); + + expect(listServices).to.have.been.calledWith(sinon.match.any, "goog-managed-by=cloudfunctions"); + }); }); describe("checkAvailability", () => { diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 7c378cb3eea..f4a371d8a24 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -1,5 +1,6 @@ import * as gcf from "../../gcp/cloudfunctions"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; +import { listServices, endpointFromService } from "../../gcp/runv2"; import * as utils from "../../utils"; import { Runtime } from "./runtimes/supported"; import { FirebaseError } from "../../error"; @@ -559,6 +560,21 @@ async function loadExistingBackend(ctx: Context): Promise { } } + try { + const runServices = await listServices(ctx.projectId, "goog-managed-by=cloudfunctions"); + for (const service of runServices) { + const endpoint = endpointFromService(service); + existingBackend.endpoints[endpoint.region] = existingBackend.endpoints[endpoint.region] || {}; + existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint; + } + } catch (err: any) { + utils.logLabeledWarning( + "functions", + `Failed to list Cloud Run services: ${err.message}. Ensure you have the Cloud Run Admin API enabled and the necessary permissions.`, + ); + unreachableRegions.run = ["unknown"]; // Indicate that Run services could not be listed + } + ctx.existingBackend = existingBackend; ctx.unreachableRegions = unreachableRegions; return ctx.existingBackend; diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 683495469d6..33ffab34908 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -515,5 +515,18 @@ describe("runv2", () => { expect(err.message).to.contain('Failed to list services: 500 "Internal Server Error"'); } }); + + it("should filter by labelSelector", async () => { + const mockServices = [{ name: "service1" }]; + getStub.resolves({ status: 200, body: { services: mockServices } }); + + const services = await runv2.listServices(PROJECT_ID, "foo=bar"); + + expect(services).to.deep.equal(mockServices); + expect(getStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/-/services`, + { queryParams: { filter: 'labels."foo"="bar"' } }, + ); + }); }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index eecddb8cb93..615f4bf58c7 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -210,7 +210,8 @@ export async function listServices(projectId: string, filter?: string): Promise< queryParams["pageToken"] = pageToken; } if (filter) { - queryParams["labelSelector"] = filter; + const parts = filter.split("="); + queryParams["filter"] = `labels."${parts[0]}"="${parts[1]}"`; } const res = await client.get<{ services?: Service[]; nextPageToken?: string }>( From 8f1c06d3bc93a1001463d16494a4a374b2249e79 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Tue, 11 Nov 2025 01:17:22 +0000 Subject: [PATCH 14/25] correct lint error --- src/deploy/functions/backend.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index 7b8a42fced0..49d3db2d32f 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -335,7 +335,10 @@ describe("Backend", () => { await backend.existingBackend(newContext()); - expect(listServices).to.have.been.calledWith(sinon.match.any, "goog-managed-by=cloudfunctions"); + expect(listServices).to.have.been.calledWith( + sinon.match.any, + "goog-managed-by=cloudfunctions", + ); }); }); From 1cef2a6e8aa46a657a44dcfd106b237abffb4696 Mon Sep 17 00:00:00 2001 From: brittanycho Date: Mon, 10 Nov 2025 17:21:38 -0800 Subject: [PATCH 15/25] Update src/commands/functions-list.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/commands/functions-list.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 7ffe28cf32b..6e655b80b43 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -24,24 +24,17 @@ export const command = new Command("functions:list") projectId, } as args.Context; - let v1Endpoints: backend.Endpoint[] = []; + let endpoints: backend.Endpoint[] = []; try { const existing = await backend.existingBackend(context); - v1Endpoints = backend.allEndpoints(existing); + endpoints = backend.allEndpoints(existing); } catch (err: any) { - logger.debug(`Failed to list v1 functions:`, err); + logger.debug(`Failed to list functions:`, err); logger.warn( - `Failed to list v1 functions. Ensure you have the Cloud Functions API enabled and the necessary permissions.`, + `Failed to list functions. Ensure you have the Cloud Functions and Cloud Run APIs enabled and the necessary permissions.`, ); } - const endpointMap = new Map(); - for (const endpoint of v1Endpoints) { - const key = `${endpoint.region}/${endpoint.id}`; - endpointMap.set(key, endpoint); - } - - const endpoints = Array.from(endpointMap.values()); endpoints.sort(backend.compareFunctions); if (endpoints.length === 0) { From 0de7514c080f9d37dd960b899553b59a6e40726d Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Tue, 11 Nov 2025 01:53:28 +0000 Subject: [PATCH 16/25] addressing feedback on parsing --- src/gcp/runv2.spec.ts | 13 +++++++++++++ src/gcp/runv2.ts | 12 ++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 33ffab34908..95108c7f4e2 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -528,5 +528,18 @@ describe("runv2", () => { { queryParams: { filter: 'labels."foo"="bar"' } }, ); }); + + it("should filter by labelSelector with equals sign in value", async () => { + const mockServices = [{ name: "service1" }]; + getStub.resolves({ status: 200, body: { services: mockServices } }); + + const services = await runv2.listServices(PROJECT_ID, "foo=bar=baz"); + + expect(services).to.deep.equal(mockServices); + expect(getStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/-/services`, + { queryParams: { filter: 'labels."foo"="bar=baz"' } }, + ); + }); }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 615f4bf58c7..574f0fe4224 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -200,7 +200,7 @@ export async function updateService(service: Omit) return svc; } -export async function listServices(projectId: string, filter?: string): Promise { +export async function listServices(projectId: string, labelSelector?: string): Promise { const allServices: Service[] = []; let pageToken: string | undefined = undefined; @@ -209,9 +209,13 @@ export async function listServices(projectId: string, filter?: string): Promise< if (pageToken) { queryParams["pageToken"] = pageToken; } - if (filter) { - const parts = filter.split("="); - queryParams["filter"] = `labels."${parts[0]}"="${parts[1]}"`; + if (labelSelector) { + const parts = labelSelector.split("="); + const key = parts.shift(); + if (key) { + const value = parts.join("="); + queryParams["filter"] = `labels."${key}"="${value}"`; + } } const res = await client.get<{ services?: Service[]; nextPageToken?: string }>( From c1f2e7b905b77929081d9ccb7731cfda25ea8607 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Tue, 11 Nov 2025 23:17:28 +0000 Subject: [PATCH 17/25] adjust how v2 functions are being called from listServices --- src/deploy/functions/backend.ts | 2 +- src/gcp/runv2.spec.ts | 65 ++++++++++++++++++++++----------- src/gcp/runv2.ts | 19 +++++----- 3 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index f4a371d8a24..71f58e3e73a 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -561,7 +561,7 @@ async function loadExistingBackend(ctx: Context): Promise { } try { - const runServices = await listServices(ctx.projectId, "goog-managed-by=cloudfunctions"); + const runServices = await listServices(ctx.projectId); for (const service of runServices) { const endpoint = endpointFromService(service); existingBackend.endpoints[endpoint.region] = existingBackend.endpoints[endpoint.region] || {}; diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 95108c7f4e2..541bc9b7bfc 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -470,7 +470,16 @@ describe("runv2", () => { }); it("should return a list of services", async () => { - const mockServices = [{ name: "service1" }, { name: "service2" }]; + const mockServices = [ + { + name: "service1", + labels: { "goog-managed-by": "cloud-functions" }, + }, + { + name: "service2", + labels: { "goog-managed-by": "firebase-functions" }, + }, + ]; getStub.resolves({ status: 200, body: { services: mockServices } }); const services = await runv2.listServices(PROJECT_ID); @@ -483,8 +492,18 @@ describe("runv2", () => { }); it("should handle pagination", async () => { - const mockServices1 = [{ name: "service1" }]; - const mockServices2 = [{ name: "service2" }]; + const mockServices1 = [ + { + name: "service1", + labels: { "goog-managed-by": "cloud-functions" }, + }, + ]; + const mockServices2 = [ + { + name: "service2", + labels: { "goog-managed-by": "firebase-functions" }, + }, + ]; getStub .onFirstCall() .resolves({ status: 200, body: { services: mockServices1, nextPageToken: "nextPage" } }); @@ -516,29 +535,33 @@ describe("runv2", () => { } }); - it("should filter by labelSelector", async () => { - const mockServices = [{ name: "service1" }]; - getStub.resolves({ status: 200, body: { services: mockServices } }); - - const services = await runv2.listServices(PROJECT_ID, "foo=bar"); - - expect(services).to.deep.equal(mockServices); - expect(getStub).to.have.been.calledOnceWithExactly( - `/projects/${PROJECT_ID}/locations/-/services`, - { queryParams: { filter: 'labels."foo"="bar"' } }, - ); - }); - - it("should filter by labelSelector with equals sign in value", async () => { - const mockServices = [{ name: "service1" }]; + it("should filter for gcfv2 and firebase-managed services", async () => { + const mockServices = [ + { + name: "service1", + labels: { "goog-managed-by": "cloud-functions" }, + }, + { + name: "service2", + labels: { "goog-managed-by": "firebase-functions" }, + }, + { + name: "service3", + labels: { "goog-managed-by": "other" }, + }, + { + name: "service4", + labels: {}, + }, + ]; getStub.resolves({ status: 200, body: { services: mockServices } }); - const services = await runv2.listServices(PROJECT_ID, "foo=bar=baz"); + const services = await runv2.listServices(PROJECT_ID); - expect(services).to.deep.equal(mockServices); + expect(services).to.deep.equal([mockServices[0], mockServices[1]]); expect(getStub).to.have.been.calledOnceWithExactly( `/projects/${PROJECT_ID}/locations/-/services`, - { queryParams: { filter: 'labels."foo"="bar=baz"' } }, + { queryParams: {} }, ); }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 574f0fe4224..98227ed85ed 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -200,7 +200,7 @@ export async function updateService(service: Omit) return svc; } -export async function listServices(projectId: string, labelSelector?: string): Promise { +export async function listServices(projectId: string): Promise { const allServices: Service[] = []; let pageToken: string | undefined = undefined; @@ -209,14 +209,6 @@ export async function listServices(projectId: string, labelSelector?: string): P if (pageToken) { queryParams["pageToken"] = pageToken; } - if (labelSelector) { - const parts = labelSelector.split("="); - const key = parts.shift(); - if (key) { - const value = parts.join("="); - queryParams["filter"] = `labels."${key}"="${value}"`; - } - } const res = await client.get<{ services?: Service[]; nextPageToken?: string }>( `/projects/${projectId}/locations/-/services`, @@ -228,7 +220,14 @@ export async function listServices(projectId: string, labelSelector?: string): P } if (res.body.services) { - allServices.push(...res.body.services); + for (const service of res.body.services) { + if ( + service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || + service.labels?.[CLIENT_NAME_LABEL] === "firebase-functions" + ) { + allServices.push(service); + } + } } pageToken = res.body.nextPageToken; } while (pageToken); From bd8ea09076ea9786803bc88cc89e02691df38081 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Tue, 11 Nov 2025 23:25:06 +0000 Subject: [PATCH 18/25] add updated tests to backend.spec.ts --- src/deploy/functions/backend.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index 49d3db2d32f..e4da058c9dd 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -332,13 +332,11 @@ describe("Backend", () => { functions: [], unreachable: [], }); + const context = { projectId: "project" } as args.Context; - await backend.existingBackend(newContext()); + await backend.existingBackend(context); - expect(listServices).to.have.been.calledWith( - sinon.match.any, - "goog-managed-by=cloudfunctions", - ); + expect(listServices).to.have.been.calledWith("project"); }); }); From 86ef3f602a3c23ce053d95d295ba6835451235cc Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Wed, 12 Nov 2025 04:12:05 +0000 Subject: [PATCH 19/25] addresses feedback from code review --- src/commands/functions-list.ts | 2 +- src/deploy/functions/args.ts | 1 - src/deploy/functions/backend.spec.ts | 119 +++++++++-------------- src/deploy/functions/backend.ts | 38 ++------ src/deploy/hosting/convertConfig.spec.ts | 1 - 5 files changed, 54 insertions(+), 107 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 6e655b80b43..18582720083 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -31,7 +31,7 @@ export const command = new Command("functions:list") } catch (err: any) { logger.debug(`Failed to list functions:`, err); logger.warn( - `Failed to list functions. Ensure you have the Cloud Functions and Cloud Run APIs enabled and the necessary permissions.`, + `Failed to list functions: ${err.message}. Ensure you have the Cloud Functions and Cloud Run APIs enabled and the necessary permissions.`, ); } diff --git a/src/deploy/functions/args.ts b/src/deploy/functions/args.ts index ca02fd4874d..be1ffb21c01 100644 --- a/src/deploy/functions/args.ts +++ b/src/deploy/functions/args.ts @@ -50,7 +50,6 @@ export interface Context { existingBackendPromise?: Promise; unreachableRegions?: { gcfV1: string[]; - gcfV2: string[]; run: string[]; }; diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index e4da058c9dd..c1096483132 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -167,10 +167,6 @@ describe("Backend", () => { ], unreachable: ["region"], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); const firstBackend = await backend.existingBackend(context); const secondBackend = await backend.existingBackend(context); @@ -178,7 +174,7 @@ describe("Backend", () => { expect(firstBackend).to.deep.equal(secondBackend); expect(listAllFunctions).to.be.calledOnce; - expect(listAllFunctionsV2).to.be.calledOnce; + expect(listServices).to.be.calledOnce; }); it("should translate functions", async () => { @@ -191,10 +187,7 @@ describe("Backend", () => { ], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); const have = await backend.existingBackend(newContext()); expect(have).to.deep.equal(backend.of({ ...ENDPOINT, httpsTrigger: {} })); @@ -205,7 +198,7 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.throws( + listServices.throws( new FirebaseError("HTTP Error: 500, Internal Error", { status: 500 }), ); @@ -224,7 +217,7 @@ describe("Backend", () => { ], unreachable: [], }); - listAllFunctionsV2.throws( + listServices.throws( new FirebaseError("HTTP Error: 404, Method not found", { status: 404 }), ); @@ -271,24 +264,10 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [HAVE_CLOUD_FUNCTION_V2], - unreachable: [], - }); + listServices.onFirstCall().resolves([]); const have = await backend.existingBackend(newContext()); - expect(have).to.deep.equal( - backend.of({ - ...ENDPOINT, - platform: "gcfv2", - concurrency: 80, - cpu: 1, - httpsTrigger: {}, - runServiceId: HAVE_CLOUD_FUNCTION_V2.serviceConfig?.service, - source: HAVE_CLOUD_FUNCTION_V2.buildConfig.source, - uri: HAVE_CLOUD_FUNCTION_V2.serviceConfig?.uri, - }), - ); + expect(have).to.deep.equal(backend.empty()); }); it("should deduce features of scheduled functions", async () => { @@ -307,10 +286,7 @@ describe("Backend", () => { ], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); const have = await backend.existingBackend(newContext()); const want = backend.of({ ...ENDPOINT, @@ -328,10 +304,7 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); const context = { projectId: "project" } as args.Context; await backend.existingBackend(context); @@ -353,15 +326,12 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); await backend.checkAvailability(newContext(), backend.empty()); expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; + expect(listServices).to.have.been.called; expect(logLabeledWarning).to.not.have.been.called; }); @@ -370,15 +340,12 @@ describe("Backend", () => { functions: [], unreachable: ["region"], }); - listAllFunctionsV2.resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); await backend.checkAvailability(newContext(), backend.empty()); expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; + expect(listServices).to.have.been.called; expect(logLabeledWarning).to.have.been.called; }); @@ -387,15 +354,20 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: ["region"], - }); + listServices.onFirstCall().resolves([]); + // Simulate unreachable run/v2 regions by manually setting context + const context = newContext(); - await backend.checkAvailability(newContext(), backend.empty()); + await backend.existingBackend(context); + + // Manually set the run region as unreachable to test the warning + if (!context.unreachableRegions) { + context.unreachableRegions = { gcfV1: [], run: [] }; + } + context.unreachableRegions.run = ["region"]; + + await backend.checkAvailability(context, backend.empty()); - expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; expect(logLabeledWarning).to.have.been.called; }); @@ -404,10 +376,7 @@ describe("Backend", () => { functions: [], unreachable: ["region"], }); - listAllFunctionsV2.resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); const want = backend.of({ ...ENDPOINT, httpsTrigger: {} }); await expect(backend.checkAvailability(newContext(), want)).to.eventually.be.rejectedWith( FirebaseError, @@ -420,17 +389,22 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: ["region"], - }); + listServices.onFirstCall().resolves([]); const want: backend.Backend = backend.of({ ...ENDPOINT, platform: "gcfv2", httpsTrigger: {}, }); - await expect(backend.checkAvailability(newContext(), want)).to.eventually.be.rejectedWith( + // Manually set the run region as unreachable to simulate unavailability + const context = newContext(); + await backend.existingBackend(context); + if (!context.unreachableRegions) { + context.unreachableRegions = { gcfV1: [], run: [] }; + } + context.unreachableRegions.run = ["region"]; + + await expect(backend.checkAvailability(context, want)).to.eventually.be.rejectedWith( FirebaseError, /The following Cloud Functions V2 regions are currently unreachable:/, ); @@ -441,16 +415,20 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: ["us-central1"], - }); + listServices.resolves([]); const want = backend.of({ ...ENDPOINT, httpsTrigger: {} }); - await backend.checkAvailability(newContext(), want); + const context = newContext(); + await backend.existingBackend(context); + if (!context.unreachableRegions) { + context.unreachableRegions = { gcfV1: [], run: [] }; + } + context.unreachableRegions.run = ["us-central1"]; + + await backend.checkAvailability(context, want); expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; + expect(listServices).to.have.been.called; expect(logLabeledWarning).to.have.been.called; }); @@ -459,16 +437,13 @@ describe("Backend", () => { functions: [], unreachable: ["us-central1"], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); const want: backend.Backend = backend.of({ ...ENDPOINT, httpsTrigger: {} }); await backend.checkAvailability(newContext(), want); expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; + expect(listServices).to.have.been.called; expect(logLabeledWarning).to.have.been.called; }); }); diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 71f58e3e73a..1fbff256637 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -1,6 +1,6 @@ import * as gcf from "../../gcp/cloudfunctions"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; -import { listServices, endpointFromService } from "../../gcp/runv2"; +import * as run from "../../gcp/runv2"; import * as utils from "../../utils"; import { Runtime } from "./runtimes/supported"; import { FirebaseError } from "../../error"; @@ -532,7 +532,6 @@ async function loadExistingBackend(ctx: Context): Promise { }; const unreachableRegions = { gcfV1: [] as string[], - gcfV2: [] as string[], run: [] as string[], }; const gcfV1Results = await gcf.listAllFunctions(ctx.projectId); @@ -543,27 +542,11 @@ async function loadExistingBackend(ctx: Context): Promise { } unreachableRegions.gcfV1 = gcfV1Results.unreachable; - let gcfV2Results; - try { - gcfV2Results = await gcfV2.listAllFunctions(ctx.projectId); - for (const apiFunction of gcfV2Results.functions) { - const endpoint = gcfV2.endpointFromFunction(apiFunction); - existingBackend.endpoints[endpoint.region] = existingBackend.endpoints[endpoint.region] || {}; - existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint; - } - unreachableRegions.gcfV2 = gcfV2Results.unreachable; - } catch (err: any) { - if (err.status === 404 && err.message?.toLowerCase().includes("method not found")) { - // customer has preview enabled without allowlist set - } else { - throw err; - } - } try { - const runServices = await listServices(ctx.projectId); + const runServices = await run.listServices(ctx.projectId); for (const service of runServices) { - const endpoint = endpointFromService(service); + const endpoint = run.endpointFromService(service); existingBackend.endpoints[endpoint.region] = existingBackend.endpoints[endpoint.region] || {}; existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint; } @@ -603,7 +586,7 @@ export async function checkAvailability(context: Context, want: Backend): Promis const neededUnreachableV1 = context.unreachableRegions?.gcfV1.filter((region) => gcfV1Regions.has(region), ); - const neededUnreachableV2 = context.unreachableRegions?.gcfV2.filter((region) => + const neededUnreachableV2 = context.unreachableRegions?.run.filter((region) => gcfV2Regions.has(region), ); if (neededUnreachableV1?.length) { @@ -631,21 +614,12 @@ export async function checkAvailability(context: Context, want: Backend): Promis ); } - if (context.unreachableRegions?.gcfV2.length) { - utils.logLabeledWarning( - "functions", - "The following Cloud Functions V2 regions are currently unreachable:\n" + - context.unreachableRegions.gcfV2.join("\n") + - "\nCloud Functions in these regions won't be deleted.", - ); - } - if (context.unreachableRegions?.run.length) { utils.logLabeledWarning( "functions", - "The following Cloud Run regions are currently unreachable:\n" + + "The following Cloud Functions V2 regions are currently unreachable:\n" + context.unreachableRegions.run.join("\n") + - "\nCloud Run services in these regions won't be deleted.", + "\nCloud Functions in these regions won't be deleted.", ); } } diff --git a/src/deploy/hosting/convertConfig.spec.ts b/src/deploy/hosting/convertConfig.spec.ts index 6fb42a54c4b..24189d36c12 100644 --- a/src/deploy/hosting/convertConfig.spec.ts +++ b/src/deploy/hosting/convertConfig.spec.ts @@ -477,7 +477,6 @@ describe("convertConfig", () => { existingBackend: existingBackend || backend.empty(), unreachableRegions: { gcfV1: [], - gcfV2: [], run: [], }, }; From 9287590f515323d36a35417d9e7855dea60e5809 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Wed, 12 Nov 2025 04:13:31 +0000 Subject: [PATCH 20/25] updates changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6592518cb..ce7f41b6d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -- Upgraded functions::list command to use cloud run api (#9425) +- Upgraded functions::list command to use cloud run api for v2 functions (#9425) From c253ae9ff1dacc38ede7e80e34fc14f30f8e876e Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Thu, 13 Nov 2025 22:26:44 +0000 Subject: [PATCH 21/25] addresses feedback --- src/commands/functions-list.ts | 4 +- src/deploy/functions/backend.spec.ts | 62 ++++------------------------ src/deploy/functions/backend.ts | 7 +--- src/gcp/runv2.ts | 6 +++ 4 files changed, 17 insertions(+), 62 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index 18582720083..fed8d718b4f 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -30,9 +30,7 @@ export const command = new Command("functions:list") endpoints = backend.allEndpoints(existing); } catch (err: any) { logger.debug(`Failed to list functions:`, err); - logger.warn( - `Failed to list functions: ${err.message}. Ensure you have the Cloud Functions and Cloud Run APIs enabled and the necessary permissions.`, - ); + logger.warn(err.message); } endpoints.sort(backend.compareFunctions); diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index c1096483132..edb6f3f7632 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -32,41 +32,7 @@ describe("Backend", () => { runtime: "nodejs16", }; - const CLOUD_FUNCTION_V2_SOURCE: gcfV2.StorageSource = { - bucket: "sample", - object: "source.zip", - generation: 42, - }; - - const CLOUD_FUNCTION_V2: gcfV2.InputCloudFunction = { - name: "projects/project/locations/region/functions/id", - buildConfig: { - entryPoint: "function", - runtime: "nodejs16", - source: { - storageSource: CLOUD_FUNCTION_V2_SOURCE, - }, - environmentVariables: {}, - }, - serviceConfig: { - service: "projects/project/locations/region/services/service", - availableCpu: "1", - maxInstanceRequestConcurrency: 80, - }, - }; const GCF_URL = "https://region-project.cloudfunctions.net/id"; - const HAVE_CLOUD_FUNCTION_V2: gcfV2.OutputCloudFunction = { - ...CLOUD_FUNCTION_V2, - serviceConfig: { - service: "service", - uri: GCF_URL, - availableCpu: "1", - maxInstanceRequestConcurrency: 80, - }, - url: GCF_URL, - state: "ACTIVE", - updateTime: new Date(), - }; const HAVE_CLOUD_FUNCTION: gcf.CloudFunction = { ...CLOUD_FUNCTION, @@ -193,18 +159,18 @@ describe("Backend", () => { expect(have).to.deep.equal(backend.of({ ...ENDPOINT, httpsTrigger: {} })); }); - it("should throw an error if v2 list api throws an error", async () => { + it("should handle v2 list api errors gracefully", async () => { listAllFunctions.onFirstCall().resolves({ functions: [], unreachable: [], }); - listServices.throws( - new FirebaseError("HTTP Error: 500, Internal Error", { status: 500 }), - ); + listServices.throws(new FirebaseError("HTTP Error: 500, Internal Error", { status: 500 })); - await expect(backend.existingBackend(newContext())).to.be.rejectedWith( - "HTTP Error: 500, Internal Error", - ); + const context = newContext(); + const have = await backend.existingBackend(context); + + expect(have).to.deep.equal(backend.empty()); + expect(context.unreachableRegions?.run).to.deep.equal(["unknown"]); }); it("should read v1 functions only when user is not allowlisted for v2", async () => { @@ -226,19 +192,7 @@ describe("Backend", () => { expect(have).to.deep.equal(backend.of({ ...ENDPOINT, httpsTrigger: {} })); }); - it("should throw an error if v2 list api throws an error", async () => { - listAllFunctions.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); - listAllFunctionsV2.throws( - new FirebaseError("HTTP Error: 500, Internal Error", { status: 500 }), - ); - await expect(backend.existingBackend(newContext())).to.be.rejectedWith( - "HTTP Error: 500, Internal Error", - ); - }); it("should read v1 functions only when user is not allowlisted for v2", async () => { listAllFunctions.onFirstCall().resolves({ @@ -359,7 +313,7 @@ describe("Backend", () => { const context = newContext(); await backend.existingBackend(context); - + // Manually set the run region as unreachable to test the warning if (!context.unreachableRegions) { context.unreachableRegions = { gcfV1: [], run: [] }; diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 1fbff256637..7afa64eef1f 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -6,6 +6,7 @@ import { Runtime } from "./runtimes/supported"; import { FirebaseError } from "../../error"; import { Context } from "./args"; import { assertExhaustive, flattenArray } from "../../functional"; +import { logger } from "../../logger"; /** Retry settings for a ScheduleSpec. */ export interface ScheduleRetryConfig { @@ -542,7 +543,6 @@ async function loadExistingBackend(ctx: Context): Promise { } unreachableRegions.gcfV1 = gcfV1Results.unreachable; - try { const runServices = await run.listServices(ctx.projectId); for (const service of runServices) { @@ -551,10 +551,7 @@ async function loadExistingBackend(ctx: Context): Promise { existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint; } } catch (err: any) { - utils.logLabeledWarning( - "functions", - `Failed to list Cloud Run services: ${err.message}. Ensure you have the Cloud Run Admin API enabled and the necessary permissions.`, - ); + logger.debug(err.message); unreachableRegions.run = ["unknown"]; // Indicate that Run services could not be listed } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 98227ed85ed..0496125ee6b 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -200,6 +200,12 @@ export async function updateService(service: Omit) return svc; } +/** + * Lists Cloud Run services in the given project. + * + * This method only returns services with the "goog-managed-by" label set to + * "cloud-functions" or "firebase-functions". + */ export async function listServices(projectId: string): Promise { const allServices: Service[] = []; let pageToken: string | undefined = undefined; From 5acc3cf45a9e3bf761c9750539d975904dcc459a Mon Sep 17 00:00:00 2001 From: brittanycho Date: Thu, 13 Nov 2025 14:56:29 -0800 Subject: [PATCH 22/25] Update src/gcp/runv2.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/gcp/runv2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 39adb9447da..8c0a7e791d3 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -222,7 +222,7 @@ export async function listServices(projectId: string): Promise { ); if (res.status !== 200) { - throw new FirebaseError(`Failed to list services: ${res.status} ${JSON.stringify(res.body)}`); + throw new FirebaseError(`Failed to list services. HTTP Error: ${res.status}`, { original: res.body as any }); } if (res.body.services) { From 812fda93ea8200caacd60d830859a923007092d5 Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Thu, 13 Nov 2025 23:16:02 +0000 Subject: [PATCH 23/25] correct linting errors --- src/deploy/functions/backend.spec.ts | 4 ---- src/gcp/runv2.ts | 4 +++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index edb6f3f7632..80ba41579e4 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -32,8 +32,6 @@ describe("Backend", () => { runtime: "nodejs16", }; - const GCF_URL = "https://region-project.cloudfunctions.net/id"; - const HAVE_CLOUD_FUNCTION: gcf.CloudFunction = { ...CLOUD_FUNCTION, buildId: "buildId", @@ -192,8 +190,6 @@ describe("Backend", () => { expect(have).to.deep.equal(backend.of({ ...ENDPOINT, httpsTrigger: {} })); }); - - it("should read v1 functions only when user is not allowlisted for v2", async () => { listAllFunctions.onFirstCall().resolves({ functions: [ diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 8c0a7e791d3..20db611c259 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -222,7 +222,9 @@ export async function listServices(projectId: string): Promise { ); if (res.status !== 200) { - throw new FirebaseError(`Failed to list services. HTTP Error: ${res.status}`, { original: res.body as any }); + throw new FirebaseError(`Failed to list services. HTTP Error: ${res.status}`, { + original: res.body as any, + }); } if (res.body.services) { From 4ebc27b1c512dc7b0834f71be416735da077c8dd Mon Sep 17 00:00:00 2001 From: brittanycho Date: Fri, 14 Nov 2025 14:46:38 -0800 Subject: [PATCH 24/25] Update src/deploy/functions/backend.spec.ts Co-authored-by: Daniel Lee --- src/deploy/functions/backend.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index 80ba41579e4..4d98ba893b7 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -346,7 +346,6 @@ describe("Backend", () => { httpsTrigger: {}, }); - // Manually set the run region as unreachable to simulate unavailability const context = newContext(); await backend.existingBackend(context); if (!context.unreachableRegions) { From 369c40201c219d9fac39b43ce0341adc4f1a9e9c Mon Sep 17 00:00:00 2001 From: Brittany Cho Date: Sat, 15 Nov 2025 01:10:18 +0000 Subject: [PATCH 25/25] updates error handling and tests --- src/commands/functions-list.ts | 10 ++-------- src/deploy/functions/backend.spec.ts | 26 ++------------------------ 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index fed8d718b4f..f55c1ef9b00 100644 --- a/src/commands/functions-list.ts +++ b/src/commands/functions-list.ts @@ -24,14 +24,8 @@ export const command = new Command("functions:list") projectId, } as args.Context; - let endpoints: backend.Endpoint[] = []; - try { - const existing = await backend.existingBackend(context); - endpoints = backend.allEndpoints(existing); - } catch (err: any) { - logger.debug(`Failed to list functions:`, err); - logger.warn(err.message); - } + const existing = await backend.existingBackend(context); + const endpoints = backend.allEndpoints(existing); endpoints.sort(backend.compareFunctions); diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index 4d98ba893b7..ea1f4af796f 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -5,7 +5,6 @@ import { FirebaseError } from "../../error"; import * as args from "./args"; import * as backend from "./backend"; import * as gcf from "../../gcp/cloudfunctions"; -import * as gcfV2 from "../../gcp/cloudfunctionsv2"; import * as runv2 from "../../gcp/runv2"; import * as utils from "../../utils"; import * as projectConfig from "../../functions/projectConfig"; @@ -90,20 +89,17 @@ describe("Backend", () => { describe("existing backend", () => { let listAllFunctions: sinon.SinonStub; - let listAllFunctionsV2: sinon.SinonStub; let listServices: sinon.SinonStub; let logLabeledWarning: sinon.SinonSpy; beforeEach(() => { listAllFunctions = sinon.stub(gcf, "listAllFunctions").rejects("Unexpected call"); - listAllFunctionsV2 = sinon.stub(gcfV2, "listAllFunctions").rejects("Unexpected v2 call"); - listServices = sinon.stub(runv2, "listServices").resolves([]); + listServices = sinon.stub(runv2, "listServices").rejects("Unexpected call"); logLabeledWarning = sinon.spy(utils, "logLabeledWarning"); }); afterEach(() => { listAllFunctions.restore(); - listAllFunctionsV2.restore(); listServices.restore(); logLabeledWarning.restore(); }); @@ -162,6 +158,7 @@ describe("Backend", () => { functions: [], unreachable: [], }); + listServices.onFirstCall().resolves([]); listServices.throws(new FirebaseError("HTTP Error: 500, Internal Error", { status: 500 })); const context = newContext(); @@ -190,25 +187,6 @@ describe("Backend", () => { expect(have).to.deep.equal(backend.of({ ...ENDPOINT, httpsTrigger: {} })); }); - it("should read v1 functions only when user is not allowlisted for v2", async () => { - listAllFunctions.onFirstCall().resolves({ - functions: [ - { - ...HAVE_CLOUD_FUNCTION, - httpsTrigger: {}, - }, - ], - unreachable: [], - }); - listAllFunctionsV2.throws( - new FirebaseError("HTTP Error: 404, Method not found", { status: 404 }), - ); - - const have = await backend.existingBackend(newContext()); - - expect(have).to.deep.equal(backend.of({ ...ENDPOINT, httpsTrigger: {} })); - }); - it("should read v2 functions when enabled", async () => { listAllFunctions.onFirstCall().resolves({ functions: [],