diff --git a/CHANGELOG.md b/CHANGELOG.md index 480ad62c456..432bf791f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,5 @@ - GitHub Action fixes for web frameworks (#6883) - Fixes issue where PubSub message `publishTime` is set to 1970-01-01T00:00:00 (#7441) - Display meaningful error message when cannot determine target. (#6594) +- Adds support for firealerts events in Eventarc emulator. (#7355) - Improved errors when an incorrect service ID is passed to `firebase deploy --only dataconnect:serviceId`. diff --git a/firebase-vscode/package-lock.json b/firebase-vscode/package-lock.json index aac177ef113..e1d605e32fb 100644 --- a/firebase-vscode/package-lock.json +++ b/firebase-vscode/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "firebase-vscode", - "version": "0.4.3", + "version": "0.4.4", "dependencies": { "@preact/signals-core": "^1.4.0", "@preact/signals-react": "1.3.6", diff --git a/src/emulator/constants.ts b/src/emulator/constants.ts index 5731ae2cb72..2c05f4756df 100644 --- a/src/emulator/constants.ts +++ b/src/emulator/constants.ts @@ -95,6 +95,7 @@ export class Constants { static SERVICE_REALTIME_DATABASE = "firebaseio.com"; static SERVICE_PUBSUB = "pubsub.googleapis.com"; static SERVICE_EVENTARC = "eventarc.googleapis.com"; + static SERVICE_FIREALERTS = "firebasealerts.googleapis.com"; // Note: the service name below are here solely for logging purposes. // There is not an emulator available for these. static SERVICE_ANALYTICS = "app-measurement.com"; diff --git a/src/emulator/eventarcEmulator.ts b/src/emulator/eventarcEmulator.ts index 3c561a8f3c5..18e42814d44 100644 --- a/src/emulator/eventarcEmulator.ts +++ b/src/emulator/eventarcEmulator.ts @@ -9,8 +9,9 @@ import { CloudEvent } from "./events/types"; import { EmulatorRegistry } from "./registry"; import { FirebaseError } from "../error"; import { cloudEventFromProtoToJson } from "./eventarcEmulatorUtils"; +import * as cors from "cors"; -interface CustomEventTrigger { +interface EmulatedEventTrigger { projectId: string; triggerName: string; eventTrigger: EventTrigger; @@ -24,25 +25,42 @@ export interface EventarcEmulatorArgs { port?: number; host?: string; } +const GOOGLE_CHANNEL = "google"; export class EventarcEmulator implements EmulatorInstance { private destroyServer?: () => Promise; private logger = EmulatorLogger.forEmulator(Emulators.EVENTARC); - private customEvents: { [key: string]: CustomEventTrigger[] } = {}; + private events: { [key: string]: EmulatedEventTrigger[] } = {}; constructor(private args: EventarcEmulatorArgs) {} createHubServer(): express.Application { const registerTriggerRoute = `/emulator/v1/projects/:project_id/triggers/:trigger_name(*)`; const registerTriggerHandler: express.RequestHandler = (req, res) => { + try { + const { projectId, triggerName, eventTrigger, key } = getTriggerIdentifiers(req); + this.logger.logLabeled( + "BULLET", + "eventarc", + `Registering Eventarc event trigger for ${key} with trigger name ${triggerName}.`, + ); + const eventTriggers = this.events[key] || []; + eventTriggers.push({ projectId, triggerName, eventTrigger }); + this.events[key] = eventTriggers; + res.status(200).send({ res: "OK" }); + } catch (error) { + res.status(400).send({ error }); + } + }; + + const getTriggerIdentifiers = (req: express.Request) => { const projectId = req.params.project_id; const triggerName = req.params.trigger_name; if (!projectId || !triggerName) { const error = "Missing project ID or trigger name."; this.logger.log("ERROR", error); - res.status(400).send({ error }); - return; + throw error; } const bodyString = (req as RequestWithRawBody).rawBody.toString(); const substituted = bodyString.replaceAll("${PROJECT_ID}", projectId); @@ -51,24 +69,58 @@ export class EventarcEmulator implements EmulatorInstance { if (!eventTrigger) { const error = `Missing event trigger for ${triggerName}.`; this.logger.log("ERROR", error); + throw error; + } + const channel = eventTrigger.channel || GOOGLE_CHANNEL; + const key = `${eventTrigger.eventType}-${channel}`; + return { projectId, triggerName, eventTrigger, key }; + }; + + const removeTriggerRoute = `/emulator/v1/remove/projects/:project_id/triggers/:trigger_name`; + const removeTriggerHandler: express.RequestHandler = (req, res) => { + try { + const { projectId, triggerName, eventTrigger, key } = getTriggerIdentifiers(req); + this.logger.logLabeled( + "BULLET", + "eventarc", + `Removing Eventarc event trigger for ${key} with trigger name ${triggerName}.`, + ); + const eventTriggers = this.events[key] || []; + const triggerIdentifier = { projectId, triggerName, eventTrigger }; + const removeIdx = eventTriggers.findIndex( + (e) => JSON.stringify(triggerIdentifier) === JSON.stringify(e), + ); + if (removeIdx === -1) { + this.logger.logLabeled("ERROR", "eventarc", "Tried to remove nonexistent trigger"); + throw new Error(`Unable to delete function trigger ${triggerName}`); + } + eventTriggers.splice(removeIdx, 1); + if (eventTriggers.length === 0) { + delete this.events[key]; + } else { + this.events[key] = eventTriggers; + } + res.status(200).send({ res: "OK" }); + } catch (error) { res.status(400).send({ error }); - return; } - const key = `${eventTrigger.eventType}-${eventTrigger.channel}`; - this.logger.logLabeled( - "BULLET", - "eventarc", - `Registering custom event trigger for ${key} with trigger name ${triggerName}.`, - ); - const customEventTriggers = this.customEvents[key] || []; - customEventTriggers.push({ projectId, triggerName, eventTrigger }); - this.customEvents[key] = customEventTriggers; - res.status(200).send({ res: "OK" }); + }; + + const getTriggersRoute = `/google/getTriggers`; + const getTriggersHandler: express.RequestHandler = (req, res) => { + res.status(200).send(this.events); }; const publishEventsRoute = `/projects/:project_id/locations/:location/channels/:channel::publishEvents`; + const publishNativeEventsRoute = `/google/publishEvents`; + const publishEventsHandler: express.RequestHandler = (req, res) => { - const channel = `projects/${req.params.project_id}/locations/${req.params.location}/channels/${req.params.channel}`; + const isCustom = req.params.project_id && req.params.channel; + + const channel = isCustom + ? `projects/${req.params.project_id}/locations/${req.params.location}/channels/${req.params.channel}` + : GOOGLE_CHANNEL; + const body = JSON.parse((req as RequestWithRawBody).rawBody.toString()); for (const event of body.events) { if (!event.type) { @@ -77,9 +129,9 @@ export class EventarcEmulator implements EmulatorInstance { } this.logger.log( "INFO", - `Received custom event at channel ${channel}: ${JSON.stringify(event, null, 2)}`, + `Received event at channel ${channel}: ${JSON.stringify(event, null, 2)}`, ); - this.triggerCustomEventFunction(channel, event); + this.triggerEventFunction(channel, event); } res.sendStatus(200); }; @@ -98,6 +150,14 @@ export class EventarcEmulator implements EmulatorInstance { const hub = express(); hub.post([registerTriggerRoute], dataMiddleware, registerTriggerHandler); hub.post([publishEventsRoute], dataMiddleware, publishEventsHandler); + hub.post( + [publishNativeEventsRoute], + dataMiddleware, + cors({ origin: true }), + publishEventsHandler, + ); + hub.post([removeTriggerRoute], dataMiddleware, removeTriggerHandler); + hub.get([getTriggersRoute], cors({ origin: true }), getTriggersHandler); hub.all("*", (req, res) => { this.logger.log("DEBUG", `Eventarc emulator received unknown request at path ${req.path}`); res.sendStatus(404); @@ -105,13 +165,14 @@ export class EventarcEmulator implements EmulatorInstance { return hub; } - async triggerCustomEventFunction(channel: string, event: CloudEvent) { + async triggerEventFunction(channel: string, event: CloudEvent): Promise { if (!EmulatorRegistry.isRunning(Emulators.FUNCTIONS)) { this.logger.log("INFO", "Functions emulator not found. This should not happen."); return Promise.reject(); } const key = `${event.type}-${channel}`; - const triggers = this.customEvents[key] || []; + const triggers = this.events[key] || []; + const eventPayload = channel === GOOGLE_CHANNEL ? event : cloudEventFromProtoToJson(event); return await Promise.all( triggers .filter( @@ -119,31 +180,33 @@ export class EventarcEmulator implements EmulatorInstance { !trigger.eventTrigger.eventFilters || this.matchesAll(event, trigger.eventTrigger.eventFilters), ) - .map((trigger) => - EmulatorRegistry.client(Emulators.FUNCTIONS) - .request, NodeJS.ReadableStream>({ - method: "POST", - path: `/functions/projects/${trigger.projectId}/triggers/${trigger.triggerName}`, - body: JSON.stringify(cloudEventFromProtoToJson(event)), - responseType: "stream", - resolveOnHTTPError: true, - }) - .then((res) => { - // Since the response type is a stream and using `resolveOnHTTPError: true`, we check status manually. - if (res.status >= 400) { - throw new FirebaseError(`Received non-200 status code: ${res.status}`); - } - }) - .catch((err) => { - this.logger.log( - "ERROR", - `Failed to trigger Functions emulator for ${trigger.triggerName}: ${err}`, - ); - }), - ), + .map((trigger) => this.callFunctionTrigger(trigger, eventPayload)), ); } + callFunctionTrigger(trigger: EmulatedEventTrigger, event: CloudEvent): Promise { + return EmulatorRegistry.client(Emulators.FUNCTIONS) + .request, NodeJS.ReadableStream>({ + method: "POST", + path: `/functions/projects/${trigger.projectId}/triggers/${trigger.triggerName}`, + body: JSON.stringify(event), + responseType: "stream", + resolveOnHTTPError: true, + }) + .then((res) => { + // Since the response type is a stream and using `resolveOnHTTPError: true`, we check status manually. + if (res.status >= 400) { + throw new FirebaseError(`Received non-200 status code: ${res.status}`); + } + }) + .catch((err) => { + this.logger.log( + "ERROR", + `Failed to trigger Functions emulator for ${trigger.triggerName}: ${err}`, + ); + }); + } + private matchesAll(event: CloudEvent, eventFilters: Record): boolean { return Object.entries(eventFilters).every(([key, value]) => { let attr = event[key] ?? event.attributes[key]; diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 82769a39197..243be296b49 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -589,6 +589,20 @@ export class FunctionsEmulator implements EmulatorInstance { this.workerPools[emulatableBackend.codebase].refresh(); } + // Remove any old trigger definitions + const toRemove = Object.keys(this.triggers).filter((recordKey) => { + const record = this.getTriggerRecordByKey(recordKey); + if (force) { + return true; // We are going to load all of the triggers anyway, so we can remove everything + } + return !triggerDefinitions.some( + (def) => + record.def.entryPoint === def.entryPoint && + JSON.stringify(record.def.eventTrigger) === JSON.stringify(def.eventTrigger), + ); + }); + await this.removeTriggers(toRemove); + // When force is true we set up all triggers, otherwise we only set up // triggers which have a unique function name const toSetup = triggerDefinitions.filter((definition) => { @@ -684,6 +698,13 @@ export class FunctionsEmulator implements EmulatorInstance { case Constants.SERVICE_STORAGE: added = this.addStorageTrigger(this.args.projectId, key, definition.eventTrigger); break; + case Constants.SERVICE_FIREALERTS: + added = await this.addFirealertsTrigger( + this.args.projectId, + key, + definition.eventTrigger, + ); + break; default: this.logger.log("DEBUG", `Unsupported trigger: ${JSON.stringify(definition)}`); break; @@ -750,6 +771,36 @@ export class FunctionsEmulator implements EmulatorInstance { } } + // Currently only cleans up eventarc and firealerts triggers + async removeTriggers(toRemove: string[]) { + for (const triggerKey of toRemove) { + const definition = this.triggers[triggerKey].def; + const service = getFunctionService(definition); + const key = this.getTriggerKey(definition); + + switch (service) { + case Constants.SERVICE_EVENTARC: + await this.removeEventarcTrigger( + this.args.projectId, + key, + definition.eventTrigger as EventTrigger, + ); + delete this.triggers[key]; + break; + case Constants.SERVICE_FIREALERTS: + await this.removeFirealertsTrigger( + this.args.projectId, + key, + definition.eventTrigger as EventTrigger, + ); + delete this.triggers[key]; + break; + default: + break; + } + } + } + addEventarcTrigger(projectId: string, key: string, eventTrigger: EventTrigger): Promise { if (!EmulatorRegistry.isRunning(Emulators.EVENTARC)) { return Promise.resolve(false); @@ -770,6 +821,78 @@ export class FunctionsEmulator implements EmulatorInstance { }); } + removeEventarcTrigger( + projectId: string, + key: string, + eventTrigger: EventTrigger, + ): Promise { + if (!EmulatorRegistry.isRunning(Emulators.EVENTARC)) { + return Promise.resolve(false); + } + const bundle = { + eventTrigger: { + ...eventTrigger, + service: "eventarc.googleapis.com", + }, + }; + logger.debug(`removeEventarcTrigger`, JSON.stringify(bundle)); + return EmulatorRegistry.client(Emulators.EVENTARC) + .post(`/emulator/v1/remove/projects/${projectId}/triggers/${key}`, bundle) + .then(() => true) + .catch((err) => { + this.logger.log("WARN", "Error removing Eventarc function: " + err); + return false; + }); + } + + addFirealertsTrigger( + projectId: string, + key: string, + eventTrigger: EventTrigger, + ): Promise { + if (!EmulatorRegistry.isRunning(Emulators.EVENTARC)) { + return Promise.resolve(false); + } + const bundle = { + eventTrigger: { + ...eventTrigger, + service: "firebasealerts.googleapis.com", + }, + }; + logger.debug(`addFirealertsTrigger`, JSON.stringify(bundle)); + return EmulatorRegistry.client(Emulators.EVENTARC) + .post(`/emulator/v1/projects/${projectId}/triggers/${key}`, bundle) + .then(() => true) + .catch((err) => { + this.logger.log("WARN", "Error adding FireAlerts function: " + err); + return false; + }); + } + + removeFirealertsTrigger( + projectId: string, + key: string, + eventTrigger: EventTrigger, + ): Promise { + if (!EmulatorRegistry.isRunning(Emulators.EVENTARC)) { + return Promise.resolve(false); + } + const bundle = { + eventTrigger: { + ...eventTrigger, + service: "firebasealerts.googleapis.com", + }, + }; + logger.debug(`removeFirealertsTrigger`, JSON.stringify(bundle)); + return EmulatorRegistry.client(Emulators.EVENTARC) + .post(`/emulator/v1/remove/projects/${projectId}/triggers/${key}`, bundle) + .then(() => true) + .catch((err) => { + this.logger.log("WARN", "Error removing FireAlerts function: " + err); + return false; + }); + } + async performPostLoadOperations(): Promise { if ( !this.blockingFunctionsConfig.triggers && diff --git a/src/emulator/functionsEmulatorShared.ts b/src/emulator/functionsEmulatorShared.ts index 3ff9e1955c2..0cf32724b26 100644 --- a/src/emulator/functionsEmulatorShared.ts +++ b/src/emulator/functionsEmulatorShared.ts @@ -21,6 +21,7 @@ import { connectableHostname } from "../utils"; /** The current v2 events that are implemented in the emulator */ const V2_EVENTS = [ events.v2.PUBSUB_PUBLISH_EVENT, + events.v2.FIREALERTS_EVENT, ...events.v2.STORAGE_EVENTS, ...events.v2.DATABASE_EVENTS, ...events.v2.FIRESTORE_EVENTS, @@ -367,6 +368,9 @@ export function getServiceFromEventType(eventType: string): string { if (eventType.includes("storage")) { return Constants.SERVICE_STORAGE; } + if (eventType.includes("firebasealerts")) { + return Constants.SERVICE_FIREALERTS; + } // Below this point are services that do not have a emulator. if (eventType.includes("analytics")) { return Constants.SERVICE_ANALYTICS; diff --git a/src/functions/events/v2.ts b/src/functions/events/v2.ts index 84fbd72085f..15132856a93 100644 --- a/src/functions/events/v2.ts +++ b/src/functions/events/v2.ts @@ -30,6 +30,9 @@ export const FIRESTORE_EVENTS = [ "google.cloud.firestore.document.v1.updated.withAuthContext", "google.cloud.firestore.document.v1.deleted.withAuthContext", ] as const; + +export const FIREALERTS_EVENT = "google.firebase.firebasealerts.alerts.v1.published"; + export const FIRESTORE_EVENT_REGEX = /^google\.cloud\.firestore\.document\.v1\.[^\.]*$/; export const FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX = /^google\.cloud\.firestore\.document\.v1\..*\.withAuthContext$/; @@ -41,4 +44,5 @@ export type Event = | (typeof DATABASE_EVENTS)[number] | typeof REMOTE_CONFIG_EVENT | typeof TEST_LAB_EVENT - | (typeof FIRESTORE_EVENTS)[number]; + | (typeof FIRESTORE_EVENTS)[number] + | typeof FIREALERTS_EVENT;