diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index f45fbfa7861..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"; @@ -244,6 +245,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 +337,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 +414,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 +434,10 @@ describe("cloudfunctionsv2", () => { }), ).to.deep.equal({ ...CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + serviceAccount: null, + }, serviceConfig: { ...CLOUD_FUNCTION_V2.serviceConfig, serviceAccountEmail: null, @@ -706,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, @@ -858,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) => { @@ -881,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; + }); }); }); diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index d3c5e629771..6235c81fff8 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -51,6 +51,7 @@ export interface BuildConfig { source: Source; sourceToken?: string; environmentVariables: Record; + serviceAccount?: string | null; // Output only build?: string; @@ -467,16 +468,22 @@ 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 */), - ); + + // 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) { + 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;