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,