diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 8bd03a22d00..5da3d905c2d 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -425,6 +425,23 @@ export interface RequiredAPI { api: string; } +export interface LifecycleHook { + task?: { + function: string; + body?: Record; + }; + callable?: { + function: string; + body?: Record; + }; + http?: { + function?: string; + url?: string; + method?: string; + body?: unknown; + }; +} + /** An API agnostic definition of an entire deployment a customer has or wants. */ export interface Backend { /** @@ -434,6 +451,7 @@ export interface Backend { environmentVariables: EnvironmentVariables; // region -> id -> Endpoint endpoints: Record>; + lifecycleHooks?: Record; } /** @@ -482,8 +500,11 @@ export function merge(...backends: Backend[]): Backend { } apiToReasons[api] = reasons; } - // Mere all environment variables. + // Merge all environment variables. merged.environmentVariables = { ...merged.environmentVariables, ...b.environmentVariables }; + if (b.lifecycleHooks) { + merged.lifecycleHooks = { ...(merged.lifecycleHooks || {}), ...b.lifecycleHooks }; + } } for (const [api, reasons] of Object.entries(apiToReasons)) { merged.requiredAPIs.push({ api, reason: Array.from(reasons).join(" ") }); @@ -498,7 +519,9 @@ export function merge(...backends: Backend[]): Backend { */ export function isEmptyBackend(backend: Backend): boolean { return ( - Object.keys(backend.requiredAPIs).length === 0 && Object.keys(backend.endpoints).length === 0 + Object.keys(backend.requiredAPIs).length === 0 && + Object.keys(backend.endpoints).length === 0 && + Object.keys(backend.lifecycleHooks || {}).length === 0 ); } diff --git a/src/deploy/functions/build.spec.ts b/src/deploy/functions/build.spec.ts index 641daf1c47e..2a453f05749 100644 --- a/src/deploy/functions/build.spec.ts +++ b/src/deploy/functions/build.spec.ts @@ -504,4 +504,56 @@ describe("applyPrefix", () => { /Function names must start with a letter/, ); }); + + it("should prefix target functions in lifecycleHooks", () => { + const testBuild: build.Build = { + endpoints: { + func1: { + region: "us-central1", + project: "test-project", + platform: "gcfv2", + runtime: "nodejs18", + entryPoint: "func1", + httpsTrigger: {}, + }, + }, + params: [], + requiredAPIs: [], + lifecycleHooks: { + afterInstall: { + task: { + function: "func1", + body: { foo: "bar" }, + }, + }, + afterUpdate: { + callable: { + function: "func1", + }, + http: { + function: "func1", + }, + }, + }, + }; + + build.applyPrefix(testBuild, "staging"); + + expect(testBuild.lifecycleHooks).to.deep.equal({ + afterInstall: { + task: { + function: "staging-func1", + body: { foo: "bar" }, + }, + }, + afterUpdate: { + callable: { + function: "staging-func1", + }, + http: { + function: "staging-func1", + }, + }, + }); + }); }); diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index efbde154987..3f1a84cefb8 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -10,6 +10,8 @@ import { defineSecret } from "firebase-functions/params"; export const REGION_TBD = "REGION_TBD"; +export type LifecycleHook = backend.LifecycleHook; + /* The union of a customer-controlled deployment and potentially deploy-time defined parameters */ export interface Build { requiredAPIs: RequiredApi[]; @@ -17,6 +19,7 @@ export interface Build { params: params.Param[]; runtime?: Runtime; extensions?: Record; + lifecycleHooks?: Record; } /** @@ -588,6 +591,9 @@ export function toBackend( const bkend = backend.of(...bkEndpoints); bkend.requiredAPIs = build.requiredAPIs; + if (build.lifecycleHooks) { + bkend.lifecycleHooks = build.lifecycleHooks; + } return bkend; } @@ -733,4 +739,18 @@ export function applyPrefix(build: Build, prefix: string): void { } } build.endpoints = newEndpoints; + + if (build.lifecycleHooks) { + for (const hook of Object.values(build.lifecycleHooks)) { + if (hook.task?.function) { + hook.task.function = `${prefix}-${hook.task.function}`; + } + if (hook.callable?.function) { + hook.callable.function = `${prefix}-${hook.callable.function}`; + } + if (hook.http?.function) { + hook.http.function = `${prefix}-${hook.http.function}`; + } + } + } } diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index fb1a8c40a69..e1c3a5e5d5e 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -297,7 +297,7 @@ export async function prepare( await ensureTriggerRegions(wantBackend); resolveCpuAndConcurrency(wantBackend); resolveDefaultTimeout(wantBackend); - validate.endpointsAreValid(wantBackend); + validate.endpointsAreValid(wantBackend, existingBackend); inferBlockingDetails(wantBackend); } diff --git a/src/deploy/functions/release/index.ts b/src/deploy/functions/release/index.ts index 886603424d3..0cc1f11a207 100644 --- a/src/deploy/functions/release/index.ts +++ b/src/deploy/functions/release/index.ts @@ -17,6 +17,7 @@ import { FirebaseError } from "../../../error"; import { getProjectNumber } from "../../../getProjectNumber"; import { release as extRelease } from "../../extensions"; import * as artifacts from "../../../functions/artifacts"; +import { executeLifecycleHooks } from "./lifecycle"; /** Releases new versions of functions and extensions to prod. */ export async function release( @@ -114,6 +115,10 @@ export async function release( const wantBackend = backend.merge(...Object.values(payload.functions).map((p) => p.wantBackend)); printTriggerUrls(wantBackend, projectNumber); + for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) { + await executeLifecycleHooks(w, h, plan, codebase); + } + await setupArtifactCleanupPolicies( options, options.projectId!, diff --git a/src/deploy/functions/release/lifecycle.spec.ts b/src/deploy/functions/release/lifecycle.spec.ts new file mode 100644 index 00000000000..36c3aeece21 --- /dev/null +++ b/src/deploy/functions/release/lifecycle.spec.ts @@ -0,0 +1,183 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as backend from "../backend"; +import { determineDeploymentDelta, executeLifecycleHooks } from "./lifecycle"; +import * as cloudtasks from "../../../gcp/cloudtasks"; +import { logger } from "../../../logger"; +import * as getProjectNumber from "../../../getProjectNumber"; +import * as computeEngine from "../../../gcp/computeEngine"; + +describe("lifecycle", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(getProjectNumber, "getProjectNumber").resolves("123456"); + sandbox + .stub(computeEngine, "getDefaultServiceAccount") + .resolves("123456-compute@developer.gserviceaccount.com"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("determineDeploymentDelta", () => { + it("returns afterInstall when haveBackend has no endpoints", () => { + const haveBackend = backend.empty(); + + const delta = determineDeploymentDelta(haveBackend); + expect(delta).to.equal("afterInstall"); + }); + + it("returns afterUpdate when haveBackend has existing endpoints", () => { + const haveBackend = backend.of({ + id: "myFunc", + project: "myProj", + region: "us-central1", + entryPoint: "myFunc", + platform: "gcfv2", + httpsTrigger: {}, + }); + + const delta = determineDeploymentDelta(haveBackend); + expect(delta).to.equal("afterUpdate"); + }); + }); + + describe("executeLifecycleHooks", () => { + it("returns false if no lifecycle hook is configured for the deployment delta", async () => { + const wantBackend = backend.empty(); + const haveBackend = backend.empty(); + + const executed = await executeLifecycleHooks(wantBackend, haveBackend); + expect(executed).to.be.false; + }); + + it("enqueues task when afterInstall TaskQueue hook is configured on fresh install", async () => { + const enqueueStub = sandbox.stub(cloudtasks, "enqueueTask").resolves(); + const loggerStub = sandbox.stub(logger, "info"); + const wantBackend = backend.of({ + id: "installHookTask", + project: "myProj", + region: "us-central1", + entryPoint: "installHookTask", + platform: "gcfv2", + uri: "https://installhooktask-12345.a.run.app", + taskQueueTrigger: {}, + }); + wantBackend.lifecycleHooks = { + afterInstall: { + task: { + function: "installHookTask", + body: { setupVersion: 1 }, + }, + }, + }; + const haveBackend = backend.empty(); + + const executed = await executeLifecycleHooks(wantBackend, haveBackend); + expect(executed).to.be.true; + expect(enqueueStub).to.have.been.calledOnce; + const [queueName, task] = enqueueStub.firstCall.args; + expect(queueName).to.equal("projects/myProj/locations/us-central1/queues/installHookTask"); + expect(task.httpRequest.url).to.equal("https://installhooktask-12345.a.run.app"); + expect(task.httpRequest.httpMethod).to.equal("POST"); + expect(task.httpRequest.body).to.equal( + Buffer.from(JSON.stringify({ setupVersion: 1 })).toString("base64"), + ); + expect(task.httpRequest.oidcToken).to.deep.equal({ + serviceAccountEmail: "123456-compute@developer.gserviceaccount.com", + }); + expect(loggerStub).to.have.been.calledWith( + sinon.match.any, + "View logs for installHookTask at: https://console.cloud.google.com/logs/query;query=resource.type%3D%22cloud_run_revision%22%0Aresource.labels.service_name%3D%22installHookTask%22%0Aresource.labels.location%3D%22us-central1%22;project=myProj", + ); + }); + + it("enqueues task when afterUpdate TaskQueue hook is configured on subsequent update", async () => { + const enqueueStub = sandbox.stub(cloudtasks, "enqueueTask").resolves(); + const wantBackend = backend.of({ + id: "updateHookTask", + project: "myProj", + region: "us-central1", + entryPoint: "updateHookTask", + platform: "gcfv2", + uri: "https://updatehooktask-12345.a.run.app", + taskQueueTrigger: {}, + }); + wantBackend.lifecycleHooks = { + afterUpdate: { + task: { + function: "updateHookTask", + body: { migrationStep: 2 }, + }, + }, + }; + const haveBackend = backend.of({ + id: "existingFunc", + project: "myProj", + region: "us-central1", + entryPoint: "existingFunc", + platform: "gcfv2", + httpsTrigger: {}, + }); + + const executed = await executeLifecycleHooks(wantBackend, haveBackend); + expect(executed).to.be.true; + expect(enqueueStub).to.have.been.calledOnce; + const [queueName, task] = enqueueStub.firstCall.args; + expect(queueName).to.equal("projects/myProj/locations/us-central1/queues/updateHookTask"); + expect(task.httpRequest.url).to.equal("https://updatehooktask-12345.a.run.app"); + expect(task.httpRequest.httpMethod).to.equal("POST"); + expect(task.httpRequest.body).to.equal( + Buffer.from(JSON.stringify({ migrationStep: 2 })).toString("base64"), + ); + expect(task.httpRequest.oidcToken).to.deep.equal({ + serviceAccountEmail: "123456-compute@developer.gserviceaccount.com", + }); + }); + + it("skips afterUpdate hook when deployment plan contains no resource modifications", async () => { + const enqueueStub = sandbox.stub(cloudtasks, "enqueueTask").resolves(); + const wantBackend = backend.of({ + id: "updateHookTask", + project: "myProj", + region: "us-central1", + entryPoint: "updateHookTask", + platform: "gcfv2", + uri: "https://updatehooktask-12345.a.run.app", + taskQueueTrigger: {}, + }); + wantBackend.lifecycleHooks = { + afterUpdate: { + task: { + function: "updateHookTask", + }, + }, + }; + const haveBackend = backend.of({ + id: "updateHookTask", + project: "myProj", + region: "us-central1", + entryPoint: "updateHookTask", + platform: "gcfv2", + uri: "https://updatehooktask-12345.a.run.app", + taskQueueTrigger: {}, + }); + + const emptyPlan = { + "default-us-central1-default": { + endpointsToCreate: [], + endpointsToUpdate: [], + endpointsToDelete: [], + endpointsToSkip: [wantBackend.endpoints["us-central1"]["updateHookTask"]], + }, + }; + + const executed = await executeLifecycleHooks(wantBackend, haveBackend, emptyPlan, "default"); + expect(executed).to.be.false; + expect(enqueueStub).to.not.have.been.called; + }); + }); +}); diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts new file mode 100644 index 00000000000..1c917d92e3f --- /dev/null +++ b/src/deploy/functions/release/lifecycle.ts @@ -0,0 +1,177 @@ +import * as backend from "../backend"; +import * as planner from "./planner"; +import { FirebaseError } from "../../../error"; +import { logger } from "../../../logger"; +import { logLabeledBullet, logLabeledSuccess, logLabeledWarning } from "../../../utils"; +import * as cloudtasks from "../../../gcp/cloudtasks"; +import * as computeEngine from "../../../gcp/computeEngine"; +import { getProjectNumber } from "../../../getProjectNumber"; + +export type LifecycleDelta = "afterInstall" | "afterUpdate"; + +/** + * Determines whether the current deployment represents a fresh codebase deployment + * (afterInstall) or an update to an existing deployment (afterUpdate). + */ +export function determineDeploymentDelta(haveBackend: backend.Backend): LifecycleDelta { + // If haveBackend has no existing endpoints, this is a fresh installation. + const hasExistingEndpoints = backend.someEndpoint(haveBackend, () => true); + if (!hasExistingEndpoints) { + return "afterInstall"; + } + return "afterUpdate"; +} + +/** + * Validates and executes matching lifecycle hooks for the deployed codebase. + * Returns true if a hook was executed, false otherwise. + */ +export async function executeLifecycleHooks( + wantBackend: backend.Backend, + haveBackend: backend.Backend, + plan?: planner.DeploymentPlan, + codebase?: string, +): Promise { + const delta = determineDeploymentDelta(haveBackend); + const hooks = wantBackend.lifecycleHooks || {}; + const hook = hooks[delta]; + + if (!hook) { + logger.debug(`No lifecycle hook configured for event: ${delta}`); + return false; + } + + if (delta === "afterUpdate" && plan) { + const relevantChangesets = Object.entries(plan) + .filter(([key]) => { + if (!codebase) return true; + if (key.startsWith(`${codebase}-`)) return true; + if (codebase === "default" && key.startsWith("-")) return true; + return false; + }) + .map(([, c]) => c); + const hasResourceModifications = relevantChangesets.some( + (changeset) => + changeset.endpointsToCreate.length > 0 || + changeset.endpointsToUpdate.length > 0 || + changeset.endpointsToDelete.length > 0, + ); + if (!hasResourceModifications) { + logLabeledBullet( + "functions", + "No resources modified in codebase. Skipping afterUpdate lifecycle hook.", + ); + return false; + } + } + + if (hook.task) { + logLabeledBullet( + "functions", + `Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`, + ); + await executeTaskQueueHook(hook.task, wantBackend); + return true; + } + + if (hook.callable) { + logLabeledBullet("functions", `Skipping hook execution for unsupported actionType: callable`); + return false; + } + + if (hook.http) { + logLabeledBullet("functions", `Skipping hook execution for unsupported actionType: http`); + return false; + } + + logLabeledWarning("functions", `No action specified for lifecycle hook`); + return false; +} + +/** + * Executes a taskQueue lifecycle hook by enqueuing a task in Cloud Tasks. + */ +async function executeTaskQueueHook( + taskHook: { function: string; body?: Record }, + wantBackend: backend.Backend, +): Promise { + const targetEndpoint = findTargetEndpoint(wantBackend, taskHook.function); + if (!targetEndpoint) { + throw new FirebaseError( + `Target endpoint "${taskHook.function}" not found in backend for lifecycle hook.`, + ); + } + + if (!backend.isTaskQueueTriggered(targetEndpoint)) { + throw new FirebaseError(`Target endpoint "${taskHook.function}" is not a task queue function.`); + } + + const queueName = cloudtasks.queueNameForEndpoint(targetEndpoint); + const bodyStr = taskHook.body ? JSON.stringify(taskHook.body) : ""; + const body = bodyStr ? Buffer.from(bodyStr).toString("base64") : undefined; + + const url = targetEndpoint.uri; + if (!url) { + throw new FirebaseError(`Target endpoint "${taskHook.function}" does not have a trigger URI.`); + } + + const projectNumber = await getProjectNumber({ projectId: targetEndpoint.project }); + const sa = + targetEndpoint.serviceAccount || (await computeEngine.getDefaultServiceAccount(projectNumber)); + + const task: cloudtasks.Task = { + httpRequest: { + url, + httpMethod: "POST", + headers: { + "Content-Type": "application/json", + }, + oidcToken: { + serviceAccountEmail: sa, + }, + }, + }; + if (body) { + task.httpRequest.body = body; + } + + try { + await cloudtasks.enqueueTask(queueName, task); + logLabeledSuccess( + "functions", + `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`, + ); + logLabeledBullet( + "functions", + `View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`, + ); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + logLabeledWarning( + "functions", + `Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`, + ); + } +} + +function findTargetEndpoint( + backendSpec: backend.Backend, + targetId: string, +): backend.Endpoint | undefined { + for (const endpoint of backend.allEndpoints(backendSpec)) { + if (endpoint.id === targetId) { + return endpoint; + } + } + return undefined; +} + +/** + * Generates the Google Cloud Console log URL for the given endpoint. + */ +function getCloudConsoleLogUrl(endpoint: backend.Endpoint): string { + const { project, region, id } = endpoint; + const serviceName = endpoint.runServiceId || id; + const query = `resource.type="cloud_run_revision"\nresource.labels.service_name="${serviceName}"\nresource.labels.location="${region}"`; + return `https://console.cloud.google.com/logs/query;query=${encodeURIComponent(query)};project=${project}`; +} diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index e401d79c534..7b88edcb20f 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -1102,4 +1102,136 @@ describe("buildFromV1Alpha", () => { expect(parsed).to.deep.equal(expected); }); }); + + describe("lifecycleHooks", () => { + it("copies valid task lifecycle hooks", () => { + const yaml: v1alpha1.WireManifest = { + specVersion: "v1alpha1", + endpoints: {}, + lifecycleHooks: { + afterInstall: { + task: { + function: "myTaskFunc", + body: { key: "value" }, + }, + }, + }, + }; + + const parsed = v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME); + const expected: build.Build = build.empty(); + expected.lifecycleHooks = { + afterInstall: { + task: { + function: "myTaskFunc", + body: { key: "value" }, + }, + }, + }; + expect(parsed).to.deep.equal(expected); + }); + + it("copies valid callable lifecycle hooks", () => { + const yaml: v1alpha1.WireManifest = { + specVersion: "v1alpha1", + endpoints: {}, + lifecycleHooks: { + afterUpdate: { + callable: { + function: "myCallableFunc", + }, + }, + }, + }; + + const parsed = v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME); + const expected: build.Build = build.empty(); + expected.lifecycleHooks = { + afterUpdate: { + callable: { + function: "myCallableFunc", + }, + }, + }; + expect(parsed).to.deep.equal(expected); + }); + + it("copies valid http lifecycle hooks", () => { + const yaml: v1alpha1.WireManifest = { + specVersion: "v1alpha1", + endpoints: {}, + lifecycleHooks: { + afterInstall: { + http: { + url: "https://example.com/hook", + body: "some-body", + }, + }, + }, + }; + + const parsed = v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME); + const expected: build.Build = build.empty(); + expected.lifecycleHooks = { + afterInstall: { + http: { + url: "https://example.com/hook", + body: "some-body", + }, + }, + }; + expect(parsed).to.deep.equal(expected); + }); + + it("throws on invalid event type", () => { + const yaml = { + specVersion: "v1alpha1", + endpoints: {}, + lifecycleHooks: { + invalidHookName: { + task: { + function: "myTaskFunc", + }, + }, + }, + }; + + expect(() => v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME)).to.throw( + FirebaseError, + /Invalid eventType "invalidHookName" for lifecycle hook/, + ); + }); + + it("throws when target function is missing in task hook", () => { + const yaml = { + specVersion: "v1alpha1", + endpoints: {}, + lifecycleHooks: { + afterInstall: { + task: {}, + }, + }, + }; + + expect(() => v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME)).to.throw( + FirebaseError, + /Invalid target "" for lifecycle hook "afterInstall"/, + ); + }); + + it("throws when action is missing", () => { + const yaml = { + specVersion: "v1alpha1", + endpoints: {}, + lifecycleHooks: { + afterInstall: {}, + }, + }; + + expect(() => v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME)).to.throw( + FirebaseError, + /No action \(task, callable, or http\) specified for lifecycle hook "afterInstall"/, + ); + }); + }); }); diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index d84282e30d0..243e0e62a88 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -87,12 +87,29 @@ export type WireExtension = { events: string[]; }; +export type WireLifecycleHook = { + task?: { + function?: string; + body?: unknown; + }; + callable?: { + function?: string; + body?: unknown; + }; + http?: { + url?: string; + function?: string; + body?: unknown; + }; +}; + export interface WireManifest { specVersion: string; params?: params.Param[]; requiredAPIs?: build.RequiredApi[]; endpoints: Record; extensions?: Record; + lifecycleHooks?: Record; } /** Returns a Build from a v1alpha1 Manifest. */ @@ -110,6 +127,7 @@ export function buildFromV1Alpha1( requiredAPIs: "array", endpoints: "object", extensions: "object", + lifecycleHooks: "object", }); const bd: build.Build = build.empty(); bd.params = manifest.params || []; @@ -129,6 +147,43 @@ export function buildFromV1Alpha1( bd.extensions[id] = be; } } + if (manifest.lifecycleHooks) { + bd.lifecycleHooks = {}; + for (const id of Object.keys(manifest.lifecycleHooks)) { + if (id !== "afterInstall" && id !== "afterUpdate") { + throw new FirebaseError(`Invalid eventType "${id}" for lifecycle hook.`); + } + const hook: WireLifecycleHook = manifest.lifecycleHooks[id]; + if (!hook || typeof hook !== "object") { + throw new FirebaseError(`Invalid lifecycle hook configuration for "${id}".`); + } + + if (hook.task) { + if (typeof hook.task.function !== "string" || !hook.task.function) { + throw new FirebaseError( + `Invalid target "${hook.task.function || ""}" for lifecycle hook "${id}"`, + ); + } + } else if (hook.callable) { + if (typeof hook.callable.function !== "string" || !hook.callable.function) { + throw new FirebaseError( + `Invalid target "${hook.callable.function || ""}" for lifecycle hook "${id}"`, + ); + } + } else if (hook.http) { + const target = hook.http.url || hook.http.function; + if (typeof target !== "string" || !target) { + throw new FirebaseError(`Invalid target "${target || ""}" for lifecycle hook "${id}"`); + } + } else { + throw new FirebaseError( + `No action (task, callable, or http) specified for lifecycle hook "${id}"`, + ); + } + + bd.lifecycleHooks[id] = hook as backend.LifecycleHook; + } + } return bd; } diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index 34a527a0b73..7ea83e6874a 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -494,6 +494,234 @@ describe("validate", () => { "The following functions have timeouts that exceed the maximum allowed for their trigger typ", ); }); + + describe("validateLifecycleHooks", () => { + it("succeeds when no lifecycle hooks are defined", () => { + const want = backend.of({ + ...ENDPOINT_BASE, + id: "myfunc", + }); + expect(() => validate.endpointsAreValid(want)).to.not.throw(); + }); + + it("succeeds when a task queue hook targets a valid task queue function", () => { + const taskEp: backend.Endpoint = { + ...ENDPOINT_BASE, + id: "mytaskfunc", + taskQueueTrigger: {}, + }; + const want = backend.of(taskEp); + want.lifecycleHooks = { + afterInstall: { + task: { + function: "mytaskfunc", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.not.throw(); + }); + + it("throws when a task queue hook targets a non-existent function", () => { + const want = backend.of({ + ...ENDPOINT_BASE, + id: "myfunc", + }); + want.lifecycleHooks = { + afterInstall: { + task: { + function: "nonexistent", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "nonexistent" not found in backend for lifecycle hook "afterInstall"/, + ); + }); + + it("throws when a task queue hook targets a function that is not a task queue function", () => { + const nonTaskEp: backend.Endpoint = { + ...ENDPOINT_BASE, + id: "nontaskfunc", + httpsTrigger: {}, + }; + const want = backend.of(nonTaskEp); + want.lifecycleHooks = { + afterInstall: { + task: { + function: "nontaskfunc", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "nontaskfunc" is not a task queue function for lifecycle hook "afterInstall"/, + ); + }); + + it("succeeds when a callable hook targets a valid callable function", () => { + const callableEp: backend.Endpoint = { + ...ENDPOINT_BASE, + id: "mycallablefunc", + callableTrigger: {}, + }; + const want = backend.of(callableEp); + want.lifecycleHooks = { + afterInstall: { + callable: { + function: "mycallablefunc", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.not.throw(); + }); + + it("throws when a callable hook targets a non-existent function", () => { + const want = backend.of({ + ...ENDPOINT_BASE, + id: "myfunc", + }); + want.lifecycleHooks = { + afterInstall: { + callable: { + function: "nonexistent", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "nonexistent" not found in backend for lifecycle hook "afterInstall"/, + ); + }); + + it("throws when a callable hook targets a function that is not a callable function", () => { + const nonCallableEp: backend.Endpoint = { + ...ENDPOINT_BASE, + id: "noncallablefunc", + httpsTrigger: {}, + }; + const want = backend.of(nonCallableEp); + want.lifecycleHooks = { + afterInstall: { + callable: { + function: "noncallablefunc", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "noncallablefunc" is not a callable function for lifecycle hook "afterInstall"/, + ); + }); + + it("succeeds when an http hook targets a valid HTTP function", () => { + const httpEp: backend.Endpoint = { + ...ENDPOINT_BASE, + id: "myhttpfunc", + httpsTrigger: {}, + }; + const want = backend.of(httpEp); + want.lifecycleHooks = { + afterInstall: { + http: { + function: "myhttpfunc", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.not.throw(); + }); + + it("throws when an http hook targets a non-existent function", () => { + const want = backend.of({ + ...ENDPOINT_BASE, + id: "myfunc", + }); + want.lifecycleHooks = { + afterInstall: { + http: { + function: "nonexistent", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "nonexistent" not found in backend for lifecycle hook "afterInstall"/, + ); + }); + + it("throws when an http hook targets a function that is not an HTTP or Callable function", () => { + const eventEp: backend.Endpoint = { + platform: "gcfv2", + id: "eventfunc", + region: "us-east1", + project: "project", + entryPoint: "func", + runtime: "nodejs16", + eventTrigger: { + eventType: "google.cloud.pubsub.topic.v1.messagePublished", + retry: false, + }, + }; + const want = backend.of(eventEp); + want.lifecycleHooks = { + afterInstall: { + http: { + function: "eventfunc", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "eventfunc" is not an HTTPS or Callable function for lifecycle hook "afterInstall"/, + ); + }); + + it("succeeds when an http hook specifies a valid URL", () => { + const want = backend.of({ + ...ENDPOINT_BASE, + id: "myfunc", + }); + want.lifecycleHooks = { + afterInstall: { + http: { + url: "https://example.com/webhook", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.not.throw(); + }); + + it("throws when a hook specifies an invalid URL", () => { + const want = backend.of({ + ...ENDPOINT_BASE, + id: "myfunc", + }); + want.lifecycleHooks = { + afterInstall: { + http: { + url: "not-a-valid-url", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Invalid URL "not-a-valid-url" specified for lifecycle hook "afterInstall"/, + ); + }); + + it("throws when a hook targets a GCF Gen 1 function", () => { + const v1Ep: backend.Endpoint = { + ...ENDPOINT_BASE, + id: "v1func", + platform: "gcfv1", + taskQueueTrigger: {}, + }; + const want = backend.of(v1Ep); + want.lifecycleHooks = { + afterInstall: { + task: { + function: "v1func", + }, + }, + }; + expect(() => validate.endpointsAreValid(want)).to.throw( + /Target endpoint "v1func" is a GCF Gen 1 function. Lifecycle hooks are only supported for GCF Gen 2 functions./, + ); + }); + }); }); describe("endpointsAreUnqiue", () => { diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index fe12d75ff8b..ba852ad691a 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -83,7 +83,11 @@ function validateScheduledTimeout(ep: backend.Endpoint): void { } /** Validate that the configuration for endpoints are valid. */ -export function endpointsAreValid(wantBackend: backend.Backend): void { +export function endpointsAreValid( + wantBackend: backend.Backend, + existingBackend?: backend.Backend, +): void { + validateLifecycleHooks(wantBackend, existingBackend); const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); validateTimeoutConfig(endpoints); @@ -439,3 +443,91 @@ export function checkFiltersIntegrity( } } } + +/** Validate that the lifecycle hooks target valid endpoints in the backend. */ +export function validateLifecycleHooks( + wantBackend: backend.Backend, + existingBackend?: backend.Backend, +): void { + if (!wantBackend.lifecycleHooks) { + return; + } + const endpoints = [ + ...backend.allEndpoints(wantBackend), + ...(existingBackend ? backend.allEndpoints(existingBackend) : []), + ]; + for (const [eventType, hook] of Object.entries(wantBackend.lifecycleHooks)) { + if (hook.task) { + const targetEndpoint = findAndValidateTargetEndpoint( + endpoints, + hook.task.function, + eventType, + ); + if (!backend.isTaskQueueTriggered(targetEndpoint)) { + throw new FirebaseError( + `Target endpoint "${hook.task.function}" is not a task queue function for lifecycle hook "${eventType}".`, + ); + } + } + + if (hook.callable) { + const targetEndpoint = findAndValidateTargetEndpoint( + endpoints, + hook.callable.function, + eventType, + ); + if (!backend.isCallableTriggered(targetEndpoint)) { + throw new FirebaseError( + `Target endpoint "${hook.callable.function}" is not a callable function for lifecycle hook "${eventType}".`, + ); + } + } + + if (hook.http) { + const httpHook = hook.http; + if (httpHook.function) { + const targetEndpoint = findAndValidateTargetEndpoint( + endpoints, + httpHook.function, + eventType, + ); + if ( + !backend.isHttpsTriggered(targetEndpoint) && + !backend.isCallableTriggered(targetEndpoint) + ) { + throw new FirebaseError( + `Target endpoint "${httpHook.function}" is not an HTTPS or Callable function for lifecycle hook "${eventType}".`, + ); + } + } + if (httpHook.url) { + try { + new URL(httpHook.url); + } catch (err) { + throw new FirebaseError( + `Invalid URL "${httpHook.url}" specified for lifecycle hook "${eventType}".`, + ); + } + } + } + } +} + +function findAndValidateTargetEndpoint( + endpoints: backend.Endpoint[], + functionName: string, + eventType: string, +): backend.Endpoint { + const targetEndpoint = endpoints.find((e) => e.id === functionName); + if (!targetEndpoint) { + throw new FirebaseError( + `Target endpoint "${functionName}" not found in backend for lifecycle hook "${eventType}".`, + ); + } + if (targetEndpoint.platform === "gcfv1") { + throw new FirebaseError( + `Target endpoint "${functionName}" is a GCF Gen 1 function. Lifecycle hooks are only supported for GCF Gen 2 functions.`, + ); + } + return targetEndpoint; +} diff --git a/src/gcp/cloudtasks.ts b/src/gcp/cloudtasks.ts index f3c10decea6..dae41d9a9aa 100644 --- a/src/gcp/cloudtasks.ts +++ b/src/gcp/cloudtasks.ts @@ -135,6 +135,24 @@ export async function deleteQueue(name: string): Promise { await client.delete(name); } +export interface Task { + httpRequest: { + url: string; + httpMethod?: string; + headers?: Record; + body?: string; + oidcToken?: { + serviceAccountEmail: string; + audience?: string; + }; + }; +} + +/** Enqueues a task in a Cloud Tasks queue. */ +export async function enqueueTask(queueName: string, task: Task): Promise { + await client.post(`${queueName}/tasks`, { task }); +} + /** Set the IAM policy of a given queue. */ export async function setIamPolicy(name: string, policy: iam.Policy): Promise { const res = await client.post<{ policy: iam.Policy }, iam.Policy>(`${name}:setIamPolicy`, {