Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Upgraded functions::list command to use cloud run api (#9425)
- Adds 2nd gen Firebase Data Connect triggers to firebase deploy (#9394).
41 changes: 36 additions & 5 deletions src/commands/functions-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,57 @@
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, ["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);

let v1Endpoints: backend.Endpoint[] = [];
try {
const existing = await backend.existingBackend(context);
v1Endpoints = backend.allEndpoints(existing);
} catch (err: any) {

Check warning on line 24 in src/commands/functions-list.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like this would list all Run services, which is not what we want.

v2Endpoints = services.map((service) => endpointFromService(service));
} catch (err: any) {

Check warning on line 35 in src/commands/functions-list.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
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.`,
Comment on lines +31 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this entire logic should live inside backend.existingBackend(context)

existingBackend is the abstraction we have that calls GCF v1/v2 API and then transform it into a common representation. We should add support for Run functions there directly (which would benefit this command along with other places where we need to list functions (e.g. during deploy)

);
}

const endpointsList = [...v1Endpoints, ...v2Endpoints].sort(backend.compareFunctions);

if (endpointsList.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) {
const trigger = backend.endpointTriggerType(endpoint);
const availableMemoryMb = endpoint.availableMemoryMb || "---";
const entry = [
endpoint.id,
endpoint.platform === "gcfv2" ? "v2" : "v1",
endpoint.platform === "gcfv2" ? "v2" : endpoint.platform === "run" ? "run" : "v1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm maybe we can use a small map, like PLATFORM_TO_DISPLAY_NAME or something?

type PLATFORM_DISPLAY_NAME = "v1" | "v2" | "run";
const PLATFORM_TO_DISPLAY_NAME: Record<type endpoint.platform, PLATFORM_DISPLAY_NAME> = {
gcfv1: "v1",
gcfv2: "v2",
run: "run",
};

// Returns 'v2', 'run', or falls back to 'v1'
const version = platformMap[endpoint.platform]

trigger,
endpoint.region,
availableMemoryMb,
Expand Down
72 changes: 68 additions & 4 deletions src/gcp/runv2.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -95,13 +98,13 @@
httpsTrigger: {},
environmentVariables: { FOO: "bar" },
};
const expectedServiceInput = JSON.parse(

Check warning on line 101 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
JSON.stringify({
...BASE_RUN_SERVICE,
name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${FUNCTION_ID.toLowerCase()}`,
}),
);
expectedServiceInput.template.containers[0].env.unshift({ name: "FOO", value: "bar" });

Check warning on line 107 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 107 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .template on an `any` value

expect(runv2.serviceFromEndpoint(endpoint, IMAGE_URI)).to.deep.equal(expectedServiceInput);
});
Expand All @@ -114,13 +117,13 @@
{ key: "MY_SECRET", secret: "secret-name", projectId: PROJECT_ID, version: "1" },
],
};
const expectedServiceInput = JSON.parse(

Check warning on line 120 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
JSON.stringify({
...BASE_RUN_SERVICE,
name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${FUNCTION_ID.toLowerCase()}`,
}),
);
expectedServiceInput.template.containers[0].env.unshift({

Check warning on line 126 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 126 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .template on an `any` value
name: "MY_SECRET",
valueSource: { secretKeyRef: { secret: "secret-name", version: "1" } },
});
Expand All @@ -134,13 +137,13 @@
minInstances: 1,
maxInstances: 10,
};
const expectedServiceInput = JSON.parse(

Check warning on line 140 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
JSON.stringify({
...BASE_RUN_SERVICE,
name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${FUNCTION_ID.toLowerCase()}`,
}),
);
expectedServiceInput.scaling = {

Check warning on line 146 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .scaling on an `any` value
minInstanceCount: 1,
maxInstanceCount: 10,
};
Expand Down Expand Up @@ -201,13 +204,11 @@
const service: Omit<runv2.Service, runv2.ServiceOutputFields> = {
...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: [
Expand Down Expand Up @@ -239,6 +240,7 @@
httpsTrigger: {},
labels: {
[runv2.RUNTIME_LABEL]: latest("nodejs"),
[runv2.CLIENT_NAME_LABEL]: "firebase-functions",
},
environmentVariables: {},
secretEnvironmentVariables: [],
Expand All @@ -259,6 +261,7 @@
...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: [
Expand Down Expand Up @@ -442,7 +445,7 @@
entryPoint: SERVICE_ID, // No FUNCTION_TARGET_ANNOTATION
availableMemoryMb: 128,
cpu: 0.5,
httpsTrigger: {},
eventTrigger: { eventType: "unknown", retry: false },
labels: {},
environmentVariables: {},
secretEnvironmentVariables: [],
Expand All @@ -452,4 +455,65 @@
expect(runv2.endpointFromService(service)).to.deep.equal(expectedEndpoint);
});
});

describe("listServices", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like its missing test on whether filtering by label works correctly

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"');
}
});
});
});
42 changes: 39 additions & 3 deletions src/gcp/runv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,37 @@ export async function updateService(service: Omit<Service, ServiceOutputFields>)
return svc;
}

export async function listServices(projectId: string, filter?: string): Promise<Service[]> {
let allServices: Service[] = [];
let pageToken: string | undefined = undefined;

do {
const queryParams: Record<string, string> = {};
if (pageToken) {
queryParams["pageToken"] = pageToken;
}
if (filter) {
queryParams["labelSelector"] = filter;
}

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} ${JSON.stringify(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, "-");
Expand Down Expand Up @@ -487,9 +518,14 @@ export function endpointFromService(service: Omit<Service, ServiceOutputFields>)
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);
Expand Down
Loading