From 4b974d3c332eca84efedea8568290fc457205354 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 14 Dec 2022 14:44:36 -0500 Subject: [PATCH 1/5] When using v2 functions enable Compute Service API and grant its P4SA necessary IAM roles --- src/deploy/extensions/prepare.ts | 6 ++ src/deploy/extensions/v2FunctionHelper.ts | 68 ++++++++++++++++ .../extensions/v2FunctionHelper.spec.ts | 81 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/deploy/extensions/v2FunctionHelper.ts create mode 100644 src/test/deploy/extensions/v2FunctionHelper.spec.ts diff --git a/src/deploy/extensions/prepare.ts b/src/deploy/extensions/prepare.ts index 74c32613f29..dca4de8c357 100644 --- a/src/deploy/extensions/prepare.ts +++ b/src/deploy/extensions/prepare.ts @@ -13,6 +13,7 @@ import { ensureSecretManagerApiEnabled } from "../../extensions/secretsUtils"; import { checkSpecForSecrets } from "./secrets"; import { displayWarningsForDeploy, outOfBandChangesWarning } from "../../extensions/warnings"; import { detectEtagChanges } from "../../extensions/etags"; +import { checkSpecForV2Functions, ensureNecessaryV2ApisAndRoles } from "./v2FunctionHelper"; export async function prepare(context: Context, options: Options, payload: Payload) { const projectId = needProjectId(options); @@ -58,6 +59,11 @@ export async function prepare(context: Context, options: Options, payload: Paylo await ensureSecretManagerApiEnabled(options); } + const usingV2Functions = await Promise.all(context.want?.map(checkSpecForV2Functions)); + if (usingV2Functions) { + await ensureNecessaryV2ApisAndRoles(options); + } + payload.instancesToCreate = context.want.filter((i) => !context.have?.some(matchesInstanceId(i))); payload.instancesToConfigure = context.want.filter((i) => context.have?.some(isConfigure(i))); payload.instancesToUpdate = context.want.filter((i) => context.have?.some(isUpdate(i))); diff --git a/src/deploy/extensions/v2FunctionHelper.ts b/src/deploy/extensions/v2FunctionHelper.ts new file mode 100644 index 00000000000..0c1f12c0b18 --- /dev/null +++ b/src/deploy/extensions/v2FunctionHelper.ts @@ -0,0 +1,68 @@ +import { getProjectNumber } from "../../getProjectNumber"; +import * as resourceManager from "../../gcp/resourceManager"; +import { logger } from "../../logger"; +import { FirebaseError } from "../../error"; +import { ensure } from "../../ensureApiEnabled"; +import * as planner from "./planner"; +import { needProjectId } from "../../projectUtils"; + +const SERVICE_AGENT_ROLE = "roles/eventarc.eventReceiver"; + +/** + * Checks whether spec contains v2 function resource. + */ +export async function checkSpecForV2Functions(i: planner.InstanceSpec): Promise { + const extensionSpec = await planner.getExtensionSpec(i); + return ( + extensionSpec.resources.findIndex((r) => r.type === "firebaseextensions.v1beta.v2function") > -1 + ); +} + +/** + * Enables APIs and grants roles necessary for running v2 functions. + */ +export async function ensureNecessaryV2ApisAndRoles(options: any) { + const projectId = needProjectId(options); + await ensure(projectId, "compute.googleapis.com", "extensions", options.markdown); + await ensureComputeP4SARole(projectId); +} + +async function ensureComputeP4SARole(projectId: string): Promise { + const projectNumber = await getProjectNumber({ projectId }); + const saEmail = `${projectNumber}-compute@developer.gserviceaccount.com`; + + let policy; + try { + policy = await resourceManager.getIamPolicy(projectId); + } catch (e) { + if (e instanceof FirebaseError && e.status === 403) { + throw new FirebaseError( + "Unable to get project IAM policy, permission denied (403). Please " + + "make sure you have sufficient project privileges or if this is a brand new project " + + "try again in a few minutes." + ); + } + throw e; + } + + if ( + policy.bindings.find( + (b) => b.role === SERVICE_AGENT_ROLE && b.members.includes("serviceAccount:" + saEmail) + ) + ) { + logger.debug("Compute Service API Agent IAM policy OK"); + return true; + } else { + logger.debug( + "Firebase Extensions Service Agent is missing a required IAM role " + + "`Firebase Extensions API Service Agent`." + ); + policy.bindings.push({ + role: SERVICE_AGENT_ROLE, + members: ["serviceAccount:" + saEmail], + }); + await resourceManager.setIamPolicy(projectId, policy, "bindings"); + logger.debug("Compute Service API Agent IAM policy updated successfully"); + return true; + } +} diff --git a/src/test/deploy/extensions/v2FunctionHelper.spec.ts b/src/test/deploy/extensions/v2FunctionHelper.spec.ts new file mode 100644 index 00000000000..1a4031dc997 --- /dev/null +++ b/src/test/deploy/extensions/v2FunctionHelper.spec.ts @@ -0,0 +1,81 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as resourceManager from "../../../gcp/resourceManager"; +import * as pn from "../../../getProjectNumber"; +import * as v2FunctionHelper from "../../../deploy/extensions/v2FunctionHelper"; +import * as ensureApiEnabled from "../../../ensureApiEnabled"; +import * as projectUtils from "../../../projectUtils"; + +const GOOD_BINDING = { + role: "roles/eventarc.eventReceiver", + members: ["serviceAccount:123456-compute@developer.gserviceaccount.com"], +}; + +describe("ensureNecessaryV2ApisAndRoles", () => { + let getIamStub: sinon.SinonStub; + let setIamStub: sinon.SinonStub; + let needProjectIdStub: sinon.SinonStub; + let getProjectNumberStub: sinon.SinonStub; + let ensureApiEnabledStub: sinon.SinonStub; + + beforeEach(() => { + getIamStub = sinon + .stub(resourceManager, "getIamPolicy") + .throws("unexpected call to resourceManager.getIamStub"); + setIamStub = sinon + .stub(resourceManager, "setIamPolicy") + .throws("unexpected call to resourceManager.setIamPolicy"); + needProjectIdStub = sinon + .stub(projectUtils, "needProjectId") + .throws("unexpected call to pn.getProjectNumber"); + getProjectNumberStub = sinon + .stub(pn, "getProjectNumber") + .throws("unexpected call to pn.getProjectNumber"); + ensureApiEnabledStub = sinon + .stub(ensureApiEnabled, "ensure") + .throws("unexpected call to ensureApiEnabled.ensure"); + + getProjectNumberStub.resolves(123456); + needProjectIdStub.returns("project_id"); + ensureApiEnabledStub.resolves(undefined); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + it("should succeed when IAM policy is correct", async () => { + getIamStub.resolves({ + etag: "etag", + version: 3, + bindings: [GOOD_BINDING], + }); + + expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({projectId: "project_id"})).to.not.throw; + + expect(getIamStub).to.have.been.calledWith("project_id"); + expect(setIamStub).to.not.have.been.called; + }); + + it("should fix the IAM policy by adding missing bindings", async () => { + getIamStub.resolves({ + etag: "etag", + version: 3, + bindings: [], + }); + setIamStub.resolves(); + + expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({projectId: "project_id"})).to.not.throw; + + expect(getIamStub).to.have.been.calledWith("project_id"); + expect(setIamStub).to.have.been.calledWith( + "project_id", + { + etag: "etag", + version: 3, + bindings: [GOOD_BINDING], + }, + "bindings" + ); + }); +}); From 72e03779e56b39041953ebb1ccd28c55d9e9174f Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 14 Dec 2022 14:48:17 -0500 Subject: [PATCH 2/5] reformatted --- src/test/deploy/extensions/v2FunctionHelper.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/deploy/extensions/v2FunctionHelper.spec.ts b/src/test/deploy/extensions/v2FunctionHelper.spec.ts index 1a4031dc997..ed8ff168add 100644 --- a/src/test/deploy/extensions/v2FunctionHelper.spec.ts +++ b/src/test/deploy/extensions/v2FunctionHelper.spec.ts @@ -51,7 +51,8 @@ describe("ensureNecessaryV2ApisAndRoles", () => { bindings: [GOOD_BINDING], }); - expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({projectId: "project_id"})).to.not.throw; + expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({ projectId: "project_id" })).to.not + .throw; expect(getIamStub).to.have.been.calledWith("project_id"); expect(setIamStub).to.not.have.been.called; @@ -65,7 +66,8 @@ describe("ensureNecessaryV2ApisAndRoles", () => { }); setIamStub.resolves(); - expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({projectId: "project_id"})).to.not.throw; + expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({ projectId: "project_id" })).to.not + .throw; expect(getIamStub).to.have.been.calledWith("project_id"); expect(setIamStub).to.have.been.calledWith( From 175f9b8d26cdb7575044dd8519355dd60209422b Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 14 Dec 2022 15:11:18 -0500 Subject: [PATCH 3/5] replaced `findIndex` with `some` --- src/deploy/extensions/v2FunctionHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deploy/extensions/v2FunctionHelper.ts b/src/deploy/extensions/v2FunctionHelper.ts index 0c1f12c0b18..1925383c55a 100644 --- a/src/deploy/extensions/v2FunctionHelper.ts +++ b/src/deploy/extensions/v2FunctionHelper.ts @@ -14,7 +14,7 @@ const SERVICE_AGENT_ROLE = "roles/eventarc.eventReceiver"; export async function checkSpecForV2Functions(i: planner.InstanceSpec): Promise { const extensionSpec = await planner.getExtensionSpec(i); return ( - extensionSpec.resources.findIndex((r) => r.type === "firebaseextensions.v1beta.v2function") > -1 + extensionSpec.resources.some((r) => r.type === "firebaseextensions.v1beta.v2function") ); } From 4fc784f38731a5c1fabf32afa2a677dcc2a3993b Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 14 Dec 2022 15:13:49 -0500 Subject: [PATCH 4/5] updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06dd776ac68..9222ed5c7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,4 @@ - Adds user-defined env vars into the functions emulator (#5330). - Support Next.js Middleware (#5320) - Log the reason for a Cloud Function if needed in Next.js (#5320) +- Fixed service enablement when installing extensions with v2 functions (#5338) From d7fb79a1a010cdb7f8a1676325884dd2bb156d94 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 14 Dec 2022 15:19:57 -0500 Subject: [PATCH 5/5] reformatted --- src/deploy/extensions/v2FunctionHelper.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/deploy/extensions/v2FunctionHelper.ts b/src/deploy/extensions/v2FunctionHelper.ts index 1925383c55a..70ae2f6983e 100644 --- a/src/deploy/extensions/v2FunctionHelper.ts +++ b/src/deploy/extensions/v2FunctionHelper.ts @@ -13,9 +13,7 @@ const SERVICE_AGENT_ROLE = "roles/eventarc.eventReceiver"; */ export async function checkSpecForV2Functions(i: planner.InstanceSpec): Promise { const extensionSpec = await planner.getExtensionSpec(i); - return ( - extensionSpec.resources.some((r) => r.type === "firebaseextensions.v1beta.v2function") - ); + return extensionSpec.resources.some((r) => r.type === "firebaseextensions.v1beta.v2function"); } /**