Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
102 changes: 102 additions & 0 deletions src/deploy/functions/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
80 changes: 79 additions & 1 deletion src/deploy/functions/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<string, backend.Backend>): void {
const endpointToCodebases: Record<string, Set<string>> = {}; // function name -> codebases
Expand Down
Loading