From a7e52b0a797cd8e858378241390c2d73816cc0f0 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Wed, 24 Jun 2026 21:15:22 +0000 Subject: [PATCH 1/9] Initial prototype of CF3 Lifecycle Hooks --- src/deploy/functions/backend.ts | 17 ++- src/deploy/functions/build.ts | 6 + src/deploy/functions/release/index.ts | 5 + .../functions/release/lifecycle.spec.ts | 83 +++++++++++++ src/deploy/functions/release/lifecycle.ts | 111 ++++++++++++++++++ .../functions/runtimes/discovery/v1alpha1.ts | 59 ++++++++++ src/gcp/cloudtasks.ts | 18 +++ 7 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/deploy/functions/release/lifecycle.spec.ts create mode 100644 src/deploy/functions/release/lifecycle.ts diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 8bd03a22d00..1838152b21e 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -425,6 +425,13 @@ export interface RequiredAPI { api: string; } +export interface LifecycleHook { + eventType: "afterInstall" | "afterUpdate"; + actionType: "http" | "callable" | "taskQueue"; + target: string; + body?: unknown; +} + /** An API agnostic definition of an entire deployment a customer has or wants. */ export interface Backend { /** @@ -434,6 +441,7 @@ export interface Backend { environmentVariables: EnvironmentVariables; // region -> id -> Endpoint endpoints: Record>; + lifecycleHooks?: Record; } /** @@ -482,8 +490,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 +509,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.ts b/src/deploy/functions/build.ts index efbde154987..f67466d6f95 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; } diff --git a/src/deploy/functions/release/index.ts b/src/deploy/functions/release/index.ts index 886603424d3..1ced0682bec 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 { wantBackend: w, haveBackend: h } of Object.values(payload.functions)) { + await executeLifecycleHooks(w, h); + } + 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..dfe12bc6ad9 --- /dev/null +++ b/src/deploy/functions/release/lifecycle.spec.ts @@ -0,0 +1,83 @@ +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"; + +describe("lifecycle", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("determineDeploymentDelta", () => { + it("returns afterInstall when haveBackend has no endpoints", () => { + const wantBackend = backend.empty(); + const haveBackend = backend.empty(); + + const delta = determineDeploymentDelta(wantBackend, haveBackend); + expect(delta).to.equal("afterInstall"); + }); + + it("returns afterUpdate when haveBackend has existing endpoints", () => { + const wantBackend = backend.empty(); + const haveBackend = backend.of({ + id: "myFunc", + project: "myProj", + region: "us-central1", + entryPoint: "myFunc", + platform: "gcfv2", + httpsTrigger: {}, + }); + + const delta = determineDeploymentDelta(wantBackend, 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 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: { + eventType: "afterInstall", + actionType: "taskQueue", + target: "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")); + }); + }); +}); diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts new file mode 100644 index 00000000000..1974e41c06a --- /dev/null +++ b/src/deploy/functions/release/lifecycle.ts @@ -0,0 +1,111 @@ +import * as backend from "../backend"; +import { FirebaseError } from "../../../error"; +import { logger } from "../../../logger"; +import * as cloudtasks from "../../../gcp/cloudtasks"; + +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( + wantBackend: backend.Backend, + 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, +): Promise { + const delta = determineDeploymentDelta(wantBackend, haveBackend); + const hooks = wantBackend.lifecycleHooks || {}; + const hook = hooks[delta]; + + if (!hook) { + logger.debug(`No lifecycle hook configured for event: ${delta}`); + return false; + } + + logger.info(`Executing ${delta} lifecycle hook targeting: ${hook.target}...`); + + if (hook.actionType === "taskQueue") { + await executeTaskQueueHook(hook, wantBackend); + return true; + } + + // Prototype currently supports taskQueue actionType. + logger.info(`Skipping hook execution for unsupported actionType: ${hook.actionType}`); + return false; +} + +/** + * Executes a taskQueue lifecycle hook by enqueuing a task in Cloud Tasks. + */ +async function executeTaskQueueHook( + hook: backend.LifecycleHook, + wantBackend: backend.Backend, +): Promise { + const targetEndpoint = findTargetEndpoint(wantBackend, hook.target); + if (!targetEndpoint) { + throw new FirebaseError(`Target endpoint "${hook.target}" not found in backend for lifecycle hook.`); + } + + if (!backend.isTaskQueueTriggered(targetEndpoint)) { + throw new FirebaseError(`Target endpoint "${hook.target}" is not a task queue function.`); + } + + const queueName = cloudtasks.queueNameForEndpoint(targetEndpoint); + const bodyStr = hook.body ? JSON.stringify(hook.body) : ""; + const body = bodyStr ? Buffer.from(bodyStr).toString("base64") : undefined; + + const url = targetEndpoint.uri; + if (!url) { + throw new FirebaseError(`Target endpoint "${hook.target}" does not have a trigger URI.`); + } + + const task: cloudtasks.Task = { + httpRequest: { + url, + httpMethod: "POST", + headers: { + "Content-Type": "application/json", + }, + }, + }; + if (body) { + task.httpRequest.body = body; + } + + try { + await cloudtasks.enqueueTask(queueName, task); + logger.info(`Successfully queued task for lifecycle hook ${hook.target} in queue ${queueName}.`); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to enqueue task for lifecycle hook ${hook.target}: ${errorMsg}`); + // Hooks follow idempotent execution contract: log warning but do not fail deploy. + } +} + +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; +} diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index d84282e30d0..98d5443585f 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,47 @@ 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]; + let actionType: "http" | "callable" | "taskQueue"; + let target: string | undefined; + let body: unknown; + + if (hook.task) { + actionType = "taskQueue"; + target = hook.task.function; + body = hook.task.body; + } else if (hook.callable) { + actionType = "callable"; + target = hook.callable.function; + body = hook.callable.body; + } else if (hook.http) { + actionType = "http"; + target = hook.http.url || hook.http.function; + body = hook.http.body; + } else { + throw new FirebaseError( + `No action (task, callable, or http) specified for lifecycle hook "${id}"`, + ); + } + + if (typeof target !== "string" || !target) { + throw new FirebaseError(`Invalid target "${target || ""}" for lifecycle hook "${id}"`); + } + + bd.lifecycleHooks[id] = { + eventType: id, + actionType, + target, + body, + }; + } + } return bd; } 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`, { From 26a2a33ad174138b087836ad2fde8155455b96fd Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Thu, 25 Jun 2026 21:58:57 +0000 Subject: [PATCH 2/9] Change schema to match functions SDK --- src/deploy/functions/backend.ts | 18 +++- src/deploy/functions/release/index.ts | 4 +- .../functions/release/lifecycle.spec.ts | 94 ++++++++++++++++++- src/deploy/functions/release/lifecycle.ts | 57 ++++++++--- .../functions/runtimes/discovery/v1alpha1.ts | 60 +++++++----- 5 files changed, 187 insertions(+), 46 deletions(-) diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 1838152b21e..5da3d905c2d 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -426,10 +426,20 @@ export interface RequiredAPI { } export interface LifecycleHook { - eventType: "afterInstall" | "afterUpdate"; - actionType: "http" | "callable" | "taskQueue"; - target: string; - body?: unknown; + 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. */ diff --git a/src/deploy/functions/release/index.ts b/src/deploy/functions/release/index.ts index 1ced0682bec..0cc1f11a207 100644 --- a/src/deploy/functions/release/index.ts +++ b/src/deploy/functions/release/index.ts @@ -115,8 +115,8 @@ export async function release( const wantBackend = backend.merge(...Object.values(payload.functions).map((p) => p.wantBackend)); printTriggerUrls(wantBackend, projectNumber); - for (const { wantBackend: w, haveBackend: h } of Object.values(payload.functions)) { - await executeLifecycleHooks(w, h); + for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) { + await executeLifecycleHooks(w, h, plan, codebase); } await setupArtifactCleanupPolicies( diff --git a/src/deploy/functions/release/lifecycle.spec.ts b/src/deploy/functions/release/lifecycle.spec.ts index dfe12bc6ad9..aae5ede3fde 100644 --- a/src/deploy/functions/release/lifecycle.spec.ts +++ b/src/deploy/functions/release/lifecycle.spec.ts @@ -62,10 +62,10 @@ describe("lifecycle", () => { }); wantBackend.lifecycleHooks = { afterInstall: { - eventType: "afterInstall", - actionType: "taskQueue", - target: "installHookTask", - body: { setupVersion: 1 }, + task: { + function: "installHookTask", + body: { setupVersion: 1 }, + }, }, }; const haveBackend = backend.empty(); @@ -77,7 +77,91 @@ describe("lifecycle", () => { 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.body).to.equal( + Buffer.from(JSON.stringify({ setupVersion: 1 })).toString("base64"), + ); + }); + + 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"), + ); + }); + + 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 index 1974e41c06a..1ff8cf02733 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -1,4 +1,5 @@ import * as backend from "../backend"; +import * as planner from "./planner"; import { FirebaseError } from "../../../error"; import { logger } from "../../../logger"; import * as cloudtasks from "../../../gcp/cloudtasks"; @@ -28,6 +29,8 @@ export function determineDeploymentDelta( export async function executeLifecycleHooks( wantBackend: backend.Backend, haveBackend: backend.Backend, + plan?: planner.DeploymentPlan, + codebase?: string, ): Promise { const delta = determineDeploymentDelta(wantBackend, haveBackend); const hooks = wantBackend.lifecycleHooks || {}; @@ -38,15 +41,39 @@ export async function executeLifecycleHooks( return false; } - logger.info(`Executing ${delta} lifecycle hook targeting: ${hook.target}...`); + if (delta === "afterUpdate" && plan) { + const relevantChangesets = Object.entries(plan) + .filter(([key]) => !codebase || key.startsWith(`${codebase}-`)) + .map(([, c]) => c); + const hasResourceModifications = relevantChangesets.some( + (changeset) => + changeset.endpointsToCreate.length > 0 || + changeset.endpointsToUpdate.length > 0 || + changeset.endpointsToDelete.length > 0, + ); + if (!hasResourceModifications) { + logger.info("No resources modified in codebase. Skipping afterUpdate lifecycle hook."); + return false; + } + } - if (hook.actionType === "taskQueue") { - await executeTaskQueueHook(hook, wantBackend); + if (hook.task) { + logger.info(`Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`); + await executeTaskQueueHook(hook.task, wantBackend); return true; } - // Prototype currently supports taskQueue actionType. - logger.info(`Skipping hook execution for unsupported actionType: ${hook.actionType}`); + if (hook.callable) { + logger.info(`Skipping hook execution for unsupported actionType: callable`); + return false; + } + + if (hook.http) { + logger.info(`Skipping hook execution for unsupported actionType: http`); + return false; + } + + logger.info(`No action specified for lifecycle hook`); return false; } @@ -54,25 +81,27 @@ export async function executeLifecycleHooks( * Executes a taskQueue lifecycle hook by enqueuing a task in Cloud Tasks. */ async function executeTaskQueueHook( - hook: backend.LifecycleHook, + taskHook: { function: string; body?: Record }, wantBackend: backend.Backend, ): Promise { - const targetEndpoint = findTargetEndpoint(wantBackend, hook.target); + const targetEndpoint = findTargetEndpoint(wantBackend, taskHook.function); if (!targetEndpoint) { - throw new FirebaseError(`Target endpoint "${hook.target}" not found in backend for lifecycle hook.`); + throw new FirebaseError( + `Target endpoint "${taskHook.function}" not found in backend for lifecycle hook.`, + ); } if (!backend.isTaskQueueTriggered(targetEndpoint)) { - throw new FirebaseError(`Target endpoint "${hook.target}" is not a task queue function.`); + throw new FirebaseError(`Target endpoint "${taskHook.function}" is not a task queue function.`); } const queueName = cloudtasks.queueNameForEndpoint(targetEndpoint); - const bodyStr = hook.body ? JSON.stringify(hook.body) : ""; + 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 "${hook.target}" does not have a trigger URI.`); + throw new FirebaseError(`Target endpoint "${taskHook.function}" does not have a trigger URI.`); } const task: cloudtasks.Task = { @@ -90,10 +119,12 @@ async function executeTaskQueueHook( try { await cloudtasks.enqueueTask(queueName, task); - logger.info(`Successfully queued task for lifecycle hook ${hook.target} in queue ${queueName}.`); + logger.info( + `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`, + ); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); - logger.warn(`Failed to enqueue task for lifecycle hook ${hook.target}: ${errorMsg}`); + logger.warn(`Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`); // Hooks follow idempotent execution contract: log warning but do not fail deploy. } } diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 98d5443585f..53889b34232 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -154,38 +154,54 @@ export function buildFromV1Alpha1( throw new FirebaseError(`Invalid eventType "${id}" for lifecycle hook.`); } const hook: WireLifecycleHook = manifest.lifecycleHooks[id]; - let actionType: "http" | "callable" | "taskQueue"; - let target: string | undefined; - let body: unknown; + const parsedHook: backend.LifecycleHook = {}; if (hook.task) { - actionType = "taskQueue"; - target = hook.task.function; - body = hook.task.body; + if (typeof hook.task.function !== "string" || !hook.task.function) { + throw new FirebaseError( + `Invalid target "${hook.task.function || ""}" for lifecycle hook "${id}"`, + ); + } + parsedHook.task = { + function: hook.task.function, + }; + if (hook.task.body !== undefined) { + parsedHook.task.body = hook.task.body as Record; + } } else if (hook.callable) { - actionType = "callable"; - target = hook.callable.function; - body = hook.callable.body; + if (typeof hook.callable.function !== "string" || !hook.callable.function) { + throw new FirebaseError( + `Invalid target "${hook.callable.function || ""}" for lifecycle hook "${id}"`, + ); + } + parsedHook.callable = { + function: hook.callable.function, + }; + if (hook.callable.body !== undefined) { + parsedHook.callable.body = hook.callable.body as Record; + } } else if (hook.http) { - actionType = "http"; - target = hook.http.url || hook.http.function; - body = hook.http.body; + const target = hook.http.url || hook.http.function; + if (typeof target !== "string" || !target) { + throw new FirebaseError(`Invalid target "${target || ""}" for lifecycle hook "${id}"`); + } + parsedHook.http = {}; + if (hook.http.function) { + parsedHook.http.function = hook.http.function; + } + if (hook.http.url) { + parsedHook.http.url = hook.http.url; + } + if (hook.http.body !== undefined) { + parsedHook.http.body = hook.http.body; + } } else { throw new FirebaseError( `No action (task, callable, or http) specified for lifecycle hook "${id}"`, ); } - if (typeof target !== "string" || !target) { - throw new FirebaseError(`Invalid target "${target || ""}" for lifecycle hook "${id}"`); - } - - bd.lifecycleHooks[id] = { - eventType: id, - actionType, - target, - body, - }; + bd.lifecycleHooks[id] = parsedHook; } } return bd; From 329b9831065197e94e51049860acf7342f602968 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 18:49:13 +0000 Subject: [PATCH 3/9] Validate, prefix, and authenticate lifecycle hooks 1. Adds validation of each trigger 2. Ensures they are prefixed properly 3. Resolves SA for OIDC token 4. Outputs a Cloud COnsole log --- src/deploy/functions/build.spec.ts | 52 ++++ src/deploy/functions/build.ts | 14 ++ .../functions/release/lifecycle.spec.ts | 17 ++ src/deploy/functions/release/lifecycle.ts | 21 +- src/deploy/functions/validate.spec.ts | 228 ++++++++++++++++++ src/deploy/functions/validate.ts | 83 +++++++ 6 files changed, 414 insertions(+), 1 deletion(-) 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 f67466d6f95..3f1a84cefb8 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -739,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/release/lifecycle.spec.ts b/src/deploy/functions/release/lifecycle.spec.ts index aae5ede3fde..db653d503b8 100644 --- a/src/deploy/functions/release/lifecycle.spec.ts +++ b/src/deploy/functions/release/lifecycle.spec.ts @@ -3,12 +3,19 @@ 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(() => { @@ -51,6 +58,7 @@ describe("lifecycle", () => { 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", @@ -80,6 +88,12 @@ describe("lifecycle", () => { 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( + "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 () => { @@ -120,6 +134,9 @@ describe("lifecycle", () => { 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 () => { diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts index 1ff8cf02733..c75a06f0e11 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -3,6 +3,8 @@ import * as planner from "./planner"; import { FirebaseError } from "../../../error"; import { logger } from "../../../logger"; import * as cloudtasks from "../../../gcp/cloudtasks"; +import * as computeEngine from "../../../gcp/computeEngine"; +import { getProjectNumber } from "../../../getProjectNumber"; export type LifecycleDelta = "afterInstall" | "afterUpdate"; @@ -104,6 +106,10 @@ async function executeTaskQueueHook( 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, @@ -111,6 +117,9 @@ async function executeTaskQueueHook( headers: { "Content-Type": "application/json", }, + oidcToken: { + serviceAccountEmail: sa, + }, }, }; if (body) { @@ -122,10 +131,10 @@ async function executeTaskQueueHook( logger.info( `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`, ); + logger.info(`View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); logger.warn(`Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`); - // Hooks follow idempotent execution contract: log warning but do not fail deploy. } } @@ -140,3 +149,13 @@ function findTargetEndpoint( } 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/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..5d3b4b2f7c2 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -84,6 +84,7 @@ function validateScheduledTimeout(ep: backend.Endpoint): void { /** Validate that the configuration for endpoints are valid. */ export function endpointsAreValid(wantBackend: backend.Backend): void { + validateLifecycleHooks(wantBackend); const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); validateTimeoutConfig(endpoints); @@ -439,3 +440,85 @@ export function checkFiltersIntegrity( } } } + +/** Validate that the lifecycle hooks target valid endpoints in the backend. */ +export function validateLifecycleHooks(wantBackend: backend.Backend): void { + if (!wantBackend.lifecycleHooks) { + return; + } + const endpoints = backend.allEndpoints(wantBackend); + 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; +} From 8fa6007a34c4de4c76cab327d8592c840be75e89 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 20:34:28 +0000 Subject: [PATCH 4/9] Add functions label to logs --- src/deploy/functions/release/lifecycle.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts index c75a06f0e11..de678e64e4b 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -2,6 +2,7 @@ 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"; @@ -54,28 +55,28 @@ export async function executeLifecycleHooks( changeset.endpointsToDelete.length > 0, ); if (!hasResourceModifications) { - logger.info("No resources modified in codebase. Skipping afterUpdate lifecycle hook."); + logLabeledBullet("functions", "No resources modified in codebase. Skipping afterUpdate lifecycle hook."); return false; } } if (hook.task) { - logger.info(`Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`); + logLabeledBullet("functions", `Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`); await executeTaskQueueHook(hook.task, wantBackend); return true; } if (hook.callable) { - logger.info(`Skipping hook execution for unsupported actionType: callable`); + logLabeledBullet("functions", `Skipping hook execution for unsupported actionType: callable`); return false; } if (hook.http) { - logger.info(`Skipping hook execution for unsupported actionType: http`); + logLabeledBullet("functions", `Skipping hook execution for unsupported actionType: http`); return false; } - logger.info(`No action specified for lifecycle hook`); + logLabeledWarning("functions", `No action specified for lifecycle hook`); return false; } @@ -128,13 +129,13 @@ async function executeTaskQueueHook( try { await cloudtasks.enqueueTask(queueName, task); - logger.info( + logLabeledBullet("functions", `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`, ); - logger.info(`View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`); + logLabeledBullet("functions", `View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); - logger.warn(`Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`); + logLabeledWarning("functions", `Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`); } } From f1e0350ccb67e214507a98dedcb7a03e74a4bed9 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 21:29:22 +0000 Subject: [PATCH 5/9] Address comments --- src/deploy/functions/prepare.ts | 2 +- src/deploy/functions/release/lifecycle.spec.ts | 1 + src/deploy/functions/release/lifecycle.ts | 7 ++++++- .../functions/runtimes/discovery/v1alpha1.ts | 3 +++ src/deploy/functions/validate.ts | 14 ++++++++++---- 5 files changed, 21 insertions(+), 6 deletions(-) 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/lifecycle.spec.ts b/src/deploy/functions/release/lifecycle.spec.ts index db653d503b8..c16c72c250f 100644 --- a/src/deploy/functions/release/lifecycle.spec.ts +++ b/src/deploy/functions/release/lifecycle.spec.ts @@ -92,6 +92,7 @@ describe("lifecycle", () => { 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", ); }); diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts index de678e64e4b..ea3555a58d3 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -46,7 +46,12 @@ export async function executeLifecycleHooks( if (delta === "afterUpdate" && plan) { const relevantChangesets = Object.entries(plan) - .filter(([key]) => !codebase || key.startsWith(`${codebase}-`)) + .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) => diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 53889b34232..261e4b98c91 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -154,6 +154,9 @@ export function buildFromV1Alpha1( 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}".`); + } const parsedHook: backend.LifecycleHook = {}; if (hook.task) { diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 5d3b4b2f7c2..6995658417c 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -83,8 +83,8 @@ function validateScheduledTimeout(ep: backend.Endpoint): void { } /** Validate that the configuration for endpoints are valid. */ -export function endpointsAreValid(wantBackend: backend.Backend): void { - validateLifecycleHooks(wantBackend); +export function endpointsAreValid(wantBackend: backend.Backend, existingBackend?: backend.Backend): void { + validateLifecycleHooks(wantBackend, existingBackend); const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); validateTimeoutConfig(endpoints); @@ -442,11 +442,17 @@ export function checkFiltersIntegrity( } /** Validate that the lifecycle hooks target valid endpoints in the backend. */ -export function validateLifecycleHooks(wantBackend: backend.Backend): void { +export function validateLifecycleHooks( + wantBackend: backend.Backend, + existingBackend?: backend.Backend, +): void { if (!wantBackend.lifecycleHooks) { return; } - const endpoints = backend.allEndpoints(wantBackend); + const endpoints = [ + ...backend.allEndpoints(wantBackend), + ...(existingBackend ? backend.allEndpoints(existingBackend) : []), + ]; for (const [eventType, hook] of Object.entries(wantBackend.lifecycleHooks)) { if (hook.task) { const targetEndpoint = findAndValidateTargetEndpoint( From cef33504af9e8bf960128dbeac25f4569b59e705 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 21:44:39 +0000 Subject: [PATCH 6/9] formatting --- src/deploy/functions/release/lifecycle.ts | 23 ++++++++++++++++++----- src/deploy/functions/validate.ts | 5 ++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts index ea3555a58d3..8602feeb647 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -60,13 +60,19 @@ export async function executeLifecycleHooks( changeset.endpointsToDelete.length > 0, ); if (!hasResourceModifications) { - logLabeledBullet("functions", "No resources modified in codebase. Skipping afterUpdate lifecycle hook."); + 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}...`); + logLabeledBullet( + "functions", + `Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`, + ); await executeTaskQueueHook(hook.task, wantBackend); return true; } @@ -134,13 +140,20 @@ async function executeTaskQueueHook( try { await cloudtasks.enqueueTask(queueName, task); - logLabeledBullet("functions", + logLabeledSuccess( + "functions", `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`, ); - logLabeledBullet("functions", `View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`); + 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}`); + logLabeledWarning( + "functions", + `Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`, + ); } } diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 6995658417c..ba852ad691a 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -83,7 +83,10 @@ function validateScheduledTimeout(ep: backend.Endpoint): void { } /** Validate that the configuration for endpoints are valid. */ -export function endpointsAreValid(wantBackend: backend.Backend, existingBackend?: backend.Backend): void { +export function endpointsAreValid( + wantBackend: backend.Backend, + existingBackend?: backend.Backend, +): void { validateLifecycleHooks(wantBackend, existingBackend); const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); From 0b6f5a91390d7240dc4f845648cbbbb4eccbac2b Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 22:15:37 +0000 Subject: [PATCH 7/9] remove unused arg --- src/deploy/functions/release/lifecycle.spec.ts | 6 ++---- src/deploy/functions/release/lifecycle.ts | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/deploy/functions/release/lifecycle.spec.ts b/src/deploy/functions/release/lifecycle.spec.ts index c16c72c250f..36c3aeece21 100644 --- a/src/deploy/functions/release/lifecycle.spec.ts +++ b/src/deploy/functions/release/lifecycle.spec.ts @@ -24,15 +24,13 @@ describe("lifecycle", () => { describe("determineDeploymentDelta", () => { it("returns afterInstall when haveBackend has no endpoints", () => { - const wantBackend = backend.empty(); const haveBackend = backend.empty(); - const delta = determineDeploymentDelta(wantBackend, haveBackend); + const delta = determineDeploymentDelta(haveBackend); expect(delta).to.equal("afterInstall"); }); it("returns afterUpdate when haveBackend has existing endpoints", () => { - const wantBackend = backend.empty(); const haveBackend = backend.of({ id: "myFunc", project: "myProj", @@ -42,7 +40,7 @@ describe("lifecycle", () => { httpsTrigger: {}, }); - const delta = determineDeploymentDelta(wantBackend, haveBackend); + const delta = determineDeploymentDelta(haveBackend); expect(delta).to.equal("afterUpdate"); }); }); diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts index 8602feeb647..979f32d46bc 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -14,7 +14,6 @@ export type LifecycleDelta = "afterInstall" | "afterUpdate"; * (afterInstall) or an update to an existing deployment (afterUpdate). */ export function determineDeploymentDelta( - wantBackend: backend.Backend, haveBackend: backend.Backend, ): LifecycleDelta { // If haveBackend has no existing endpoints, this is a fresh installation. @@ -35,7 +34,7 @@ export async function executeLifecycleHooks( plan?: planner.DeploymentPlan, codebase?: string, ): Promise { - const delta = determineDeploymentDelta(wantBackend, haveBackend); + const delta = determineDeploymentDelta(haveBackend); const hooks = wantBackend.lifecycleHooks || {}; const hook = hooks[delta]; From 73702ed273b65a0c8e60946719f3870bfeb134a3 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 22:42:18 +0000 Subject: [PATCH 8/9] Clean up manifest parsing --- .../runtimes/discovery/v1alpha1.spec.ts | 132 ++++++++++++++++++ .../functions/runtimes/discovery/v1alpha1.ts | 25 +--- 2 files changed, 133 insertions(+), 24 deletions(-) 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 261e4b98c91..243e0e62a88 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -157,7 +157,6 @@ export function buildFromV1Alpha1( if (!hook || typeof hook !== "object") { throw new FirebaseError(`Invalid lifecycle hook configuration for "${id}".`); } - const parsedHook: backend.LifecycleHook = {}; if (hook.task) { if (typeof hook.task.function !== "string" || !hook.task.function) { @@ -165,46 +164,24 @@ export function buildFromV1Alpha1( `Invalid target "${hook.task.function || ""}" for lifecycle hook "${id}"`, ); } - parsedHook.task = { - function: hook.task.function, - }; - if (hook.task.body !== undefined) { - parsedHook.task.body = hook.task.body as Record; - } } 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}"`, ); } - parsedHook.callable = { - function: hook.callable.function, - }; - if (hook.callable.body !== undefined) { - parsedHook.callable.body = hook.callable.body as Record; - } } 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}"`); } - parsedHook.http = {}; - if (hook.http.function) { - parsedHook.http.function = hook.http.function; - } - if (hook.http.url) { - parsedHook.http.url = hook.http.url; - } - if (hook.http.body !== undefined) { - parsedHook.http.body = hook.http.body; - } } else { throw new FirebaseError( `No action (task, callable, or http) specified for lifecycle hook "${id}"`, ); } - bd.lifecycleHooks[id] = parsedHook; + bd.lifecycleHooks[id] = hook as backend.LifecycleHook; } } return bd; From bf9ba2b5bf6c24535b53249ba50124d3de8c951c Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 26 Jun 2026 22:52:24 +0000 Subject: [PATCH 9/9] formatting --- src/deploy/functions/release/lifecycle.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/deploy/functions/release/lifecycle.ts b/src/deploy/functions/release/lifecycle.ts index 979f32d46bc..1c917d92e3f 100644 --- a/src/deploy/functions/release/lifecycle.ts +++ b/src/deploy/functions/release/lifecycle.ts @@ -13,9 +13,7 @@ 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 { +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) {