From 1a3b885d15d75aadd7484296ac4437365b06dbe6 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Wed, 27 Mar 2024 10:52:09 -0700 Subject: [PATCH] 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 https://github.com/firebase/firebase-tools/issues/6774#issuecomment-1953399977 - 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 --- CHANGELOG.md | 1 + schema/firebase-config.json | 14 +- src/deploy/functions/args.ts | 2 +- src/deploy/functions/backend.ts | 4 +- src/deploy/functions/build.ts | 4 +- src/deploy/functions/prepare.ts | 18 +- src/deploy/functions/release/fabricator.ts | 4 +- .../functions/runtimes/discovery/index.ts | 8 +- .../functions/runtimes/discovery/v1alpha1.ts | 6 +- src/deploy/functions/runtimes/index.ts | 83 ++----- src/deploy/functions/runtimes/node/index.ts | 13 +- .../node/parseRuntimeAndValidateSDK.ts | 76 ++----- .../functions/runtimes/node/parseTriggers.ts | 10 +- src/deploy/functions/runtimes/python/index.ts | 27 ++- src/deploy/functions/runtimes/supported.ts | 210 ++++++++++++++++++ src/emulator/controller.ts | 16 +- src/emulator/functionsEmulator.ts | 7 +- src/extensions/emulator/optionsHelper.ts | 3 +- src/extensions/emulator/specHelper.ts | 16 +- src/extensions/types.ts | 2 +- src/extensions/utils.ts | 15 +- src/firebaseConfig.ts | 13 +- src/functional.ts | 4 +- src/gcp/cloudfunctions.ts | 14 +- src/gcp/cloudfunctionsv2.ts | 8 +- src/init/features/functions/python.ts | 9 +- src/test/deploy/functions/backend.spec.ts | 2 +- src/test/deploy/functions/checkIam.spec.ts | 2 +- .../runtimes/discovery/v1alpha1.spec.ts | 4 +- .../deploy/functions/runtimes/index.spec.ts | 9 - .../functions/runtimes/node/index.spec.ts | 8 +- .../node/parseRuntimeAndValidateSDK.spec.ts | 51 +---- .../functions/runtimes/python/index.spec.ts | 8 - .../functions/runtimes/supported.spec.ts | 68 ++++++ .../deploy/functions/services/auth.spec.ts | 2 +- .../functions/triggerRegionHelper.spec.ts | 2 +- .../extensions/emulator/specHelper.spec.ts | 14 +- .../extensions/emulator/triggerHelper.spec.ts | 2 +- src/test/functions/secrets.spec.ts | 16 +- 39 files changed, 485 insertions(+), 290 deletions(-) create mode 100644 src/deploy/functions/runtimes/supported.ts delete mode 100644 src/test/deploy/functions/runtimes/index.spec.ts create mode 100644 src/test/deploy/functions/runtimes/supported.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..69b478cdd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Add support timelines for functions runtimes (#6866) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index b4bb9139591..4fa4305c5e5 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -622,7 +622,12 @@ "nodejs14", "nodejs16", "nodejs18", - "nodejs20" + "nodejs20", + "nodejs6", + "nodejs8", + "python310", + "python311", + "python312" ], "type": "string" }, @@ -678,7 +683,12 @@ "nodejs14", "nodejs16", "nodejs18", - "nodejs20" + "nodejs20", + "nodejs6", + "nodejs8", + "python310", + "python311", + "python312" ], "type": "string" }, diff --git a/src/deploy/functions/args.ts b/src/deploy/functions/args.ts index bc6e1e21db8..112678c9a37 100644 --- a/src/deploy/functions/args.ts +++ b/src/deploy/functions/args.ts @@ -2,7 +2,7 @@ import * as backend from "./backend"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; import * as projectConfig from "../../functions/projectConfig"; import * as deployHelper from "./functionsDeployHelper"; -import { Runtime } from "./runtimes"; +import { Runtime } from "./runtimes/supported"; // These types should probably be in a root deploy.ts, but we can only boil the ocean one bit at a time. interface CodebasePayload { diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 7d1d9c25f10..0b5e4cd041a 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -1,7 +1,7 @@ import * as gcf from "../../gcp/cloudfunctions"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; import * as utils from "../../utils"; -import * as runtimes from "./runtimes"; +import { Runtime } from "./runtimes/supported"; import { FirebaseError } from "../../error"; import { Context } from "./args"; import { flattenArray } from "../../functional"; @@ -350,7 +350,7 @@ export type Endpoint = TargetIds & Triggered & { entryPoint: string; platform: FunctionsPlatform; - runtime: runtimes.Runtime | runtimes.DeprecatedRuntime; + runtime: Runtime; // 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/build.ts b/src/deploy/functions/build.ts index 5a432c9aa40..7b1519f28eb 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -6,7 +6,7 @@ import { FirebaseError } from "../../error"; import { assertExhaustive, mapObject, nullsafeVisitor } from "../../functional"; import { UserEnvsOpts, writeUserEnvs } from "../../functions/env"; import { FirebaseConfig } from "./args"; -import { Runtime } from "./runtimes"; +import { Runtime } from "./runtimes/supported"; import { ExprParseError } from "./cel"; /* The union of a customer-controlled deployment and potentially deploy-time defined parameters */ @@ -240,7 +240,7 @@ export type Endpoint = Triggered & { project: string; // The runtime being deployed to this endpoint. Currently targeting "nodejs16." - runtime: string; + runtime: Runtime; // Firebase default of 80. Cloud default of 1 concurrency?: Field; diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 22d59485e5e..86e7c223cae 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -7,6 +7,7 @@ import * as ensureApiEnabled from "../../ensureApiEnabled"; import * as functionsConfig from "../../functionsConfig"; import * as functionsEnv from "../../functions/env"; import * as runtimes from "./runtimes"; +import * as supported from "./runtimes/supported"; import * as validate from "./validate"; import * as ensure from "./ensure"; import { @@ -415,7 +416,6 @@ export function resolveCpuAndConcurrency(want: backend.Backend): void { /** * Exported for use by an internal command (internaltesting:functions:discover) only. - * * @internal */ export async function loadCodebases( @@ -442,12 +442,22 @@ export async function loadCodebases( projectId, sourceDir, projectDir: options.config.projectDir, - runtime: codebaseConfig.runtime || "", }; + const firebaseJsonRuntime = codebaseConfig.runtime; + if (firebaseJsonRuntime && !supported.isRuntime(firebaseJsonRuntime as string)) { + throw new FirebaseError( + `Functions codebase ${codebase} has invalid runtime ` + + `${firebaseJsonRuntime} specified in firebase.json. Valid values are: ` + + Object.keys(supported.RUNTIMES) + .map((s) => `- ${s}`) + .join("\n"), + ); + } const runtimeDelegate = await runtimes.getRuntimeDelegate(delegateContext); - logger.debug(`Validating ${runtimeDelegate.name} source`); + logger.debug(`Validating ${runtimeDelegate.language} source`); + supported.guardVersionSupport(runtimeDelegate.runtime); await runtimeDelegate.validate(); - logger.debug(`Building ${runtimeDelegate.name} source`); + logger.debug(`Building ${runtimeDelegate.language} source`); await runtimeDelegate.build(); const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId); diff --git a/src/deploy/functions/release/fabricator.ts b/src/deploy/functions/release/fabricator.ts index ad550fd5d8d..47466fdf062 100644 --- a/src/deploy/functions/release/fabricator.ts +++ b/src/deploy/functions/release/fabricator.ts @@ -5,7 +5,7 @@ import { FirebaseError } from "../../../error"; import { SourceTokenScraper } from "./sourceTokenScraper"; import { Timer } from "./timer"; import { assertExhaustive } from "../../../functional"; -import { getHumanFriendlyRuntimeName } from "../runtimes"; +import { RUNTIMES } from "../runtimes/supported"; import { eventarcOrigin, functionsOrigin, functionsV2Origin } from "../../../api"; import { logger } from "../../../logger"; import * as args from "../args"; @@ -731,7 +731,7 @@ export class Fabricator { } logOpStart(op: string, endpoint: backend.Endpoint): void { - const runtime = getHumanFriendlyRuntimeName(endpoint.runtime); + const runtime = RUNTIMES[endpoint.runtime].friendly; const platform = getHumanFriendlyPlatformName(endpoint.platform); const label = helper.getFunctionLabel(endpoint); utils.logLabeledBullet( diff --git a/src/deploy/functions/runtimes/discovery/index.ts b/src/deploy/functions/runtimes/discovery/index.ts index 3b47800c75e..f95b4f8f8c3 100644 --- a/src/deploy/functions/runtimes/discovery/index.ts +++ b/src/deploy/functions/runtimes/discovery/index.ts @@ -7,7 +7,7 @@ import { promisify } from "util"; import { logger } from "../../../../logger"; import * as api from "../../.../../../../api"; import * as build from "../../build"; -import * as runtimes from ".."; +import { Runtime } from "../supported"; import * as v1alpha1 from "./v1alpha1"; import { FirebaseError } from "../../../../error"; @@ -20,7 +20,7 @@ export function yamlToBuild( yaml: any, project: string, region: string, - runtime: runtimes.Runtime, + runtime: Runtime, ): build.Build { try { if (!yaml.specVersion) { @@ -43,7 +43,7 @@ export function yamlToBuild( export async function detectFromYaml( directory: string, project: string, - runtime: runtimes.Runtime, + runtime: Runtime, ): Promise { let text: string; try { @@ -68,7 +68,7 @@ export async function detectFromYaml( export async function detectFromPort( port: number, project: string, - runtime: runtimes.Runtime, + runtime: Runtime, timeout = 10_000 /* 10s to boot up */, ): Promise { let res: Response; diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 4446572f23a..3f5f51ec32f 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -1,7 +1,7 @@ import * as build from "../../build"; import * as backend from "../../backend"; import * as params from "../../params"; -import * as runtimes from ".."; +import { Runtime } from "../supported"; import { copyIfPresent, convertIfPresent, secondsFromDuration } from "../../../../gcp/proto"; import { assertKeyTypes, requireKeys } from "./parsing"; @@ -83,7 +83,7 @@ export function buildFromV1Alpha1( yaml: unknown, project: string, region: string, - runtime: runtimes.Runtime, + runtime: Runtime, ): build.Build { const manifest = JSON.parse(JSON.stringify(yaml)) as WireManifest; requireKeys("", manifest, "endpoints"); @@ -257,7 +257,7 @@ function parseEndpointForBuild( ep: WireEndpoint, project: string, defaultRegion: string, - runtime: runtimes.Runtime, + runtime: Runtime, ): build.Endpoint { let triggered: build.Triggered; if (build.isEventTriggered(ep)) { diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index bf46c01e89d..cb3aa66d1ab 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -4,78 +4,20 @@ import * as node from "./node"; import * as python from "./python"; import * as validate from "../validate"; import { FirebaseError } from "../../../error"; - -/** Supported runtimes for new Cloud Functions. */ -const RUNTIMES: string[] = [ - "nodejs10", - "nodejs12", - "nodejs14", - "nodejs16", - "nodejs18", - "nodejs20", - "python310", - "python311", - "python312", -]; -// Experimental runtimes are part of the Runtime type, but are in a -// different list to help guard against some day accidentally iterating over -// and printing a hidden runtime to the user. -const EXPERIMENTAL_RUNTIMES: string[] = []; -export type Runtime = (typeof RUNTIMES)[number] | (typeof EXPERIMENTAL_RUNTIMES)[number]; - -/** Runtimes that can be found in existing backends but not used for new functions. */ -const DEPRECATED_RUNTIMES = ["nodejs6", "nodejs8"]; -export type DeprecatedRuntime = (typeof DEPRECATED_RUNTIMES)[number]; - -/** Type deduction helper for a runtime string */ -export function isDeprecatedRuntime(runtime: string): runtime is DeprecatedRuntime { - return DEPRECATED_RUNTIMES.includes(runtime); -} - -/** Type deduction helper for a runtime string. */ -export function isValidRuntime(runtime: string): runtime is Runtime { - return RUNTIMES.includes(runtime) || EXPERIMENTAL_RUNTIMES.includes(runtime); -} - -const MESSAGE_FRIENDLY_RUNTIMES: Record = { - nodejs6: "Node.js 6 (Deprecated)", - nodejs8: "Node.js 8 (Deprecated)", - nodejs10: "Node.js 10", - nodejs12: "Node.js 12", - nodejs14: "Node.js 14", - nodejs16: "Node.js 16", - nodejs18: "Node.js 18", - nodejs20: "Node.js 20", - python310: "Python 3.10", - python311: "Python 3.11", - python312: "Python 3.12", -}; - -/** - * Returns a friendly string denoting the chosen runtime: Node.js 8 for nodejs 8 - * for example. If no friendly name for runtime is found, returns back the raw runtime. - * @param runtime name of runtime in raw format, ie, "nodejs8" or "nodejs10" - * @return A human-friendly string describing the runtime. - */ -export function getHumanFriendlyRuntimeName(runtime: Runtime | DeprecatedRuntime): string { - return MESSAGE_FRIENDLY_RUNTIMES[runtime] || runtime; -} +import * as supported from "./supported"; /** * RuntimeDelegate is a language-agnostic strategy for managing * customer source. */ export interface RuntimeDelegate { - /** A friendly name for the runtime; used for debug purposes */ - name: string; + /** The language for the runtime; used for debug purposes */ + language: supported.Language; /** * The name of the specific runtime of this source code. - * This will often differ from `name` because `name` will be - * version-free but this will include a specific runtime for - * the GCF API. */ - runtime: Runtime; + runtime: supported.Runtime; /** * Path to the bin used to run the source code. @@ -124,24 +66,25 @@ export interface DelegateContext { projectDir: string; // Absolute path of the source directory. sourceDir: string; - runtime?: string; + runtime?: supported.Runtime; } type Factory = (context: DelegateContext) => Promise; const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate]; /** - * + * Gets the delegate object responsible for discovering, building, and hosting + * code of a given language. */ export async function getRuntimeDelegate(context: DelegateContext): Promise { const { projectDir, sourceDir, runtime } = context; - validate.functionsDirectoryExists(sourceDir, projectDir); - // There isn't currently an easy way to map from runtime name to a delegate, but we can at least guarantee - // that any explicit runtime from firebase.json is valid - if (runtime && !isValidRuntime(runtime)) { - throw new FirebaseError(`Cannot deploy function with runtime ${runtime}`); + if (runtime && !supported.isRuntime(runtime)) { + throw new FirebaseError( + `firebase.json specifies invalid runtime ${runtime as string} for directory ${sourceDir}`, + ); } + validate.functionsDirectoryExists(sourceDir, projectDir); for (const factory of factories) { const delegate = await factory(context); @@ -150,5 +93,5 @@ export async function getRuntimeDelegate(context: DelegateContext): Promise { +export async function tryCreateDelegate(context: DelegateContext): Promise { const packageJsonPath = path.join(context.sourceDir, "package.json"); if (!(await promisify(fs.exists)(packageJsonPath))) { @@ -39,7 +38,7 @@ export async function tryCreateDelegate( // We should find a way to refactor this code so we're not repeatedly invoking node. const runtime = getRuntimeChoice(context.sourceDir, context.runtime); - if (!runtime.startsWith("nodejs")) { + if (!supported.runtimeIsLanguage(runtime, "nodejs")) { logger.debug( "Customer has a package.json but did not get a nodejs runtime. This should not happen", ); @@ -54,13 +53,13 @@ export async function tryCreateDelegate( // and both files load package.json. Maybe the delegate should be constructed with a package.json and // that can be passed to both methods. export class Delegate { - public readonly name = "nodejs"; + public readonly language = "nodejs"; constructor( private readonly projectId: string, private readonly projectDir: string, private readonly sourceDir: string, - public readonly runtime: runtimes.Runtime, + public readonly runtime: supported.Runtime, ) {} // Using a caching interface because we (may/will) eventually depend on the SDK version diff --git a/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts b/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts index 5cf23e39760..1d16fcfba52 100644 --- a/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts +++ b/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts @@ -1,50 +1,23 @@ import * as path from "path"; -import * as clc from "colorette"; import { FirebaseError } from "../../../../error"; -import * as runtimes from "../../runtimes"; +import * as supported from "../supported"; // have to require this because no @types/cjson available // eslint-disable-next-line @typescript-eslint/no-var-requires const cjson = require("cjson"); -const ENGINE_RUNTIMES: Record = { - 6: "nodejs6", - 8: "nodejs8", - 10: "nodejs10", - 12: "nodejs12", - 14: "nodejs14", - 16: "nodejs16", - 18: "nodejs18", - 20: "nodejs20", -}; - -const ENGINE_RUNTIMES_NAMES = Object.values(ENGINE_RUNTIMES); +const supportedNodeVersions: string[] = Object.keys(supported.RUNTIMES) + .filter((s) => supported.runtimeIsLanguage(s as supported.Runtime, "nodejs")) + .filter((s) => !supported.isDecommissioned(s as supported.Runtime)) + .map((s) => s.substring("nodejs".length)); export const RUNTIME_NOT_SET = - "`runtime` field is required but was not found in firebase.json.\n" + + "`runtime` field is required but was not found in firebase.json or package.json.\n" + "To fix this, add the following lines to the `functions` section of your firebase.json:\n" + - '"runtime": "nodejs18"\n'; - -export const UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG = clc.bold( - `functions.runtime value is unsupported. ` + - `Valid choices are: ${clc.bold("nodejs{10|12|14|16|18|20}")}.`, -); - -export const UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG = clc.bold( - `package.json in functions directory has an engines field which is unsupported. ` + - `Valid choices are: ${clc.bold('{"node": 10|12|14|16|18|20}')}`, -); - -export const DEPRECATED_NODE_VERSION_INFO = - `\n\nDeploys to runtimes below Node.js 10 are now disabled in the Firebase CLI. ` + - `${clc.bold( - `Existing Node.js 8 functions ${clc.underline("will stop executing at a future date")}`, - )}. Update existing functions to Node.js 10 or greater as soon as possible.`; + `"runtime": "${supported.latest("nodejs")}" or set the "engine" field in package.json\n`; -function getRuntimeChoiceFromPackageJson( - sourceDir: string, -): runtimes.Runtime | runtimes.DeprecatedRuntime { +function getRuntimeChoiceFromPackageJson(sourceDir: string): supported.Runtime { const packageJsonPath = path.join(sourceDir, "package.json"); let loaded; try { @@ -61,7 +34,14 @@ function getRuntimeChoiceFromPackageJson( throw new FirebaseError(RUNTIME_NOT_SET); } - return ENGINE_RUNTIMES[engines.node]; + const runtime = `nodejs${engines.node}`; + if (!supported.isRuntime(runtime)) { + throw new FirebaseError( + `Detected node engine ${engines.node} in package.json, which is not a ` + + `supported version. Valid versions are ${supportedNodeVersions.join(", ")}`, + ); + } + return runtime; } /** @@ -71,23 +51,9 @@ function getRuntimeChoiceFromPackageJson( * @param runtimeFromConfig runtime from the `functions` section of firebase.json file (may be empty). * @return The runtime, e.g. `nodejs12`. */ -export function getRuntimeChoice(sourceDir: string, runtimeFromConfig?: string): runtimes.Runtime { - const runtime = runtimeFromConfig || getRuntimeChoiceFromPackageJson(sourceDir); - const errorMessage = - (runtimeFromConfig - ? UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG - : UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG) + DEPRECATED_NODE_VERSION_INFO; - - if (!runtime || !ENGINE_RUNTIMES_NAMES.includes(runtime)) { - throw new FirebaseError(errorMessage, { exit: 1 }); - } - - // Note: the runtimes.isValidRuntime should always be true because we've verified - // it's in ENGINE_RUNTIME_NAMES and not in DEPRECATED_RUNTIMES. This is still a - // good defense in depth and also lets us upcast the response to Runtime safely. - if (runtimes.isDeprecatedRuntime(runtime) || !runtimes.isValidRuntime(runtime)) { - throw new FirebaseError(errorMessage, { exit: 1 }); - } - - return runtime; +export function getRuntimeChoice( + sourceDir: string, + runtimeFromConfig?: supported.Runtime, +): supported.Runtime { + return runtimeFromConfig || getRuntimeChoiceFromPackageJson(sourceDir); } diff --git a/src/deploy/functions/runtimes/node/parseTriggers.ts b/src/deploy/functions/runtimes/node/parseTriggers.ts index b817bad030b..8d15dc1e378 100644 --- a/src/deploy/functions/runtimes/node/parseTriggers.ts +++ b/src/deploy/functions/runtimes/node/parseTriggers.ts @@ -8,7 +8,7 @@ import * as backend from "../../backend"; import * as build from "../../build"; import * as api from "../../../../api"; import * as proto from "../../../../gcp/proto"; -import * as runtimes from "../../runtimes"; +import { Runtime } from "../../runtimes/supported"; import * as events from "../../../../functions/events"; import { nullsafeVisitor } from "../../../../functional"; @@ -146,7 +146,7 @@ export function useStrategy(): Promise { export async function discoverBuild( projectId: string, sourceDir: string, - runtime: runtimes.Runtime, + runtime: Runtime, configValues: backend.RuntimeConfigValues, envs: backend.EnvironmentVariables, ): Promise { @@ -168,7 +168,7 @@ export async function discoverBuild( export async function discoverBackend( projectId: string, sourceDir: string, - runtime: runtimes.Runtime, + runtime: Runtime, configValues: backend.RuntimeConfigValues, envs: backend.EnvironmentVariables, ): Promise { @@ -207,7 +207,7 @@ export function mergeRequiredAPIs(backend: backend.Backend) { */ export function addResourcesToBuild( projectId: string, - runtime: runtimes.Runtime, + runtime: Runtime, annotation: TriggerAnnotation, want: build.Build, ): void { @@ -406,7 +406,7 @@ export function addResourcesToBuild( */ export function addResourcesToBackend( projectId: string, - runtime: runtimes.Runtime, + runtime: Runtime, annotation: TriggerAnnotation, want: backend.Backend, ): void { diff --git a/src/deploy/functions/runtimes/python/index.ts b/src/deploy/functions/runtimes/python/index.ts index 4633433ddbe..1ffcc93fea0 100644 --- a/src/deploy/functions/runtimes/python/index.ts +++ b/src/deploy/functions/runtimes/python/index.ts @@ -8,16 +8,15 @@ import * as portfinder from "portfinder"; import * as runtimes from ".."; import * as backend from "../../backend"; import * as discovery from "../discovery"; +import * as supported from "../supported"; import { logger } from "../../../../logger"; import { DEFAULT_VENV_DIR, runWithVirtualEnv, virtualEnvCmd } from "../../../../functions/python"; import { FirebaseError } from "../../../../error"; import { Build } from "../../build"; - -export const LATEST_VERSION: runtimes.Runtime = "python312"; +import { assertExhaustive } from "../../../../functional"; /** * Create a runtime delegate for the Python runtime, if applicable. - * * @param context runtimes.DelegateContext * @return Delegate Python runtime delegate */ @@ -30,9 +29,15 @@ export async function tryCreateDelegate( logger.debug("Customer code is not Python code."); return; } - const runtime = context.runtime ? context.runtime : LATEST_VERSION; - if (!runtimes.isValidRuntime(runtime)) { - throw new FirebaseError(`Runtime ${runtime} is not a valid Python runtime`); + const runtime = context.runtime ?? supported.latest("python"); + if (!supported.isRuntime(runtime)) { + throw new FirebaseError(`Runtime ${runtime as string} is not a valid Python runtime`); + } + if (!supported.runtimeIsLanguage(runtime, "python")) { + throw new FirebaseError( + `Internal error. Trying to construct a python runtime delegate for runtime ${runtime}`, + { exit: 1 }, + ); } return Promise.resolve(new Delegate(context.projectId, context.sourceDir, runtime)); } @@ -42,7 +47,9 @@ export async function tryCreateDelegate( * * By default, returns "python" */ -export function getPythonBinary(runtime: runtimes.Runtime): string { +export function getPythonBinary( + runtime: supported.Runtime & supported.RuntimeOf<"python">, +): string { if (process.platform === "win32") { // There is no easy way to get specific version of python executable in Windows. return "python.exe"; @@ -54,15 +61,15 @@ export function getPythonBinary(runtime: runtimes.Runtime): string { } else if (runtime === "python312") { return "python3.12"; } - return "python"; + assertExhaustive(runtime, `Unhandled python runtime ${runtime as string}`); } export class Delegate implements runtimes.RuntimeDelegate { - public readonly name = "python"; + public readonly language = "python"; constructor( private readonly projectId: string, private readonly sourceDir: string, - public readonly runtime: runtimes.Runtime, + public readonly runtime: supported.Runtime & supported.RuntimeOf<"python">, ) {} private _bin = ""; diff --git a/src/deploy/functions/runtimes/supported.ts b/src/deploy/functions/runtimes/supported.ts new file mode 100644 index 00000000000..8fb8f3cd71b --- /dev/null +++ b/src/deploy/functions/runtimes/supported.ts @@ -0,0 +1,210 @@ +import { FirebaseError } from "../../../error"; +import * as utils from "../../../utils"; + +// N.B. The status "deprecated" and "decommmissioned" is informational only. +// The deprecationDate and decommmissionDate are the canonical values. +// Updating the definition to "decommissioned", however, will omit the runtime +// name from firebaseConfig's json schema. +export type RuntimeStatus = "experimental" | "beta" | "GA" | "deprecated" | "decommissioned"; + +type Day = `${number}-${number}-${number}`; + +/** Supported languages. All Runtime are a language + version. */ +export type Language = "nodejs" | "python"; + +/** + * Helper type that is more friendlier than string interpolation everywhere. + * Unfortunately, as Runtime has literal numbers and RuntimeOf accepts any + * number, RuntimeOf and Runtime must be intersected. It might help + * readability to rename Runtime to KnownRuntime so that it reads better to see + * KnownRuntime & RuntimeOf<"python">. + */ +export type RuntimeOf = `${T}${number}`; + +export interface RuntimeData { + friendly: string; + status: RuntimeStatus; + deprecationDate: Day; + decommissionDate: Day; +} + +// We can neither use the "satisfies" keyword nor the metaprogramming library +// in this file to ensure RUNTIMES implements the right interfaces, so we must +// use the copied assertImplements below. Some day these hacks will go away. +function runtimes, RuntimeData>>(r: T): T { + return r; +} + +export const RUNTIMES = runtimes({ + nodejs6: { + friendly: "Node.js 6", + status: "decommissioned", + deprecationDate: "2019-04-17", + decommissionDate: "2020-08-01", + }, + nodejs8: { + friendly: "Node.js 8", + status: "decommissioned", + deprecationDate: "2020-06-05", + decommissionDate: "2021-02-01", + }, + nodejs10: { + friendly: "Node.js 10", + status: "GA", + deprecationDate: "2024-01-30", + decommissionDate: "2025-01-30", + }, + nodejs12: { + friendly: "Node.js 12", + status: "GA", + deprecationDate: "2024-01-30", + decommissionDate: "2025-01-30", + }, + nodejs14: { + friendly: "Node.js 14", + status: "GA", + deprecationDate: "2024-01-30", + decommissionDate: "2025-01-30", + }, + nodejs16: { + friendly: "Node.js 16", + status: "GA", + deprecationDate: "2024-01-30", + decommissionDate: "2025-01-30", + }, + nodejs18: { + friendly: "Node.js 18", + status: "GA", + deprecationDate: "2025-04-30", + decommissionDate: "2025-10-31", + }, + nodejs20: { + friendly: "Node.js 20", + status: "GA", + deprecationDate: "2026-04-30", + decommissionDate: "2026-10-31", + }, + python310: { + friendly: "Python 3.10", + status: "GA", + deprecationDate: "2026-10-04", + decommissionDate: "2027-04-30", + }, + python311: { + friendly: "Python 3.11", + status: "GA", + deprecationDate: "2027-10-24", + decommissionDate: "2028-04-30", + }, + python312: { + friendly: "Python 3.12", + status: "GA", + deprecationDate: "2028-10-02", + decommissionDate: "2029-04-30", + }, +}); + +export type Runtime = keyof typeof RUNTIMES & RuntimeOf; + +export type DecommissionedRuntime = { + [R in keyof typeof RUNTIMES]: (typeof RUNTIMES)[R] extends { status: "decommissioned" } + ? R + : never; +}[keyof typeof RUNTIMES]; + +/** Type deduction helper for a runtime string. */ +export function isRuntime(maybe: string): maybe is Runtime { + return maybe in RUNTIMES; +} + +/** Type deduction helper to narrow a runtime to a language. */ +export function runtimeIsLanguage( + runtime: Runtime, + language: L, +): runtime is Runtime & RuntimeOf { + return runtime.startsWith(language); +} + +/** + * Find the latest supported Runtime for a Language. + */ +export function latest( + language: T, + runtimes: Runtime[] = Object.keys(RUNTIMES) as Runtime[], +): RuntimeOf & Runtime { + const sorted = runtimes + .filter((s) => runtimeIsLanguage(s, language)) + // node8 is less than node20 + .sort((left, right) => { + const leftVersion = +left.substring(language.length); + const rightVersion = +right.substring(language.length); + if (isNaN(leftVersion) || isNaN(rightVersion)) { + throw new FirebaseError("Internal error. Runtime or language names are malformed", { + exit: 1, + }); + } + return leftVersion - rightVersion; + }); + const latest = utils.last(sorted); + if (!latest) { + throw new FirebaseError( + `Internal error trying to find the latest supported runtime for ${language}`, + { exit: 1 }, + ); + } + return latest as RuntimeOf & Runtime; +} + +/** + * Whether a runtime is decommissioned. + * Accepts now as a parameter to increase testability + */ +export function isDecommissioned(runtime: Runtime, now: Date = new Date()): boolean { + const cutoff = new Date(RUNTIMES[runtime].decommissionDate); + return cutoff < now; +} + +/** + * Prints a warning if a runtime is in or nearing its deprecation time. Throws + * an error if the runtime is decommissioned. Accepts time as a parameter to + * increase testability. + */ +export function guardVersionSupport(runtime: Runtime, now: Date = new Date()): void { + const { deprecationDate, decommissionDate } = RUNTIMES[runtime]; + + const decommission = new Date(decommissionDate); + if (now >= decommission) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + throw new FirebaseError( + `Runtime ${RUNTIMES[runtime].friendly} was decommissioned on ${decommissionDate}. To deploy ` + + "you must first upgrade your runtime version.", + { exit: 1 }, + ); + } + + const deprecation = new Date(deprecationDate); + if (now >= deprecation) { + utils.logLabeledWarning( + "functions", + `Runtime ${RUNTIMES[runtime].friendly} was deprecated on ${deprecationDate} and will be ` + + `decommissioned on ${decommissionDate}, after which you will not be able ` + + "to deploy without upgrading. Consider upgrading now to avoid disruption. See " + + "https://cloud.google.com/functions/docs/runtime-support for full " + + "details on the lifecycle policy", + ); + return; + } + + const warning = new Date(); + warning.setDate(deprecation.getDate() - 90); + if (now >= warning) { + utils.logLabeledWarning( + "functions", + `Runtime ${RUNTIMES[runtime].friendly} will be deprecated on ${deprecationDate} and will be ` + + `decommissioned on ${decommissionDate}, after which you will not be able ` + + "to deploy without upgrading. Consider upgrading now to avoid disruption. See " + + "https://cloud.google.com/functions/docs/runtime-support for full " + + "details on the lifecycle policy", + ); + } +} diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 16e8bff8363..e3c3763a072 100644 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -53,6 +53,7 @@ import { requiresJava } from "./downloadableEmulators"; import { prepareFrameworks } from "../frameworks"; import * as experiments from "../experiments"; import { EmulatorListenConfig, PortName, resolveHostAndAssignPorts } from "./portUtils"; +import { Runtime, isRuntime, latest } from "../deploy/functions/runtimes/supported"; const START_LOGGING_EMULATOR = utils.envOverride( "START_LOGGING_EMULATOR", @@ -492,7 +493,20 @@ export async function startAll( for (const cfg of functionsCfg) { const functionsDir = path.join(projectDir, cfg.source); - const runtime = (options.extDevRuntime as string | undefined) ?? cfg.runtime; + let runtime = (options.extDevRuntime ?? cfg.runtime) as Runtime | undefined; + if (!runtime) { + // N.B: extensions are wonky. They don't typically include an engine + // in package.json and there is no firebase.json for their runtime + // name. extensions.yaml has resources[].properties.runtime, but this + // varies per function! This default will work for now, but will break + // once extensions support python. + runtime = latest("nodejs"); + } + if (!isRuntime(runtime)) { + throw new FirebaseError( + `Cannot load functions from ${functionsDir} because it has invalid runtime ${runtime as string}`, + ); + } emulatableBackends.push({ functionsDir, runtime, diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 92a522852e8..b29edf669cb 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -62,6 +62,7 @@ import { BlockingFunctionsConfig } from "../gcp/identityPlatform"; import { resolveBackend } from "../deploy/functions/build"; import { setEnvVarsForEmulators } from "./env"; import { runWithVirtualEnv } from "../functions/python"; +import { Runtime } from "../deploy/functions/runtimes/supported"; const EVENT_INVOKE_GA4 = "functions_invoke"; // event name GA4 (alphanumertic) @@ -87,7 +88,7 @@ export interface EmulatableBackend { secretEnv: backend.SecretEnvVar[]; codebase: string; predefinedTriggers?: ParsedTriggerDefinition[]; - runtime?: string; + runtime?: Runtime; bin?: string; extensionInstanceId?: string; extension?: Extension; // Only present for published extensions @@ -512,9 +513,9 @@ export class FunctionsEmulator implements EmulatorInstance { runtime: emulatableBackend.runtime, }; const runtimeDelegate = await runtimes.getRuntimeDelegate(runtimeDelegateContext); - logger.debug(`Validating ${runtimeDelegate.name} source`); + logger.debug(`Validating ${runtimeDelegate.language} source`); await runtimeDelegate.validate(); - logger.debug(`Building ${runtimeDelegate.name} source`); + logger.debug(`Building ${runtimeDelegate.language} source`); await runtimeDelegate.build(); // Retrieve information from the runtime delegate. diff --git a/src/extensions/emulator/optionsHelper.ts b/src/extensions/emulator/optionsHelper.ts index 471f60dd63d..aca084c904d 100644 --- a/src/extensions/emulator/optionsHelper.ts +++ b/src/extensions/emulator/optionsHelper.ts @@ -7,6 +7,7 @@ import * as extensionsHelper from "../extensionsHelper"; import * as planner from "../../deploy/extensions/planner"; import { needProjectId } from "../../projectUtils"; import { SecretEnvVar } from "../../deploy/functions/backend"; +import { Runtime } from "../../deploy/functions/runtimes/supported"; /** * TODO: Better name? Also, should this be in extensionsEmulator instead? @@ -15,7 +16,7 @@ export async function getExtensionFunctionInfo( instance: planner.DeploymentInstanceSpec, paramValues: Record, ): Promise<{ - runtime: string; + runtime: Runtime; extensionTriggers: ParsedTriggerDefinition[]; nonSecretEnv: Record; secretEnvVariables: SecretEnvVar[]; diff --git a/src/extensions/emulator/specHelper.ts b/src/extensions/emulator/specHelper.ts index e5bdb307314..2ef3c4956c5 100644 --- a/src/extensions/emulator/specHelper.ts +++ b/src/extensions/emulator/specHelper.ts @@ -2,6 +2,7 @@ import * as yaml from "js-yaml"; import * as path from "path"; import * as fs from "fs-extra"; +import * as supported from "../../deploy/functions/runtimes/supported"; import { ExtensionSpec, Resource } from "../types"; import { FirebaseError } from "../../error"; import { substituteParams } from "../extensionsHelper"; @@ -111,24 +112,27 @@ export function getFunctionProperties(resources: Resource[]) { return resources.map((r) => r.properties); } -export const DEFAULT_RUNTIME = "nodejs14"; +export const DEFAULT_RUNTIME: supported.Runtime = supported.latest("nodejs"); /** * Get runtime associated with the resources. If multiple runtimes exists, choose the latest runtime. * e.g. prefer nodejs14 over nodejs12. + * N.B. (inlined): I'm not sure why this code always assumes nodejs. It seems to + * work though and nobody is complaining that they can't run the Python + * emulator so I'm not investigating why it works. */ -export function getRuntime(resources: Resource[]): string { +export function getRuntime(resources: Resource[]): supported.Runtime { if (resources.length === 0) { return DEFAULT_RUNTIME; } const invalidRuntimes: string[] = []; - const runtimes = resources.map((r: Resource) => { + const runtimes: supported.Runtime[] = resources.map((r: Resource) => { const runtime = getResourceRuntime(r); if (!runtime) { return DEFAULT_RUNTIME; } - if (!/^(nodejs)?([0-9]+)/.test(runtime)) { + if (!supported.runtimeIsLanguage(runtime, "nodejs")) { invalidRuntimes.push(runtime); return DEFAULT_RUNTIME; } @@ -142,7 +146,5 @@ export function getRuntime(resources: Resource[]): string { ); } // Assumes that all runtimes target the nodejs. - // Rely on lexicographically order of nodejs runtime to pick the latest version. - // e.g. nodejs12 < nodejs14 < nodejs18 < nodejs20 ... - return runtimes.sort()[runtimes.length - 1]; + return supported.latest("nodejs", runtimes); } diff --git a/src/extensions/types.ts b/src/extensions/types.ts index a2c29ce3137..fdb342a4db3 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -1,5 +1,5 @@ import { MemoryOptions } from "../deploy/functions/backend"; -import { Runtime } from "../deploy/functions/runtimes"; +import { Runtime } from "../deploy/functions/runtimes/supported"; import * as proto from "../gcp/proto"; import { SpecParamType } from "./extensionsHelper"; diff --git a/src/extensions/utils.ts b/src/extensions/utils.ts index 7ffc31526b6..71a0c715614 100644 --- a/src/extensions/utils.ts +++ b/src/extensions/utils.ts @@ -6,8 +6,11 @@ import { FUNCTIONS_V2_RESOURCE_TYPE, } from "./types"; import { RegistryEntry } from "./resolveSource"; +import { Runtime } from "../deploy/functions/runtimes/supported"; -// Modified version of the once function from prompt, to return as a joined string. +/** + * Modified version of the once function from prompt, to return as a joined string. + */ export async function onceWithJoin(question: any): Promise { const response = await promptOnce(question); if (Array.isArray(response)) { @@ -22,7 +25,9 @@ interface ListItem { checked: boolean; // Whether the option should be checked by default } -// Convert extension option to Inquirer-friendly list for the prompt, with all items unchecked. +/** + * Convert extension option to Inquirer-friendly list for the prompt, with all items unchecked. + */ export function convertExtensionOptionToLabeledList(options: ParamOption[]): ListItem[] { return options.map((option: ParamOption): ListItem => { return { @@ -33,7 +38,9 @@ export function convertExtensionOptionToLabeledList(options: ParamOption[]): Lis }); } -// Convert map of RegistryEntry into Inquirer-friendly list for prompt, with all items unchecked. +/** + * Convert map of RegistryEntry into Inquirer-friendly list for prompt, with all items unchecked. + */ export function convertOfficialExtensionsToList(officialExts: { [key: string]: RegistryEntry; }): ListItem[] { @@ -78,7 +85,7 @@ export function formatTimestamp(timestamp: string): string { * etc, and this utility will do its best to identify the runtime specified for * this resource. */ -export function getResourceRuntime(resource: Resource): string | undefined { +export function getResourceRuntime(resource: Resource): Runtime | undefined { switch (resource.type) { case FUNCTIONS_RESOURCE_TYPE: return resource.properties?.runtime; diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 590ac28dff4..58c0b601dd3 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -7,6 +7,8 @@ import type { HttpsOptions } from "firebase-functions/v2/https"; import { IngressSetting, MemoryOption, VpcEgressSetting } from "firebase-functions/v2/options"; +import { Runtime, DecommissionedRuntime } from "./deploy/functions/runtimes/supported"; + /** * Creates a type that requires at least one key to be present in an interface * type. For example, RequireAtLeastOne<{ foo: string; bar: string }> can hold @@ -17,15 +19,6 @@ export type RequireAtLeastOne = { [K in keyof T]-?: Required> & Partial>>; }[keyof T]; -// should be sourced from - https://github.com/firebase/firebase-tools/blob/master/src/deploy/functions/runtimes/index.ts#L15 -type CloudFunctionRuntimes = - | "nodejs10" - | "nodejs12" - | "nodejs14" - | "nodejs16" - | "nodejs18" - | "nodejs20"; - export type Deployable = { predeploy?: string | string[]; postdeploy?: string | string[]; @@ -174,7 +167,7 @@ export type FirestoreConfig = FirestoreSingle | FirestoreMultiple; export type FunctionConfig = { source?: string; ignore?: string[]; - runtime?: CloudFunctionRuntimes; + runtime?: Exclude; codebase?: string; } & Deployable; diff --git a/src/functional.ts b/src/functional.ts index f93f91f8aa8..f68f2d48ae3 100644 --- a/src/functional.ts +++ b/src/functional.ts @@ -86,9 +86,9 @@ export const zipIn = }; /** Used with type guards to guarantee that all cases have been covered. */ -export function assertExhaustive(val: never): never { +export function assertExhaustive(val: never, message?: string): never { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`Never has a value (${val}).`); + throw new Error(message || `Never has a value (${val}).`); } /** diff --git a/src/gcp/cloudfunctions.ts b/src/gcp/cloudfunctions.ts index 0ec35f283b8..763135de952 100644 --- a/src/gcp/cloudfunctions.ts +++ b/src/gcp/cloudfunctions.ts @@ -5,7 +5,7 @@ import { logger } from "../logger"; import * as backend from "../deploy/functions/backend"; import * as utils from "../utils"; import * as proto from "./proto"; -import * as runtimes from "../deploy/functions/runtimes"; +import * as supported from "../deploy/functions/runtimes/supported"; import * as iam from "./iam"; import * as projectConfig from "../functions/projectConfig"; import { Client } from "../apiv2"; @@ -103,7 +103,7 @@ export interface CloudFunction { // end oneof trigger; entryPoint: string; - runtime: runtimes.Runtime; + runtime: supported.Runtime; // Default = 60s timeout?: proto.Duration | null; @@ -519,8 +519,11 @@ export function endpointFromFunction(gcfFunction: CloudFunction): backend.Endpoi securityLevel = gcfFunction.httpsTrigger.securityLevel; } - if (!runtimes.isValidRuntime(gcfFunction.runtime)) { - logger.debug("GCFv1 function has a deprecated runtime:", JSON.stringify(gcfFunction, null, 2)); + if (!supported.isRuntime(gcfFunction.runtime)) { + logger.debug( + "GCF 1st gen function has unsupported runtime:", + JSON.stringify(gcfFunction, null, 2), + ); } const endpoint: backend.Endpoint = { @@ -589,10 +592,11 @@ export function functionFromEndpoint( ); } - if (!runtimes.isValidRuntime(endpoint.runtime)) { + if (!supported.isRuntime(endpoint.runtime)) { throw new FirebaseError( "Failed internal assertion. Trying to deploy a new function with a deprecated runtime." + " This should never happen", + { exit: 1 }, ); } const gcfFunction: Omit = { diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index 9b5d5bbe29a..82f14bce6fb 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -5,7 +5,7 @@ import { logger } from "../logger"; import { AUTH_BLOCKING_EVENTS } from "../functions/events/v1"; import { PUBSUB_PUBLISH_EVENT } from "../functions/events/v2"; import * as backend from "../deploy/functions/backend"; -import * as runtimes from "../deploy/functions/runtimes"; +import * as supported from "../deploy/functions/runtimes/supported"; import * as proto from "./proto"; import * as utils from "../utils"; import * as projectConfig from "../functions/projectConfig"; @@ -44,7 +44,7 @@ export type RetryPolicy = /** Settings for building a container out of the customer source. */ export interface BuildConfig { - runtime: runtimes.Runtime; + runtime: supported.Runtime; entryPoint: string; source: Source; sourceToken?: string; @@ -478,7 +478,7 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc ); } - if (!runtimes.isValidRuntime(endpoint.runtime)) { + if (!supported.isRuntime(endpoint.runtime)) { throw new FirebaseError( "Failed internal assertion. Trying to deploy a new function with a deprecated runtime." + " This should never happen", @@ -699,7 +699,7 @@ export function endpointFromFunction(gcfFunction: OutputCloudFunction): backend. trigger = { httpsTrigger: {} }; } - if (!runtimes.isValidRuntime(gcfFunction.buildConfig.runtime)) { + if (!supported.isRuntime(gcfFunction.buildConfig.runtime)) { logger.debug("GCFv2 function has a deprecated runtime:", JSON.stringify(gcfFunction, null, 2)); } diff --git a/src/init/features/functions/python.ts b/src/init/features/functions/python.ts index a05bf83f1f0..60fdbac4dad 100644 --- a/src/init/features/functions/python.ts +++ b/src/init/features/functions/python.ts @@ -3,9 +3,10 @@ import * as spawn from "cross-spawn"; import * as path from "path"; import { Config } from "../../../config"; -import { getPythonBinary, LATEST_VERSION } from "../../../deploy/functions/runtimes/python"; +import { getPythonBinary } from "../../../deploy/functions/runtimes/python"; import { runWithVirtualEnv } from "../../../functions/python"; import { promptOnce } from "../../../prompt"; +import { latest } from "../../../deploy/functions/runtimes/supported"; const TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/python"); 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 { await config.askWriteProjectFile(`${setup.functions.source}/main.py`, MAIN_TEMPLATE); // Write the latest supported runtime version to the config. - config.set("functions.runtime", LATEST_VERSION); + config.set("functions.runtime", latest("python")); // Add python specific ignores to config. config.set("functions.ignore", ["venv", "__pycache__"]); // Setup VENV. - const venvProcess = spawn(getPythonBinary(LATEST_VERSION), ["-m", "venv", "venv"], { + const venvProcess = spawn(getPythonBinary(latest("python")), ["-m", "venv", "venv"], { shell: true, cwd: config.path(setup.functions.source), stdio: [/* stdin= */ "pipe", /* stdout= */ "pipe", /* stderr= */ "pipe", "pipe"], @@ -58,7 +59,7 @@ export async function setup(setup: any, config: Config): Promise { upgradeProcess.on("error", reject); }); const installProcess = runWithVirtualEnv( - [getPythonBinary(LATEST_VERSION), "-m", "pip", "install", "-r", "requirements.txt"], + [getPythonBinary(latest("python")), "-m", "pip", "install", "-r", "requirements.txt"], config.path(setup.functions.source), {}, { stdio: ["inherit", "inherit", "inherit"] }, diff --git a/src/test/deploy/functions/backend.spec.ts b/src/test/deploy/functions/backend.spec.ts index d28c3aaf91f..992f654e51e 100644 --- a/src/test/deploy/functions/backend.spec.ts +++ b/src/test/deploy/functions/backend.spec.ts @@ -455,7 +455,7 @@ describe("Backend", () => { describe("compareFunctions", () => { const fnMembers = { project: "project", - runtime: "nodejs14", + runtime: "nodejs14" as const, httpsTrigger: {}, }; diff --git a/src/test/deploy/functions/checkIam.spec.ts b/src/test/deploy/functions/checkIam.spec.ts index 25ebe417649..ee51211dd9b 100644 --- a/src/test/deploy/functions/checkIam.spec.ts +++ b/src/test/deploy/functions/checkIam.spec.ts @@ -21,7 +21,7 @@ const BINDING = { const SPEC = { region: "us-west1", project: projectNumber, - runtime: "nodejs14", + runtime: "nodejs14" as const, }; describe("checkIam", () => { diff --git a/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts b/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index 894f1a7c2a6..b0141beaa86 100644 --- a/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import * as backend from "../../../../../deploy/functions/backend"; import * as build from "../../../../../deploy/functions/build"; -import { Runtime } from "../../../../../deploy/functions/runtimes"; +import { Runtime } from "../../../../../deploy/functions/runtimes/supported"; import * as v1alpha1 from "../../../../../deploy/functions/runtimes/discovery/v1alpha1"; import { BEFORE_CREATE_EVENT } from "../../../../../functions/events/v1"; import { Param } from "../../../../../deploy/functions/params"; @@ -10,7 +10,7 @@ import { FirebaseError } from "../../../../../error"; const PROJECT = "project"; const REGION = "region"; -const RUNTIME: Runtime = "node14"; +const RUNTIME: Runtime = "nodejs14"; const MIN_WIRE_ENDPOINT: Omit = { entryPoint: "entryPoint", }; diff --git a/src/test/deploy/functions/runtimes/index.spec.ts b/src/test/deploy/functions/runtimes/index.spec.ts deleted file mode 100644 index 460783f733b..00000000000 --- a/src/test/deploy/functions/runtimes/index.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect } from "chai"; - -import * as runtimes from "../../../../deploy/functions/runtimes"; - -describe("getHumanFriendlyRuntimeName", () => { - it("should properly convert raw runtime to human friendly runtime", () => { - expect(runtimes.getHumanFriendlyRuntimeName("nodejs6")).to.contain("Node.js"); - }); -}); diff --git a/src/test/deploy/functions/runtimes/node/index.spec.ts b/src/test/deploy/functions/runtimes/node/index.spec.ts index 753a85877ec..228080baa35 100644 --- a/src/test/deploy/functions/runtimes/node/index.spec.ts +++ b/src/test/deploy/functions/runtimes/node/index.spec.ts @@ -6,6 +6,7 @@ import * as node from "../../../../../deploy/functions/runtimes/node"; import * as versioning from "../../../../../deploy/functions/runtimes/node/versioning"; import * as utils from "../../../../../utils"; import { FirebaseError } from "../../../../../error"; +import { Runtime } from "../../../../../deploy/functions/runtimes/supported"; const PROJECT_ID = "test-project"; const PROJECT_DIR = "/some/path"; @@ -66,7 +67,12 @@ describe("NodeDelegate", () => { it("throws errors if requested runtime version is invalid", () => { const invalidRuntime = "foobar"; - const delegate = new node.Delegate(PROJECT_ID, PROJECT_DIR, SOURCE_DIR, invalidRuntime); + const delegate = new node.Delegate( + PROJECT_ID, + PROJECT_DIR, + SOURCE_DIR, + invalidRuntime as Runtime, + ); expect(() => delegate.getNodeBinary()).to.throw(FirebaseError); }); diff --git a/src/test/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.spec.ts b/src/test/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.spec.ts index 4121c1d0cc6..9491055a09e 100644 --- a/src/test/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.spec.ts +++ b/src/test/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.spec.ts @@ -21,18 +21,6 @@ describe("getRuntimeChoice", () => { }); context("when the runtime is set in firebase.json", () => { - it("should error if runtime field is set to node 6", () => { - expect(() => { - runtime.getRuntimeChoice("path/to/source", "nodejs6"); - }).to.throw(runtime.UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG); - }); - - it("should error if runtime field is set to node 8", () => { - expect(() => { - runtime.getRuntimeChoice("path/to/source", "nodejs8"); - }).to.throw(runtime.UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG); - }); - it("should return node 10 if runtime field is set to node 10", () => { expect(runtime.getRuntimeChoice("path/to/source", "nodejs10")).to.equal("nodejs10"); }); @@ -48,75 +36,52 @@ describe("getRuntimeChoice", () => { it("should return node 16 if runtime field is set to node 16", () => { expect(runtime.getRuntimeChoice("path/to/source", "nodejs16")).to.equal("nodejs16"); }); - - it("should throw error if unsupported node version set", () => { - expect(() => runtime.getRuntimeChoice("path/to/source", "nodejs11")).to.throw( - FirebaseError, - runtime.UNSUPPORTED_NODE_VERSION_FIREBASE_JSON_MSG, - ); - }); }); context("when the runtime is not set in firebase.json", () => { - it("should error if engines field is set to node 6", () => { - cjsonStub.returns({ engines: { node: "6" } }); - - expect(() => { - runtime.getRuntimeChoice("path/to/source", ""); - }).to.throw(runtime.UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG); - }); - - it("should error if engines field is set to node 8", () => { - cjsonStub.returns({ engines: { node: "8" } }); - - expect(() => { - runtime.getRuntimeChoice("path/to/source", ""); - }).to.throw(runtime.UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG); - }); - it("should return node 10 if engines field is set to node 10", () => { cjsonStub.returns({ engines: { node: "10" } }); - expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs10"); + expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs10"); }); it("should return node 12 if engines field is set to node 12", () => { cjsonStub.returns({ engines: { node: "12" } }); - expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs12"); + expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs12"); }); it("should return node 14 if engines field is set to node 14", () => { cjsonStub.returns({ engines: { node: "14" } }); - expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs14"); + expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs14"); }); it("should return node 16 if engines field is set to node 16", () => { cjsonStub.returns({ engines: { node: "16" } }); - expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs16"); + expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs16"); }); it("should print warning when firebase-functions version is below 2.0.0", () => { cjsonStub.returns({ engines: { node: "16" } }); - runtime.getRuntimeChoice("path/to/source", ""); + runtime.getRuntimeChoice("path/to/source"); }); it("should not throw error if user's SDK version fails to be fetched", () => { cjsonStub.returns({ engines: { node: "10" } }); // Intentionally not setting SDKVersionStub so it can fail to be fetched. - expect(runtime.getRuntimeChoice("path/to/source", "")).to.equal("nodejs10"); + expect(runtime.getRuntimeChoice("path/to/source")).to.equal("nodejs10"); }); it("should throw error if unsupported node version set", () => { cjsonStub.returns({ engines: { node: "11" }, }); - expect(() => runtime.getRuntimeChoice("path/to/source", "")).to.throw( + expect(() => runtime.getRuntimeChoice("path/to/source")).to.throw( FirebaseError, - runtime.UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG, + /Detected node engine 11 in package.json, which is not a supported version. Valid versions are/, ); }); }); diff --git a/src/test/deploy/functions/runtimes/python/index.spec.ts b/src/test/deploy/functions/runtimes/python/index.spec.ts index d2c9e3bfd09..cf8e224e19b 100644 --- a/src/test/deploy/functions/runtimes/python/index.spec.ts +++ b/src/test/deploy/functions/runtimes/python/index.spec.ts @@ -26,14 +26,6 @@ describe("PythonDelegate", () => { expect(delegate.getPythonBinary()).to.equal("python3.10"); }); - it("returns generic python binary given non-recognized python runtime", () => { - platformMock.value("darwin"); - const requestedRuntime = "python308"; - const delegate = new python.Delegate(PROJECT_ID, SOURCE_DIR, requestedRuntime); - - expect(delegate.getPythonBinary()).to.equal("python"); - }); - it("always returns version-neutral, python.exe on windows", () => { platformMock.value("win32"); const requestedRuntime = "python310"; diff --git a/src/test/deploy/functions/runtimes/supported.spec.ts b/src/test/deploy/functions/runtimes/supported.spec.ts new file mode 100644 index 00000000000..2f48e0a4138 --- /dev/null +++ b/src/test/deploy/functions/runtimes/supported.spec.ts @@ -0,0 +1,68 @@ +import { expect } from "chai"; +import * as supported from "../../../../deploy/functions/runtimes/supported"; +import * as utils from "../../../../utils"; +import * as sinon from "sinon"; +import { FirebaseError } from "../../../../error"; + +describe("supported runtimes", () => { + it("sorts latest numerically, not lexographically", () => { + expect(supported.latest("nodejs")).to.not.equal("nodejs8"); + }); + + it("identifies decommissioned runtimes", () => { + expect(supported.isDecommissioned("nodejs8")).to.be.true; + }); + + describe("isRuntime", () => { + it("identifies valid runtimes", () => { + expect(supported.isRuntime("nodejs20")).to.be.true; + }); + + it("identifies invalid runtimes", () => { + expect(supported.isRuntime("prolog1")).to.be.false; + }); + }); + + describe("guardVersionSupport", () => { + let logLabeledWarning: sinon.SinonStub; + beforeEach(() => { + logLabeledWarning = sinon.stub(utils, "logLabeledWarning"); + }); + + afterEach(() => { + logLabeledWarning.restore(); + }); + + it("throws an error for decommissioned runtimes", () => { + expect(() => supported.guardVersionSupport("nodejs8")).to.throw( + FirebaseError, + "Runtime Node.js 8 was decommissioned on 2021-02-01. " + + "To deploy you must first upgrade your runtime version", + ); + }); + + it("warns for a deprecated runtime", () => { + supported.guardVersionSupport("nodejs20", new Date("2026-04-30")); + expect(logLabeledWarning).to.have.been.calledWith( + "functions", + "Runtime Node.js 20 was deprecated on 2026-04-30 and will be " + + "decommissioned on 2026-10-31, after which you will not be able to " + + "deploy without upgrading. Consider upgrading now to avoid disruption. See " + + "https://cloud.google.com/functions/docs/runtime-support for full " + + "details on the lifecycle policy", + ); + }); + + it("warns leading up to deprecation", () => { + supported.guardVersionSupport("nodejs20", new Date("2026-04-01")); + expect(logLabeledWarning).to.have.been.calledWith( + "functions", + "Runtime Node.js 20 will be deprecated on 2026-04-30 and will be " + + "decommissioned on 2026-10-31, after which you will not be able to " + + "deploy without upgrading. Consider upgrading now to avoid disruption. See " + + "https://cloud.google.com/functions/docs/runtime-support for full " + + "details on the lifecycle policy", + ); + }); + }); +}); diff --git a/src/test/deploy/functions/services/auth.spec.ts b/src/test/deploy/functions/services/auth.spec.ts index 0933dfcc79c..4a7128cc6d8 100644 --- a/src/test/deploy/functions/services/auth.spec.ts +++ b/src/test/deploy/functions/services/auth.spec.ts @@ -10,7 +10,7 @@ const BASE_EP = { region: "us-east1", project: "project", entryPoint: "func", - runtime: "nodejs16", + runtime: "nodejs16" as const, }; const authBlockingService = new auth.AuthBlockingService(); diff --git a/src/test/deploy/functions/triggerRegionHelper.spec.ts b/src/test/deploy/functions/triggerRegionHelper.spec.ts index 5e4e46e3a84..b159f777a70 100644 --- a/src/test/deploy/functions/triggerRegionHelper.spec.ts +++ b/src/test/deploy/functions/triggerRegionHelper.spec.ts @@ -8,7 +8,7 @@ import * as triggerRegionHelper from "../../../deploy/functions/triggerRegionHel const SPEC = { region: "us-west1", project: "my-project", - runtime: "nodejs14", + runtime: "nodejs14" as const, }; describe("TriggerRegionHelper", () => { diff --git a/src/test/extensions/emulator/specHelper.spec.ts b/src/test/extensions/emulator/specHelper.spec.ts index 6c6720b0f08..6c85adf66c7 100644 --- a/src/test/extensions/emulator/specHelper.spec.ts +++ b/src/test/extensions/emulator/specHelper.spec.ts @@ -4,6 +4,7 @@ import * as path from "path"; import * as specHelper from "../../../extensions/emulator/specHelper"; import { Resource } from "../../../extensions/types"; import { FirebaseError } from "../../../error"; +import { Runtime } from "../../../deploy/functions/runtimes/supported"; const testResource: Resource = { name: "test-resource", @@ -102,13 +103,13 @@ describe("getRuntime", () => { const r1 = { ...testResource, properties: { - runtime: "nodejs14", + runtime: "nodejs14" as const, }, }; const r2 = { ...testResource, properties: { - runtime: "nodejs14", + runtime: "nodejs14" as const, }, }; expect(specHelper.getRuntime([r1, r2])).to.equal("nodejs14"); @@ -118,13 +119,13 @@ describe("getRuntime", () => { const r1 = { ...testResource, properties: { - runtime: "nodejs12", + runtime: "nodejs12" as const, }, }; const r2 = { ...testResource, properties: { - runtime: "nodejs14", + runtime: "nodejs14" as const, }, }; expect(specHelper.getRuntime([r1, r2])).to.equal("nodejs14"); @@ -150,13 +151,14 @@ describe("getRuntime", () => { const r1 = { ...testResource, properties: { - runtime: "dotnet6", + // Note: as const won't work since this is actually an invalid runtime. + runtime: "dotnet6" as Runtime, }, }; const r2 = { ...testResource, properties: { - runtime: "nodejs14", + runtime: "nodejs14" as const, }, }; expect(() => specHelper.getRuntime([r1, r2])).to.throw(FirebaseError); diff --git a/src/test/extensions/emulator/triggerHelper.spec.ts b/src/test/extensions/emulator/triggerHelper.spec.ts index dfd40898cbe..9e5338a6111 100644 --- a/src/test/extensions/emulator/triggerHelper.spec.ts +++ b/src/test/extensions/emulator/triggerHelper.spec.ts @@ -200,7 +200,7 @@ describe("triggerHelper", () => { type: "firebaseextensions.v1beta.v2function", properties: { buildConfig: { - runtime: "node16", + runtime: "nodejs16", }, location: "us-cental1", serviceConfig: { diff --git a/src/test/functions/secrets.spec.ts b/src/test/functions/secrets.spec.ts index 249127fd865..76f2296218c 100644 --- a/src/test/functions/secrets.spec.ts +++ b/src/test/functions/secrets.spec.ts @@ -17,7 +17,7 @@ const ENDPOINT = { region: "region", project: "project", entryPoint: "id", - runtime: "nodejs16", + runtime: "nodejs16" as const, platform: "gcfv1" as const, httpsTrigger: {}, }; @@ -64,14 +64,16 @@ describe("functions/secret", () => { expect(warnStub).to.have.been.calledOnce; }); - it("throws error if given non-conventional key w/ forced option", () => { - expect(secrets.ensureValidKey("throwError", { ...options, force: true })).to.be.rejectedWith( - FirebaseError, - ); + it("throws error if given non-conventional key w/ forced option", async () => { + await expect( + secrets.ensureValidKey("throwError", { ...options, force: true }), + ).to.be.rejectedWith(FirebaseError); }); - it("throws error if given reserved key", () => { - expect(secrets.ensureValidKey("FIREBASE_CONFIG", options)).to.be.rejectedWith(FirebaseError); + it("throws error if given reserved key", async () => { + await expect(secrets.ensureValidKey("FIREBASE_CONFIG", options)).to.be.rejectedWith( + FirebaseError, + ); }); });