diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index ba735ff68ba..30342c2ec97 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -72,7 +72,7 @@ describe("Backend", () => { const RUN_SERVICE: run.Service = { name: "projects/project/locations/region/services/id", labels: { - "goog-managed-by": "cloud-functions", + "goog-managed-by": "cloudfunctions", "goog-cloudfunctions-runtime": "nodejs16", "firebase-functions-codebase": "default", }, @@ -128,6 +128,17 @@ describe("Backend", () => { expect(backend.functionName(ENDPOINT)).to.equal( "projects/project/locations/region/functions/id", ); + expect(backend.eventarcTriggerIdForFunction(ENDPOINT)).to.equal("firebase-id-region"); + expect( + backend.eventarcTriggerIdForFunction({ ...ENDPOINT, region: "us-central1" }), + ).not.to.equal(backend.eventarcTriggerIdForFunction({ ...ENDPOINT, region: "europe-west1" })); + expect( + backend.eventarcTriggerIdForFunction({ + ...ENDPOINT, + id: "a".repeat(60), + region: "us-central1", + }), + ).to.have.length(63); }); it("merge", () => { @@ -332,7 +343,7 @@ describe("Backend", () => { }, labels: { "deployment-tool": "cli-firebase", - "goog-managed-by": "cloud-functions", + "goog-managed-by": "cloudfunctions", "goog-cloudfunctions-runtime": "nodejs16", "firebase-functions-codebase": "default", }, @@ -340,6 +351,7 @@ describe("Backend", () => { ingressSettings: "ALLOW_ALL" as const, timeoutSeconds: 60, serviceAccount: null, + runServiceId: "id", }; delete wantEndpoint.state; @@ -347,6 +359,44 @@ describe("Backend", () => { expect(listAllFunctionsV2).to.not.have.been.called; }); + it("should read existing functions from Cloud Run when Dart functions are enabled", async () => { + isEnabled.withArgs("dartfunctions").returns(true); + listAllFunctions.onFirstCall().resolves({ + functions: [], + unreachable: [], + }); + listAllFunctionsV2.onFirstCall().resolves({ + functions: [HAVE_CLOUD_FUNCTION_V2], + unreachable: [], + }); + const directRunService: run.Service = { + ...RUN_SERVICE, + name: "projects/project/locations/region/services/dart-function", + labels: { + ...RUN_SERVICE.labels, + "goog-managed-by": "firebase-functions", + "goog-cloudfunctions-runtime": "dart3", + }, + annotations: { + "firebase-functions-metadata": JSON.stringify({ + functionId: "dart-function", + }), + }, + }; + listServices.onFirstCall().resolves([RUN_SERVICE, directRunService]); + + const context = newContext(); + context.projectId = "project"; + const have = await backend.existingBackend(context); + + expect(listServices).to.have.been.calledOnceWith("project"); + expect(listAllFunctionsV2).to.have.been.calledOnceWith("project"); + expect(backend.allEndpoints(have).map((endpoint) => endpoint.platform)).to.have.members([ + "gcfv2", + "run", + ]); + }); + it("should handle Cloud Run list errors gracefully when experiment is enabled", async () => { isEnabled.withArgs("functionsrunapionly").returns(true); listAllFunctions.onFirstCall().resolves({ @@ -387,7 +437,7 @@ describe("Backend", () => { }, labels: { "deployment-tool": "cli-firebase", - "goog-managed-by": "cloud-functions", + "goog-managed-by": "cloudfunctions", "goog-cloudfunctions-runtime": "nodejs16", "firebase-functions-codebase": "default", }, @@ -395,6 +445,7 @@ describe("Backend", () => { ingressSettings: "ALLOW_ALL" as const, timeoutSeconds: 60, serviceAccount: null, + runServiceId: "id", }; delete wantEndpoint.state; @@ -500,6 +551,29 @@ describe("Backend", () => { ); }); + it("should throw when Cloud Run discovery fails for a Dart deployment", async () => { + isEnabled.withArgs("dartfunctions").returns(true); + listAllFunctions.onFirstCall().resolves({ + functions: [], + unreachable: [], + }); + listAllFunctionsV2.onFirstCall().resolves({ + functions: [], + unreachable: [], + }); + listServices.rejects(new Error("Cloud Run API unavailable")); + const want = backend.of({ + ...ENDPOINT, + platform: "run", + httpsTrigger: {}, + }); + + await expect(backend.checkAvailability(newContext(), want)).to.eventually.be.rejectedWith( + FirebaseError, + /Existing Dart functions cannot be reconciled safely/, + ); + }); + it("Should only warn when deploying GCFv1 and GCFv2 is unavailable.", async () => { listAllFunctions.onFirstCall().resolves({ functions: [], diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 1761c317fad..9f1c18887d4 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -8,6 +8,7 @@ import { Context } from "./args"; import { assertExhaustive, flattenArray } from "../../functional"; import { logger } from "../../logger"; import * as experiments from "../../experiments"; +import * as crypto from "crypto"; /** Retry settings for a ScheduleSpec. */ export interface ScheduleRetryConfig { @@ -581,6 +582,17 @@ export function scheduleIdForFunction(cloudFunction: TargetIds): string { return `firebase-schedule-${cloudFunction.id}-${cloudFunction.region}`; } +/** Returns a region-qualified Eventarc trigger ID within the 63-character API limit. */ +export function eventarcTriggerIdForFunction(cloudFunction: TargetIds): string { + const normalizedId = cloudFunction.id.toLowerCase().replace(/_/g, "-"); + const fullId = `firebase-${normalizedId}-${cloudFunction.region}`; + if (fullId.length <= 63) { + return fullId; + } + const hash = crypto.createHash("sha256").update(fullId).digest("hex").slice(0, 8); + return `${fullId.slice(0, 54)}-${hash}`; +} + /** * A caching accessor of the existing backend. * The method explicitly loads Cloud Functions from their API but implicitly deduces @@ -619,14 +631,17 @@ async function loadExistingBackend(ctx: Context): Promise { } unreachableRegions.gcfV1 = gcfV1Results.unreachable; + const addEndpoint = (endpoint: Endpoint): void => { + existingBackend.endpoints[endpoint.region] = existingBackend.endpoints[endpoint.region] || {}; + existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint; + }; + if (experiments.isEnabled("functionsrunapionly")) { await loadCloudRunServices(ctx, existingBackend, unreachableRegions, false); } else { const gcfV2Results = await gcfV2.listAllFunctions(ctx.projectId); for (const apiFunction of gcfV2Results.functions) { - const endpoint = gcfV2.endpointFromFunction(apiFunction); - existingBackend.endpoints[endpoint.region] = existingBackend.endpoints[endpoint.region] || {}; - existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint; + addEndpoint(gcfV2.endpointFromFunction(apiFunction)); } unreachableRegions.gcfV2 = gcfV2Results.unreachable; @@ -681,9 +696,12 @@ export async function checkAvailability(context: Context, want: Backend): Promis await existingBackend(context); const gcfV1Regions = new Set(); const gcfV2Regions = new Set(); + let hasRunEndpoints = false; for (const ep of allEndpoints(want)) { if (ep.platform === "gcfv1") { gcfV1Regions.add(ep.region); + } else if (ep.platform === "run") { + hasRunEndpoints = true; } else { gcfV2Regions.add(ep.region); } @@ -711,6 +729,13 @@ export async function checkAvailability(context: Context, want: Backend): Promis ); } + if (hasRunEndpoints && context.unreachableRegions?.run.length) { + throw new FirebaseError( + "Cloud Run services could not be listed for this project. " + + "Existing Dart functions cannot be reconciled safely. Ensure the Cloud Run API is enabled and that you have permission to list services, then try again.", + ); + } + if (context.unreachableRegions?.gcfV1.length) { utils.logLabeledWarning( "functions", diff --git a/src/deploy/functions/checkIam.spec.ts b/src/deploy/functions/checkIam.spec.ts index b1dadcef3c6..06ee083c57b 100644 --- a/src/deploy/functions/checkIam.spec.ts +++ b/src/deploy/functions/checkIam.spec.ts @@ -3,6 +3,7 @@ import * as sinon from "sinon"; import * as checkIam from "./checkIam"; import * as storage from "../../gcp/storage"; import * as rm from "../../gcp/resourceManager"; +import * as iam from "../../gcp/iam"; import * as backend from "./backend"; const projectId = "my-project"; @@ -75,6 +76,26 @@ describe("checkIam", () => { }); }); + describe("obtainEventarcDeliveryAgentBindings", () => { + it("grants delivery roles to explicit service accounts", () => { + const serviceAccount = "eventarc@my-project.iam.gserviceaccount.com"; + + const bindings = checkIam.obtainEventarcDeliveryAgentBindings([serviceAccount]); + expect(bindings).to.deep.equal([ + { + role: checkIam.RUN_INVOKER_ROLE, + members: [`serviceAccount:${serviceAccount}`], + }, + { + role: checkIam.EVENTARC_EVENT_RECEIVER_ROLE, + members: [`serviceAccount:${serviceAccount}`], + }, + ]); + bindings[0].members.push("serviceAccount:another@example.com"); + expect(bindings[1].members).not.to.include("serviceAccount:another@example.com"); + }); + }); + describe("ensureServiceAgentRoles", () => { it("should return early if we do not have new services", async () => { const v1EventFn: backend.Endpoint = { @@ -119,6 +140,67 @@ describe("checkIam", () => { expect(setIamStub).to.not.have.been.called; }); + it("should ensure delivery roles for new direct Run event functions", async () => { + const serviceAccount = "eventarc@"; + const expandedServiceAccount = "eventarc@my-project.iam.gserviceaccount.com"; + const runEventFn: backend.Endpoint = { + id: "dartFirestore", + entryPoint: "dartFirestore", + platform: "run", + eventTrigger: { + eventType: "google.cloud.firestore.document.v1.written", + retry: false, + serviceAccount, + }, + ...SPEC, + project: projectId, + }; + getIamStub.resolves({ + etag: "etag", + version: 3, + bindings: [BINDING], + }); + setIamStub.resolves(); + + await checkIam.ensureServiceAgentRoles( + projectId, + projectNumber, + backend.of(runEventFn), + backend.empty(), + ); + + const updatedPolicy = setIamStub.firstCall.args[1]; + const eventReceiverBinding = updatedPolicy.bindings.find( + (binding: iam.Binding) => binding.role === checkIam.EVENTARC_EVENT_RECEIVER_ROLE, + ); + expect(eventReceiverBinding?.members).to.include(`serviceAccount:${expandedServiceAccount}`); + }); + + it("should not read IAM for unchanged direct Run event functions", async () => { + const runEventFn: backend.Endpoint = { + id: "dartFirestore", + entryPoint: "dartFirestore", + platform: "run", + eventTrigger: { + eventType: "google.cloud.firestore.document.v1.written", + retry: false, + serviceAccount: "eventarc@", + }, + ...SPEC, + project: projectId, + }; + + await checkIam.ensureServiceAgentRoles( + projectId, + projectNumber, + backend.of(runEventFn), + backend.of(runEventFn), + ); + + expect(getIamStub).to.not.have.been.called; + expect(setIamStub).to.not.have.been.called; + }); + it("should return early if we fail to get the IAM policy", async () => { storageStub.resolves(STORAGE_RES); getIamStub.rejects("Failed to get the IAM policy"); diff --git a/src/deploy/functions/checkIam.ts b/src/deploy/functions/checkIam.ts index f39031ac079..2106506d899 100644 --- a/src/deploy/functions/checkIam.ts +++ b/src/deploy/functions/checkIam.ts @@ -7,6 +7,7 @@ import { Options } from "../../options"; import { flattenArray } from "../../functional"; import * as iam from "../../gcp/iam"; import * as gce from "../../gcp/computeEngine"; +import * as proto from "../../gcp/proto"; import * as args from "./args"; import * as backend from "./backend"; import { trackGA4 } from "../../track"; @@ -170,14 +171,19 @@ export function obtainPubSubServiceAgentBindings(projectNumber: string): iam.Bin export async function obtainDefaultComputeServiceAgentBindings( projectNumber: string, ): Promise { - const defaultComputeServiceAgent = `serviceAccount:${await gce.getDefaultServiceAccount(projectNumber)}`; + return obtainEventarcDeliveryAgentBindings([await gce.getDefaultServiceAccount(projectNumber)]); +} + +/** Returns the roles required for service accounts that deliver Eventarc events to Cloud Run. */ +export function obtainEventarcDeliveryAgentBindings(serviceAccounts: string[]): iam.Binding[] { + const members = serviceAccounts.map((serviceAccount) => `serviceAccount:${serviceAccount}`); const runInvokerBinding: iam.Binding = { role: RUN_INVOKER_ROLE, - members: [defaultComputeServiceAgent], + members: [...members], }; const eventarcEventReceiverBinding: iam.Binding = { role: EVENTARC_EVENT_RECEIVER_ROLE, - members: [defaultComputeServiceAgent], + members: [...members], }; return [runInvokerBinding, eventarcEventReceiverBinding]; } @@ -249,7 +255,42 @@ export async function ensureServiceAgentRoles( const newServices = wantServices.filter( (wantS) => !haveServices.find((haveS) => wantS.name === haveS.name), ); - if (newServices.length === 0) { + const runEventEndpoints = backend + .allEndpoints(want) + .filter( + (endpoint): endpoint is backend.Endpoint & backend.EventTriggered => + endpoint.platform === "run" && backend.isEventTriggered(endpoint), + ); + const changedRunEventEndpoints: Array = []; + const runEventServiceAccounts = new Set(); + let defaultServiceAccount: string | undefined; + const deliveryServiceAccount = async ( + endpoint: backend.Endpoint & backend.EventTriggered, + ): Promise => { + const configuredServiceAccount = + endpoint.eventTrigger.serviceAccount || endpoint.serviceAccount; + if (!configuredServiceAccount) { + defaultServiceAccount ??= await gce.getDefaultServiceAccount(projectNumber); + } + return proto.formatServiceAccount( + configuredServiceAccount || defaultServiceAccount!, + endpoint.project, + true, + ); + }; + for (const endpoint of runEventEndpoints) { + const existing = have.endpoints[endpoint.region]?.[endpoint.id]; + const existingServiceAccount = + existing?.platform === "run" && backend.isEventTriggered(existing) + ? await deliveryServiceAccount(existing) + : undefined; + const desiredServiceAccount = await deliveryServiceAccount(endpoint); + if (!existingServiceAccount || existingServiceAccount !== desiredServiceAccount) { + changedRunEventEndpoints.push(endpoint); + runEventServiceAccounts.add(desiredServiceAccount); + } + } + if (newServices.length === 0 && runEventServiceAccounts.size === 0) { return; } @@ -264,6 +305,14 @@ export async function ensureServiceAgentRoles( requiredBindings.push(...obtainPubSubServiceAgentBindings(projectNumber)); requiredBindings.push(...(await obtainDefaultComputeServiceAgentBindings(projectNumber))); } + if (runEventServiceAccounts.size > 0) { + requiredBindings.push({ + role: EVENTARC_EVENT_RECEIVER_ROLE, + members: [...runEventServiceAccounts].map( + (serviceAccount) => `serviceAccount:${serviceAccount}`, + ), + }); + } if (requiredBindings.length === 0) { return; } @@ -271,7 +320,10 @@ export async function ensureServiceAgentRoles( projectId, projectNumber, requiredBindings, - newServices.map((service) => service.api), + [ + ...newServices.map((service) => service.api), + ...changedRunEventEndpoints.map((endpoint) => endpoint.id), + ], dryRun, ); } diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 06b845d87a1..e1818a9a94f 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1196,6 +1196,36 @@ describe("prepare", () => { generateServiceIdentityStub.calledWith("project", "eventarc.googleapis.com", "functions"), ).to.be.true; }); + + it("should enable Eventarc APIs for event-triggered Cloud Run services", async () => { + const e: backend.Endpoint = { + id: "dartFirestore", + platform: "run", + region: "us-central1", + project: "project", + entryPoint: "entry", + runtime: "dart3", + eventTrigger: { + eventType: "google.cloud.firestore.document.v1.written", + retry: false, + }, + }; + + await prepare.ensureAllRequiredAPIsEnabled("project", backend.of(e)); + + expect(ensureApiStub.calledWith("project", "https://run.googleapis.com", "functions")).to.be + .true; + expect(ensureApiStub.calledWith("project", "https://eventarc.googleapis.com", "functions")).to + .be.true; + expect(ensureApiStub.calledWith("project", "https://pubsub.googleapis.com", "functions")).to + .be.true; + expect( + generateServiceIdentityStub.calledWith("project", "pubsub.googleapis.com", "functions"), + ).to.be.true; + expect( + generateServiceIdentityStub.calledWith("project", "eventarc.googleapis.com", "functions"), + ).to.be.true; + }); }); describe("discoverSecurityDetails", () => { diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index d43006a19e3..3168a74fe98 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -920,7 +920,12 @@ export async function ensureAllRequiredAPIsEnabled( return ensureApiEnabled.ensure(projectNumber, api, "functions", /* silent=*/ false); }), ); - if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2")) { + if ( + backend.someEndpoint( + wantBackend, + (e) => e.platform === "gcfv2" || (e.platform === "run" && backend.isEventTriggered(e)), + ) + ) { // Note: Some of these are premium APIs that require billing to be enabled. // We'd eventually have to add special error handling for billing APIs, but // enableCloudBuild is called above and has this special casing already. diff --git a/src/deploy/functions/release/fabricator.spec.ts b/src/deploy/functions/release/fabricator.spec.ts index bcb17f49f66..ddc8323fc82 100644 --- a/src/deploy/functions/release/fabricator.spec.ts +++ b/src/deploy/functions/release/fabricator.spec.ts @@ -59,6 +59,10 @@ describe("Fabricator", () => { tasks.queueFromEndpoint.restore(); tasks.queueNameForEndpoint.restore(); runv2.serviceFromEndpoint.restore(); + runv2.functionNameToServiceName.restore(); + eventarc.triggerMatches.restore(); + eventarc.triggerRequiresReplacement.restore(); + eventarc.triggerForCreate.restore(); gcf.createFunction.rejects(new Error("unexpected gcf.createFunction")); gcf.updateFunction.rejects(new Error("unexpected gcf.updateFunction")); gcf.deleteFunction.rejects(new Error("unexpected gcf.deleteFunction")); @@ -74,6 +78,10 @@ describe("Fabricator", () => { eventarc.deleteChannel.rejects(new Error("unexpected eventarc.deleteChannel")); eventarc.getChannel.rejects(new Error("unexpected eventarc.getChannel")); eventarc.updateChannel.rejects(new Error("unexpected eventarc.updateChannel")); + eventarc.getTrigger.rejects(new Error("unexpected eventarc.getTrigger")); + eventarc.createTrigger.rejects(new Error("unexpected eventarc.createTrigger")); + eventarc.updateTrigger.rejects(new Error("unexpected eventarc.updateTrigger")); + eventarc.deleteTrigger.rejects(new Error("unexpected eventarc.deleteTrigger")); run.getIamPolicy.rejects(new Error("unexpected run.getIamPolicy")); run.setIamPolicy.rejects(new Error("unexpected run.setIamPolicy")); run.setInvokerCreate.rejects(new Error("unexpected run.setInvokerCreate")); @@ -1842,6 +1850,335 @@ describe("Fabricator", () => { }); }); + describe("Cloud Run Eventarc triggers", () => { + const firestoreTrigger: backend.EventTrigger = { + eventType: "google.cloud.firestore.document.v1.written", + eventFilters: { + database: "(default)", + namespace: "(default)", + }, + eventFilterPathPatterns: { + document: "users/{userId}", + }, + retry: false, + region: "nam5", + serviceAccount: "eventarc@test-project.iam.gserviceaccount.com", + }; + + function firestoreEndpoint(): backend.Endpoint & backend.EventTriggered { + return endpoint( + { eventTrigger: JSON.parse(JSON.stringify(firestoreTrigger)) }, + { + platform: "run", + project: "test-project", + runtime: "dart3", + id: "Firestore_Handler", + }, + ) as backend.Endpoint & backend.EventTriggered; + } + + it("converts a Firestore endpoint to an Eventarc trigger", async () => { + const trigger = await fab.eventarcTriggerFromEndpoint(firestoreEndpoint()); + + expect(trigger).to.deep.equal({ + name: "projects/test-project/locations/nam5/triggers/firebase-firestore-handler-us-central1", + eventFilters: [ + { + attribute: "type", + value: "google.cloud.firestore.document.v1.written", + }, + { + attribute: "database", + value: "(default)", + }, + { + attribute: "namespace", + value: "(default)", + }, + { + attribute: "document", + value: "users/{userId}", + operator: "match-path-pattern", + }, + ], + serviceAccount: "eventarc@test-project.iam.gserviceaccount.com", + destination: { + cloudRun: { + service: "firestore-handler", + region: "us-central1", + }, + }, + labels: { + "deployment-tool": "cli-firebase", + runtime: "dart3", + }, + eventDataContentType: "application/protobuf", + }); + }); + + it("resolves the project's default compute service account", async () => { + const ep = firestoreEndpoint(); + ep.eventTrigger.serviceAccount = null; + ep.serviceAccount = null; + const getDefaultServiceAccount = sinon + .stub(gce, "getDefaultServiceAccount") + .resolves("custom-default@test-project.iam.gserviceaccount.com"); + + const trigger = await fab.eventarcTriggerFromEndpoint(ep); + + expect(getDefaultServiceAccount).to.have.been.calledOnceWith("1234567"); + expect(trigger.serviceAccount).to.equal( + "custom-default@test-project.iam.gserviceaccount.com", + ); + }); + + it("expands shorthand service accounts and scopes Run invoker access", async () => { + const ep = firestoreEndpoint(); + ep.eventTrigger.serviceAccount = "eventarc@"; + ep.runServiceId = "firestore-handler"; + run.setInvokerUpdate.resolves(); + + const trigger = await fab.eventarcTriggerFromEndpoint(ep); + await fab.setEventarcInvoker(ep, [trigger.serviceAccount]); + + expect(trigger.serviceAccount).to.equal("eventarc@test-project.iam.gserviceaccount.com"); + expect(run.setInvokerUpdate).to.have.been.calledOnceWith( + "test-project", + "projects/test-project/locations/us-central1/services/firestore-handler", + ["eventarc@test-project.iam.gserviceaccount.com"], + ); + }); + + it("creates a missing Firestore trigger and polls its operation", async () => { + eventarc.getTrigger.resolves(undefined); + eventarc.createTrigger.resolves({ name: "operations/create-trigger" } as any); + poller.pollOperation.resolves(); + run.setInvokerUpdate.resolves(); + const ep = firestoreEndpoint(); + + await fab.setTrigger(ep); + + expect(eventarc.createTrigger).to.have.been.calledOnceWith( + sinon.match({ + name: "projects/test-project/locations/nam5/triggers/firebase-firestore-handler-us-central1", + }), + ); + expect(poller.pollOperation).to.have.been.calledOnceWith( + sinon.match({ + operationResourceName: "operations/create-trigger", + }), + ); + }); + + it("treats a matching trigger as created after a create conflict", async () => { + const ep = firestoreEndpoint(); + const desired = await fab.eventarcTriggerFromEndpoint(ep); + const conflict = new Error("already exists"); + (conflict as any).status = 409; + eventarc.getTrigger.onFirstCall().resolves(undefined); + eventarc.getTrigger.onSecondCall().resolves(desired); + eventarc.createTrigger.rejects(conflict); + run.setInvokerUpdate.resolves(); + + await fab.setTrigger(ep); + + expect(eventarc.createTrigger).to.have.been.calledOnce; + expect(poller.pollOperation).not.to.have.been.called; + }); + + it("does not recreate an existing Firestore trigger", async () => { + const ep = firestoreEndpoint(); + eventarc.getTrigger.resolves(await fab.eventarcTriggerFromEndpoint(ep)); + run.setInvokerUpdate.resolves(); + + await fab.setTrigger(ep); + + expect(eventarc.createTrigger).not.to.have.been.called; + expect(eventarc.updateTrigger).not.to.have.been.called; + expect(eventarc.deleteTrigger).not.to.have.been.called; + }); + + it("patches mutable trigger fields without deleting the trigger", async () => { + const ep = firestoreEndpoint(); + const existing = await fab.eventarcTriggerFromEndpoint(ep); + existing.labels = { + "deployment-tool": "cli-firebase", + runtime: "dart2", + owner: "customer", + }; + eventarc.getTrigger.resolves(existing); + eventarc.updateTrigger.resolves({ name: "operations/update-trigger" } as any); + poller.pollOperation.resolves(); + run.setInvokerUpdate.resolves(); + + await fab.setTrigger(ep); + + expect(eventarc.updateTrigger).to.have.been.calledOnceWith( + sinon.match({ + labels: { + "deployment-tool": "cli-firebase", + runtime: "dart3", + owner: "customer", + }, + }), + ); + expect(eventarc.deleteTrigger).not.to.have.been.called; + expect(eventarc.createTrigger).not.to.have.been.called; + }); + + it("keeps both Run invokers when a trigger PATCH outcome is uncertain", async () => { + const ep = firestoreEndpoint(); + ep.eventTrigger.serviceAccount = "new@test-project.iam.gserviceaccount.com"; + const existing = await fab.eventarcTriggerFromEndpoint(ep); + existing.serviceAccount = "old@test-project.iam.gserviceaccount.com"; + eventarc.getTrigger.resolves(existing); + eventarc.updateTrigger.resolves({ name: "operations/update-trigger" } as any); + poller.pollOperation.rejects(new Error("operation status unavailable")); + run.setInvokerUpdate.resolves(); + + await expect(fab.setTrigger(ep)).to.be.rejectedWith(reporter.DeploymentError); + + expect(run.setInvokerUpdate.firstCall.args[2]).to.have.members([ + "old@test-project.iam.gserviceaccount.com", + "new@test-project.iam.gserviceaccount.com", + ]); + expect(run.setInvokerUpdate.lastCall.args[2]).to.have.members([ + "old@test-project.iam.gserviceaccount.com", + "new@test-project.iam.gserviceaccount.com", + ]); + }); + + it("replaces a Firestore trigger when its immutable routing changes", async () => { + const ep = firestoreEndpoint(); + const existing = await fab.eventarcTriggerFromEndpoint(ep); + existing.eventFilters = existing.eventFilters.map((filter) => + filter.attribute === "document" ? { ...filter, value: "posts/{postId}" } : filter, + ); + eventarc.getTrigger.resolves(existing); + eventarc.deleteTrigger.resolves({ name: "operations/delete-trigger" } as any); + eventarc.createTrigger.resolves({ name: "operations/create-trigger" } as any); + poller.pollOperation.resolves(); + run.setInvokerUpdate.resolves(); + + await fab.setTrigger(ep); + + expect(eventarc.deleteTrigger).to.have.been.calledOnceWith( + "projects/test-project/locations/nam5/triggers/firebase-firestore-handler-us-central1", + ); + expect(eventarc.createTrigger).to.have.been.calledOnce; + expect(poller.pollOperation).to.have.been.calledTwice; + }); + + it("restores the previous trigger when replacement creation fails", async () => { + const ep = firestoreEndpoint(); + const existing = await fab.eventarcTriggerFromEndpoint(ep); + existing.eventFilters = existing.eventFilters.map((filter) => + filter.attribute === "document" ? { ...filter, value: "posts/{postId}" } : filter, + ); + eventarc.getTrigger.onFirstCall().resolves(existing); + eventarc.getTrigger.onSecondCall().resolves(undefined); + eventarc.deleteTrigger.resolves({ name: "operations/delete-trigger" } as any); + eventarc.createTrigger.onFirstCall().rejects(new Error("create failed")); + eventarc.createTrigger + .onSecondCall() + .resolves({ name: "operations/rollback-trigger" } as any); + poller.pollOperation.resolves(); + run.setInvokerUpdate.resolves(); + + await expect(fab.setTrigger(ep)).to.be.rejectedWith(reporter.DeploymentError); + + expect(eventarc.createTrigger).to.have.been.calledTwice; + expect(eventarc.createTrigger.secondCall).to.have.been.calledWith( + sinon.match({ + name: "projects/test-project/locations/nam5/triggers/firebase-firestore-handler-us-central1", + eventFilters: existing.eventFilters, + }), + ); + }); + + it("restores the previous trigger when delete operation polling fails", async () => { + const ep = firestoreEndpoint(); + const existing = await fab.eventarcTriggerFromEndpoint(ep); + existing.eventFilters = existing.eventFilters.map((filter) => + filter.attribute === "document" ? { ...filter, value: "posts/{postId}" } : filter, + ); + eventarc.getTrigger.onFirstCall().resolves(existing); + eventarc.getTrigger.onSecondCall().resolves(undefined); + eventarc.deleteTrigger.resolves({ name: "operations/delete-trigger" } as any); + eventarc.createTrigger.resolves({ name: "operations/rollback-trigger" } as any); + poller.pollOperation.onFirstCall().rejects(new Error("delete poll failed")); + poller.pollOperation.onSecondCall().resolves(); + run.setInvokerUpdate.resolves(); + + await expect(fab.setTrigger(ep)).to.be.rejectedWith(reporter.DeploymentError); + + expect(eventarc.createTrigger).to.have.been.calledOnceWith( + sinon.match({ + eventFilters: existing.eventFilters, + }), + ); + }); + + it("restores the previous Run invoker when replacement fails", async () => { + const ep = firestoreEndpoint(); + ep.eventTrigger.serviceAccount = "new@test-project.iam.gserviceaccount.com"; + const existing = await fab.eventarcTriggerFromEndpoint(ep); + existing.serviceAccount = "old@test-project.iam.gserviceaccount.com"; + existing.eventFilters = existing.eventFilters.map((filter) => + filter.attribute === "document" ? { ...filter, value: "posts/{postId}" } : filter, + ); + eventarc.getTrigger.onFirstCall().resolves(existing); + eventarc.getTrigger.onSecondCall().resolves(undefined); + eventarc.deleteTrigger.resolves({ name: "operations/delete-trigger" } as any); + eventarc.createTrigger.onFirstCall().rejects(new Error("create failed")); + eventarc.createTrigger + .onSecondCall() + .resolves({ name: "operations/rollback-trigger" } as any); + poller.pollOperation.resolves(); + run.setInvokerUpdate.resolves(); + + await expect(fab.setTrigger(ep)).to.be.rejectedWith(reporter.DeploymentError); + + expect(run.setInvokerUpdate.firstCall.args[2]).to.have.members([ + "old@test-project.iam.gserviceaccount.com", + "new@test-project.iam.gserviceaccount.com", + ]); + expect(run.setInvokerUpdate.lastCall.args[2]).to.deep.equal([ + "old@test-project.iam.gserviceaccount.com", + ]); + }); + + it("deletes an existing Firestore trigger and polls its operation", async () => { + eventarc.getTrigger.resolves({ name: "existing-trigger" } as any); + eventarc.deleteTrigger.resolves({ name: "operations/delete-trigger" } as any); + poller.pollOperation.resolves(); + const ep = firestoreEndpoint(); + + await fab.deleteTrigger(ep); + + expect(eventarc.deleteTrigger).to.have.been.calledOnceWith( + "projects/test-project/locations/nam5/triggers/firebase-firestore-handler-us-central1", + ); + expect(poller.pollOperation).to.have.been.calledOnceWith( + sinon.match({ + operationResourceName: "operations/delete-trigger", + }), + ); + }); + + it("deletes a legacy direct Run trigger with unknown event metadata", async () => { + eventarc.getTrigger.resolves({ name: "existing-trigger" } as any); + eventarc.deleteTrigger.resolves({ name: "operations/delete-trigger" } as any); + poller.pollOperation.resolves(); + const ep = firestoreEndpoint(); + ep.eventTrigger.eventType = "unknown"; + + await fab.deleteTrigger(ep); + + expect(eventarc.deleteTrigger).to.have.been.calledOnce; + }); + }); + describe("createRunFunction", () => { it("creates a Cloud Run service with correct configuration", async () => { runv2.createService.resolves({ uri: "https://service", name: "service" } as any); @@ -1921,6 +2258,28 @@ describe("Fabricator", () => { expect(run.setInvokerCreate).to.not.have.been.called; }); + + it("normalizes the Cloud Run service ID consistently", async () => { + runv2.createService.resolves({ uri: "https://service", name: "service" } as any); + run.setInvokerCreate.resolves(); + const ep = endpoint( + { httpsTrigger: {} }, + { + platform: "run", + id: "My_Function", + }, + ); + + await fab.createRunFunction(ep); + + expect(runv2.createService).to.have.been.calledWith( + ep.project, + ep.region, + "my-function", + sinon.match.any, + ); + expect(ep.runServiceId).to.equal("my-function"); + }); }); describe("updateRunFunction", () => { @@ -2029,6 +2388,21 @@ describe("Fabricator", () => { expect(runv2.deleteService).to.have.been.called; }); + + it("normalizes the Cloud Run service ID before deletion", async () => { + runv2.deleteService.resolves(); + const ep = endpoint( + { httpsTrigger: {} }, + { + platform: "run", + id: "My_Function", + }, + ); + + await fab.deleteRunFunction(ep); + + expect(runv2.deleteService).to.have.been.calledWith(ep.project, ep.region, "my-function"); + }); }); describe("declarative security phases", () => { diff --git a/src/deploy/functions/release/fabricator.ts b/src/deploy/functions/release/fabricator.ts index b43339dd955..63b5370e8be 100644 --- a/src/deploy/functions/release/fabricator.ts +++ b/src/deploy/functions/release/fabricator.ts @@ -1,7 +1,7 @@ import * as clc from "colorette"; import { DEFAULT_RETRY_CODES, Executor } from "./executor"; -import { FirebaseError } from "../../../error"; +import { FirebaseError, getErrStatus } from "../../../error"; import { SourceTokenScraper } from "./sourceTokenScraper"; import { Timer } from "./timer"; import { assertExhaustive } from "../../../functional"; @@ -23,11 +23,13 @@ import * as pubsub from "../../../gcp/pubsub"; import * as reporter from "./reporter"; import * as run from "../../../gcp/run"; import * as runV2 from "../../../gcp/runv2"; +import * as proto from "../../../gcp/proto"; import * as scheduler from "../../../gcp/cloudscheduler"; import * as utils from "../../../utils"; import * as services from "../services"; import { getDataConnectP4SA } from "../services/dataconnect"; import { AUTH_BLOCKING_EVENTS } from "../../../functions/events/v1"; +import { FIRESTORE_EVENTS } from "../../../functions/events/v2"; import * as gce from "../../../gcp/computeEngine"; import { getHumanFriendlyPlatformName } from "../functionsDeployHelper"; @@ -805,16 +807,12 @@ export class Fabricator { }, }; + const serviceId = runV2.functionNameToServiceName(endpoint.id); await this.runFunctionExecutor .run(async () => { - const op = await runV2.createService( - endpoint.project, - endpoint.region, - endpoint.id, - service, - ); + const op = await runV2.createService(endpoint.project, endpoint.region, serviceId, service); endpoint.uri = op.uri; - endpoint.runServiceId = endpoint.id; + endpoint.runServiceId = serviceId; }) .catch(rethrowAs(endpoint, "create")); @@ -857,7 +855,8 @@ export class Fabricator { .run(async () => { const op = await runV2.updateService(service); endpoint.uri = op.uri; - endpoint.runServiceId = endpoint.id; + endpoint.runServiceId = + endpoint.runServiceId ?? runV2.functionNameToServiceName(endpoint.id); }) .catch(rethrowAs(endpoint, "update")); @@ -880,7 +879,11 @@ export class Fabricator { await this.runFunctionExecutor .run(async () => { try { - await runV2.deleteService(endpoint.project, endpoint.region, endpoint.id); + await runV2.deleteService( + endpoint.project, + endpoint.region, + endpoint.runServiceId ?? runV2.functionNameToServiceName(endpoint.id), + ); } catch (err: any) { if (err.status === 404) { return; @@ -923,7 +926,14 @@ export class Fabricator { // Set/Delete trigger is responsible for wiring up a function with any trigger not owned // by the GCF API. This includes schedules, task queues, and blocking function triggers. async setTrigger(endpoint: backend.Endpoint): Promise { - if (backend.isScheduleTriggered(endpoint)) { + if (backend.isEventTriggered(endpoint) && endpoint.platform === "run") { + if (!FIRESTORE_EVENTS.some((eventType) => eventType === endpoint.eventTrigger.eventType)) { + throw new FirebaseError( + `Event type ${endpoint.eventTrigger.eventType} is not supported for Cloud Run functions yet.`, + ); + } + await this.upsertEventarcTrigger(endpoint); + } else if (backend.isScheduleTriggered(endpoint)) { if (endpoint.platform === "gcfv1") { await this.upsertScheduleV1(endpoint); return; @@ -950,7 +960,9 @@ export class Fabricator { } async deleteTrigger(endpoint: backend.Endpoint): Promise { - if (backend.isScheduleTriggered(endpoint)) { + if (backend.isEventTriggered(endpoint) && endpoint.platform === "run") { + await this.deleteEventarcTrigger(endpoint); + } else if (backend.isScheduleTriggered(endpoint)) { if (endpoint.platform === "gcfv1") { await this.deleteScheduleV1(endpoint); return; @@ -980,6 +992,230 @@ export class Fabricator { // published and the customer will still get charged. } + async eventarcTriggerFromEndpoint( + endpoint: backend.Endpoint & backend.EventTriggered, + ): Promise { + const triggerRegion = endpoint.eventTrigger.region || endpoint.region; + const triggerId = backend.eventarcTriggerIdForFunction(endpoint); + const serviceId = endpoint.runServiceId ?? runV2.functionNameToServiceName(endpoint.id); + const eventFilters: eventarc.EventFilter[] = [ + { + attribute: "type", + value: endpoint.eventTrigger.eventType, + }, + ]; + + for (const [attribute, value] of Object.entries(endpoint.eventTrigger.eventFilters || {})) { + if (attribute !== "type") { + eventFilters.push({ attribute, value }); + } + } + for (const [attribute, value] of Object.entries( + endpoint.eventTrigger.eventFilterPathPatterns || {}, + )) { + eventFilters.push({ attribute, value, operator: "match-path-pattern" }); + } + + const serviceAccount = await this.eventarcServiceAccount(endpoint); + + return { + name: `projects/${endpoint.project}/locations/${triggerRegion}/triggers/${triggerId}`, + eventFilters, + serviceAccount, + destination: { + cloudRun: { + service: serviceId, + region: endpoint.region, + }, + }, + labels: { + "deployment-tool": "cli-firebase", + ...(endpoint.runtime ? { runtime: endpoint.runtime } : {}), + }, + eventDataContentType: "application/protobuf", + ...(endpoint.eventTrigger.channel ? { channel: endpoint.eventTrigger.channel } : {}), + }; + } + + async eventarcServiceAccount( + endpoint: backend.Endpoint & backend.EventTriggered, + ): Promise { + const serviceAccount = + endpoint.eventTrigger.serviceAccount || + endpoint.serviceAccount || + (await gce.getDefaultServiceAccount(this.projectNumber)); + return proto.formatServiceAccount(serviceAccount, endpoint.project, true); + } + + async setEventarcInvoker( + endpoint: backend.Endpoint & backend.EventTriggered, + serviceAccounts: string[], + ): Promise { + const serviceId = endpoint.runServiceId ?? runV2.functionNameToServiceName(endpoint.id); + await this.executor.run(() => + run.setInvokerUpdate( + endpoint.project, + `projects/${endpoint.project}/locations/${endpoint.region}/services/${serviceId}`, + serviceAccounts, + ), + ); + } + + async upsertEventarcTrigger(endpoint: backend.Endpoint & backend.EventTriggered): Promise { + const trigger = await this.eventarcTriggerFromEndpoint(endpoint); + const existing = await this.executor.run(() => eventarc.getTrigger(trigger.name)); + if (!existing) { + await this.setEventarcInvoker(endpoint, [trigger.serviceAccount]).catch( + rethrowAs(endpoint, "set invoker"), + ); + try { + const op = await this.executor.run(() => eventarc.createTrigger(trigger), { + retryCodes: [429, 503], + }); + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `create-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: op.name, + }); + } catch (err) { + if (getErrStatus(err) === 409) { + const created = await eventarc.getTrigger(trigger.name); + if (created && eventarc.triggerMatches(created, trigger)) { + return; + } + } + throw rethrowAs(endpoint, "upsert eventarc trigger")(err); + } + return; + } + + if (eventarc.triggerMatches(existing, trigger)) { + logger.debug("Skipping Eventarc trigger update because it already matches", trigger.name); + await this.setEventarcInvoker(endpoint, [trigger.serviceAccount]).catch( + rethrowAs(endpoint, "set invoker"), + ); + return; + } + + const reconciledTrigger: eventarc.Trigger = { + ...trigger, + labels: { ...existing.labels, ...trigger.labels }, + }; + const previousServiceAccount = + existing.serviceAccount || + proto.formatServiceAccount( + await gce.getDefaultServiceAccount(this.projectNumber), + endpoint.project, + true, + ); + const transitionServiceAccounts = [ + ...new Set([previousServiceAccount, reconciledTrigger.serviceAccount]), + ]; + await this.setEventarcInvoker(endpoint, transitionServiceAccounts).catch( + rethrowAs(endpoint, "set invoker"), + ); + + let previousTriggerRestored = false; + try { + if (!eventarc.triggerRequiresReplacement(existing, trigger)) { + await this.executor.run(async () => { + const op = await eventarc.updateTrigger(reconciledTrigger); + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `update-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: op.name, + }); + }); + } else { + let replacementStarted = false; + try { + await this.executor.run( + async () => { + const deleteOp = await eventarc.deleteTrigger(trigger.name); + replacementStarted = true; + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `replace-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: deleteOp.name, + }); + const op = await eventarc.createTrigger(reconciledTrigger); + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `create-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: op.name, + }); + }, + { retryCodes: [] }, + ); + } catch (err) { + if (replacementStarted) { + try { + const failedReplacement = await eventarc.getTrigger(trigger.name); + if (failedReplacement) { + const cleanupOp = await eventarc.deleteTrigger(trigger.name); + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `cleanup-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: cleanupOp.name, + }); + } + const rollbackOp = await eventarc.createTrigger(eventarc.triggerForCreate(existing)); + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `rollback-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: rollbackOp.name, + }); + previousTriggerRestored = true; + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new FirebaseError( + `Failed to replace Eventarc trigger ${trigger.name} and failed to restore the previous trigger: ${rollbackMessage}`, + { original: err as Error }, + ); + } + } + throw err; + } + } + await this.setEventarcInvoker(endpoint, [reconciledTrigger.serviceAccount]); + } catch (err) { + try { + await this.setEventarcInvoker( + endpoint, + previousTriggerRestored ? [previousServiceAccount] : transitionServiceAccounts, + ); + } catch (invokerRollbackError) { + const rollbackMessage = + invokerRollbackError instanceof Error + ? invokerRollbackError.message + : String(invokerRollbackError); + throw new FirebaseError( + `Failed to update Eventarc trigger ${trigger.name} and failed to restore its Cloud Run invoker: ${rollbackMessage}`, + { original: err as Error }, + ); + } + throw rethrowAs(endpoint, "upsert eventarc trigger")(err); + } + } + + async deleteEventarcTrigger(endpoint: backend.Endpoint & backend.EventTriggered): Promise { + const trigger = await this.eventarcTriggerFromEndpoint(endpoint); + await this.executor + .run(async () => { + if ((await eventarc.getTrigger(trigger.name)) === undefined) { + return; + } + const op = await eventarc.deleteTrigger(trigger.name); + await poller.pollOperation({ + ...eventarcPollerOptions, + pollerName: `delete-eventarc-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`, + operationResourceName: op.name, + }); + }) + .catch(rethrowAs(endpoint, "delete eventarc trigger")); + } + async upsertScheduleV1(endpoint: backend.Endpoint & backend.ScheduleTriggered): Promise { // The Pub/Sub topic is already created const job = await scheduler.jobFromEndpoint( diff --git a/src/deploy/functions/release/planner.spec.ts b/src/deploy/functions/release/planner.spec.ts index 19ff8200c58..254769249cc 100644 --- a/src/deploy/functions/release/planner.spec.ts +++ b/src/deploy/functions/release/planner.spec.ts @@ -94,32 +94,34 @@ describe("planner", () => { }); }); - it("knows to delete & recreate when trigger regions change", () => { - const original: backend.Endpoint = func("a", "b", { - eventTrigger: { - eventType: "google.cloud.storage.object.v1.finalized", - eventFilters: { bucket: "my-bucket" }, - region: "us-west1", - retry: false, - }, - }); - original.platform = "gcfv2"; - const changed: backend.Endpoint = func("a", "b", { - eventTrigger: { - eventType: "google.cloud.storage.object.v1.finalzied", - eventFilters: { bucket: "my-bucket" }, - region: "us", - retry: false, - }, - }); - changed.platform = "gcfv2"; - allowV2Upgrades(); - expect(planner.calculateUpdate(changed, original)).to.deep.equal({ - endpoint: changed, - unsafe: false, - deleteAndRecreate: original, + for (const platform of ["gcfv2", "run"] as const) { + it(`knows to delete & recreate ${platform} functions when trigger regions change`, () => { + const original: backend.Endpoint = func("a", "b", { + eventTrigger: { + eventType: "google.cloud.storage.object.v1.finalized", + eventFilters: { bucket: "my-bucket" }, + region: "us-west1", + retry: false, + }, + }); + original.platform = platform; + const changed: backend.Endpoint = func("a", "b", { + eventTrigger: { + eventType: "google.cloud.storage.object.v1.finalzied", + eventFilters: { bucket: "my-bucket" }, + region: "us", + retry: false, + }, + }); + changed.platform = platform; + allowV2Upgrades(); + expect(planner.calculateUpdate(changed, original)).to.deep.equal({ + endpoint: changed, + unsafe: false, + deleteAndRecreate: original, + }); }); - }); + } it("knows to upgrade in-place in the general case", () => { const v1Function: backend.Endpoint = { diff --git a/src/deploy/functions/release/planner.ts b/src/deploy/functions/release/planner.ts index 092b50d0fea..684c00ec2c3 100644 --- a/src/deploy/functions/release/planner.ts +++ b/src/deploy/functions/release/planner.ts @@ -271,10 +271,10 @@ export function upgradedToGCFv2WithoutSettingConcurrency( * a user listens to a different bucket, which happens to have a different region. */ export function changedTriggerRegion(want: backend.Endpoint, have: backend.Endpoint): boolean { - if (want.platform !== "gcfv2") { + if (want.platform !== "gcfv2" && want.platform !== "run") { return false; } - if (have.platform !== "gcfv2") { + if (have.platform !== want.platform) { return false; } if (!backend.isEventTriggered(want)) { diff --git a/src/deploy/functions/release/reporter.ts b/src/deploy/functions/release/reporter.ts index fa395bc9dab..ab3d5134008 100644 --- a/src/deploy/functions/release/reporter.ts +++ b/src/deploy/functions/release/reporter.ts @@ -27,6 +27,8 @@ export type OperationType = | "delete schedule" | "upsert task queue" | "upsert eventarc channel" + | "upsert eventarc trigger" + | "delete eventarc trigger" | "disable task queue" | "create topic" | "delete topic" diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index 165317a106b..9f6ab2b73c7 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -125,6 +125,36 @@ describe("validate", () => { httpsTrigger: {}, }; + it("rejects unsupported Cloud Run event types before deployment", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "run", + eventTrigger: { + eventType: "google.cloud.pubsub.topic.v1.messagePublished", + retry: false, + }, + }; + + expect(() => validate.endpointsAreValid(backend.of(ep))).to.throw( + "is not supported for Cloud Run functions yet", + ); + }); + + it("rejects unsupported Cloud Run event retry policies", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "run", + eventTrigger: { + eventType: "google.cloud.firestore.document.v1.written", + retry: true, + }, + }; + + expect(() => validate.endpointsAreValid(backend.of(ep))).to.throw( + "Retry policies are not supported", + ); + }); + it("disallows concurrency for GCF gen 1", () => { const ep: backend.Endpoint = { ...ENDPOINT_BASE, diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 14efb258f4a..6be724a00d3 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -11,6 +11,7 @@ import * as backend from "./backend"; import * as utils from "../../utils"; import * as secrets from "../../functions/secrets"; import { assertExhaustive } from "../../functional"; +import { FIRESTORE_EVENTS } from "../../functions/events/v2"; /** * GCF Gen 1 has a max timeout of 540s. @@ -94,6 +95,18 @@ export function endpointsAreValid( validateTimeoutConfig(endpoints); for (const ep of endpoints) { validateScheduledTimeout(ep); + if (ep.platform === "run" && backend.isEventTriggered(ep)) { + if (!FIRESTORE_EVENTS.some((eventType) => eventType === ep.eventTrigger.eventType)) { + throw new FirebaseError( + `Event type ${ep.eventTrigger.eventType} is not supported for Cloud Run functions yet.`, + ); + } + if (ep.eventTrigger.retry) { + throw new FirebaseError( + `Retry policies are not supported for Cloud Run event function ${ep.id} yet.`, + ); + } + } const service = serviceForEndpoint(ep); if (backend.isBlockingTriggered(ep)) { if (service.name === "noop") { diff --git a/src/gcp/eventarc.spec.ts b/src/gcp/eventarc.spec.ts new file mode 100644 index 00000000000..c5e7eaa8143 --- /dev/null +++ b/src/gcp/eventarc.spec.ts @@ -0,0 +1,227 @@ +import { expect } from "chai"; +import * as nock from "nock"; + +import * as api from "../api"; +import * as eventarc from "./eventarc"; + +const TRIGGER_NAME = "projects/test-project/locations/us-central1/triggers/firestore-handler"; + +const TEST_TRIGGER: eventarc.Trigger = { + name: TRIGGER_NAME, + eventFilters: [ + { + attribute: "type", + value: "google.cloud.firestore.document.v1.written", + }, + { + attribute: "database", + value: "(default)", + }, + { + attribute: "document", + value: "users/{userId}", + operator: "match-path-pattern", + }, + ], + serviceAccount: "123456-compute@developer.gserviceaccount.com", + destination: { + cloudRun: { + service: "firestore-handler", + region: "us-central1", + }, + }, + labels: { + "deployment-tool": "cli-firebase", + }, + eventDataContentType: "application/protobuf", +}; + +const TEST_OPERATION: eventarc.Operation = { + name: "projects/test-project/locations/us-central1/operations/create-trigger", + metadata: { + createTime: "2026-07-27T09:00:00Z", + target: TRIGGER_NAME, + verb: "create", + requestedCancellation: false, + apiVersion: "v1", + }, + done: false, +}; + +describe("eventarc", () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe("getTrigger", () => { + it("gets an existing trigger", async () => { + nock(api.eventarcOrigin()).get(`/v1/${TRIGGER_NAME}`).reply(200, TEST_TRIGGER); + + await expect(eventarc.getTrigger(TRIGGER_NAME)).to.eventually.deep.equal(TEST_TRIGGER); + expect(nock.isDone()).to.be.true; + }); + + it("returns undefined for a missing trigger", async () => { + nock(api.eventarcOrigin()).get(`/v1/${TRIGGER_NAME}`).reply(404); + + await expect(eventarc.getTrigger(TRIGGER_NAME)).to.eventually.be.undefined; + expect(nock.isDone()).to.be.true; + }); + + it("preserves non-404 HTTP errors", async () => { + nock(api.eventarcOrigin()) + .get(`/v1/${TRIGGER_NAME}`) + .reply(403, { + error: { message: "permission denied" }, + }); + + await expect(eventarc.getTrigger(TRIGGER_NAME)).to.be.rejectedWith( + "Failed to get Eventarc trigger", + ); + expect(nock.isDone()).to.be.true; + }); + }); + + it("creates a trigger", async () => { + nock(api.eventarcOrigin()) + .post("/v1/projects/test-project/locations/us-central1/triggers", (body) => { + expect(body).to.deep.equal(TEST_TRIGGER); + return true; + }) + .query({ triggerId: "firestore-handler" }) + .reply(200, TEST_OPERATION); + + await expect(eventarc.createTrigger(TEST_TRIGGER)).to.eventually.deep.equal(TEST_OPERATION); + expect(nock.isDone()).to.be.true; + }); + + it("updates mutable trigger fields", async () => { + nock(api.eventarcOrigin()) + .patch(`/v1/${TRIGGER_NAME}`, (body) => { + expect(body).to.deep.equal(TEST_TRIGGER); + return true; + }) + .query({ + updateMask: "serviceAccount,destination,labels,eventDataContentType", + }) + .reply(200, TEST_OPERATION); + + await expect(eventarc.updateTrigger(TEST_TRIGGER)).to.eventually.deep.equal(TEST_OPERATION); + expect(nock.isDone()).to.be.true; + }); + + it("deletes a trigger", async () => { + nock(api.eventarcOrigin()).delete(`/v1/${TRIGGER_NAME}`).reply(200, TEST_OPERATION); + + await expect(eventarc.deleteTrigger(TRIGGER_NAME)).to.eventually.deep.equal(TEST_OPERATION); + expect(nock.isDone()).to.be.true; + }); + + describe("triggerMatches", () => { + it("ignores event filter order and output-only fields", () => { + const existing: eventarc.Trigger = { + ...TEST_TRIGGER, + eventFilters: [...TEST_TRIGGER.eventFilters].reverse(), + destination: { + cloudRun: { + ...TEST_TRIGGER.destination.cloudRun, + path: "/", + }, + }, + uid: "server-assigned", + state: "ACTIVE", + }; + + expect(eventarc.triggerMatches(existing, TEST_TRIGGER)).to.be.true; + }); + + it("detects immutable routing changes", () => { + const existing: eventarc.Trigger = { + ...TEST_TRIGGER, + destination: { + cloudRun: { + ...TEST_TRIGGER.destination.cloudRun, + service: "another-service", + }, + }, + }; + + expect(eventarc.triggerMatches(existing, TEST_TRIGGER)).to.be.false; + }); + + it("requires replacement only for immutable filters and channels", () => { + expect( + eventarc.triggerRequiresReplacement( + { + ...TEST_TRIGGER, + labels: { runtime: "dart2" }, + serviceAccount: "another@test-project.iam.gserviceaccount.com", + }, + TEST_TRIGGER, + ), + ).to.be.false; + expect( + eventarc.triggerRequiresReplacement( + { + ...TEST_TRIGGER, + eventFilters: [{ attribute: "type", value: "another-event" }], + }, + TEST_TRIGGER, + ), + ).to.be.true; + }); + + it("does not crash for a non-Cloud Run destination", () => { + const existing = { + ...TEST_TRIGGER, + destination: { workflow: "projects/test/locations/test/workflows/test" }, + } as unknown as eventarc.Trigger; + + expect(eventarc.triggerMatches(existing, TEST_TRIGGER)).to.be.false; + }); + + it("detects channel removal and managed label changes", () => { + expect( + eventarc.triggerMatches( + { + ...TEST_TRIGGER, + channel: "projects/test-project/locations/us-central1/channels/custom", + }, + TEST_TRIGGER, + ), + ).to.be.false; + expect( + eventarc.triggerMatches( + { + ...TEST_TRIGGER, + labels: { "deployment-tool": "another-tool" }, + }, + TEST_TRIGGER, + ), + ).to.be.false; + }); + + it("accepts Eventarc's default Google channel when no custom channel is requested", () => { + expect( + eventarc.triggerMatches( + { + ...TEST_TRIGGER, + channel: "projects/test-project/locations/us-central1/channels/googleChannel", + }, + TEST_TRIGGER, + ), + ).to.be.true; + }); + }); + + it("strips output-only fields before recreating a trigger", () => { + expect( + eventarc.triggerForCreate({ + ...TEST_TRIGGER, + uid: "server-assigned", + state: "ACTIVE", + createTime: "2026-07-27T09:00:00Z", + }), + ).to.deep.equal(TEST_TRIGGER); + }); +}); diff --git a/src/gcp/eventarc.ts b/src/gcp/eventarc.ts index 038d08524cb..468a2584659 100644 --- a/src/gcp/eventarc.ts +++ b/src/gcp/eventarc.ts @@ -1,7 +1,8 @@ import { Client } from "../apiv2"; import { eventarcOrigin } from "../api"; -import { last } from "lodash"; +import { isEqual, last } from "lodash"; import { fieldMasks } from "./proto"; +import { FirebaseError, getError } from "../error"; export const API_VERSION = "v1"; @@ -29,6 +30,39 @@ export interface Channel { cryptoKeyName?: string; } +export interface EventFilter { + attribute: string; + value: string; + operator?: "match-path-pattern"; +} + +export interface CloudRunDestination { + service: string; + region: string; + path?: string; +} + +export interface Trigger { + name: string; + eventFilters: EventFilter[]; + serviceAccount: string; + destination: { + cloudRun: CloudRunDestination; + }; + channel?: string; + labels?: Record; + eventDataContentType?: string; + uid?: string; + createTime?: string; + updateTime?: string; + state?: "PENDING" | "ACTIVE" | "INACTIVE"; +} + +export type TriggerUpdate = Pick< + Trigger, + "name" | "serviceAccount" | "destination" | "labels" | "eventDataContentType" +>; + interface OperationMetadata { createTime: string; target: string; @@ -37,7 +71,7 @@ interface OperationMetadata { apiVersion: string; } -interface Operation { +export interface Operation { name: string; metadata: OperationMetadata; done: boolean; @@ -92,3 +126,112 @@ export async function updateChannel(channel: Channel): Promise { export async function deleteChannel(name: string): Promise { await client.delete(name); } + +/** + * Gets an Eventarc trigger. + */ +export async function getTrigger(name: string): Promise { + const res = await client.get(name, { resolveOnHTTPError: true }); + if (res.status === 404) { + return undefined; + } + if (res.status >= 400) { + throw new FirebaseError(`Failed to get Eventarc trigger ${name}. HTTP Error: ${res.status}`, { + status: res.status, + original: getError(res.body), + }); + } + return res.body; +} + +/** + * Creates an Eventarc trigger. + */ +export async function createTrigger(trigger: Trigger): Promise { + const pathParts = trigger.name.split("/"); + const res = await client.post( + pathParts.slice(0, -2).join("/") + "/triggers", + trigger, + { + queryParams: { triggerId: last(pathParts)! }, + }, + ); + return res.body; +} + +/** + * Updates the mutable fields of an Eventarc trigger. + */ +export async function updateTrigger(trigger: TriggerUpdate): Promise { + const res = await client.patch(trigger.name, trigger, { + queryParams: { + updateMask: ["serviceAccount", "destination", "labels", "eventDataContentType"].join(","), + }, + }); + return res.body; +} + +/** + * Deletes an Eventarc trigger. + */ +export async function deleteTrigger(name: string): Promise { + const res = await client.delete(name); + return res.body; +} + +/** + * Checks whether changing a trigger requires deleting and recreating it. + */ +export function triggerRequiresReplacement(existing: Trigger, desired: Trigger): boolean { + const normalizedFilters = (filters: EventFilter[]): EventFilter[] => + [...(filters || [])].sort((left, right) => { + const leftKey = `${left.attribute}\0${left.operator || ""}\0${left.value}`; + const rightKey = `${right.attribute}\0${right.operator || ""}\0${right.value}`; + return leftKey.localeCompare(rightKey); + }); + + const channelMatches = desired.channel + ? existing.channel === desired.channel + : !existing.channel || existing.channel.endsWith("/channels/googleChannel"); + + return ( + !isEqual(normalizedFilters(existing.eventFilters), normalizedFilters(desired.eventFilters)) || + !channelMatches + ); +} + +/** + * Checks whether all managed fields of an Eventarc trigger match. + */ +export function triggerMatches(existing: Trigger, desired: Trigger): boolean { + const normalizedPath = (path?: string): string => (path === "/" ? "" : path || ""); + const existingCloudRun = existing.destination?.cloudRun; + if (!existingCloudRun) { + return false; + } + const destinationMatches = + existingCloudRun.service === desired.destination.cloudRun.service && + existingCloudRun.region === desired.destination.cloudRun.region && + normalizedPath(existingCloudRun.path) === normalizedPath(desired.destination.cloudRun.path); + + return ( + !triggerRequiresReplacement(existing, desired) && + existing.serviceAccount === desired.serviceAccount && + destinationMatches && + existing.eventDataContentType === desired.eventDataContentType && + Object.entries(desired.labels || {}).every(([key, value]) => existing.labels?.[key] === value) + ); +} + +/** Removes output-only fields before recreating an existing trigger. */ +export function triggerForCreate(trigger: Trigger): Trigger { + return { + name: trigger.name, + eventFilters: trigger.eventFilters, + serviceAccount: trigger.serviceAccount, + destination: trigger.destination, + ...(trigger.channel ? { channel: trigger.channel } : {}), + ...(trigger.labels ? { labels: trigger.labels } : {}), + ...(trigger.eventDataContentType ? { eventDataContentType: trigger.eventDataContentType } : {}), + }; +} diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index c35e4b44efc..a6dd8b8a834 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -78,6 +78,56 @@ describe("runv2", () => { expect(runv2.serviceFromEndpoint(endpoint, IMAGE_URI)).to.deep.equal(BASE_RUN_SERVICE); }); + it("should use the authoritative Run service ID", () => { + const endpoint: backend.Endpoint = { + ...BASE_ENDPOINT_RUN, + runServiceId: "existing-service-id", + httpsTrigger: {}, + }; + + expect(runv2.serviceFromEndpoint(endpoint, IMAGE_URI).name).to.equal( + `projects/${PROJECT_ID}/locations/${LOCATION}/services/existing-service-id`, + ); + }); + + it("should preserve event trigger metadata for backend reconstruction", () => { + const eventTrigger: backend.EventTrigger = { + eventType: "google.cloud.firestore.document.v1.written", + eventFilters: { + database: "(default)", + namespace: "(default)", + }, + eventFilterPathPatterns: { + document: "users/{userId}", + }, + retry: false, + region: "nam5", + }; + const endpoint: backend.Endpoint = { + ...BASE_ENDPOINT_RUN, + eventTrigger, + labels: { + "deployment-tool": "cli-firebase", + }, + }; + + const service = runv2.serviceFromEndpoint(endpoint, IMAGE_URI); + const metadata = JSON.parse(service.annotations![runv2.FIREBASE_FUNCTION_METADTA_ANNOTATION]); + + expect(metadata).to.deep.equal({ + functionId: FUNCTION_ID, + eventTrigger, + }); + expect(service.labels?.["deployment-tool"]).to.equal("cli-firebase"); + const reconstructed = runv2.endpointFromService(service); + expect(backend.isEventTriggered(reconstructed)).to.be.true; + if (!backend.isEventTriggered(reconstructed)) { + throw new Error("Expected an event-triggered endpoint"); + } + expect(reconstructed.eventTrigger).to.deep.equal(eventTrigger); + expect(reconstructed.runServiceId).to.equal(SERVICE_ID); + }); + it("should handle different codebase", () => { const endpoint: backend.Endpoint = { ...BASE_ENDPOINT_RUN, @@ -190,19 +240,47 @@ describe("runv2", () => { expect(runv2.serviceFromEndpoint(endpoint, IMAGE_URI)).to.deep.equal(expectedServiceInput); }); - it("should remove deployment-tool label", () => { + it("should preserve deployment-tool label for lifecycle reconciliation", () => { const endpoint: backend.Endpoint = { ...BASE_ENDPOINT_RUN, httpsTrigger: {}, - labels: { "deployment-tool": "firebase-cli" }, + labels: { "deployment-tool": "cli-firebase--Agent.V1" }, }; const result = runv2.serviceFromEndpoint(endpoint, IMAGE_URI); - expect(result.labels?.["deployment-tool"]).to.be.undefined; + expect(result.labels?.["deployment-tool"]).to.equal("cli-firebase--agent-v1"); expect(result.labels?.[runv2.CLIENT_NAME_LABEL]).to.equal("firebase-functions"); }); + + it("should expand shorthand service accounts", () => { + const endpoint: backend.Endpoint = { + ...BASE_ENDPOINT_RUN, + httpsTrigger: {}, + serviceAccount: "runner@", + }; + + expect(runv2.serviceFromEndpoint(endpoint, IMAGE_URI).template.serviceAccount).to.equal( + `runner@${PROJECT_ID}.iam.gserviceaccount.com`, + ); + }); }); describe("endpointFromService", () => { + it("should recover legacy direct Run CloudEvent services", () => { + const service = structuredClone(BASE_RUN_SERVICE); + service.template.containers![0].env = service.template.containers![0].env?.map((variable) => + variable.name === runv2.FUNCTION_SIGNATURE_TYPE_ENV + ? { ...variable, value: "cloudevent" } + : variable, + ); + + const endpoint = runv2.endpointFromService(service); + + expect(backend.isEventTriggered(endpoint)).to.be.true; + if (backend.isEventTriggered(endpoint)) { + expect(endpoint.eventTrigger.eventType).to.equal("unknown"); + } + }); + it("should copy a minimal service", () => { const service: Omit = { ...BASE_RUN_SERVICE, @@ -238,6 +316,7 @@ describe("runv2", () => { region: LOCATION, runtime: latest("nodejs"), entryPoint: "customEntryPoint", + runServiceId: SERVICE_ID, availableMemoryMb: 256, cpu: 1, httpsTrigger: {}, @@ -256,60 +335,63 @@ describe("runv2", () => { expect(runv2.endpointFromService(service)).to.deep.equal(expectedEndpoint); }); - it("should detect a service that's GCF managed", () => { - const service: Omit = { - ...BASE_RUN_SERVICE, - name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, - labels: { - [runv2.RUNTIME_LABEL]: latest("nodejs"), - [runv2.CLIENT_NAME_LABEL]: "cloud-functions", // This indicates it's GCF managed - }, - annotations: { - ...BASE_RUN_SERVICE.annotations, - [runv2.FUNCTION_ID_ANNOTATION]: FUNCTION_ID, // Using FUNCTION_ID_ANNOTATION as primary source for id - [runv2.FUNCTION_TARGET_ANNOTATION]: "customEntryPoint", - [runv2.TRIGGER_TYPE_ANNOTATION]: "HTTP_TRIGGER", - }, - template: { - containers: [ - { - name: "worker", - image: IMAGE_URI, - resources: { - limits: { - cpu: "1", - memory: "256Mi", + for (const clientName of ["cloudfunctions", "cloud-functions"]) { + it(`should detect a service that's GCF managed with ${clientName}`, () => { + const service: Omit = { + ...BASE_RUN_SERVICE, + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + labels: { + [runv2.RUNTIME_LABEL]: latest("nodejs"), + [runv2.CLIENT_NAME_LABEL]: clientName, + }, + annotations: { + ...BASE_RUN_SERVICE.annotations, + [runv2.FUNCTION_ID_ANNOTATION]: FUNCTION_ID, // Using FUNCTION_ID_ANNOTATION as primary source for id + [runv2.FUNCTION_TARGET_ANNOTATION]: "customEntryPoint", + [runv2.TRIGGER_TYPE_ANNOTATION]: "HTTP_TRIGGER", + }, + template: { + containers: [ + { + name: "worker", + image: IMAGE_URI, + resources: { + limits: { + cpu: "1", + memory: "256Mi", + }, }, }, - }, - ], - }, - }; - - const expectedEndpoint: backend.Endpoint = { - platform: "gcfv2", - id: FUNCTION_ID, - project: PROJECT_ID, - region: LOCATION, - runtime: latest("nodejs"), - entryPoint: "customEntryPoint", - availableMemoryMb: 256, - cpu: 1, - httpsTrigger: {}, - labels: { - "deployment-tool": "cli-firebase", - [runv2.RUNTIME_LABEL]: latest("nodejs"), - [runv2.CLIENT_NAME_LABEL]: "cloud-functions", - }, - environmentVariables: {}, - secretEnvironmentVariables: [], - ingressSettings: "ALLOW_ALL", - serviceAccount: null, - timeoutSeconds: 60, - }; - - expect(runv2.endpointFromService(service)).to.deep.equal(expectedEndpoint); - }); + ], + }, + }; + + const expectedEndpoint: backend.Endpoint = { + platform: "gcfv2", + id: FUNCTION_ID, + project: PROJECT_ID, + region: LOCATION, + runtime: latest("nodejs"), + entryPoint: "customEntryPoint", + runServiceId: SERVICE_ID, + availableMemoryMb: 256, + cpu: 1, + httpsTrigger: {}, + labels: { + "deployment-tool": "cli-firebase", + [runv2.RUNTIME_LABEL]: latest("nodejs"), + [runv2.CLIENT_NAME_LABEL]: clientName, + }, + environmentVariables: {}, + secretEnvironmentVariables: [], + ingressSettings: "ALLOW_ALL", + serviceAccount: null, + timeoutSeconds: 60, + }; + + expect(runv2.endpointFromService(service)).to.deep.equal(expectedEndpoint); + }); + } it("should derive id from FIREBASE_FUNCTIONS_METADATA if present", () => { const service: Omit = { @@ -454,6 +536,7 @@ describe("runv2", () => { region: LOCATION, runtime: latest("nodejs"), // Default runtime entryPoint: SERVICE_ID, // No FUNCTION_TARGET_ANNOTATION + runServiceId: SERVICE_ID, availableMemoryMb: 128, cpu: 0.5, httpsTrigger: {}, @@ -489,7 +572,7 @@ describe("runv2", () => { const mockServices = [ { name: "service1", - labels: { "goog-managed-by": "cloud-functions" }, + labels: { "goog-managed-by": "cloudfunctions" }, }, { name: "service2", @@ -511,7 +594,7 @@ describe("runv2", () => { const mockServices1 = [ { name: "service1", - labels: { "goog-managed-by": "cloud-functions" }, + labels: { "goog-managed-by": "cloudfunctions" }, }, ]; const mockServices2 = [ diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 5b3a2117269..8836cfcea62 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -305,8 +305,7 @@ export async function listServices(projectId: string): Promise { if (res.body.services) { for (const service of res.body.services) { if ( - service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || - service.labels?.[CLIENT_NAME_LABEL] === "cloudfunctions" || + GCF_CLIENT_NAMES.has(service.labels?.[CLIENT_NAME_LABEL] || "") || service.labels?.[CLIENT_NAME_LABEL] === "firebase-functions" ) { allServices.push(service); @@ -319,8 +318,8 @@ export async function listServices(projectId: string): Promise { return allServices; } -// TODO: Replace with real version: -function functionNameToServiceName(id: string): string { +// TODO: Replace with real version. +export function functionNameToServiceName(id: string): string { return id.toLowerCase().replace(/_/g, "-"); } @@ -547,6 +546,7 @@ export const RUNTIME_LABEL = "goog-cloudfunctions-runtime"; // In GCF 2nd gen this is cloudfunctions but is the empty string after ejecting. We can use a new value to detect how much // of the fleet has migrated. export const CLIENT_NAME_LABEL = "goog-managed-by"; +const GCF_CLIENT_NAMES = new Set(["cloud-functions", "cloudfunctions"]); // NOTE: Any annotation with a google domain prefix is read-only and a holdover from the GCF API. export const TRIGGER_TYPE_ANNOTATION = "cloudfunctions.googleapis.com/trigger-type"; @@ -559,7 +559,7 @@ export const FUNCTION_SIGNATURE_TYPE_ENV = "FUNCTION_SIGNATURE_TYPE"; export const FIREBASE_FUNCTION_METADTA_ANNOTATION = "firebase-functions-metadata"; export interface FirebaseFunctionMetadata { functionId: string; - // TODO: Trigger type since we cannot set cloudfunctions.googleapis.com/trigger-type + eventTrigger?: backend.EventTrigger; } // Partial implementation. A full implementation may require more refactoring. @@ -596,12 +596,9 @@ export function endpointFromService(service: Omit) logger.debug("Converting a service to an endpoint with an invalid memory option", memory); } const cpu = Number(service.template.containers![0]!.resources!.limits!.cpu); + const signatureType = env.find((e) => e.name === FUNCTION_SIGNATURE_TYPE_ENV)?.value; const endpoint: backend.Endpoint = { - platform: - service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || - service.labels?.[CLIENT_NAME_LABEL] === "cloudfunctions" - ? "gcfv2" - : "run", + platform: GCF_CLIENT_NAMES.has(service.labels?.[CLIENT_NAME_LABEL] || "") ? "gcfv2" : "run", id, project, labels: { ...service.labels, "deployment-tool": "cli-firebase" }, @@ -623,18 +620,27 @@ export function endpointFromService(service: Omit) : service.annotations?.["run.googleapis.com/ingress"] === "internal-and-cloud-load-balancing" ? "ALLOW_INTERNAL_AND_GCLB" : "ALLOW_ALL") as backend.IngressSettings, - // TODO: Figure out how to encode all trigger types to the underlying Run service that is compatible with both V2 functions and "direct to run" functions - ...(!service.annotations?.[TRIGGER_TYPE_ANNOTATION] || - service.annotations?.[TRIGGER_TYPE_ANNOTATION] === "HTTP_TRIGGER" - ? { httpsTrigger: {} } - : { - eventTrigger: { - eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown", - // TODO: Figure out how to recover the retry info from Run (vs Functions API) as we currently default to false. - retry: false, - }, - }), + ...(metadata.eventTrigger + ? { eventTrigger: metadata.eventTrigger } + : signatureType === "cloudevent" + ? { + eventTrigger: { + eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown", + retry: false, + }, + } + : !service.annotations?.[TRIGGER_TYPE_ANNOTATION] || + service.annotations?.[TRIGGER_TYPE_ANNOTATION] === "HTTP_TRIGGER" + ? { httpsTrigger: {} } + : { + eventTrigger: { + eventType: service.annotations?.[TRIGGER_TYPE_ANNOTATION] || "unknown", + // TODO: Figure out how to recover retry info for services not created by Firebase. + retry: false, + }, + }), }; + endpoint.runServiceId = svcId; proto.renameIfPresent(endpoint, service.template, "concurrency", "maxInstanceRequestConcurrency"); proto.renameIfPresent(endpoint, service.labels || {}, "codebase", CODEBASE_LABEL); proto.renameIfPresent(endpoint, service.scaling || {}, "minInstances", "minInstanceCount"); @@ -676,11 +682,13 @@ export function serviceFromEndpoint( ...(endpoint.runtime ? { [RUNTIME_LABEL]: endpoint.runtime } : {}), [CLIENT_NAME_LABEL]: "firebase-functions", }; - - // A bit of a hack, but other code assumes the Functions method of indicating deployment tool and - // injects this as a label. To avoid thinking that this is actually meaningful in the CRF world, - // we delete it here. - delete labels["deployment-tool"]; + if (labels["deployment-tool"]) { + labels["deployment-tool"] = labels["deployment-tool"] + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "-") + .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") + .slice(0, 63); + } // TODO: hash if (endpoint.codebase) { @@ -690,6 +698,7 @@ export function serviceFromEndpoint( const annotations: Record = { [FIREBASE_FUNCTION_METADTA_ANNOTATION]: JSON.stringify({ functionId: endpoint.id, + ...(backend.isEventTriggered(endpoint) ? { eventTrigger: endpoint.eventTrigger } : {}), }), }; @@ -742,9 +751,9 @@ export function serviceFromEndpoint( proto.renameIfPresent(template, endpoint, "maxInstanceRequestConcurrency", "concurrency"); const service: Omit = { - name: `projects/${endpoint.project}/locations/${endpoint.region}/services/${functionNameToServiceName( - endpoint.id, - )}`, + name: `projects/${endpoint.project}/locations/${endpoint.region}/services/${ + endpoint.runServiceId ?? functionNameToServiceName(endpoint.id) + }`, labels, annotations, template, @@ -760,7 +769,11 @@ export function serviceFromEndpoint( // TODO: other trigger types (callable, scheduled, etc) if (endpoint.serviceAccount) { - template.serviceAccount = endpoint.serviceAccount; + template.serviceAccount = proto.formatServiceAccount( + endpoint.serviceAccount, + endpoint.project, + true, + ); } if (endpoint.timeoutSeconds) {