diff --git a/src/commands/functions-list.ts b/src/commands/functions-list.ts index ad7ed657533..f55c1ef9b00 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 { 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") - .before(requirePermissions, ["cloudfunctions.functions.list"]) + .before(requirePermissions, ["cloudfunctions.functions.list", "run.services.list"]) .action(async (options: Options) => { + const projectId = needProjectId(options); const context = { - projectId: needProjectId(options), + projectId, } as args.Context; + const existing = await backend.existingBackend(context); - const endpointsList = backend.allEndpoints(existing).sort(backend.compareFunctions); + const endpoints = backend.allEndpoints(existing); + + endpoints.sort(backend.compareFunctions); + + if (endpoints.length === 0) { + logger.info(`No functions found in project ${projectId}.`); + return []; + } + const table = new Table({ head: ["Function", "Version", "Trigger", "Location", "Memory", "Runtime"], style: { head: ["yellow"] }, - }); - for (const endpoint of endpointsList) { + }) as any; + + for (const endpoint of endpoints) { const trigger = backend.endpointTriggerType(endpoint); const availableMemoryMb = endpoint.availableMemoryMb || "---"; const entry = [ endpoint.id, - endpoint.platform === "gcfv2" ? "v2" : "v1", + PLATFORM_TO_DISPLAY_NAME[endpoint.platform] || "v1", trigger, endpoint.region, availableMemoryMb, @@ -34,5 +53,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/args.ts b/src/deploy/functions/args.ts index ca02fd4874d..f5ec1c40b4f 100644 --- a/src/deploy/functions/args.ts +++ b/src/deploy/functions/args.ts @@ -50,8 +50,7 @@ export interface Context { existingBackendPromise?: Promise; unreachableRegions?: { gcfV1: string[]; - gcfV2: string[]; - run: string[]; + run?: string[]; }; // Tracks metrics about codebase deployments to send to GA4 diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index ef05278a44d..4fa9c757ca6 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -5,7 +5,7 @@ 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"; @@ -31,42 +31,6 @@ 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, buildId: "buildId", @@ -75,6 +39,41 @@ describe("Backend", () => { status: "ACTIVE", }; + const RUN_SERVICE: runv2.Service = { + name: "projects/project/locations/region/services/id", + labels: { + "goog-managed-by": "cloud-functions", + "goog-cloudfunctions-runtime": "nodejs16", + "firebase-functions-codebase": "default", + }, + annotations: { + "cloudfunctions.googleapis.com/function-id": "id", + "cloudfunctions.googleapis.com/trigger-type": "HTTP_TRIGGER", + }, + template: { + containers: [ + { + name: "worker", + image: "image", + env: [{ name: "FUNCTION_TARGET", value: "function" }], + resources: { + limits: { + cpu: "1", + memory: "256Mi", + }, + }, + }, + ], + containerConcurrency: 80, + }, + generation: 1, + createTime: "2023-01-01T00:00:00Z", + updateTime: "2023-01-01T00:00:00Z", + creator: "user", + lastModifier: "user", + etag: "etag", + }; + describe("Helper functions", () => { it("isEmptyBackend", () => { expect(backend.isEmptyBackend(backend.empty())).to.be.true; @@ -125,18 +124,18 @@ 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").rejects("Unexpected call"); logLabeledWarning = sinon.spy(utils, "logLabeledWarning"); }); afterEach(() => { listAllFunctions.restore(); - listAllFunctionsV2.restore(); + listServices.restore(); logLabeledWarning.restore(); }); @@ -163,10 +162,7 @@ describe("Backend", () => { ], unreachable: ["region"], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.onFirstCall().resolves([]); const firstBackend = await backend.existingBackend(context); const secondBackend = await backend.existingBackend(context); @@ -174,7 +170,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 () => { @@ -187,60 +183,24 @@ 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: {} })); }); - 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: [], }); - 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({ - 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 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 }), - ); + const context = newContext(); + listServices.throws(new FirebaseError("HTTP Error: 500, Internal Error", { status: 500 })); + const have = await backend.existingBackend(context); - await expect(backend.existingBackend(newContext())).to.be.rejectedWith( - "HTTP Error: 500, Internal Error", - ); + 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 () => { @@ -253,7 +213,7 @@ describe("Backend", () => { ], unreachable: [], }); - listAllFunctionsV2.throws( + listServices.throws( new FirebaseError("HTTP Error: 404, Method not found", { status: 404 }), ); @@ -267,24 +227,30 @@ describe("Backend", () => { functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [HAVE_CLOUD_FUNCTION_V2], - unreachable: [], - }); + listServices.onFirstCall().resolves([RUN_SERVICE]); 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, - }), - ); + const want = backend.of({ + ...ENDPOINT, + platform: "gcfv2", + httpsTrigger: {}, + concurrency: 80, + cpu: 1, + availableMemoryMb: 256, + environmentVariables: { + FUNCTION_TARGET: "function", + }, + secretEnvironmentVariables: [], + labels: { + "goog-managed-by": "cloud-functions", + "goog-cloudfunctions-runtime": "nodejs16", + "firebase-functions-codebase": "default", + }, + codebase: projectConfig.DEFAULT_CODEBASE, + state: "ACTIVE", + }); + + expect(have).to.deep.equal(want); }); it("should deduce features of scheduled functions", async () => { @@ -303,10 +269,7 @@ describe("Backend", () => { ], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); + listServices.resolves([]); const have = await backend.existingBackend(newContext()); const want = backend.of({ ...ENDPOINT, @@ -318,6 +281,19 @@ describe("Backend", () => { expect(have).to.deep.equal(want); }); + + it("should list services with correct filter", async () => { + listAllFunctions.onFirstCall().resolves({ + functions: [], + unreachable: [], + }); + listServices.resolves([]); + const context = { projectId: "project" } as args.Context; + + await backend.existingBackend(context); + + expect(listServices).to.have.been.calledWith("project"); + }); }); describe("checkAvailability", () => { @@ -333,15 +309,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; }); @@ -350,32 +323,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(logLabeledWarning).to.have.been.called; - }); - - it("should warn if an unused GCFv2 backend is unavailable", async () => { - listAllFunctions.onFirstCall().resolves({ - functions: [], - unreachable: [], - }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: ["region"], - }); - - 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; }); @@ -384,10 +337,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, @@ -395,61 +345,61 @@ describe("Backend", () => { ); }); - it("should throw if a GCFv2 needed region is unavailable", async () => { + it("Should only warn when deploying GCFv2 and GCFv1 is unavailable.", async () => { listAllFunctions.onFirstCall().resolves({ functions: [], - unreachable: [], - }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: ["region"], - }); - const want: backend.Backend = backend.of({ - ...ENDPOINT, - platform: "gcfv2", - httpsTrigger: {}, + unreachable: ["us-central1"], }); + listServices.resolves([]); - await expect(backend.checkAvailability(newContext(), want)).to.eventually.be.rejectedWith( - FirebaseError, - /The following Cloud Functions V2 regions are currently unreachable:/, - ); + const want: backend.Backend = backend.of({ ...ENDPOINT, httpsTrigger: {} }); + await backend.checkAvailability(newContext(), want); + + expect(listAllFunctions).to.have.been.called; + expect(listServices).to.have.been.called; + expect(logLabeledWarning).to.have.been.called; }); - it("Should only warn when deploying GCFv1 and GCFv2 is unavailable.", async () => { + it("should warn if an unused Cloud Run region is unavailable", async () => { listAllFunctions.onFirstCall().resolves({ functions: [], unreachable: [], }); - listAllFunctionsV2.onFirstCall().resolves({ - functions: [], - unreachable: ["us-central1"], - }); + listServices.resolves([]); + const context = newContext(); + context.unreachableRegions = { gcfV1: [], run: ["region"] }; + // Pre-populate the promise to prevent loadExistingBackend from overwriting unreachableRegions + context.existingBackendPromise = Promise.resolve(backend.empty()); - const want = backend.of({ ...ENDPOINT, httpsTrigger: {} }); - await backend.checkAvailability(newContext(), want); + await backend.checkAvailability(context, backend.empty()); - expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; - expect(logLabeledWarning).to.have.been.called; + expect(logLabeledWarning).to.have.been.calledWith( + "functions", + sinon.match(/The following Cloud Run regions are currently unreachable/), + ); }); - it("Should only warn when deploying GCFv2 and GCFv1 is unavailable.", async () => { + it("should throw if a needed Cloud Run region is unavailable", async () => { listAllFunctions.onFirstCall().resolves({ - functions: [], - unreachable: ["us-central1"], - }); - listAllFunctionsV2.onFirstCall().resolves({ functions: [], unreachable: [], }); + listServices.resolves([]); + const context = newContext(); + context.unreachableRegions = { gcfV1: [], run: ["region"] }; + context.existingBackendPromise = Promise.resolve(backend.empty()); - const want: backend.Backend = backend.of({ ...ENDPOINT, httpsTrigger: {} }); - await backend.checkAvailability(newContext(), want); + const want = backend.of({ + ...ENDPOINT, + platform: "gcfv2", + httpsTrigger: {}, + region: "region", + }); - expect(listAllFunctions).to.have.been.called; - expect(listAllFunctionsV2).to.have.been.called; - expect(logLabeledWarning).to.have.been.called; + await expect(backend.checkAvailability(context, want)).to.eventually.be.rejectedWith( + FirebaseError, + /The following Cloud Run regions are currently unreachable:/, + ); }); }); }); diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 02c057791ec..9a92aecb87e 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -1,10 +1,12 @@ import * as gcf from "../../gcp/cloudfunctions"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; +import * as run from "../../gcp/runv2"; import * as utils from "../../utils"; 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 { @@ -531,7 +533,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); @@ -542,20 +543,19 @@ 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); + const runServices = await run.listServices(ctx.projectId); + for (const service of runServices) { + const endpoint = run.endpointFromService(service); 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; + logger.debug(err.message); + unreachableRegions.run = ["unknown"]; } } @@ -575,34 +575,36 @@ async function loadExistingBackend(ctx: Context): Promise { export async function checkAvailability(context: Context, want: Backend): Promise { await existingBackend(context); const gcfV1Regions = new Set(); - const gcfV2Regions = new Set(); + const cloudRunRegions = new Set(); for (const ep of allEndpoints(want)) { if (ep.platform === "gcfv1") { gcfV1Regions.add(ep.region); } else { - gcfV2Regions.add(ep.region); + cloudRunRegions.add(ep.region); } } const neededUnreachableV1 = context.unreachableRegions?.gcfV1.filter((region) => gcfV1Regions.has(region), ); - const neededUnreachableV2 = context.unreachableRegions?.gcfV2.filter((region) => - gcfV2Regions.has(region), - ); + if (neededUnreachableV1?.length) { throw new FirebaseError( "The following Cloud Functions regions are currently unreachable:\n\t" + - neededUnreachableV1.join("\n\t") + - "\nThis deployment contains functions in those regions. Please try again in a few minutes, or exclude these regions from your deployment.", + neededUnreachableV1.join("\n\t") + + "\nThis deployment contains functions in those regions. Please try again in a few minutes, or exclude these regions from your deployment.", ); } - if (neededUnreachableV2?.length) { + const neededUnreachableCloudRun = context.unreachableRegions?.run?.filter((region) => + cloudRunRegions.has(region), + ); + + if (neededUnreachableCloudRun?.length) { throw new FirebaseError( - "The following Cloud Functions V2 regions are currently unreachable:\n\t" + - neededUnreachableV2.join("\n\t") + - "\nThis deployment contains functions in those regions. Please try again in a few minutes, or exclude these regions from your deployment.", + "The following Cloud Run regions are currently unreachable:\n\t" + + neededUnreachableCloudRun.join("\n\t") + + "\nThis deployment contains functions in those regions. Please try again in a few minutes, or exclude these regions from your deployment.", ); } @@ -610,26 +612,18 @@ export async function checkAvailability(context: Context, want: Backend): Promis utils.logLabeledWarning( "functions", "The following Cloud Functions regions are currently unreachable:\n" + - context.unreachableRegions.gcfV1.join("\n") + - "\nCloud Functions in these regions won't be deleted.", - ); - } - - 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.", + context.unreachableRegions.gcfV1.join("\n") + + "\nCloud Functions in these regions won't be deleted.", ); } - if (context.unreachableRegions?.run.length) { + const unreachableRun = context.unreachableRegions?.run; + if (unreachableRun?.length) { utils.logLabeledWarning( "functions", "The following Cloud Run regions are currently unreachable:\n" + - context.unreachableRegions.run.join("\n") + - "\nCloud Run services in these regions won't be deleted.", + unreachableRun.join("\n") + + "\nCloud Run functions in these regions won't be deleted.", ); } } @@ -692,18 +686,18 @@ export function regionalEndpoints(backend: Backend, region: string): Endpoint[] /** A curried function used for filters, returns a matcher for functions in a backend. */ export const hasEndpoint = (backend: Backend) => - (endpoint: Endpoint): boolean => { - return ( - !!backend.endpoints[endpoint.region] && !!backend.endpoints[endpoint.region][endpoint.id] - ); - }; + (endpoint: Endpoint): boolean => { + return ( + !!backend.endpoints[endpoint.region] && !!backend.endpoints[endpoint.region][endpoint.id] + ); + }; /** A curried function that is the opposite of hasEndpoint */ export const missingEndpoint = (backend: Backend) => - (endpoint: Endpoint): boolean => { - return !hasEndpoint(backend)(endpoint); - }; + (endpoint: Endpoint): boolean => { + return !hasEndpoint(backend)(endpoint); + }; /** * A standard method for sorting endpoints for display. 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: [], }, }; diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 4d5f0b200f6..541bc9b7bfc 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: [ @@ -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,114 @@ 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", + 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); + + 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", + 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" } }); + 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"'); + } + }); + + 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); + + expect(services).to.deep.equal([mockServices[0], mockServices[1]]); + expect(getStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/-/services`, + { queryParams: {} }, + ); + }); + }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index ae0593cd88e..5cf734d9280 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -200,6 +200,49 @@ 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; + + 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. HTTP Error: ${res.status}`, { + original: res.body as any, + }); + } + + if (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); + + return allServices; +} + // TODO: Replace with real version: function functionNameToServiceName(id: string): string { return id.toLowerCase().replace(/_/g, "-"); @@ -487,9 +530,14 @@ export function endpointFromService(service: Omit) 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); @@ -510,6 +558,8 @@ export function endpointFromService(service: Omit) version: e.valueSource.secretKeyRef.version || "latest", }; }); + + endpoint.state = "ACTIVE"; return endpoint; }