Skip to content

Commit 2a25c5f

Browse files
authored
When using v2 functions enable Compute Service API and grant its P4SA necessary IAM roles (#5338)
1 parent 3d89222 commit 2a25c5f

File tree

4 files changed

+156
-0
lines changed

4 files changed

+156
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
- Adds user-defined env vars into the functions emulator (#5330).
33
- Support Next.js Middleware (#5320)
44
- Log the reason for a Cloud Function if needed in Next.js (#5320)
5+
- Fixed service enablement when installing extensions with v2 functions (#5338)

src/deploy/extensions/prepare.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ensureSecretManagerApiEnabled } from "../../extensions/secretsUtils";
1313
import { checkSpecForSecrets } from "./secrets";
1414
import { displayWarningsForDeploy, outOfBandChangesWarning } from "../../extensions/warnings";
1515
import { detectEtagChanges } from "../../extensions/etags";
16+
import { checkSpecForV2Functions, ensureNecessaryV2ApisAndRoles } from "./v2FunctionHelper";
1617

1718
export async function prepare(context: Context, options: Options, payload: Payload) {
1819
const projectId = needProjectId(options);
@@ -58,6 +59,11 @@ export async function prepare(context: Context, options: Options, payload: Paylo
5859
await ensureSecretManagerApiEnabled(options);
5960
}
6061

62+
const usingV2Functions = await Promise.all(context.want?.map(checkSpecForV2Functions));
63+
if (usingV2Functions) {
64+
await ensureNecessaryV2ApisAndRoles(options);
65+
}
66+
6167
payload.instancesToCreate = context.want.filter((i) => !context.have?.some(matchesInstanceId(i)));
6268
payload.instancesToConfigure = context.want.filter((i) => context.have?.some(isConfigure(i)));
6369
payload.instancesToUpdate = context.want.filter((i) => context.have?.some(isUpdate(i)));
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { getProjectNumber } from "../../getProjectNumber";
2+
import * as resourceManager from "../../gcp/resourceManager";
3+
import { logger } from "../../logger";
4+
import { FirebaseError } from "../../error";
5+
import { ensure } from "../../ensureApiEnabled";
6+
import * as planner from "./planner";
7+
import { needProjectId } from "../../projectUtils";
8+
9+
const SERVICE_AGENT_ROLE = "roles/eventarc.eventReceiver";
10+
11+
/**
12+
* Checks whether spec contains v2 function resource.
13+
*/
14+
export async function checkSpecForV2Functions(i: planner.InstanceSpec): Promise<boolean> {
15+
const extensionSpec = await planner.getExtensionSpec(i);
16+
return extensionSpec.resources.some((r) => r.type === "firebaseextensions.v1beta.v2function");
17+
}
18+
19+
/**
20+
* Enables APIs and grants roles necessary for running v2 functions.
21+
*/
22+
export async function ensureNecessaryV2ApisAndRoles(options: any) {
23+
const projectId = needProjectId(options);
24+
await ensure(projectId, "compute.googleapis.com", "extensions", options.markdown);
25+
await ensureComputeP4SARole(projectId);
26+
}
27+
28+
async function ensureComputeP4SARole(projectId: string): Promise<boolean> {
29+
const projectNumber = await getProjectNumber({ projectId });
30+
const saEmail = `${projectNumber}[email protected]`;
31+
32+
let policy;
33+
try {
34+
policy = await resourceManager.getIamPolicy(projectId);
35+
} catch (e) {
36+
if (e instanceof FirebaseError && e.status === 403) {
37+
throw new FirebaseError(
38+
"Unable to get project IAM policy, permission denied (403). Please " +
39+
"make sure you have sufficient project privileges or if this is a brand new project " +
40+
"try again in a few minutes."
41+
);
42+
}
43+
throw e;
44+
}
45+
46+
if (
47+
policy.bindings.find(
48+
(b) => b.role === SERVICE_AGENT_ROLE && b.members.includes("serviceAccount:" + saEmail)
49+
)
50+
) {
51+
logger.debug("Compute Service API Agent IAM policy OK");
52+
return true;
53+
} else {
54+
logger.debug(
55+
"Firebase Extensions Service Agent is missing a required IAM role " +
56+
"`Firebase Extensions API Service Agent`."
57+
);
58+
policy.bindings.push({
59+
role: SERVICE_AGENT_ROLE,
60+
members: ["serviceAccount:" + saEmail],
61+
});
62+
await resourceManager.setIamPolicy(projectId, policy, "bindings");
63+
logger.debug("Compute Service API Agent IAM policy updated successfully");
64+
return true;
65+
}
66+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as resourceManager from "../../../gcp/resourceManager";
4+
import * as pn from "../../../getProjectNumber";
5+
import * as v2FunctionHelper from "../../../deploy/extensions/v2FunctionHelper";
6+
import * as ensureApiEnabled from "../../../ensureApiEnabled";
7+
import * as projectUtils from "../../../projectUtils";
8+
9+
const GOOD_BINDING = {
10+
role: "roles/eventarc.eventReceiver",
11+
members: ["serviceAccount:[email protected]"],
12+
};
13+
14+
describe("ensureNecessaryV2ApisAndRoles", () => {
15+
let getIamStub: sinon.SinonStub;
16+
let setIamStub: sinon.SinonStub;
17+
let needProjectIdStub: sinon.SinonStub;
18+
let getProjectNumberStub: sinon.SinonStub;
19+
let ensureApiEnabledStub: sinon.SinonStub;
20+
21+
beforeEach(() => {
22+
getIamStub = sinon
23+
.stub(resourceManager, "getIamPolicy")
24+
.throws("unexpected call to resourceManager.getIamStub");
25+
setIamStub = sinon
26+
.stub(resourceManager, "setIamPolicy")
27+
.throws("unexpected call to resourceManager.setIamPolicy");
28+
needProjectIdStub = sinon
29+
.stub(projectUtils, "needProjectId")
30+
.throws("unexpected call to pn.getProjectNumber");
31+
getProjectNumberStub = sinon
32+
.stub(pn, "getProjectNumber")
33+
.throws("unexpected call to pn.getProjectNumber");
34+
ensureApiEnabledStub = sinon
35+
.stub(ensureApiEnabled, "ensure")
36+
.throws("unexpected call to ensureApiEnabled.ensure");
37+
38+
getProjectNumberStub.resolves(123456);
39+
needProjectIdStub.returns("project_id");
40+
ensureApiEnabledStub.resolves(undefined);
41+
});
42+
43+
afterEach(() => {
44+
sinon.verifyAndRestore();
45+
});
46+
47+
it("should succeed when IAM policy is correct", async () => {
48+
getIamStub.resolves({
49+
etag: "etag",
50+
version: 3,
51+
bindings: [GOOD_BINDING],
52+
});
53+
54+
expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({ projectId: "project_id" })).to.not
55+
.throw;
56+
57+
expect(getIamStub).to.have.been.calledWith("project_id");
58+
expect(setIamStub).to.not.have.been.called;
59+
});
60+
61+
it("should fix the IAM policy by adding missing bindings", async () => {
62+
getIamStub.resolves({
63+
etag: "etag",
64+
version: 3,
65+
bindings: [],
66+
});
67+
setIamStub.resolves();
68+
69+
expect(await v2FunctionHelper.ensureNecessaryV2ApisAndRoles({ projectId: "project_id" })).to.not
70+
.throw;
71+
72+
expect(getIamStub).to.have.been.calledWith("project_id");
73+
expect(setIamStub).to.have.been.calledWith(
74+
"project_id",
75+
{
76+
etag: "etag",
77+
version: 3,
78+
bindings: [GOOD_BINDING],
79+
},
80+
"bindings"
81+
);
82+
});
83+
});

0 commit comments

Comments
 (0)