From 7d059f14558ea593d09ab14fd0b9d9789f4d4851 Mon Sep 17 00:00:00 2001 From: Takehiro Shoji <105960989+shojiiii@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:14:45 +0900 Subject: [PATCH] feat(functions): support configurable runtime update policies ### Description Related to #11330. Explore an opt-in runtimeUpdatePolicy setting in firebase.json for Gen 2 functions. Accept automatic and on-deploy per functions configuration and map the selected policy to the Cloud Functions v2 build configuration. Read deployed policies so policy-only changes are not skipped when source and environment hashes match. Omission preserves existing deployment behavior and leaves new functions to use the API default. This is an initial implementation proposal for design discussion. The option name, its placement in firebase.json, and whether Firebase CLI should manage this setting remain open to maintainer feedback. ### Scenarios Tested - 262 related unit tests passed, including both policy values, omission, invalid configuration, mixed platforms, request bodies and masks, deployed policy conversion, and policy-only deployment changes. - Lint and TypeScript compilation passed. - Full unit suite passed with TZ=UTC: 5,912 passing, 11 pending. UTC avoids an existing datetimeString failure under Asia/Tokyo. - Live Cloud Functions deployment has not been tested. ### Sample Commands Set functions.runtimeUpdatePolicy to automatic or on-deploy in firebase.json, then run: firebase deploy --only functions:hello --- CHANGELOG.md | 1 + README.md | 20 ++++ schema/firebase-config.json | 21 +++++ src/deploy/functions/backend.ts | 2 + src/deploy/functions/prepare.spec.ts | 31 +++++++ src/deploy/functions/prepare.ts | 16 ++++ src/deploy/functions/release/planner.spec.ts | 28 ++++++ src/deploy/functions/release/planner.ts | 3 + src/firebaseConfig.ts | 3 + src/functions/projectConfig.spec.ts | 18 ++++ src/functions/projectConfig.ts | 9 ++ src/gcp/cloudfunctionsv2.spec.ts | 98 ++++++++++++++++++++ src/gcp/cloudfunctionsv2.ts | 13 +++ 13 files changed, 263 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..cbcfbd5156a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Added a `functions.runtimeUpdatePolicy` setting to explicitly select `automatic` or `on-deploy` runtime security updates for 2nd gen functions. diff --git a/README.md b/README.md index e59e415ff6b..f269a20c219 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,26 @@ Detailed doc is [here](https://firebase.google.com/docs/cli/auth). | **functions:delete** | Delete one or more Cloud Functions by name or group name. | | **functions:shell** | Locally emulate functions and start Node.js shell where these local functions can be invoked with test data. | +#### Runtime security updates + +Set `runtimeUpdatePolicy` in a functions configuration in `firebase.json` to choose +when runtime security patches are applied to its 2nd gen functions: + +```json +{ + "functions": { + "source": "functions", + "runtimeUpdatePolicy": "automatic" + } +} +``` + +`automatic` applies runtime security patches without requiring a deployment. +`on-deploy` applies them only when the function is redeployed. When omitted, +deployments preserve the policy of existing functions and new functions use the +Cloud Functions API default. This setting applies independently to each functions +configuration and has no effect on 1st gen functions or the local emulators. + ### Hosting Commands | Command | Description | diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 1338124e049..075f106b943 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1009,6 +1009,13 @@ ], "type": "string" }, + "runtimeUpdatePolicy": { + "enum": [ + "automatic", + "on-deploy" + ], + "type": "string" + }, "source": { "description": "Local directory containing the kit source code.", "type": "string" @@ -1097,6 +1104,13 @@ ], "type": "string" }, + "runtimeUpdatePolicy": { + "enum": [ + "automatic", + "on-deploy" + ], + "type": "string" + }, "source": { "type": "string" } @@ -1213,6 +1227,13 @@ }, "runtime": { "$ref": "#/definitions/ActiveRuntime" + }, + "runtimeUpdatePolicy": { + "enum": [ + "automatic", + "on-deploy" + ], + "type": "string" } }, "required": [ diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 570bb765ce0..6a72a45cc6d 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -383,6 +383,8 @@ export type Endpoint = TargetIds & entryPoint: string; platform: FunctionsPlatform; runtime?: Runtime; + // Only applies to 2nd gen functions. Omission leaves the policy unmanaged. + runtimeUpdatePolicy?: "automatic" | "on-deploy"; // Output only // "Codebase" is not part of the container contract. Instead, it's value is provided by firebase.json or derived diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index c6f8c01329f..678366a04f4 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -31,6 +31,37 @@ import * as ensure from "./ensure"; import * as functionsConfig from "../../functionsConfig"; import * as args from "./args"; +describe("applyRuntimeUpdatePolicy", () => { + function endpoint(platform: backend.FunctionsPlatform, id = "fn"): backend.Endpoint { + return { + id, + platform, + project: "project", + region: "us-central1", + entryPoint: id, + httpsTrigger: {}, + }; + } + + for (const policy of ["automatic", "on-deploy"] as const) { + it(`applies ${policy} only to Gen 2 functions in a mixed codebase`, () => { + const gen1 = endpoint("gcfv1", "gen1"); + const gen2 = endpoint("gcfv2", "gen2"); + const run = endpoint("run", "run"); + prepare.applyRuntimeUpdatePolicy(backend.of(gen1, gen2, run), policy); + expect(gen2.runtimeUpdatePolicy).to.equal(policy); + expect(gen1).not.to.have.property("runtimeUpdatePolicy"); + expect(run).not.to.have.property("runtimeUpdatePolicy"); + }); + } + + it("does not set a policy when the configuration is omitted", () => { + const gen2 = endpoint("gcfv2"); + prepare.applyRuntimeUpdatePolicy(backend.of(gen2), undefined); + expect(gen2).not.to.have.property("runtimeUpdatePolicy"); + }); +}); + describe("partition env helper", () => { it("splits a Record into two based on which keys begin with FIREBASE_SECRET_REF", () => { const input = { diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 4d495411d22..ce3ea37a4b0 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -71,6 +71,21 @@ import * as resourcemanager from "../../gcp/resourceManager"; export const EVENTARC_SOURCE_ENV = "EVENTARC_CLOUD_EVENT_SOURCE"; export const DECLARATIVE_SECURITY_ETAG_LABEL = "firebase-declarative-security-etag"; +/** Applies the codebase runtime update policy to its 2nd gen functions. */ +export function applyRuntimeUpdatePolicy( + want: backend.Backend, + policy: backend.Endpoint["runtimeUpdatePolicy"], +): void { + if (policy === undefined) { + return; + } + for (const endpoint of backend.allEndpoints(want)) { + if (endpoint.platform === "gcfv2") { + endpoint.runtimeUpdatePolicy = policy; + } + } +} + /** * Discovers and coordinates declarative security details for a codebase. * Mutates `want` Backend to populate managed service account and etag labels. @@ -353,6 +368,7 @@ export async function prepare( }); functionsEnv.writeResolvedParams(resolvedEnvs, userEnvs, userEnvOpt); + applyRuntimeUpdatePolicy(wantBackend, config.runtimeUpdatePolicy); if ( experiments.isEnabled("secretEnvParams") && experiments.isEnabled("writeDefaultSecretBindings") diff --git a/src/deploy/functions/release/planner.spec.ts b/src/deploy/functions/release/planner.spec.ts index 8b44a92f14d..1ae50cfac0d 100644 --- a/src/deploy/functions/release/planner.spec.ts +++ b/src/deploy/functions/release/planner.spec.ts @@ -139,6 +139,34 @@ describe("planner", () => { }); describe("toSkipPredicate", () => { + for (const policy of ["automatic", "on-deploy"] as const) { + for (const deployed of ["automatic", "on-deploy", undefined] as const) { + it(`compares requested policy ${policy} with deployed policy ${deployed ?? "unspecified"} even with matching hashes`, () => { + const want: backend.Endpoint = { + ...func("fn", "region"), + platform: "gcfv2", + hash: "same-hash", + runtimeUpdatePolicy: policy, + }; + const have = { ...want, runtimeUpdatePolicy: deployed }; + const result = planner.calculateChangesets({ fn: want }, { fn: have }, (e) => e.region); + expect(result.region.endpointsToSkip).to.have.lengthOf(policy === deployed ? 1 : 0); + expect(result.region.endpointsToUpdate).to.have.lengthOf(policy === deployed ? 0 : 1); + }); + } + } + + it("can skip an unchanged function whose deployed policy is unmanaged", () => { + const want: backend.Endpoint = { + ...func("fn", "region"), + platform: "gcfv2", + hash: "same-hash", + }; + const have: backend.Endpoint = { ...want, runtimeUpdatePolicy: "on-deploy" }; + const result = planner.calculateChangesets({ fn: want }, { fn: have }, (e) => e.region); + expect(result.region.endpointsToSkip).to.have.lengthOf(1); + }); + it("should skip functions with matching hashes", () => { const wantFn = func("skip", "region"); const haveFn = func("skip", "region"); diff --git a/src/deploy/functions/release/planner.ts b/src/deploy/functions/release/planner.ts index 95646f11822..8c41e817a58 100644 --- a/src/deploy/functions/release/planner.ts +++ b/src/deploy/functions/release/planner.ts @@ -90,6 +90,9 @@ export function calculateChangesets( !!( !want[id].targetedByOnly && // Don't skip the function if its --only targeted. have[id].state === "ACTIVE" && // Only skip the function if its in a known good state + // A codebase policy change must deploy even when source and env hashes match. + (want[id].runtimeUpdatePolicy === undefined || + want[id].runtimeUpdatePolicy === have[id].runtimeUpdatePolicy) && have[id].hash && want[id].hash && want[id].hash === have[id].hash diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 816886ff7b2..50788c2cde9 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -179,6 +179,9 @@ type FunctionConfigBase = { // Optional: The Node.js/Python runtime version to use for Cloud Functions. // Example: "nodejs20", "python312". Must be a supported runtime version. runtime?: ActiveRuntime; + // Optional: Runtime security update policy for 2nd gen functions in this codebase. + // When omitted, updates preserve the deployed policy and creates use the API default. + runtimeUpdatePolicy?: "automatic" | "on-deploy"; } & Deployable; /** diff --git a/src/functions/projectConfig.spec.ts b/src/functions/projectConfig.spec.ts index 6b63601927c..89b545e1a08 100644 --- a/src/functions/projectConfig.spec.ts +++ b/src/functions/projectConfig.spec.ts @@ -25,6 +25,24 @@ describe("projectConfig", () => { }); describe("validate", () => { + for (const policy of ["automatic", "on-deploy"] as const) { + it(`accepts the ${policy} runtime update policy`, () => { + const config = { ...TEST_CONFIG_0, runtimeUpdatePolicy: policy }; + expect(projectConfig.validate([config])[0]).to.include(config); + }); + } + + for (const policy of ["invalid", "", null, false, {}]) { + it(`rejects invalid runtime update policy ${JSON.stringify(policy)}`, () => { + expect(() => + projectConfig.validate([ + // @ts-expect-error invalid runtime update policy for test + { ...TEST_CONFIG_0, runtimeUpdatePolicy: policy }, + ]), + ).to.throw(FirebaseError, /functions.runtimeUpdatePolicy/); + }); + } + it("passes validation for simple config", () => { expect(projectConfig.validate([TEST_CONFIG_0])).to.deep.equal([ { ...TEST_CONFIG_0, codebase: "default" }, diff --git a/src/functions/projectConfig.ts b/src/functions/projectConfig.ts index ec660d580a8..ef300731382 100644 --- a/src/functions/projectConfig.ts +++ b/src/functions/projectConfig.ts @@ -103,6 +103,15 @@ export function validatePrefix(prefix: string): void { } function validateSingle(config: FunctionConfig): ValidatedSingle { + if ( + config.runtimeUpdatePolicy !== undefined && + config.runtimeUpdatePolicy !== "automatic" && + config.runtimeUpdatePolicy !== "on-deploy" + ) { + throw new FirebaseError( + 'functions.runtimeUpdatePolicy must be either "automatic" or "on-deploy".', + ); + } if ("kit" in config) { experiments.assertEnabled("kits", "use functions kits"); if (!config.kit) { diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index bd2698aa448..364b7d277c1 100644 --- a/src/gcp/cloudfunctionsv2.spec.ts +++ b/src/gcp/cloudfunctionsv2.spec.ts @@ -66,6 +66,20 @@ describe("cloudfunctionsv2", () => { }; describe("functionFromEndpoint", () => { + for (const policy of ["automatic", "on-deploy"] as const) { + it(`maps ${policy} to exactly one runtime update policy field`, () => { + const fn = cloudfunctionsv2.functionFromEndpoint({ + ...ENDPOINT, + httpsTrigger: {}, + runtimeUpdatePolicy: policy, + }); + const selected = policy === "automatic" ? "automaticUpdatePolicy" : "onDeployUpdatePolicy"; + const other = policy === "automatic" ? "onDeployUpdatePolicy" : "automaticUpdatePolicy"; + expect(fn.buildConfig).to.have.deep.property(selected, {}); + expect(fn.buildConfig).not.to.have.property(other); + }); + } + it("should guard against version mixing", () => { expect(() => { cloudfunctionsv2.functionFromEndpoint({ ...ENDPOINT, httpsTrigger: {}, platform: "gcfv1" }); @@ -515,6 +529,25 @@ describe("cloudfunctionsv2", () => { }); describe("endpointFromFunction", () => { + for (const policy of ["automatic", "on-deploy"] as const) { + it(`reads the deployed ${policy} policy without copying output-only runtimeVersion`, () => { + const fn: cloudfunctionsv2.OutputCloudFunction = { + ...HAVE_CLOUD_FUNCTION_V2, + buildConfig: { + ...CLOUD_FUNCTION_V2.buildConfig, + ...(policy === "automatic" + ? { automaticUpdatePolicy: {} } + : { onDeployUpdatePolicy: { runtimeVersion: "output-only-version" } }), + }, + }; + const endpoint = cloudfunctionsv2.endpointFromFunction(fn); + expect(endpoint.runtimeUpdatePolicy).to.equal(policy); + const converted = cloudfunctionsv2.functionFromEndpoint(endpoint); + const selected = policy === "automatic" ? "automaticUpdatePolicy" : "onDeployUpdatePolicy"; + expect(converted.buildConfig).to.have.deep.property(selected, {}); + }); + } + it("should copy a minimal version", () => { expect(cloudfunctionsv2.endpointFromFunction(HAVE_CLOUD_FUNCTION_V2)).to.deep.equal({ ...ENDPOINT, @@ -904,6 +937,35 @@ describe("cloudfunctionsv2", () => { }); describe("createFunction", () => { + for (const policy of ["automatic", "on-deploy", undefined] as const) { + it(`creates a function with runtime update policy ${policy ?? "unspecified"}`, async () => { + const fn = cloudfunctionsv2.functionFromEndpoint({ + ...ENDPOINT, + httpsTrigger: {}, + ...(policy ? { runtimeUpdatePolicy: policy } : {}), + }); + const selected = policy === "automatic" ? "automaticUpdatePolicy" : "onDeployUpdatePolicy"; + const other = policy === "automatic" ? "onDeployUpdatePolicy" : "automaticUpdatePolicy"; + const scope = nock(functionsV2Origin()) + .post( + "/v2/projects/project/locations/region/functions", + (body: cloudfunctionsv2.InputCloudFunction) => { + if (policy) { + expect(body.buildConfig).to.have.deep.property(selected, {}); + } else { + expect(body.buildConfig).not.to.have.property(selected); + } + expect(body.buildConfig).not.to.have.property(other); + return true; + }, + ) + .query({ functionId: "id" }) + .reply(200, { name: "operations/123", done: true }); + await cloudfunctionsv2.createFunction(fn); + expect(scope.isDone()).to.be.true; + }); + } + it("should set default environment variables", async () => { const testFunction = { ...CLOUD_FUNCTION_V2, @@ -943,6 +1005,42 @@ describe("cloudfunctionsv2", () => { }); describe("updateFunction", () => { + for (const policy of ["automatic", "on-deploy", undefined] as const) { + it(`sends the correct policy body and update mask for ${policy ?? "unspecified"}`, async () => { + const fn = cloudfunctionsv2.functionFromEndpoint({ + ...ENDPOINT, + httpsTrigger: {}, + ...(policy ? { runtimeUpdatePolicy: policy } : {}), + }); + const selected = policy === "automatic" ? "automaticUpdatePolicy" : "onDeployUpdatePolicy"; + const other = policy === "automatic" ? "onDeployUpdatePolicy" : "automaticUpdatePolicy"; + const scope = nock(functionsV2Origin()) + .patch(`/v2/${fn.name}`, (body: cloudfunctionsv2.InputCloudFunction) => { + if (policy) { + expect(body.buildConfig).to.have.deep.property(selected, {}); + } else { + expect(body.buildConfig).not.to.have.property(selected); + } + expect(body.buildConfig).not.to.have.property(other); + return true; + }) + .query((query: { updateMask?: string }) => { + const masks = String(query.updateMask).split(","); + if (policy) { + expect(masks).to.include(`buildConfig.${selected}`); + } else { + expect(masks).not.to.include(`buildConfig.${selected}`); + } + expect(masks).not.to.include(`buildConfig.${other}`); + expect(masks).not.to.include("buildConfig.onDeployUpdatePolicy.runtimeVersion"); + return true; + }) + .reply(200, { name: "operations/123", done: true }); + await cloudfunctionsv2.updateFunction(fn); + expect(scope.isDone()).to.be.true; + }); + } + it("should set default environment variables", async () => { const scope = nock(functionsV2Origin()) .patch("/v2/projects/project/locations/region/functions/id", (body) => { diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index e3d263c7000..916eee6d453 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -53,6 +53,9 @@ export interface BuildConfig { entryPoint: string; source: Source; sourceToken?: string; + // oneof runtime_update_policy + automaticUpdatePolicy?: Record; + onDeployUpdatePolicy?: { runtimeVersion?: string }; // runtimeVersion is output only. environmentVariables: Record; // Output only @@ -468,6 +471,11 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc }; proto.copyIfPresent(gcfFunction, endpoint, "labels"); + if (endpoint.runtimeUpdatePolicy === "automatic") { + gcfFunction.buildConfig.automaticUpdatePolicy = {}; + } else if (endpoint.runtimeUpdatePolicy === "on-deploy") { + gcfFunction.buildConfig.onDeployUpdatePolicy = {}; + } proto.copyIfPresent( gcfFunction.serviceConfig, endpoint, @@ -734,6 +742,11 @@ export function endpointFromFunction(gcfFunction: OutputCloudFunction): backend. runtime: gcfFunction.buildConfig?.runtime || undefined, source: gcfFunction.buildConfig?.source, }; + if (gcfFunction.buildConfig?.automaticUpdatePolicy) { + endpoint.runtimeUpdatePolicy = "automatic"; + } else if (gcfFunction.buildConfig?.onDeployUpdatePolicy) { + endpoint.runtimeUpdatePolicy = "on-deploy"; + } if (gcfFunction.serviceConfig) { proto.copyIfPresent( endpoint,