Skip to content
Draft
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
@@ -0,0 +1 @@
- Added a `functions.runtimeUpdatePolicy` setting to explicitly select `automatic` or `on-deploy` runtime security updates for 2nd gen functions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 21 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,13 @@
],
"type": "string"
},
"runtimeUpdatePolicy": {
"enum": [
"automatic",
"on-deploy"
],
"type": "string"
},
"source": {
"description": "Local directory containing the kit source code.",
"type": "string"
Expand Down Expand Up @@ -1097,6 +1104,13 @@
],
"type": "string"
},
"runtimeUpdatePolicy": {
"enum": [
"automatic",
"on-deploy"
],
"type": "string"
},
"source": {
"type": "string"
}
Expand Down Expand Up @@ -1213,6 +1227,13 @@
},
"runtime": {
"$ref": "#/definitions/ActiveRuntime"
},
"runtimeUpdatePolicy": {
"enum": [
"automatic",
"on-deploy"
],
"type": "string"
}
},
"required": [
Expand Down
2 changes: 2 additions & 0 deletions src/deploy/functions/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
16 changes: 16 additions & 0 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -353,6 +368,7 @@ export async function prepare(
});

functionsEnv.writeResolvedParams(resolvedEnvs, userEnvs, userEnvOpt);
applyRuntimeUpdatePolicy(wantBackend, config.runtimeUpdatePolicy);
if (
experiments.isEnabled("secretEnvParams") &&
experiments.isEnabled("writeDefaultSecretBindings")
Expand Down
28 changes: 28 additions & 0 deletions src/deploy/functions/release/planner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions src/deploy/functions/release/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/firebaseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
18 changes: 18 additions & 0 deletions src/functions/projectConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
9 changes: 9 additions & 0 deletions src/functions/projectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".',
);
}
Comment on lines +106 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To adhere to the repository style guide (Rule 34: Extract magic numbers and static constants), we should avoid inlining the repeated magic strings "automatic" and "on-deploy". We can extract them into a local allowedPolicies constant array, which also simplifies the validation logic and makes it more maintainable.

Suggested change
if (
config.runtimeUpdatePolicy !== undefined &&
config.runtimeUpdatePolicy !== "automatic" &&
config.runtimeUpdatePolicy !== "on-deploy"
) {
throw new FirebaseError(
'functions.runtimeUpdatePolicy must be either "automatic" or "on-deploy".',
);
}
const allowedPolicies: readonly string[] = ["automatic", "on-deploy"];
if (
config.runtimeUpdatePolicy !== undefined &&
!allowedPolicies.includes(config.runtimeUpdatePolicy)
) {
throw new FirebaseError(
'functions.runtimeUpdatePolicy must be either "automatic" or "on-deploy".',
);
}
References
  1. Extract magic numbers and static constants: Define static IDs, default timeouts, millisecond offsets, and repeated strings as top-level constants (UPPER_SNAKE_CASE) instead of inlining magic values inside functions. Mark static configuration arrays and lookup tables as readonly or as const. (link)

if ("kit" in config) {
experiments.assertEnabled("kits", "use functions kits");
if (!config.kit) {
Expand Down
98 changes: 98 additions & 0 deletions src/gcp/cloudfunctionsv2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading