Skip to content

Commit 9dc6d38

Browse files
authored
Provision default compute service account (#6797)
* initial change * update * update tests * lint * lint * lint * fix test
1 parent df99641 commit 9dc6d38

File tree

4 files changed

+168
-22
lines changed

4 files changed

+168
-22
lines changed

src/commands/apphosting-backends-create.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ import { ensureApiEnabled } from "../gcp/apphosting";
88
export const command = new Command("apphosting:backends:create")
99
.description("Create a backend in a Firebase project")
1010
.option("-l, --location <location>", "Specify the region of the backend", "")
11+
.option(
12+
"-s, --service-account <serviceAccount>",
13+
"Specify the service account used to run the server",
14+
"",
15+
)
1116
.before(ensureApiEnabled)
1217
.before(requireInteractive)
1318
.action(async (options: Options) => {
1419
const projectId = needProjectId(options);
1520
const location = options.location;
16-
await doSetup(projectId, location as string | null);
21+
const serviceAccount = options.serviceAccount;
22+
await doSetup(projectId, location as string | null, serviceAccount as string | null);
1723
});

src/gcp/apphosting.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface Backend {
4141
createTime: string;
4242
updateTime: string;
4343
uri: string;
44+
computeServiceAccount?: string;
4445
}
4546

4647
export type BackendOutputOnlyFields = "name" | "createTime" | "updateTime" | "uri";

src/init/features/apphosting/index.ts

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
Build,
1313
Rollout,
1414
} from "../../../gcp/apphosting";
15+
import { addServiceAccountToRoles } from "../../../gcp/resourceManager";
16+
import { createServiceAccount } from "../../../gcp/iam";
1517
import { Repository } from "../../../gcp/cloudbuild";
1618
import { FirebaseError } from "../../../error";
1719
import { promptOnce } from "../../../prompt";
@@ -20,6 +22,8 @@ import { ensure } from "../../../ensureApiEnabled";
2022
import * as deploymentTool from "../../../deploymentTool";
2123
import { DeepOmit } from "../../../metaprogramming";
2224

25+
const DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME = "firebase-app-hosting-compute";
26+
2327
const apphostingPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
2428
apiOrigin: apphostingOrigin,
2529
apiVersion: API_VERSION,
@@ -30,7 +34,11 @@ const apphostingPollerOptions: Omit<poller.OperationPollerOptions, "operationRes
3034
/**
3135
* Set up a new App Hosting backend.
3236
*/
33-
export async function doSetup(projectId: string, location: string | null): Promise<void> {
37+
export async function doSetup(
38+
projectId: string,
39+
location: string | null,
40+
serviceAccount: string | null,
41+
): Promise<void> {
3442
await Promise.all([
3543
ensure(projectId, "cloudbuild.googleapis.com", "apphosting", true),
3644
ensure(projectId, "secretmanager.googleapis.com", "apphosting", true),
@@ -73,7 +81,13 @@ export async function doSetup(projectId: string, location: string | null): Promi
7381

7482
const cloudBuildConnRepo = await repo.linkGitHubRepository(projectId, location);
7583

76-
const backend = await createBackend(projectId, location, backendId, cloudBuildConnRepo);
84+
const backend = await createBackend(
85+
projectId,
86+
location,
87+
backendId,
88+
cloudBuildConnRepo,
89+
serviceAccount,
90+
);
7791

7892
// TODO: Once tag patterns are implemented, prompt which method the user
7993
// prefers. We could reduce the number of questions asked by letting people
@@ -137,31 +151,83 @@ async function promptNewBackendId(
137151
}
138152
}
139153

154+
function defaultComputeServiceAccountEmail(projectId: string): string {
155+
return `${DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME}@${projectId}.iam.gserviceaccount.com`;
156+
}
157+
140158
/**
141-
* Creates (and waits for) a new backend.
159+
* Creates (and waits for) a new backend. Optionally may create the default compute service account if
160+
* it was requested and doesn't exist.
142161
*/
143162
export async function createBackend(
144163
projectId: string,
145164
location: string,
146165
backendId: string,
147166
repository: Repository,
167+
serviceAccount: string | null,
148168
): Promise<Backend> {
169+
const defaultServiceAccount = defaultComputeServiceAccountEmail(projectId);
149170
const backendReqBody: Omit<Backend, BackendOutputOnlyFields> = {
150171
servingLocality: "GLOBAL_ACCESS",
151172
codebase: {
152173
repository: `${repository.name}`,
153174
rootDirectory: "/",
154175
},
155176
labels: deploymentTool.labels(),
177+
computeServiceAccount: serviceAccount || defaultServiceAccount,
156178
};
157179

158-
const op = await apphosting.createBackend(projectId, location, backendReqBody, backendId);
159-
const backend = await poller.pollOperation<Backend>({
160-
...apphostingPollerOptions,
161-
pollerName: `create-${projectId}-${location}-${backendId}`,
162-
operationResourceName: op.name,
163-
});
164-
return backend;
180+
// TODO: remove computeServiceAccount when the backend supports the field.
181+
delete backendReqBody.computeServiceAccount;
182+
183+
async function createBackendAndPoll() {
184+
const op = await apphosting.createBackend(projectId, location, backendReqBody, backendId);
185+
return await poller.pollOperation<Backend>({
186+
...apphostingPollerOptions,
187+
pollerName: `create-${projectId}-${location}-${backendId}`,
188+
operationResourceName: op.name,
189+
});
190+
}
191+
192+
try {
193+
return await createBackendAndPoll();
194+
} catch (err: any) {
195+
if (err.status === 403 && err.message.includes(defaultServiceAccount)) {
196+
// Create the default service account if it doesn't exist and try again.
197+
await provisionDefaultComputeServiceAccount(projectId);
198+
return await createBackendAndPoll();
199+
}
200+
throw err;
201+
}
202+
}
203+
204+
async function provisionDefaultComputeServiceAccount(projectId: string): Promise<void> {
205+
try {
206+
await createServiceAccount(
207+
projectId,
208+
DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME,
209+
"Firebase App Hosting compute service account",
210+
"Default service account used to run builds and deploys for Firebase App Hosting",
211+
);
212+
} catch (err: any) {
213+
// 409 Already Exists errors can safely be ignored.
214+
if (err.status !== 409) {
215+
throw err;
216+
}
217+
}
218+
await addServiceAccountToRoles(
219+
projectId,
220+
defaultComputeServiceAccountEmail(projectId),
221+
[
222+
// TODO: Update to roles/firebaseapphosting.computeRunner when it is available.
223+
"roles/firebaseapphosting.viewer",
224+
"roles/artifactregistry.createOnPushWriter",
225+
"roles/logging.logWriter",
226+
"roles/storage.objectAdmin",
227+
"roles/firebase.sdkAdminServiceAgent",
228+
],
229+
/* skipAccountLookup= */ true,
230+
);
165231
}
166232

167233
/**

src/test/init/apphosting/index.spec.ts

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@ import * as sinon from "sinon";
22
import { expect } from "chai";
33

44
import * as apphosting from "../../../gcp/apphosting";
5+
import * as iam from "../../../gcp/iam";
6+
import * as resourceManager from "../../../gcp/resourceManager";
57
import * as poller from "../../../operation-poller";
68
import { createBackend, setDefaultTrafficPolicy } from "../../../init/features/apphosting/index";
79
import * as deploymentTool from "../../../deploymentTool";
10+
import { FirebaseError } from "../../../error";
811

912
describe("operationsConverter", () => {
1013
const sandbox: sinon.SinonSandbox = sinon.createSandbox();
1114

1215
let pollOperationStub: sinon.SinonStub;
1316
let createBackendStub: sinon.SinonStub;
1417
let updateTrafficStub: sinon.SinonStub;
18+
let createServiceAccountStub: sinon.SinonStub;
19+
let addServiceAccountToRolesStub: sinon.SinonStub;
1520

1621
beforeEach(() => {
1722
pollOperationStub = sandbox
@@ -23,6 +28,12 @@ describe("operationsConverter", () => {
2328
updateTrafficStub = sandbox
2429
.stub(apphosting, "updateTraffic")
2530
.throws("Unexpected updateTraffic call");
31+
createServiceAccountStub = sandbox
32+
.stub(iam, "createServiceAccount")
33+
.throws("Unexpected createServiceAccount call");
34+
addServiceAccountToRolesStub = sandbox
35+
.stub(resourceManager, "addServiceAccountToRoles")
36+
.throws("Unexpected addServiceAccountToRoles call");
2637
});
2738

2839
afterEach(() => {
@@ -54,24 +65,86 @@ describe("operationsConverter", () => {
5465
updateTime: "1",
5566
};
5667

57-
const backendInput: Omit<apphosting.Backend, apphosting.BackendOutputOnlyFields> = {
58-
servingLocality: "GLOBAL_ACCESS",
59-
codebase: {
60-
repository: cloudBuildConnRepo.name,
61-
rootDirectory: "/",
62-
},
63-
labels: deploymentTool.labels(),
64-
};
65-
6668
it("should create a new backend", async () => {
6769
createBackendStub.resolves(op);
6870
pollOperationStub.resolves(completeBackend);
6971

70-
await createBackend(projectId, location, backendId, cloudBuildConnRepo);
71-
72+
await createBackend(
73+
projectId,
74+
location,
75+
backendId,
76+
cloudBuildConnRepo,
77+
"custom-service-account",
78+
);
79+
80+
const backendInput: Omit<apphosting.Backend, apphosting.BackendOutputOnlyFields> = {
81+
servingLocality: "GLOBAL_ACCESS",
82+
codebase: {
83+
repository: cloudBuildConnRepo.name,
84+
rootDirectory: "/",
85+
},
86+
labels: deploymentTool.labels(),
87+
};
7288
expect(createBackendStub).to.be.calledWith(projectId, location, backendInput);
7389
});
7490

91+
it("should provision the default compute service account", async () => {
92+
createBackendStub.resolves(op);
93+
pollOperationStub
94+
// Initial CreateBackend operation should throw a permission denied to trigger service account creation.
95+
.onFirstCall()
96+
.throws(
97+
new FirebaseError(
98+
`missing actAs permission on firebase-app-hosting-compute@${projectId}.iam.gserviceaccount.com`,
99+
{ status: 403 },
100+
),
101+
)
102+
.onSecondCall()
103+
.resolves(completeBackend);
104+
createServiceAccountStub.resolves({});
105+
addServiceAccountToRolesStub.resolves({});
106+
107+
await createBackend(
108+
projectId,
109+
location,
110+
backendId,
111+
cloudBuildConnRepo,
112+
/* serviceAccount= */ null,
113+
);
114+
115+
// CreateBackend should be called twice; once initially and once after the service account was created
116+
expect(createBackendStub).to.be.calledTwice;
117+
expect(createServiceAccountStub).to.be.calledOnce;
118+
expect(addServiceAccountToRolesStub).to.be.calledOnce;
119+
});
120+
121+
it("does not try to provision a custom service account", () => {
122+
createBackendStub.resolves(op);
123+
pollOperationStub
124+
// Initial CreateBackend operation should throw a permission denied to
125+
// potentially trigger service account creation.
126+
.onFirstCall()
127+
.throws(
128+
new FirebaseError("missing actAs permission on my-service-account", { status: 403 }),
129+
)
130+
.onSecondCall()
131+
.resolves(completeBackend);
132+
133+
expect(
134+
createBackend(
135+
projectId,
136+
location,
137+
backendId,
138+
cloudBuildConnRepo,
139+
/* serviceAccount= */ "my-service-account",
140+
),
141+
).to.be.rejectedWith(FirebaseError, "missing actAs permission on my-service-account");
142+
143+
expect(createBackendStub).to.be.calledOnce;
144+
expect(createServiceAccountStub).to.not.have.been.called;
145+
expect(addServiceAccountToRolesStub).to.not.have.been.called;
146+
});
147+
75148
it("should set default rollout policy to 100% all at once", async () => {
76149
const completeTraffic: apphosting.Traffic = {
77150
name: `projects/${projectId}/locations/${location}/backends/${backendId}/traffic`,

0 commit comments

Comments
 (0)