Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

When using v2 functions enable Compute Service API and grant its P4SA necessary IAM roles #5338

Merged
merged 5 commits into from Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions src/deploy/extensions/prepare.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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)));
Expand Down
66 changes: 66 additions & 0 deletions src/deploy/extensions/v2FunctionHelper.ts
@@ -0,0 +1,66 @@
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<boolean> {
const extensionSpec = await planner.getExtensionSpec(i);
return extensionSpec.resources.some((r) => r.type === "firebaseextensions.v1beta.v2function");
}

/**
* 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<boolean> {
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;
}
}
83 changes: 83 additions & 0 deletions src/test/deploy/extensions/v2FunctionHelper.spec.ts
@@ -0,0 +1,83 @@
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"
);
});
});