Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
123 changes: 118 additions & 5 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 All @@ -154,13 +157,13 @@
httpsTrigger: {},
concurrency: 50,
};
const expectedServiceInput = JSON.parse(

Check warning on line 160 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.containerConcurrency = 50;

Check warning on line 166 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 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,114 @@
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: {} },
);
});
});
});

Check failure on line 568 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎`

Check failure on line 568 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Insert `⏎`

Check failure on line 568 in src/gcp/runv2.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎`
56 changes: 52 additions & 4 deletions src/gcp/runv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,49 @@
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<Service[]> {
const allServices: Service[] = [];
let pageToken: string | undefined = undefined;

do {
const queryParams: Record<string, string> = {};
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, "-");
Expand Down Expand Up @@ -487,9 +530,14 @@
service.annotations?.[FUNCTION_TARGET_ANNOTATION] ||
service.annotations?.[FUNCTION_ID_ANNOTATION] ||
id,

// TODO: trigger types.
httpsTrigger: {},
...(service.annotations?.[TRIGGER_TYPE_ANNOTATION] === "HTTP_TRIGGER"
Copy link
Contributor

Choose a reason for hiding this comment

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

note: There are actually few other triggers types that all kind of look like HTTP function:

  • HTTP function
  • Callable function
  • Auth Blocking function
  • Task Queue function
  • Scheduled function

I'd be okay leaving this as todo as we figure out how to encode this information to the underlying Run service that is compatible with both V2 functions and "direct to run" functions

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added TODO

? { httpsTrigger: {} }
: {
eventTrigger: {
eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown",
retry: false,
Copy link
Contributor

Choose a reason for hiding this comment

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

can you add a TODO comment here on figuring out how we currently set this to false b/c we don't know how to recover the info from Run (vs Functions API)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added TODO

},
}),
};
proto.renameIfPresent(endpoint, service.template, "concurrency", "containerConcurrency");
proto.renameIfPresent(endpoint, service.labels || {}, "codebase", CODEBASE_LABEL);
Expand Down Expand Up @@ -599,4 +647,4 @@

// TODO: other trigger types, service accounts, concurrency, etc.
return service;
}
}

Check failure on line 650 in src/gcp/runv2.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎`

Check failure on line 650 in src/gcp/runv2.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Insert `⏎`

Check failure on line 650 in src/gcp/runv2.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Insert `⏎`
Loading