From 4bea57827f8256925beb54005fd2148d74577d5f Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 9 Dec 2025 11:45:03 +0000 Subject: [PATCH 1/6] test: add failing test for issue 8841 --- src/gcp/cloudfunctionsv2.spec.ts | 20 ++++++++++++++++++++ src/gcp/cloudfunctionsv2.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index f45fbfa7861..0b6827cac7e 100644 --- a/src/gcp/cloudfunctionsv2.spec.ts +++ b/src/gcp/cloudfunctionsv2.spec.ts @@ -427,6 +427,26 @@ describe("cloudfunctionsv2", () => { }, }); }); + + it("should set buildConfig.serviceAccount when serviceAccount is specified", () => { + expect( + cloudfunctionsv2.functionFromEndpoint({ + ...ENDPOINT, + serviceAccount: "custom@project.iam.gserviceaccount.com", + httpsTrigger: {}, + }), + ).to.deep.equal({ + ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: "projects/project/serviceAccounts/custom@project.iam.gserviceaccount.com", + }, + serviceConfig: { + ...CLOUD_FUNCTION_V2.serviceConfig, + serviceAccountEmail: "custom@project.iam.gserviceaccount.com", + }, + }); + }); }); describe("endpointFromFunction", () => { diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index aaafac63f78..9a8ba01aaa4 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -51,6 +51,10 @@ export interface BuildConfig { source: Source; sourceToken?: string; environmentVariables: Record; + // TODO(#8841): Add serviceAccount field to support custom service accounts for builds. + // The GCF v2 API supports buildConfig.serviceAccount but we're not setting it, + // causing deployments to fail when the default compute SA is deleted. + // See: https://github.com/firebase/firebase-tools/issues/8841 // Output only build?: string; @@ -454,6 +458,10 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc }, // We don't use build environment variables, environmentVariables: {}, + // TODO(#8841): Set serviceAccount here to match the runtime service account. + // When endpoint.serviceAccount is specified, we should set: + // serviceAccount: proto.formatServiceAccount(endpoint.serviceAccount, endpoint.project, true) + // This ensures the custom SA is used for building, not just runtime. }, serviceConfig: {}, }; From 203a9ea7883a280760578aa144d9e2a926355046 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 9 Dec 2025 15:53:10 +0300 Subject: [PATCH 2/6] fix(functions): set buildConfig.serviceAccount for GCFv2 deployments --- src/gcp/cloudfunctionsv2.spec.ts | 16 ++++++++++++++++ src/gcp/cloudfunctionsv2.ts | 18 ++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index 0b6827cac7e..ede46bf3961 100644 --- a/src/gcp/cloudfunctionsv2.spec.ts +++ b/src/gcp/cloudfunctionsv2.spec.ts @@ -244,6 +244,10 @@ describe("cloudfunctionsv2", () => { const fullGcfFunction: cloudfunctionsv2.InputCloudFunction = { ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: "projects/project/serviceAccounts/inlined@google.com", + }, labels: { ...CLOUD_FUNCTION_V2.labels, foo: "bar", @@ -332,6 +336,10 @@ describe("cloudfunctionsv2", () => { const saGcfFunction: cloudfunctionsv2.InputCloudFunction = { ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: "projects/project/serviceAccounts/sa@google.com", + }, eventTrigger: { eventType: events.v2.DATABASE_EVENTS[0], eventFilters: [ @@ -405,6 +413,10 @@ describe("cloudfunctionsv2", () => { }), ).to.deep.equal({ ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: `projects/${ENDPOINT.project}/serviceAccounts/sa@${ENDPOINT.project}.iam.gserviceaccount.com`, + }, serviceConfig: { ...CLOUD_FUNCTION_V2.serviceConfig, serviceAccountEmail: `sa@${ENDPOINT.project}.iam.gserviceaccount.com`, @@ -421,6 +433,10 @@ describe("cloudfunctionsv2", () => { }), ).to.deep.equal({ ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: null, + }, serviceConfig: { ...CLOUD_FUNCTION_V2.serviceConfig, serviceAccountEmail: null, diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index 9a8ba01aaa4..35d7fb7ebf2 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -51,10 +51,7 @@ export interface BuildConfig { source: Source; sourceToken?: string; environmentVariables: Record; - // TODO(#8841): Add serviceAccount field to support custom service accounts for builds. - // The GCF v2 API supports buildConfig.serviceAccount but we're not setting it, - // causing deployments to fail when the default compute SA is deleted. - // See: https://github.com/firebase/firebase-tools/issues/8841 + serviceAccount?: string | null; // Output only build?: string; @@ -458,10 +455,15 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc }, // We don't use build environment variables, environmentVariables: {}, - // TODO(#8841): Set serviceAccount here to match the runtime service account. - // When endpoint.serviceAccount is specified, we should set: - // serviceAccount: proto.formatServiceAccount(endpoint.serviceAccount, endpoint.project, true) - // This ensures the custom SA is used for building, not just runtime. + ...(endpoint.serviceAccount !== undefined && { + serviceAccount: endpoint.serviceAccount + ? `projects/${endpoint.project}/serviceAccounts/${proto.formatServiceAccount( + endpoint.serviceAccount, + endpoint.project, + true, + )}` + : null, + }), }, serviceConfig: {}, }; From 14df88caf45136930165e10f8f044989c1d4efa5 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 9 Dec 2025 18:45:19 +0300 Subject: [PATCH 3/6] refactor(functions): use proto.convertIfPresent for buildConfig.serviceAccount --- src/gcp/cloudfunctionsv2.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index 35d7fb7ebf2..ad7c31c6168 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -455,20 +455,25 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc }, // We don't use build environment variables, environmentVariables: {}, - ...(endpoint.serviceAccount !== undefined && { - serviceAccount: endpoint.serviceAccount - ? `projects/${endpoint.project}/serviceAccounts/${proto.formatServiceAccount( - endpoint.serviceAccount, - endpoint.project, - true, - )}` - : null, - }), }, serviceConfig: {}, }; proto.copyIfPresent(gcfFunction, endpoint, "labels"); + proto.convertIfPresent( + gcfFunction.buildConfig, + endpoint, + "serviceAccount", + "serviceAccount", + (from) => + !from + ? null + : `projects/${endpoint.project}/serviceAccounts/${proto.formatServiceAccount( + from, + endpoint.project, + true, + )}`, + ); proto.copyIfPresent( gcfFunction.serviceConfig, endpoint, From 7692be0563c5130f3958d63b51cfe56044790eb4 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 9 Dec 2025 20:21:30 +0300 Subject: [PATCH 4/6] refactor(functions): service account logic in functionFromEndpoint --- src/gcp/cloudfunctionsv2.spec.ts | 20 ------------------ src/gcp/cloudfunctionsv2.ts | 36 +++++++++++--------------------- 2 files changed, 12 insertions(+), 44 deletions(-) diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index ede46bf3961..ff2c8ecef06 100644 --- a/src/gcp/cloudfunctionsv2.spec.ts +++ b/src/gcp/cloudfunctionsv2.spec.ts @@ -443,26 +443,6 @@ describe("cloudfunctionsv2", () => { }, }); }); - - it("should set buildConfig.serviceAccount when serviceAccount is specified", () => { - expect( - cloudfunctionsv2.functionFromEndpoint({ - ...ENDPOINT, - serviceAccount: "custom@project.iam.gserviceaccount.com", - httpsTrigger: {}, - }), - ).to.deep.equal({ - ...CLOUD_FUNCTION_V2, - buildConfig: { - ...CLOUD_FUNCTION_V2.buildConfig, - serviceAccount: "projects/project/serviceAccounts/custom@project.iam.gserviceaccount.com", - }, - serviceConfig: { - ...CLOUD_FUNCTION_V2.serviceConfig, - serviceAccountEmail: "custom@project.iam.gserviceaccount.com", - }, - }); - }); }); describe("endpointFromFunction", () => { diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index ad7c31c6168..e564c12bcad 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -460,20 +460,6 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc }; proto.copyIfPresent(gcfFunction, endpoint, "labels"); - proto.convertIfPresent( - gcfFunction.buildConfig, - endpoint, - "serviceAccount", - "serviceAccount", - (from) => - !from - ? null - : `projects/${endpoint.project}/serviceAccounts/${proto.formatServiceAccount( - from, - endpoint.project, - true, - )}`, - ); proto.copyIfPresent( gcfFunction.serviceConfig, endpoint, @@ -482,16 +468,18 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc "ingressSettings", "timeoutSeconds", ); - proto.convertIfPresent( - gcfFunction.serviceConfig, - endpoint, - "serviceAccountEmail", - "serviceAccount", - (from) => - !from - ? null - : proto.formatServiceAccount(from, endpoint.project, true /* removeTypePrefix */), - ); + + if (Object.prototype.hasOwnProperty.call(endpoint, "serviceAccount")) { + const serviceAccount = endpoint.serviceAccount; + if (!serviceAccount) { + gcfFunction.buildConfig.serviceAccount = null; + gcfFunction.serviceConfig.serviceAccountEmail = null; + } else { + const email = proto.formatServiceAccount(serviceAccount, endpoint.project, true); + gcfFunction.buildConfig.serviceAccount = `projects/${endpoint.project}/serviceAccounts/${email}`; + gcfFunction.serviceConfig.serviceAccountEmail = email; + } + } // Memory must be set because the default value of GCF gen 2 is Megabytes and // we use mebibytes const mem = endpoint.availableMemoryMb || backend.DEFAULT_MEMORY; From a28defb2a101a8710e356aa965763aba07746dfb Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 14 May 2026 15:33:19 +0100 Subject: [PATCH 5/6] docs: add comment to improve readability --- src/gcp/cloudfunctionsv2.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index 84821e6e2d6..6235c81fff8 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -469,6 +469,10 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc "timeoutSeconds", ); + // endpoint.serviceAccount is one user-facing option, but the v2 API splits it + // across buildConfig.serviceAccount (resource name) and serviceConfig.serviceAccountEmail + // (email only). convertIfPresent handles one destination field at a time, which + // would duplicate the presence check, null handling, and email formatting. if (Object.prototype.hasOwnProperty.call(endpoint, "serviceAccount")) { const serviceAccount = endpoint.serviceAccount; if (!serviceAccount) { From cfb836f1e05c0b5dad82b936150373b2c18fd7db Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 14 May 2026 15:33:42 +0100 Subject: [PATCH 6/6] tests: cover v2 service account update and reverse mapping --- src/gcp/cloudfunctionsv2.spec.ts | 113 +++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index ff2c8ecef06..5285f2b39e1 100644 --- a/src/gcp/cloudfunctionsv2.spec.ts +++ b/src/gcp/cloudfunctionsv2.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; import * as nock from "nock"; +import type { ParsedUrlQuery } from "querystring"; import * as cloudfunctionsv2 from "./cloudfunctionsv2"; import * as backend from "../deploy/functions/backend"; @@ -722,6 +723,45 @@ describe("cloudfunctionsv2", () => { }); }); + it("should preserve null service account", () => { + expect( + cloudfunctionsv2.endpointFromFunction({ + ...HAVE_CLOUD_FUNCTION_V2, + serviceConfig: { + ...HAVE_CLOUD_FUNCTION_V2.serviceConfig, + service: "service", + uri: RUN_URI, + serviceAccountEmail: null, + }, + }), + ).to.deep.equal({ + ...ENDPOINT, + httpsTrigger: {}, + platform: "gcfv2", + uri: GCF_URL, + serviceAccount: null, + }); + }); + + it("should leave service account unset when omitted", () => { + const endpoint = cloudfunctionsv2.endpointFromFunction({ + ...HAVE_CLOUD_FUNCTION_V2, + serviceConfig: { + ...HAVE_CLOUD_FUNCTION_V2.serviceConfig, + service: "service", + uri: RUN_URI, + }, + }); + + expect(endpoint).to.deep.equal({ + ...ENDPOINT, + httpsTrigger: {}, + platform: "gcfv2", + uri: GCF_URL, + }); + expect(endpoint).not.to.have.property("serviceAccount"); + }); + it("should transform fields", () => { const extraFields: backend.ServiceConfiguration = { minInstances: 1, @@ -874,6 +914,19 @@ describe("cloudfunctionsv2", () => { }); describe("updateFunction", () => { + const expectServiceAccountUpdateMask = (queryParams: ParsedUrlQuery): boolean => { + const updateMask = queryParams.updateMask; + expect(updateMask).to.be.a("string"); + if (typeof updateMask !== "string") { + return false; + } + expect(updateMask.split(",")).to.include.members([ + "buildConfig.serviceAccount", + "serviceConfig.serviceAccountEmail", + ]); + return true; + }; + it("should set default environment variables", async () => { const scope = nock(functionsV2Origin()) .patch("/v2/projects/project/locations/region/functions/id", (body) => { @@ -897,5 +950,65 @@ describe("cloudfunctionsv2", () => { await cloudfunctionsv2.updateFunction(CLOUD_FUNCTION_V2); expect(scope.isDone()).to.be.true; }); + + it("should include custom service account fields in update mask", async () => { + const testFunction: cloudfunctionsv2.InputCloudFunction = { + ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: "projects/project/serviceAccounts/inlined@google.com", + }, + serviceConfig: { + ...CLOUD_FUNCTION_V2.serviceConfig, + serviceAccountEmail: "inlined@google.com", + }, + }; + + const scope = nock(functionsV2Origin()) + .patch( + "/v2/projects/project/locations/region/functions/id", + (body: cloudfunctionsv2.InputCloudFunction) => { + expect(body.buildConfig.serviceAccount).to.equal( + "projects/project/serviceAccounts/inlined@google.com", + ); + expect(body.serviceConfig.serviceAccountEmail).to.equal("inlined@google.com"); + return true; + }, + ) + .query(expectServiceAccountUpdateMask) + .reply(200, { name: "operations/123", done: true }); + + await cloudfunctionsv2.updateFunction(testFunction); + expect(scope.isDone()).to.be.true; + }); + + it("should include null service account fields in update mask", async () => { + const testFunction: cloudfunctionsv2.InputCloudFunction = { + ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: null, + }, + serviceConfig: { + ...CLOUD_FUNCTION_V2.serviceConfig, + serviceAccountEmail: null, + }, + }; + + const scope = nock(functionsV2Origin()) + .patch( + "/v2/projects/project/locations/region/functions/id", + (body: cloudfunctionsv2.InputCloudFunction) => { + expect(body.buildConfig.serviceAccount).to.equal(null); + expect(body.serviceConfig.serviceAccountEmail).to.equal(null); + return true; + }, + ) + .query(expectServiceAccountUpdateMask) + .reply(200, { name: "operations/123", done: true }); + + await cloudfunctionsv2.updateFunction(testFunction); + expect(scope.isDone()).to.be.true; + }); }); });