Skip to content

Commit 1a3b885

Browse files
authoredMar 27, 2024··
Strengthen typing of Runtimes+Langauges and their support timelines. (#6866)
* Strengthen typing of Runtimes+Langauges and their support timelines. - Factors out Runtimes into a new file so that they can be imported into firebaseConfig.ts and have a single source of truth and prevent the error encountered in #6774 (comment) - Adds a formal concept of languages and the ability to discriminate runtimes per language - Makes all information about a runtime table driven so there can never be a runtime without all necessary metadata - Adds the ability to dynamically fetch the latest runtime for a language - Adds support timelines for all runtimes - Unifies runtime support checks across all runtimes/languages. There are now separate warnings for when deprecation is upcoming (90d), when a runtime is deprecated, and when a runtime is decommissioned. * Changelog * Regenerate firebase-config.json with node20 * Finally fixed schema issues * Default extensions emulators to the latest version of node * Create a DeprecatedRuntime type autogenerated by status that can be omitted from json schemas. * PR feedback; use helper types more * Add link to policy in error message * Fix tests
1 parent ed9e2d6 commit 1a3b885

39 files changed

+485
-290
lines changed
 

‎CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add support timelines for functions runtimes (#6866)

‎schema/firebase-config.json

+12-2
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,12 @@
622622
"nodejs14",
623623
"nodejs16",
624624
"nodejs18",
625-
"nodejs20"
625+
"nodejs20",
626+
"nodejs6",
627+
"nodejs8",
628+
"python310",
629+
"python311",
630+
"python312"
626631
],
627632
"type": "string"
628633
},
@@ -678,7 +683,12 @@
678683
"nodejs14",
679684
"nodejs16",
680685
"nodejs18",
681-
"nodejs20"
686+
"nodejs20",
687+
"nodejs6",
688+
"nodejs8",
689+
"python310",
690+
"python311",
691+
"python312"
682692
],
683693
"type": "string"
684694
},

‎src/deploy/functions/args.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as backend from "./backend";
22
import * as gcfV2 from "../../gcp/cloudfunctionsv2";
33
import * as projectConfig from "../../functions/projectConfig";
44
import * as deployHelper from "./functionsDeployHelper";
5-
import { Runtime } from "./runtimes";
5+
import { Runtime } from "./runtimes/supported";
66

77
// These types should probably be in a root deploy.ts, but we can only boil the ocean one bit at a time.
88
interface CodebasePayload {

‎src/deploy/functions/backend.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as gcf from "../../gcp/cloudfunctions";
22
import * as gcfV2 from "../../gcp/cloudfunctionsv2";
33
import * as utils from "../../utils";
4-
import * as runtimes from "./runtimes";
4+
import { Runtime } from "./runtimes/supported";
55
import { FirebaseError } from "../../error";
66
import { Context } from "./args";
77
import { flattenArray } from "../../functional";
@@ -350,7 +350,7 @@ export type Endpoint = TargetIds &
350350
Triggered & {
351351
entryPoint: string;
352352
platform: FunctionsPlatform;
353-
runtime: runtimes.Runtime | runtimes.DeprecatedRuntime;
353+
runtime: Runtime;
354354

355355
// Output only
356356
// "Codebase" is not part of the container contract. Instead, it's value is provided by firebase.json or derived

‎src/deploy/functions/build.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { FirebaseError } from "../../error";
66
import { assertExhaustive, mapObject, nullsafeVisitor } from "../../functional";
77
import { UserEnvsOpts, writeUserEnvs } from "../../functions/env";
88
import { FirebaseConfig } from "./args";
9-
import { Runtime } from "./runtimes";
9+
import { Runtime } from "./runtimes/supported";
1010
import { ExprParseError } from "./cel";
1111

1212
/* The union of a customer-controlled deployment and potentially deploy-time defined parameters */
@@ -240,7 +240,7 @@ export type Endpoint = Triggered & {
240240
project: string;
241241

242242
// The runtime being deployed to this endpoint. Currently targeting "nodejs16."
243-
runtime: string;
243+
runtime: Runtime;
244244

245245
// Firebase default of 80. Cloud default of 1
246246
concurrency?: Field<number>;

‎src/deploy/functions/prepare.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as ensureApiEnabled from "../../ensureApiEnabled";
77
import * as functionsConfig from "../../functionsConfig";
88
import * as functionsEnv from "../../functions/env";
99
import * as runtimes from "./runtimes";
10+
import * as supported from "./runtimes/supported";
1011
import * as validate from "./validate";
1112
import * as ensure from "./ensure";
1213
import {
@@ -415,7 +416,6 @@ export function resolveCpuAndConcurrency(want: backend.Backend): void {
415416

416417
/**
417418
* Exported for use by an internal command (internaltesting:functions:discover) only.
418-
*
419419
* @internal
420420
*/
421421
export async function loadCodebases(
@@ -442,12 +442,22 @@ export async function loadCodebases(
442442
projectId,
443443
sourceDir,
444444
projectDir: options.config.projectDir,
445-
runtime: codebaseConfig.runtime || "",
446445
};
446+
const firebaseJsonRuntime = codebaseConfig.runtime;
447+
if (firebaseJsonRuntime && !supported.isRuntime(firebaseJsonRuntime as string)) {
448+
throw new FirebaseError(
449+
`Functions codebase ${codebase} has invalid runtime ` +
450+
`${firebaseJsonRuntime} specified in firebase.json. Valid values are: ` +
451+
Object.keys(supported.RUNTIMES)
452+
.map((s) => `- ${s}`)
453+
.join("\n"),
454+
);
455+
}
447456
const runtimeDelegate = await runtimes.getRuntimeDelegate(delegateContext);
448-
logger.debug(`Validating ${runtimeDelegate.name} source`);
457+
logger.debug(`Validating ${runtimeDelegate.language} source`);
458+
supported.guardVersionSupport(runtimeDelegate.runtime);
449459
await runtimeDelegate.validate();
450-
logger.debug(`Building ${runtimeDelegate.name} source`);
460+
logger.debug(`Building ${runtimeDelegate.language} source`);
451461
await runtimeDelegate.build();
452462

453463
const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId);

‎src/deploy/functions/release/fabricator.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FirebaseError } from "../../../error";
55
import { SourceTokenScraper } from "./sourceTokenScraper";
66
import { Timer } from "./timer";
77
import { assertExhaustive } from "../../../functional";
8-
import { getHumanFriendlyRuntimeName } from "../runtimes";
8+
import { RUNTIMES } from "../runtimes/supported";
99
import { eventarcOrigin, functionsOrigin, functionsV2Origin } from "../../../api";
1010
import { logger } from "../../../logger";
1111
import * as args from "../args";
@@ -731,7 +731,7 @@ export class Fabricator {
731731
}
732732

733733
logOpStart(op: string, endpoint: backend.Endpoint): void {
734-
const runtime = getHumanFriendlyRuntimeName(endpoint.runtime);
734+
const runtime = RUNTIMES[endpoint.runtime].friendly;
735735
const platform = getHumanFriendlyPlatformName(endpoint.platform);
736736
const label = helper.getFunctionLabel(endpoint);
737737
utils.logLabeledBullet(

‎src/deploy/functions/runtimes/discovery/index.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { promisify } from "util";
77
import { logger } from "../../../../logger";
88
import * as api from "../../.../../../../api";
99
import * as build from "../../build";
10-
import * as runtimes from "..";
10+
import { Runtime } from "../supported";
1111
import * as v1alpha1 from "./v1alpha1";
1212
import { FirebaseError } from "../../../../error";
1313

@@ -20,7 +20,7 @@ export function yamlToBuild(
2020
yaml: any,
2121
project: string,
2222
region: string,
23-
runtime: runtimes.Runtime,
23+
runtime: Runtime,
2424
): build.Build {
2525
try {
2626
if (!yaml.specVersion) {
@@ -43,7 +43,7 @@ export function yamlToBuild(
4343
export async function detectFromYaml(
4444
directory: string,
4545
project: string,
46-
runtime: runtimes.Runtime,
46+
runtime: Runtime,
4747
): Promise<build.Build | undefined> {
4848
let text: string;
4949
try {
@@ -68,7 +68,7 @@ export async function detectFromYaml(
6868
export async function detectFromPort(
6969
port: number,
7070
project: string,
71-
runtime: runtimes.Runtime,
71+
runtime: Runtime,
7272
timeout = 10_000 /* 10s to boot up */,
7373
): Promise<build.Build> {
7474
let res: Response;

‎src/deploy/functions/runtimes/discovery/v1alpha1.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as build from "../../build";
22
import * as backend from "../../backend";
33
import * as params from "../../params";
4-
import * as runtimes from "..";
4+
import { Runtime } from "../supported";
55

66
import { copyIfPresent, convertIfPresent, secondsFromDuration } from "../../../../gcp/proto";
77
import { assertKeyTypes, requireKeys } from "./parsing";
@@ -83,7 +83,7 @@ export function buildFromV1Alpha1(
8383
yaml: unknown,
8484
project: string,
8585
region: string,
86-
runtime: runtimes.Runtime,
86+
runtime: Runtime,
8787
): build.Build {
8888
const manifest = JSON.parse(JSON.stringify(yaml)) as WireManifest;
8989
requireKeys("", manifest, "endpoints");
@@ -257,7 +257,7 @@ function parseEndpointForBuild(
257257
ep: WireEndpoint,
258258
project: string,
259259
defaultRegion: string,
260-
runtime: runtimes.Runtime,
260+
runtime: Runtime,
261261
): build.Endpoint {
262262
let triggered: build.Triggered;
263263
if (build.isEventTriggered(ep)) {

‎src/deploy/functions/runtimes/index.ts

+13-70
Original file line numberDiff line numberDiff line change
@@ -4,78 +4,20 @@ import * as node from "./node";
44
import * as python from "./python";
55
import * as validate from "../validate";
66
import { FirebaseError } from "../../../error";
7-
8-
/** Supported runtimes for new Cloud Functions. */
9-
const RUNTIMES: string[] = [
10-
"nodejs10",
11-
"nodejs12",
12-
"nodejs14",
13-
"nodejs16",
14-
"nodejs18",
15-
"nodejs20",
16-
"python310",
17-
"python311",
18-
"python312",
19-
];
20-
// Experimental runtimes are part of the Runtime type, but are in a
21-
// different list to help guard against some day accidentally iterating over
22-
// and printing a hidden runtime to the user.
23-
const EXPERIMENTAL_RUNTIMES: string[] = [];
24-
export type Runtime = (typeof RUNTIMES)[number] | (typeof EXPERIMENTAL_RUNTIMES)[number];
25-
26-
/** Runtimes that can be found in existing backends but not used for new functions. */
27-
const DEPRECATED_RUNTIMES = ["nodejs6", "nodejs8"];
28-
export type DeprecatedRuntime = (typeof DEPRECATED_RUNTIMES)[number];
29-
30-
/** Type deduction helper for a runtime string */
31-
export function isDeprecatedRuntime(runtime: string): runtime is DeprecatedRuntime {
32-
return DEPRECATED_RUNTIMES.includes(runtime);
33-
}
34-
35-
/** Type deduction helper for a runtime string. */
36-
export function isValidRuntime(runtime: string): runtime is Runtime {
37-
return RUNTIMES.includes(runtime) || EXPERIMENTAL_RUNTIMES.includes(runtime);
38-
}
39-
40-
const MESSAGE_FRIENDLY_RUNTIMES: Record<Runtime | DeprecatedRuntime, string> = {
41-
nodejs6: "Node.js 6 (Deprecated)",
42-
nodejs8: "Node.js 8 (Deprecated)",
43-
nodejs10: "Node.js 10",
44-
nodejs12: "Node.js 12",
45-
nodejs14: "Node.js 14",
46-
nodejs16: "Node.js 16",
47-
nodejs18: "Node.js 18",
48-
nodejs20: "Node.js 20",
49-
python310: "Python 3.10",
50-
python311: "Python 3.11",
51-
python312: "Python 3.12",
52-
};
53-
54-
/**
55-
* Returns a friendly string denoting the chosen runtime: Node.js 8 for nodejs 8
56-
* for example. If no friendly name for runtime is found, returns back the raw runtime.
57-
* @param runtime name of runtime in raw format, ie, "nodejs8" or "nodejs10"
58-
* @return A human-friendly string describing the runtime.
59-
*/
60-
export function getHumanFriendlyRuntimeName(runtime: Runtime | DeprecatedRuntime): string {
61-
return MESSAGE_FRIENDLY_RUNTIMES[runtime] || runtime;
62-
}
7+
import * as supported from "./supported";
638

649
/**
6510
* RuntimeDelegate is a language-agnostic strategy for managing
6611
* customer source.
6712
*/
6813
export interface RuntimeDelegate {
69-
/** A friendly name for the runtime; used for debug purposes */
70-
name: string;
14+
/** The language for the runtime; used for debug purposes */
15+
language: supported.Language;
7116

7217
/**
7318
* The name of the specific runtime of this source code.
74-
* This will often differ from `name` because `name` will be
75-
* version-free but this will include a specific runtime for
76-
* the GCF API.
7719
*/
78-
runtime: Runtime;
20+
runtime: supported.Runtime;
7921

8022
/**
8123
* Path to the bin used to run the source code.
@@ -124,24 +66,25 @@ export interface DelegateContext {
12466
projectDir: string;
12567
// Absolute path of the source directory.
12668
sourceDir: string;
127-
runtime?: string;
69+
runtime?: supported.Runtime;
12870
}
12971

13072
type Factory = (context: DelegateContext) => Promise<RuntimeDelegate | undefined>;
13173
const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate];
13274

13375
/**
134-
*
76+
* Gets the delegate object responsible for discovering, building, and hosting
77+
* code of a given language.
13578
*/
13679
export async function getRuntimeDelegate(context: DelegateContext): Promise<RuntimeDelegate> {
13780
const { projectDir, sourceDir, runtime } = context;
138-
validate.functionsDirectoryExists(sourceDir, projectDir);
13981

140-
// There isn't currently an easy way to map from runtime name to a delegate, but we can at least guarantee
141-
// that any explicit runtime from firebase.json is valid
142-
if (runtime && !isValidRuntime(runtime)) {
143-
throw new FirebaseError(`Cannot deploy function with runtime ${runtime}`);
82+
if (runtime && !supported.isRuntime(runtime)) {
83+
throw new FirebaseError(
84+
`firebase.json specifies invalid runtime ${runtime as string} for directory ${sourceDir}`,
85+
);
14486
}
87+
validate.functionsDirectoryExists(sourceDir, projectDir);
14588

14689
for (const factory of factories) {
14790
const delegate = await factory(context);
@@ -150,5 +93,5 @@ export async function getRuntimeDelegate(context: DelegateContext): Promise<Runt
15093
}
15194
}
15295

153-
throw new FirebaseError(`Could not detect language for functions at ${sourceDir}`);
96+
throw new FirebaseError(`Could not detect runtime for functions at ${sourceDir}`);
15497
}

‎src/deploy/functions/runtimes/node/index.ts

+6-7
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { logLabeledSuccess, logLabeledWarning, randomInt } from "../../../../uti
1313
import * as backend from "../../backend";
1414
import * as build from "../../build";
1515
import * as discovery from "../discovery";
16-
import * as runtimes from "..";
16+
import { DelegateContext } from "..";
17+
import * as supported from "../supported";
1718
import * as validate from "./validate";
1819
import * as versioning from "./versioning";
1920
import * as parseTriggers from "./parseTriggers";
@@ -24,9 +25,7 @@ const MIN_FUNCTIONS_SDK_VERSION = "3.20.0";
2425
/**
2526
*
2627
*/
27-
export async function tryCreateDelegate(
28-
context: runtimes.DelegateContext,
29-
): Promise<Delegate | undefined> {
28+
export async function tryCreateDelegate(context: DelegateContext): Promise<Delegate | undefined> {
3029
const packageJsonPath = path.join(context.sourceDir, "package.json");
3130

3231
if (!(await promisify(fs.exists)(packageJsonPath))) {
@@ -39,7 +38,7 @@ export async function tryCreateDelegate(
3938
// We should find a way to refactor this code so we're not repeatedly invoking node.
4039
const runtime = getRuntimeChoice(context.sourceDir, context.runtime);
4140

42-
if (!runtime.startsWith("nodejs")) {
41+
if (!supported.runtimeIsLanguage(runtime, "nodejs")) {
4342
logger.debug(
4443
"Customer has a package.json but did not get a nodejs runtime. This should not happen",
4544
);
@@ -54,13 +53,13 @@ export async function tryCreateDelegate(
5453
// and both files load package.json. Maybe the delegate should be constructed with a package.json and
5554
// that can be passed to both methods.
5655
export class Delegate {
57-
public readonly name = "nodejs";
56+
public readonly language = "nodejs";
5857

5958
constructor(
6059
private readonly projectId: string,
6160
private readonly projectDir: string,
6261
private readonly sourceDir: string,
63-
public readonly runtime: runtimes.Runtime,
62+
public readonly runtime: supported.Runtime,
6463
) {}
6564

6665
// Using a caching interface because we (may/will) eventually depend on the SDK version
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,23 @@
11
import * as path from "path";
2-
import * as clc from "colorette";
32

43
import { FirebaseError } from "../../../../error";
5-
import * as runtimes from "../../runtimes";
4+
import * as supported from "../supported";
65

76
// have to require this because no @types/cjson available
87
// eslint-disable-next-line @typescript-eslint/no-var-requires
98
const cjson = require("cjson");
109

11-
const ENGINE_RUNTIMES: Record<number, runtimes.Runtime | runtimes.DeprecatedRuntime> = {
12-
6: "nodejs6",
13-
8: "nodejs8",
14-
10: "nodejs10",
15-
12: "nodejs12",
16-
14: "nodejs14",
17-
16: "nodejs16",
18-
18: "nodejs18",
19-
20: "nodejs20",
20-
};
21-
22-
const ENGINE_RUNTIMES_NAMES = Object.values(ENGINE_RUNTIMES);
10+
const supportedNodeVersions: string[] = Object.keys(supported.RUNTIMES)
11+
.filter((s) => supported.runtimeIsLanguage(s as supported.Runtime, "nodejs"))
12+
.filter((s) => !supported.isDecommissioned(s as supported.Runtime))
13+
.map((s) => s.substring("nodejs".length));
2314

2415
export const RUNTIME_NOT_SET =
25-
"`runtime` field is required but was not found in firebase.json.\n" +
16+
"`runtime` field is required but was not found in firebase.json or package.json.\n" +
2617
"To fix this, add the following lines to the `functions` section of your firebase.json:\n" +
27-
'"runtime": "nodejs18"\n';
28-
29-
export const UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG = clc.bold(
30-
`functions.runtime value is unsupported. ` +
31-
`Valid choices are: ${clc.bold("nodejs{10|12|14|16|18|20}")}.`,
32-
);
33-
34-
export const UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG = clc.bold(
35-
`package.json in functions directory has an engines field which is unsupported. ` +
36-
`Valid choices are: ${clc.bold('{"node": 10|12|14|16|18|20}')}`,
37-
);
38-
39-
export const DEPRECATED_NODE_VERSION_INFO =
40-
`\n\nDeploys to runtimes below Node.js 10 are now disabled in the Firebase CLI. ` +
41-
`${clc.bold(
42-
`Existing Node.js 8 functions ${clc.underline("will stop executing at a future date")}`,
43-
)}. Update existing functions to Node.js 10 or greater as soon as possible.`;
18+
`"runtime": "${supported.latest("nodejs")}" or set the "engine" field in package.json\n`;
4419

45-
function getRuntimeChoiceFromPackageJson(
46-
sourceDir: string,
47-
): runtimes.Runtime | runtimes.DeprecatedRuntime {
20+
function getRuntimeChoiceFromPackageJson(sourceDir: string): supported.Runtime {
4821
const packageJsonPath = path.join(sourceDir, "package.json");
4922
let loaded;
5023
try {
@@ -61,7 +34,14 @@ function getRuntimeChoiceFromPackageJson(
6134
throw new FirebaseError(RUNTIME_NOT_SET);
6235
}
6336

64-
return ENGINE_RUNTIMES[engines.node];
37+
const runtime = `nodejs${engines.node}`;
38+
if (!supported.isRuntime(runtime)) {
39+
throw new FirebaseError(
40+
`Detected node engine ${engines.node} in package.json, which is not a ` +
41+
`supported version. Valid versions are ${supportedNodeVersions.join(", ")}`,
42+
);
43+
}
44+
return runtime;
6545
}
6646

6747
/**
@@ -71,23 +51,9 @@ function getRuntimeChoiceFromPackageJson(
7151
* @param runtimeFromConfig runtime from the `functions` section of firebase.json file (may be empty).
7252
* @return The runtime, e.g. `nodejs12`.
7353
*/
74-
export function getRuntimeChoice(sourceDir: string, runtimeFromConfig?: string): runtimes.Runtime {
75-
const runtime = runtimeFromConfig || getRuntimeChoiceFromPackageJson(sourceDir);
76-
const errorMessage =
77-
(runtimeFromConfig
78-
? UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG
79-
: UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG) + DEPRECATED_NODE_VERSION_INFO;
80-
81-
if (!runtime || !ENGINE_RUNTIMES_NAMES.includes(runtime)) {
82-
throw new FirebaseError(errorMessage, { exit: 1 });
83-
}
84-
85-
// Note: the runtimes.isValidRuntime should always be true because we've verified
86-
// it's in ENGINE_RUNTIME_NAMES and not in DEPRECATED_RUNTIMES. This is still a
87-
// good defense in depth and also lets us upcast the response to Runtime safely.
88-
if (runtimes.isDeprecatedRuntime(runtime) || !runtimes.isValidRuntime(runtime)) {
89-
throw new FirebaseError(errorMessage, { exit: 1 });
90-
}
91-
92-
return runtime;
54+
export function getRuntimeChoice(
55+
sourceDir: string,
56+
runtimeFromConfig?: supported.Runtime,
57+
): supported.Runtime {
58+
return runtimeFromConfig || getRuntimeChoiceFromPackageJson(sourceDir);
9359
}

‎src/deploy/functions/runtimes/node/parseTriggers.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as backend from "../../backend";
88
import * as build from "../../build";
99
import * as api from "../../../../api";
1010
import * as proto from "../../../../gcp/proto";
11-
import * as runtimes from "../../runtimes";
11+
import { Runtime } from "../../runtimes/supported";
1212
import * as events from "../../../../functions/events";
1313
import { nullsafeVisitor } from "../../../../functional";
1414

@@ -146,7 +146,7 @@ export function useStrategy(): Promise<boolean> {
146146
export async function discoverBuild(
147147
projectId: string,
148148
sourceDir: string,
149-
runtime: runtimes.Runtime,
149+
runtime: Runtime,
150150
configValues: backend.RuntimeConfigValues,
151151
envs: backend.EnvironmentVariables,
152152
): Promise<build.Build> {
@@ -168,7 +168,7 @@ export async function discoverBuild(
168168
export async function discoverBackend(
169169
projectId: string,
170170
sourceDir: string,
171-
runtime: runtimes.Runtime,
171+
runtime: Runtime,
172172
configValues: backend.RuntimeConfigValues,
173173
envs: backend.EnvironmentVariables,
174174
): Promise<backend.Backend> {
@@ -207,7 +207,7 @@ export function mergeRequiredAPIs(backend: backend.Backend) {
207207
*/
208208
export function addResourcesToBuild(
209209
projectId: string,
210-
runtime: runtimes.Runtime,
210+
runtime: Runtime,
211211
annotation: TriggerAnnotation,
212212
want: build.Build,
213213
): void {
@@ -406,7 +406,7 @@ export function addResourcesToBuild(
406406
*/
407407
export function addResourcesToBackend(
408408
projectId: string,
409-
runtime: runtimes.Runtime,
409+
runtime: Runtime,
410410
annotation: TriggerAnnotation,
411411
want: backend.Backend,
412412
): void {

‎src/deploy/functions/runtimes/python/index.ts

+17-10
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,15 @@ import * as portfinder from "portfinder";
88
import * as runtimes from "..";
99
import * as backend from "../../backend";
1010
import * as discovery from "../discovery";
11+
import * as supported from "../supported";
1112
import { logger } from "../../../../logger";
1213
import { DEFAULT_VENV_DIR, runWithVirtualEnv, virtualEnvCmd } from "../../../../functions/python";
1314
import { FirebaseError } from "../../../../error";
1415
import { Build } from "../../build";
15-
16-
export const LATEST_VERSION: runtimes.Runtime = "python312";
16+
import { assertExhaustive } from "../../../../functional";
1717

1818
/**
1919
* Create a runtime delegate for the Python runtime, if applicable.
20-
*
2120
* @param context runtimes.DelegateContext
2221
* @return Delegate Python runtime delegate
2322
*/
@@ -30,9 +29,15 @@ export async function tryCreateDelegate(
3029
logger.debug("Customer code is not Python code.");
3130
return;
3231
}
33-
const runtime = context.runtime ? context.runtime : LATEST_VERSION;
34-
if (!runtimes.isValidRuntime(runtime)) {
35-
throw new FirebaseError(`Runtime ${runtime} is not a valid Python runtime`);
32+
const runtime = context.runtime ?? supported.latest("python");
33+
if (!supported.isRuntime(runtime)) {
34+
throw new FirebaseError(`Runtime ${runtime as string} is not a valid Python runtime`);
35+
}
36+
if (!supported.runtimeIsLanguage(runtime, "python")) {
37+
throw new FirebaseError(
38+
`Internal error. Trying to construct a python runtime delegate for runtime ${runtime}`,
39+
{ exit: 1 },
40+
);
3641
}
3742
return Promise.resolve(new Delegate(context.projectId, context.sourceDir, runtime));
3843
}
@@ -42,7 +47,9 @@ export async function tryCreateDelegate(
4247
*
4348
* By default, returns "python"
4449
*/
45-
export function getPythonBinary(runtime: runtimes.Runtime): string {
50+
export function getPythonBinary(
51+
runtime: supported.Runtime & supported.RuntimeOf<"python">,
52+
): string {
4653
if (process.platform === "win32") {
4754
// There is no easy way to get specific version of python executable in Windows.
4855
return "python.exe";
@@ -54,15 +61,15 @@ export function getPythonBinary(runtime: runtimes.Runtime): string {
5461
} else if (runtime === "python312") {
5562
return "python3.12";
5663
}
57-
return "python";
64+
assertExhaustive(runtime, `Unhandled python runtime ${runtime as string}`);
5865
}
5966

6067
export class Delegate implements runtimes.RuntimeDelegate {
61-
public readonly name = "python";
68+
public readonly language = "python";
6269
constructor(
6370
private readonly projectId: string,
6471
private readonly sourceDir: string,
65-
public readonly runtime: runtimes.Runtime,
72+
public readonly runtime: supported.Runtime & supported.RuntimeOf<"python">,
6673
) {}
6774

6875
private _bin = "";
+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { FirebaseError } from "../../../error";
2+
import * as utils from "../../../utils";
3+
4+
// N.B. The status "deprecated" and "decommmissioned" is informational only.
5+
// The deprecationDate and decommmissionDate are the canonical values.
6+
// Updating the definition to "decommissioned", however, will omit the runtime
7+
// name from firebaseConfig's json schema.
8+
export type RuntimeStatus = "experimental" | "beta" | "GA" | "deprecated" | "decommissioned";
9+
10+
type Day = `${number}-${number}-${number}`;
11+
12+
/** Supported languages. All Runtime are a language + version. */
13+
export type Language = "nodejs" | "python";
14+
15+
/**
16+
* Helper type that is more friendlier than string interpolation everywhere.
17+
* Unfortunately, as Runtime has literal numbers and RuntimeOf accepts any
18+
* number, RuntimeOf<L> and Runtime must be intersected. It might help
19+
* readability to rename Runtime to KnownRuntime so that it reads better to see
20+
* KnownRuntime & RuntimeOf<"python">.
21+
*/
22+
export type RuntimeOf<T extends Language> = `${T}${number}`;
23+
24+
export interface RuntimeData {
25+
friendly: string;
26+
status: RuntimeStatus;
27+
deprecationDate: Day;
28+
decommissionDate: Day;
29+
}
30+
31+
// We can neither use the "satisfies" keyword nor the metaprogramming library
32+
// in this file to ensure RUNTIMES implements the right interfaces, so we must
33+
// use the copied assertImplements below. Some day these hacks will go away.
34+
function runtimes<T extends Record<RuntimeOf<Language>, RuntimeData>>(r: T): T {
35+
return r;
36+
}
37+
38+
export const RUNTIMES = runtimes({
39+
nodejs6: {
40+
friendly: "Node.js 6",
41+
status: "decommissioned",
42+
deprecationDate: "2019-04-17",
43+
decommissionDate: "2020-08-01",
44+
},
45+
nodejs8: {
46+
friendly: "Node.js 8",
47+
status: "decommissioned",
48+
deprecationDate: "2020-06-05",
49+
decommissionDate: "2021-02-01",
50+
},
51+
nodejs10: {
52+
friendly: "Node.js 10",
53+
status: "GA",
54+
deprecationDate: "2024-01-30",
55+
decommissionDate: "2025-01-30",
56+
},
57+
nodejs12: {
58+
friendly: "Node.js 12",
59+
status: "GA",
60+
deprecationDate: "2024-01-30",
61+
decommissionDate: "2025-01-30",
62+
},
63+
nodejs14: {
64+
friendly: "Node.js 14",
65+
status: "GA",
66+
deprecationDate: "2024-01-30",
67+
decommissionDate: "2025-01-30",
68+
},
69+
nodejs16: {
70+
friendly: "Node.js 16",
71+
status: "GA",
72+
deprecationDate: "2024-01-30",
73+
decommissionDate: "2025-01-30",
74+
},
75+
nodejs18: {
76+
friendly: "Node.js 18",
77+
status: "GA",
78+
deprecationDate: "2025-04-30",
79+
decommissionDate: "2025-10-31",
80+
},
81+
nodejs20: {
82+
friendly: "Node.js 20",
83+
status: "GA",
84+
deprecationDate: "2026-04-30",
85+
decommissionDate: "2026-10-31",
86+
},
87+
python310: {
88+
friendly: "Python 3.10",
89+
status: "GA",
90+
deprecationDate: "2026-10-04",
91+
decommissionDate: "2027-04-30",
92+
},
93+
python311: {
94+
friendly: "Python 3.11",
95+
status: "GA",
96+
deprecationDate: "2027-10-24",
97+
decommissionDate: "2028-04-30",
98+
},
99+
python312: {
100+
friendly: "Python 3.12",
101+
status: "GA",
102+
deprecationDate: "2028-10-02",
103+
decommissionDate: "2029-04-30",
104+
},
105+
});
106+
107+
export type Runtime = keyof typeof RUNTIMES & RuntimeOf<Language>;
108+
109+
export type DecommissionedRuntime = {
110+
[R in keyof typeof RUNTIMES]: (typeof RUNTIMES)[R] extends { status: "decommissioned" }
111+
? R
112+
: never;
113+
}[keyof typeof RUNTIMES];
114+
115+
/** Type deduction helper for a runtime string. */
116+
export function isRuntime(maybe: string): maybe is Runtime {
117+
return maybe in RUNTIMES;
118+
}
119+
120+
/** Type deduction helper to narrow a runtime to a language. */
121+
export function runtimeIsLanguage<L extends Language>(
122+
runtime: Runtime,
123+
language: L,
124+
): runtime is Runtime & RuntimeOf<L> {
125+
return runtime.startsWith(language);
126+
}
127+
128+
/**
129+
* Find the latest supported Runtime for a Language.
130+
*/
131+
export function latest<T extends Language>(
132+
language: T,
133+
runtimes: Runtime[] = Object.keys(RUNTIMES) as Runtime[],
134+
): RuntimeOf<T> & Runtime {
135+
const sorted = runtimes
136+
.filter((s) => runtimeIsLanguage(s, language))
137+
// node8 is less than node20
138+
.sort((left, right) => {
139+
const leftVersion = +left.substring(language.length);
140+
const rightVersion = +right.substring(language.length);
141+
if (isNaN(leftVersion) || isNaN(rightVersion)) {
142+
throw new FirebaseError("Internal error. Runtime or language names are malformed", {
143+
exit: 1,
144+
});
145+
}
146+
return leftVersion - rightVersion;
147+
});
148+
const latest = utils.last(sorted);
149+
if (!latest) {
150+
throw new FirebaseError(
151+
`Internal error trying to find the latest supported runtime for ${language}`,
152+
{ exit: 1 },
153+
);
154+
}
155+
return latest as RuntimeOf<T> & Runtime;
156+
}
157+
158+
/**
159+
* Whether a runtime is decommissioned.
160+
* Accepts now as a parameter to increase testability
161+
*/
162+
export function isDecommissioned(runtime: Runtime, now: Date = new Date()): boolean {
163+
const cutoff = new Date(RUNTIMES[runtime].decommissionDate);
164+
return cutoff < now;
165+
}
166+
167+
/**
168+
* Prints a warning if a runtime is in or nearing its deprecation time. Throws
169+
* an error if the runtime is decommissioned. Accepts time as a parameter to
170+
* increase testability.
171+
*/
172+
export function guardVersionSupport(runtime: Runtime, now: Date = new Date()): void {
173+
const { deprecationDate, decommissionDate } = RUNTIMES[runtime];
174+
175+
const decommission = new Date(decommissionDate);
176+
if (now >= decommission) {
177+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
178+
throw new FirebaseError(
179+
`Runtime ${RUNTIMES[runtime].friendly} was decommissioned on ${decommissionDate}. To deploy ` +
180+
"you must first upgrade your runtime version.",
181+
{ exit: 1 },
182+
);
183+
}
184+
185+
const deprecation = new Date(deprecationDate);
186+
if (now >= deprecation) {
187+
utils.logLabeledWarning(
188+
"functions",
189+
`Runtime ${RUNTIMES[runtime].friendly} was deprecated on ${deprecationDate} and will be ` +
190+
`decommissioned on ${decommissionDate}, after which you will not be able ` +
191+
"to deploy without upgrading. Consider upgrading now to avoid disruption. See " +
192+
"https://cloud.google.com/functions/docs/runtime-support for full " +
193+
"details on the lifecycle policy",
194+
);
195+
return;
196+
}
197+
198+
const warning = new Date();
199+
warning.setDate(deprecation.getDate() - 90);
200+
if (now >= warning) {
201+
utils.logLabeledWarning(
202+
"functions",
203+
`Runtime ${RUNTIMES[runtime].friendly} will be deprecated on ${deprecationDate} and will be ` +
204+
`decommissioned on ${decommissionDate}, after which you will not be able ` +
205+
"to deploy without upgrading. Consider upgrading now to avoid disruption. See " +
206+
"https://cloud.google.com/functions/docs/runtime-support for full " +
207+
"details on the lifecycle policy",
208+
);
209+
}
210+
}

‎src/emulator/controller.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { requiresJava } from "./downloadableEmulators";
5353
import { prepareFrameworks } from "../frameworks";
5454
import * as experiments from "../experiments";
5555
import { EmulatorListenConfig, PortName, resolveHostAndAssignPorts } from "./portUtils";
56+
import { Runtime, isRuntime, latest } from "../deploy/functions/runtimes/supported";
5657

5758
const START_LOGGING_EMULATOR = utils.envOverride(
5859
"START_LOGGING_EMULATOR",
@@ -492,7 +493,20 @@ export async function startAll(
492493

493494
for (const cfg of functionsCfg) {
494495
const functionsDir = path.join(projectDir, cfg.source);
495-
const runtime = (options.extDevRuntime as string | undefined) ?? cfg.runtime;
496+
let runtime = (options.extDevRuntime ?? cfg.runtime) as Runtime | undefined;
497+
if (!runtime) {
498+
// N.B: extensions are wonky. They don't typically include an engine
499+
// in package.json and there is no firebase.json for their runtime
500+
// name. extensions.yaml has resources[].properties.runtime, but this
501+
// varies per function! This default will work for now, but will break
502+
// once extensions support python.
503+
runtime = latest("nodejs");
504+
}
505+
if (!isRuntime(runtime)) {
506+
throw new FirebaseError(
507+
`Cannot load functions from ${functionsDir} because it has invalid runtime ${runtime as string}`,
508+
);
509+
}
496510
emulatableBackends.push({
497511
functionsDir,
498512
runtime,

‎src/emulator/functionsEmulator.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { BlockingFunctionsConfig } from "../gcp/identityPlatform";
6262
import { resolveBackend } from "../deploy/functions/build";
6363
import { setEnvVarsForEmulators } from "./env";
6464
import { runWithVirtualEnv } from "../functions/python";
65+
import { Runtime } from "../deploy/functions/runtimes/supported";
6566

6667
const EVENT_INVOKE_GA4 = "functions_invoke"; // event name GA4 (alphanumertic)
6768

@@ -87,7 +88,7 @@ export interface EmulatableBackend {
8788
secretEnv: backend.SecretEnvVar[];
8889
codebase: string;
8990
predefinedTriggers?: ParsedTriggerDefinition[];
90-
runtime?: string;
91+
runtime?: Runtime;
9192
bin?: string;
9293
extensionInstanceId?: string;
9394
extension?: Extension; // Only present for published extensions
@@ -512,9 +513,9 @@ export class FunctionsEmulator implements EmulatorInstance {
512513
runtime: emulatableBackend.runtime,
513514
};
514515
const runtimeDelegate = await runtimes.getRuntimeDelegate(runtimeDelegateContext);
515-
logger.debug(`Validating ${runtimeDelegate.name} source`);
516+
logger.debug(`Validating ${runtimeDelegate.language} source`);
516517
await runtimeDelegate.validate();
517-
logger.debug(`Building ${runtimeDelegate.name} source`);
518+
logger.debug(`Building ${runtimeDelegate.language} source`);
518519
await runtimeDelegate.build();
519520

520521
// Retrieve information from the runtime delegate.

‎src/extensions/emulator/optionsHelper.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as extensionsHelper from "../extensionsHelper";
77
import * as planner from "../../deploy/extensions/planner";
88
import { needProjectId } from "../../projectUtils";
99
import { SecretEnvVar } from "../../deploy/functions/backend";
10+
import { Runtime } from "../../deploy/functions/runtimes/supported";
1011

1112
/**
1213
* TODO: Better name? Also, should this be in extensionsEmulator instead?
@@ -15,7 +16,7 @@ export async function getExtensionFunctionInfo(
1516
instance: planner.DeploymentInstanceSpec,
1617
paramValues: Record<string, string>,
1718
): Promise<{
18-
runtime: string;
19+
runtime: Runtime;
1920
extensionTriggers: ParsedTriggerDefinition[];
2021
nonSecretEnv: Record<string, string>;
2122
secretEnvVariables: SecretEnvVar[];

‎src/extensions/emulator/specHelper.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as yaml from "js-yaml";
22
import * as path from "path";
33
import * as fs from "fs-extra";
44

5+
import * as supported from "../../deploy/functions/runtimes/supported";
56
import { ExtensionSpec, Resource } from "../types";
67
import { FirebaseError } from "../../error";
78
import { substituteParams } from "../extensionsHelper";
@@ -111,24 +112,27 @@ export function getFunctionProperties(resources: Resource[]) {
111112
return resources.map((r) => r.properties);
112113
}
113114

114-
export const DEFAULT_RUNTIME = "nodejs14";
115+
export const DEFAULT_RUNTIME: supported.Runtime = supported.latest("nodejs");
115116

116117
/**
117118
* Get runtime associated with the resources. If multiple runtimes exists, choose the latest runtime.
118119
* e.g. prefer nodejs14 over nodejs12.
120+
* N.B. (inlined): I'm not sure why this code always assumes nodejs. It seems to
121+
* work though and nobody is complaining that they can't run the Python
122+
* emulator so I'm not investigating why it works.
119123
*/
120-
export function getRuntime(resources: Resource[]): string {
124+
export function getRuntime(resources: Resource[]): supported.Runtime {
121125
if (resources.length === 0) {
122126
return DEFAULT_RUNTIME;
123127
}
124128

125129
const invalidRuntimes: string[] = [];
126-
const runtimes = resources.map((r: Resource) => {
130+
const runtimes: supported.Runtime[] = resources.map((r: Resource) => {
127131
const runtime = getResourceRuntime(r);
128132
if (!runtime) {
129133
return DEFAULT_RUNTIME;
130134
}
131-
if (!/^(nodejs)?([0-9]+)/.test(runtime)) {
135+
if (!supported.runtimeIsLanguage(runtime, "nodejs")) {
132136
invalidRuntimes.push(runtime);
133137
return DEFAULT_RUNTIME;
134138
}
@@ -142,7 +146,5 @@ export function getRuntime(resources: Resource[]): string {
142146
);
143147
}
144148
// Assumes that all runtimes target the nodejs.
145-
// Rely on lexicographically order of nodejs runtime to pick the latest version.
146-
// e.g. nodejs12 < nodejs14 < nodejs18 < nodejs20 ...
147-
return runtimes.sort()[runtimes.length - 1];
149+
return supported.latest("nodejs", runtimes);
148150
}

‎src/extensions/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { MemoryOptions } from "../deploy/functions/backend";
2-
import { Runtime } from "../deploy/functions/runtimes";
2+
import { Runtime } from "../deploy/functions/runtimes/supported";
33
import * as proto from "../gcp/proto";
44
import { SpecParamType } from "./extensionsHelper";
55

‎src/extensions/utils.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
FUNCTIONS_V2_RESOURCE_TYPE,
77
} from "./types";
88
import { RegistryEntry } from "./resolveSource";
9+
import { Runtime } from "../deploy/functions/runtimes/supported";
910

10-
// Modified version of the once function from prompt, to return as a joined string.
11+
/**
12+
* Modified version of the once function from prompt, to return as a joined string.
13+
*/
1114
export async function onceWithJoin(question: any): Promise<string> {
1215
const response = await promptOnce(question);
1316
if (Array.isArray(response)) {
@@ -22,7 +25,9 @@ interface ListItem {
2225
checked: boolean; // Whether the option should be checked by default
2326
}
2427

25-
// Convert extension option to Inquirer-friendly list for the prompt, with all items unchecked.
28+
/**
29+
* Convert extension option to Inquirer-friendly list for the prompt, with all items unchecked.
30+
*/
2631
export function convertExtensionOptionToLabeledList(options: ParamOption[]): ListItem[] {
2732
return options.map((option: ParamOption): ListItem => {
2833
return {
@@ -33,7 +38,9 @@ export function convertExtensionOptionToLabeledList(options: ParamOption[]): Lis
3338
});
3439
}
3540

36-
// Convert map of RegistryEntry into Inquirer-friendly list for prompt, with all items unchecked.
41+
/**
42+
* Convert map of RegistryEntry into Inquirer-friendly list for prompt, with all items unchecked.
43+
*/
3744
export function convertOfficialExtensionsToList(officialExts: {
3845
[key: string]: RegistryEntry;
3946
}): ListItem[] {
@@ -78,7 +85,7 @@ export function formatTimestamp(timestamp: string): string {
7885
* etc, and this utility will do its best to identify the runtime specified for
7986
* this resource.
8087
*/
81-
export function getResourceRuntime(resource: Resource): string | undefined {
88+
export function getResourceRuntime(resource: Resource): Runtime | undefined {
8289
switch (resource.type) {
8390
case FUNCTIONS_RESOURCE_TYPE:
8491
return resource.properties?.runtime;

‎src/firebaseConfig.ts

+3-10
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import type { HttpsOptions } from "firebase-functions/v2/https";
99
import { IngressSetting, MemoryOption, VpcEgressSetting } from "firebase-functions/v2/options";
10+
import { Runtime, DecommissionedRuntime } from "./deploy/functions/runtimes/supported";
11+
1012
/**
1113
* Creates a type that requires at least one key to be present in an interface
1214
* type. For example, RequireAtLeastOne<{ foo: string; bar: string }> can hold
@@ -17,15 +19,6 @@ export type RequireAtLeastOne<T> = {
1719
[K in keyof T]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<keyof T, K>>>;
1820
}[keyof T];
1921

20-
// should be sourced from - https://github.com/firebase/firebase-tools/blob/master/src/deploy/functions/runtimes/index.ts#L15
21-
type CloudFunctionRuntimes =
22-
| "nodejs10"
23-
| "nodejs12"
24-
| "nodejs14"
25-
| "nodejs16"
26-
| "nodejs18"
27-
| "nodejs20";
28-
2922
export type Deployable = {
3023
predeploy?: string | string[];
3124
postdeploy?: string | string[];
@@ -174,7 +167,7 @@ export type FirestoreConfig = FirestoreSingle | FirestoreMultiple;
174167
export type FunctionConfig = {
175168
source?: string;
176169
ignore?: string[];
177-
runtime?: CloudFunctionRuntimes;
170+
runtime?: Exclude<Runtime, DecommissionedRuntime>;
178171
codebase?: string;
179172
} & Deployable;
180173

‎src/functional.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,9 @@ export const zipIn =
8686
};
8787

8888
/** Used with type guards to guarantee that all cases have been covered. */
89-
export function assertExhaustive(val: never): never {
89+
export function assertExhaustive(val: never, message?: string): never {
9090
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
91-
throw new Error(`Never has a value (${val}).`);
91+
throw new Error(message || `Never has a value (${val}).`);
9292
}
9393

9494
/**

‎src/gcp/cloudfunctions.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { logger } from "../logger";
55
import * as backend from "../deploy/functions/backend";
66
import * as utils from "../utils";
77
import * as proto from "./proto";
8-
import * as runtimes from "../deploy/functions/runtimes";
8+
import * as supported from "../deploy/functions/runtimes/supported";
99
import * as iam from "./iam";
1010
import * as projectConfig from "../functions/projectConfig";
1111
import { Client } from "../apiv2";
@@ -103,7 +103,7 @@ export interface CloudFunction {
103103
// end oneof trigger;
104104

105105
entryPoint: string;
106-
runtime: runtimes.Runtime;
106+
runtime: supported.Runtime;
107107
// Default = 60s
108108
timeout?: proto.Duration | null;
109109

@@ -519,8 +519,11 @@ export function endpointFromFunction(gcfFunction: CloudFunction): backend.Endpoi
519519
securityLevel = gcfFunction.httpsTrigger.securityLevel;
520520
}
521521

522-
if (!runtimes.isValidRuntime(gcfFunction.runtime)) {
523-
logger.debug("GCFv1 function has a deprecated runtime:", JSON.stringify(gcfFunction, null, 2));
522+
if (!supported.isRuntime(gcfFunction.runtime)) {
523+
logger.debug(
524+
"GCF 1st gen function has unsupported runtime:",
525+
JSON.stringify(gcfFunction, null, 2),
526+
);
524527
}
525528

526529
const endpoint: backend.Endpoint = {
@@ -589,10 +592,11 @@ export function functionFromEndpoint(
589592
);
590593
}
591594

592-
if (!runtimes.isValidRuntime(endpoint.runtime)) {
595+
if (!supported.isRuntime(endpoint.runtime)) {
593596
throw new FirebaseError(
594597
"Failed internal assertion. Trying to deploy a new function with a deprecated runtime." +
595598
" This should never happen",
599+
{ exit: 1 },
596600
);
597601
}
598602
const gcfFunction: Omit<CloudFunction, OutputOnlyFields> = {

‎src/gcp/cloudfunctionsv2.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { logger } from "../logger";
55
import { AUTH_BLOCKING_EVENTS } from "../functions/events/v1";
66
import { PUBSUB_PUBLISH_EVENT } from "../functions/events/v2";
77
import * as backend from "../deploy/functions/backend";
8-
import * as runtimes from "../deploy/functions/runtimes";
8+
import * as supported from "../deploy/functions/runtimes/supported";
99
import * as proto from "./proto";
1010
import * as utils from "../utils";
1111
import * as projectConfig from "../functions/projectConfig";
@@ -44,7 +44,7 @@ export type RetryPolicy =
4444

4545
/** Settings for building a container out of the customer source. */
4646
export interface BuildConfig {
47-
runtime: runtimes.Runtime;
47+
runtime: supported.Runtime;
4848
entryPoint: string;
4949
source: Source;
5050
sourceToken?: string;
@@ -478,7 +478,7 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc
478478
);
479479
}
480480

481-
if (!runtimes.isValidRuntime(endpoint.runtime)) {
481+
if (!supported.isRuntime(endpoint.runtime)) {
482482
throw new FirebaseError(
483483
"Failed internal assertion. Trying to deploy a new function with a deprecated runtime." +
484484
" This should never happen",
@@ -699,7 +699,7 @@ export function endpointFromFunction(gcfFunction: OutputCloudFunction): backend.
699699
trigger = { httpsTrigger: {} };
700700
}
701701

702-
if (!runtimes.isValidRuntime(gcfFunction.buildConfig.runtime)) {
702+
if (!supported.isRuntime(gcfFunction.buildConfig.runtime)) {
703703
logger.debug("GCFv2 function has a deprecated runtime:", JSON.stringify(gcfFunction, null, 2));
704704
}
705705

‎src/init/features/functions/python.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import * as spawn from "cross-spawn";
33
import * as path from "path";
44

55
import { Config } from "../../../config";
6-
import { getPythonBinary, LATEST_VERSION } from "../../../deploy/functions/runtimes/python";
6+
import { getPythonBinary } from "../../../deploy/functions/runtimes/python";
77
import { runWithVirtualEnv } from "../../../functions/python";
88
import { promptOnce } from "../../../prompt";
9+
import { latest } from "../../../deploy/functions/runtimes/supported";
910

1011
const TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/python");
1112
const MAIN_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "main.py"), "utf8");
@@ -24,12 +25,12 @@ export async function setup(setup: any, config: Config): Promise<void> {
2425
await config.askWriteProjectFile(`${setup.functions.source}/main.py`, MAIN_TEMPLATE);
2526

2627
// Write the latest supported runtime version to the config.
27-
config.set("functions.runtime", LATEST_VERSION);
28+
config.set("functions.runtime", latest("python"));
2829
// Add python specific ignores to config.
2930
config.set("functions.ignore", ["venv", "__pycache__"]);
3031

3132
// Setup VENV.
32-
const venvProcess = spawn(getPythonBinary(LATEST_VERSION), ["-m", "venv", "venv"], {
33+
const venvProcess = spawn(getPythonBinary(latest("python")), ["-m", "venv", "venv"], {
3334
shell: true,
3435
cwd: config.path(setup.functions.source),
3536
stdio: [/* stdin= */ "pipe", /* stdout= */ "pipe", /* stderr= */ "pipe", "pipe"],
@@ -58,7 +59,7 @@ export async function setup(setup: any, config: Config): Promise<void> {
5859
upgradeProcess.on("error", reject);
5960
});
6061
const installProcess = runWithVirtualEnv(
61-
[getPythonBinary(LATEST_VERSION), "-m", "pip", "install", "-r", "requirements.txt"],
62+
[getPythonBinary(latest("python")), "-m", "pip", "install", "-r", "requirements.txt"],
6263
config.path(setup.functions.source),
6364
{},
6465
{ stdio: ["inherit", "inherit", "inherit"] },

‎src/test/deploy/functions/backend.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ describe("Backend", () => {
455455
describe("compareFunctions", () => {
456456
const fnMembers = {
457457
project: "project",
458-
runtime: "nodejs14",
458+
runtime: "nodejs14" as const,
459459
httpsTrigger: {},
460460
};
461461

‎src/test/deploy/functions/checkIam.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const BINDING = {
2121
const SPEC = {
2222
region: "us-west1",
2323
project: projectNumber,
24-
runtime: "nodejs14",
24+
runtime: "nodejs14" as const,
2525
};
2626

2727
describe("checkIam", () => {

‎src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { expect } from "chai";
22

33
import * as backend from "../../../../../deploy/functions/backend";
44
import * as build from "../../../../../deploy/functions/build";
5-
import { Runtime } from "../../../../../deploy/functions/runtimes";
5+
import { Runtime } from "../../../../../deploy/functions/runtimes/supported";
66
import * as v1alpha1 from "../../../../../deploy/functions/runtimes/discovery/v1alpha1";
77
import { BEFORE_CREATE_EVENT } from "../../../../../functions/events/v1";
88
import { Param } from "../../../../../deploy/functions/params";
99
import { FirebaseError } from "../../../../../error";
1010

1111
const PROJECT = "project";
1212
const REGION = "region";
13-
const RUNTIME: Runtime = "node14";
13+
const RUNTIME: Runtime = "nodejs14";
1414
const MIN_WIRE_ENDPOINT: Omit<v1alpha1.WireEndpoint, "httpsTrigger"> = {
1515
entryPoint: "entryPoint",
1616
};

‎src/test/deploy/functions/runtimes/index.spec.ts

-9
This file was deleted.

‎src/test/deploy/functions/runtimes/node/index.spec.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as node from "../../../../../deploy/functions/runtimes/node";
66
import * as versioning from "../../../../../deploy/functions/runtimes/node/versioning";
77
import * as utils from "../../../../../utils";
88
import { FirebaseError } from "../../../../../error";
9+
import { Runtime } from "../../../../../deploy/functions/runtimes/supported";
910

1011
const PROJECT_ID = "test-project";
1112
const PROJECT_DIR = "/some/path";
@@ -66,7 +67,12 @@ describe("NodeDelegate", () => {
6667

6768
it("throws errors if requested runtime version is invalid", () => {
6869
const invalidRuntime = "foobar";
69-
const delegate = new node.Delegate(PROJECT_ID, PROJECT_DIR, SOURCE_DIR, invalidRuntime);
70+
const delegate = new node.Delegate(
71+
PROJECT_ID,
72+
PROJECT_DIR,
73+
SOURCE_DIR,
74+
invalidRuntime as Runtime,
75+
);
7076

7177
expect(() => delegate.getNodeBinary()).to.throw(FirebaseError);
7278
});

‎src/test/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.spec.ts

+8-43
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,6 @@ describe("getRuntimeChoice", () => {
2121
});
2222

2323
context("when the runtime is set in firebase.json", () => {
24-
it("should error if runtime field is set to node 6", () => {
25-
expect(() => {
26-
runtime.getRuntimeChoice("path/to/source", "nodejs6");
27-
}).to.throw(runtime.UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG);
28-
});
29-
30-
it("should error if runtime field is set to node 8", () => {
31-
expect(() => {
32-
runtime.getRuntimeChoice("path/to/source", "nodejs8");
33-
}).to.throw(runtime.UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG);
34-
});
35-
3624
it("should return node 10 if runtime field is set to node 10", () => {
3725
expect(runtime.getRuntimeChoice("path/to/source", "nodejs10")).to.equal("nodejs10");
3826
});
@@ -48,75 +36,52 @@ describe("getRuntimeChoice", () => {
4836
it("should return node 16 if runtime field is set to node 16", () => {
4937
expect(runtime.getRuntimeChoice("path/to/source", "nodejs16")).to.equal("nodejs16");
5038
});
51-
52-
it("should throw error if unsupported node version set", () => {
53-
expect(() => runtime.getRuntimeChoice("path/to/source", "nodejs11")).to.throw(
54-
FirebaseError,
55-
runtime.UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG,
56-
);
57-
});
5839
});
5940

6041
context("when the runtime is not set in firebase.json", () => {
61-
it("should error if engines field is set to node 6", () => {
62-
cjsonStub.returns({ engines: { node: "6" } });
63-
64-
expect(() => {
65-
runtime.getRuntimeChoice("path/to/source", "");
66-
}).to.throw(runtime.UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG);
67-
});
68-
69-
it("should error if engines field is set to node 8", () => {
70-
cjsonStub.returns({ engines: { node: "8" } });
71-
72-
expect(() => {
73-
runtime.getRuntimeChoice("path/to/source", "");
74-
}).to.throw(runtime.UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG);
75-
});
76-
7742
it("should return node 10 if engines field is set to node 10", () => {
7843
cjsonStub.returns({ engines: { node: "10" } });
7944

80-
expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs10");
45+
expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs10");
8146
});
8247

8348
it("should return node 12 if engines field is set to node 12", () => {
8449
cjsonStub.returns({ engines: { node: "12" } });
8550

86-
expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs12");
51+
expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs12");
8752
});
8853

8954
it("should return node 14 if engines field is set to node 14", () => {
9055
cjsonStub.returns({ engines: { node: "14" } });
9156

92-
expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs14");
57+
expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs14");
9358
});
9459

9560
it("should return node 16 if engines field is set to node 16", () => {
9661
cjsonStub.returns({ engines: { node: "16" } });
9762

98-
expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs16");
63+
expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs16");
9964
});
10065

10166
it("should print warning when firebase-functions version is below 2.0.0", () => {
10267
cjsonStub.returns({ engines: { node: "16" } });
10368

104-
runtime.getRuntimeChoice("path/to/source", "");
69+
runtime.getRuntimeChoice("path/to/source");
10570
});
10671

10772
it("should not throw error if user's SDK version fails to be fetched", () => {
10873
cjsonStub.returns({ engines: { node: "10" } });
10974
// Intentionally not setting SDKVersionStub so it can fail to be fetched.
110-
expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs10");
75+
expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs10");
11176
});
11277

11378
it("should throw error if unsupported node version set", () => {
11479
cjsonStub.returns({
11580
engines: { node: "11" },
11681
});
117-
expect(() => runtime.getRuntimeChoice("path/to/source", "")).to.throw(
82+
expect(() => runtime.getRuntimeChoice("path/to/source")).to.throw(
11883
FirebaseError,
119-
runtime.UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG,
84+
/Detected node engine 11 in package.json, which is not a supported version. Valid versions are/,
12085
);
12186
});
12287
});

‎src/test/deploy/functions/runtimes/python/index.spec.ts

-8
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ describe("PythonDelegate", () => {
2626
expect(delegate.getPythonBinary()).to.equal("python3.10");
2727
});
2828

29-
it("returns generic python binary given non-recognized python runtime", () => {
30-
platformMock.value("darwin");
31-
const requestedRuntime = "python308";
32-
const delegate = new python.Delegate(PROJECT_ID, SOURCE_DIR, requestedRuntime);
33-
34-
expect(delegate.getPythonBinary()).to.equal("python");
35-
});
36-
3729
it("always returns version-neutral, python.exe on windows", () => {
3830
platformMock.value("win32");
3931
const requestedRuntime = "python310";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { expect } from "chai";
2+
import * as supported from "../../../../deploy/functions/runtimes/supported";
3+
import * as utils from "../../../../utils";
4+
import * as sinon from "sinon";
5+
import { FirebaseError } from "../../../../error";
6+
7+
describe("supported runtimes", () => {
8+
it("sorts latest numerically, not lexographically", () => {
9+
expect(supported.latest("nodejs")).to.not.equal("nodejs8");
10+
});
11+
12+
it("identifies decommissioned runtimes", () => {
13+
expect(supported.isDecommissioned("nodejs8")).to.be.true;
14+
});
15+
16+
describe("isRuntime", () => {
17+
it("identifies valid runtimes", () => {
18+
expect(supported.isRuntime("nodejs20")).to.be.true;
19+
});
20+
21+
it("identifies invalid runtimes", () => {
22+
expect(supported.isRuntime("prolog1")).to.be.false;
23+
});
24+
});
25+
26+
describe("guardVersionSupport", () => {
27+
let logLabeledWarning: sinon.SinonStub;
28+
beforeEach(() => {
29+
logLabeledWarning = sinon.stub(utils, "logLabeledWarning");
30+
});
31+
32+
afterEach(() => {
33+
logLabeledWarning.restore();
34+
});
35+
36+
it("throws an error for decommissioned runtimes", () => {
37+
expect(() => supported.guardVersionSupport("nodejs8")).to.throw(
38+
FirebaseError,
39+
"Runtime Node.js 8 was decommissioned on 2021-02-01. " +
40+
"To deploy you must first upgrade your runtime version",
41+
);
42+
});
43+
44+
it("warns for a deprecated runtime", () => {
45+
supported.guardVersionSupport("nodejs20", new Date("2026-04-30"));
46+
expect(logLabeledWarning).to.have.been.calledWith(
47+
"functions",
48+
"Runtime Node.js 20 was deprecated on 2026-04-30 and will be " +
49+
"decommissioned on 2026-10-31, after which you will not be able to " +
50+
"deploy without upgrading. Consider upgrading now to avoid disruption. See " +
51+
"https://cloud.google.com/functions/docs/runtime-support for full " +
52+
"details on the lifecycle policy",
53+
);
54+
});
55+
56+
it("warns leading up to deprecation", () => {
57+
supported.guardVersionSupport("nodejs20", new Date("2026-04-01"));
58+
expect(logLabeledWarning).to.have.been.calledWith(
59+
"functions",
60+
"Runtime Node.js 20 will be deprecated on 2026-04-30 and will be " +
61+
"decommissioned on 2026-10-31, after which you will not be able to " +
62+
"deploy without upgrading. Consider upgrading now to avoid disruption. See " +
63+
"https://cloud.google.com/functions/docs/runtime-support for full " +
64+
"details on the lifecycle policy",
65+
);
66+
});
67+
});
68+
});

‎src/test/deploy/functions/services/auth.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const BASE_EP = {
1010
region: "us-east1",
1111
project: "project",
1212
entryPoint: "func",
13-
runtime: "nodejs16",
13+
runtime: "nodejs16" as const,
1414
};
1515

1616
const authBlockingService = new auth.AuthBlockingService();

‎src/test/deploy/functions/triggerRegionHelper.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as triggerRegionHelper from "../../../deploy/functions/triggerRegionHel
88
const SPEC = {
99
region: "us-west1",
1010
project: "my-project",
11-
runtime: "nodejs14",
11+
runtime: "nodejs14" as const,
1212
};
1313

1414
describe("TriggerRegionHelper", () => {

‎src/test/extensions/emulator/specHelper.spec.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from "path";
44
import * as specHelper from "../../../extensions/emulator/specHelper";
55
import { Resource } from "../../../extensions/types";
66
import { FirebaseError } from "../../../error";
7+
import { Runtime } from "../../../deploy/functions/runtimes/supported";
78

89
const testResource: Resource = {
910
name: "test-resource",
@@ -102,13 +103,13 @@ describe("getRuntime", () => {
102103
const r1 = {
103104
...testResource,
104105
properties: {
105-
runtime: "nodejs14",
106+
runtime: "nodejs14" as const,
106107
},
107108
};
108109
const r2 = {
109110
...testResource,
110111
properties: {
111-
runtime: "nodejs14",
112+
runtime: "nodejs14" as const,
112113
},
113114
};
114115
expect(specHelper.getRuntime([r1, r2])).to.equal("nodejs14");
@@ -118,13 +119,13 @@ describe("getRuntime", () => {
118119
const r1 = {
119120
...testResource,
120121
properties: {
121-
runtime: "nodejs12",
122+
runtime: "nodejs12" as const,
122123
},
123124
};
124125
const r2 = {
125126
...testResource,
126127
properties: {
127-
runtime: "nodejs14",
128+
runtime: "nodejs14" as const,
128129
},
129130
};
130131
expect(specHelper.getRuntime([r1, r2])).to.equal("nodejs14");
@@ -150,13 +151,14 @@ describe("getRuntime", () => {
150151
const r1 = {
151152
...testResource,
152153
properties: {
153-
runtime: "dotnet6",
154+
// Note: as const won't work since this is actually an invalid runtime.
155+
runtime: "dotnet6" as Runtime,
154156
},
155157
};
156158
const r2 = {
157159
...testResource,
158160
properties: {
159-
runtime: "nodejs14",
161+
runtime: "nodejs14" as const,
160162
},
161163
};
162164
expect(() => specHelper.getRuntime([r1, r2])).to.throw(FirebaseError);

‎src/test/extensions/emulator/triggerHelper.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ describe("triggerHelper", () => {
200200
type: "firebaseextensions.v1beta.v2function",
201201
properties: {
202202
buildConfig: {
203-
runtime: "node16",
203+
runtime: "nodejs16",
204204
},
205205
location: "us-cental1",
206206
serviceConfig: {

‎src/test/functions/secrets.spec.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const ENDPOINT = {
1717
region: "region",
1818
project: "project",
1919
entryPoint: "id",
20-
runtime: "nodejs16",
20+
runtime: "nodejs16" as const,
2121
platform: "gcfv1" as const,
2222
httpsTrigger: {},
2323
};
@@ -64,14 +64,16 @@ describe("functions/secret", () => {
6464
expect(warnStub).to.have.been.calledOnce;
6565
});
6666

67-
it("throws error if given non-conventional key w/ forced option", () => {
68-
expect(secrets.ensureValidKey("throwError", { ...options, force: true })).to.be.rejectedWith(
69-
FirebaseError,
70-
);
67+
it("throws error if given non-conventional key w/ forced option", async () => {
68+
await expect(
69+
secrets.ensureValidKey("throwError", { ...options, force: true }),
70+
).to.be.rejectedWith(FirebaseError);
7171
});
7272

73-
it("throws error if given reserved key", () => {
74-
expect(secrets.ensureValidKey("FIREBASE_CONFIG", options)).to.be.rejectedWith(FirebaseError);
73+
it("throws error if given reserved key", async () => {
74+
await expect(secrets.ensureValidKey("FIREBASE_CONFIG", options)).to.be.rejectedWith(
75+
FirebaseError,
76+
);
7577
});
7678
});
7779

0 commit comments

Comments
 (0)
Please sign in to comment.