diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2b05b922d..fe4109ce388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,4 @@ - [BREAKING] Removed support for running emulators with Java versions prior to 21. - Add a confirmation in `firebase init dataconnect` before asking for app idea description. (#9282) - [BREAKING] Removed deprecated `firebase --open-sesame` and `firebase --close-sesame` commands. Use `firebase experiments:enable` and `firebase experiments:disable` instead. +- [BREAKING] Enforce strict timeout validation for functions. (#9540) diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index c0ddc527cc5..2954ea48310 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -662,4 +662,106 @@ describe("validate", () => { } }); }); + + describe("validateTimeoutConfig", () => { + const ENDPOINT_BASE: backend.Endpoint = { + platform: "gcfv2", + id: "id", + region: "us-east1", + project: "project", + entryPoint: "func", + runtime: "nodejs16", + httpsTrigger: {}, + }; + + it("should allow valid HTTP v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + httpsTrigger: {}, + timeoutSeconds: 3600, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should allow function without timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + httpsTrigger: {}, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid HTTP v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + httpsTrigger: {}, + timeoutSeconds: 3601, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + + it("should allow valid Event v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + eventTrigger: { + eventType: "google.cloud.storage.object.v1.finalized", + eventFilters: { bucket: "b" }, + retry: false, + }, + timeoutSeconds: 540, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid Event v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + eventTrigger: { + eventType: "google.cloud.storage.object.v1.finalized", + eventFilters: { bucket: "b" }, + retry: false, + }, + timeoutSeconds: 541, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + + it("should allow valid Scheduled v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + scheduleTrigger: { schedule: "every 5 minutes" }, + timeoutSeconds: 1800, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid Scheduled v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + scheduleTrigger: { schedule: "every 5 minutes" }, + timeoutSeconds: 1801, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + + it("should allow valid Gen 1 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv1", + httpsTrigger: {}, + timeoutSeconds: 540, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid Gen 1 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv1", + httpsTrigger: {}, + timeoutSeconds: 541, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + }); }); diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 05afe30b5ad..64a8623a32f 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -4,11 +4,42 @@ import * as clc from "colorette"; import { FirebaseError } from "../../error"; import { getSecretVersion, SecretVersion } from "../../gcp/secretManager"; import { logger } from "../../logger"; +import { getFunctionLabel } from "./functionsDeployHelper"; +import { serviceForEndpoint } from "./services"; import * as fsutils from "../../fsutils"; import * as backend from "./backend"; import * as utils from "../../utils"; import * as secrets from "../../functions/secrets"; -import { serviceForEndpoint } from "./services"; + +/** + * GCF Gen 1 has a max timeout of 540s. + */ +const MAX_V1_TIMEOUT_SECONDS = 540; + +/** + * Eventarc triggers are implicitly limited by Pub/Sub's ack deadline (600s). + * However, GCFv2 API prevents creation of functions with timeout > 540s. + * See https://cloud.google.com/pubsub/docs/subscription-properties#ack_deadline + */ +const MAX_V2_EVENTS_TIMEOUT_SECONDS = 540; + +/** + * Cloud Scheduler has a max attempt deadline of 30 minutes. + * See https://cloud.google.com/scheduler/docs/reference/rest/v1/projects.locations.jobs#Job.FIELDS.attempt_deadline + */ +const MAX_V2_SCHEDULE_TIMEOUT_SECONDS = 1800; + +/** + * Cloud Tasks has a max dispatch deadline of 30 minutes. + * See https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#Task.FIELDS.dispatch_deadline + */ +const MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS = 1800; + +/** + * HTTP and Callable functions have a max timeout of 60 minutes. + * See https://cloud.google.com/run/docs/configuring/request-timeout + */ +const MAX_V2_HTTP_TIMEOUT_SECONDS = 3600; function matchingIds( endpoints: backend.Endpoint[], @@ -32,6 +63,7 @@ const cpu = (endpoint: backend.Endpoint): number => { export function endpointsAreValid(wantBackend: backend.Backend): void { const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); + validateTimeoutConfig(endpoints); for (const ep of endpoints) { serviceForEndpoint(ep).validateTrigger(ep, wantBackend); } @@ -145,6 +177,52 @@ export function cpuConfigIsValid(endpoints: backend.Endpoint[]): void { } } +/** + * Validates that the timeout for each endpoint is within acceptable limits. + * This is a breaking change to prevent dangerous infinite retry loops and confusing timeouts. + */ +export function validateTimeoutConfig(endpoints: backend.Endpoint[]): void { + const invalidEndpoints: { ep: backend.Endpoint; limit: number }[] = []; + for (const ep of endpoints) { + const timeout = ep.timeoutSeconds; + if (!timeout) { + continue; + } + + let limit: number | undefined; + if (ep.platform === "gcfv1") { + limit = MAX_V1_TIMEOUT_SECONDS; + } else if (backend.isEventTriggered(ep)) { + limit = MAX_V2_EVENTS_TIMEOUT_SECONDS; + } else if (backend.isScheduleTriggered(ep)) { + limit = MAX_V2_SCHEDULE_TIMEOUT_SECONDS; + } else if (backend.isTaskQueueTriggered(ep)) { + limit = MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS; + } else if (backend.isHttpsTriggered(ep) || backend.isCallableTriggered(ep)) { + limit = MAX_V2_HTTP_TIMEOUT_SECONDS; + } + + if (limit !== undefined && timeout > limit) { + invalidEndpoints.push({ ep, limit }); + } + } + + if (invalidEndpoints.length === 0) { + return; + } + + const invalidList = invalidEndpoints + .sort((a, b) => backend.compareFunctions(a.ep, b.ep)) + .map(({ ep, limit }) => `\t${getFunctionLabel(ep)}: ${ep.timeoutSeconds}s (limit: ${limit}s)`) + .join("\n"); + + const msg = + "The following functions have timeouts that exceed the maximum allowed for their trigger type:\n\n" + + invalidList + + "\n\nFor more information, see https://firebase.google.com/docs/functions/quotas#time_limits"; + throw new FirebaseError(msg); +} + /** Validate that all endpoints in the given set of backends are unique */ export function endpointsAreUnique(backends: Record): void { const endpointToCodebases: Record> = {}; // function name -> codebases