Skip to content

Commit

Permalink
Strengthen typing of Runtimes+Langauges and their support timelines. (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
inlined authored Mar 27, 2024
1 parent ed9e2d6 commit 1a3b885
Show file tree
Hide file tree
Showing 39 changed files with 485 additions and 290 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add support timelines for functions runtimes (#6866)
14 changes: 12 additions & 2 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,12 @@
"nodejs14",
"nodejs16",
"nodejs18",
"nodejs20"
"nodejs20",
"nodejs6",
"nodejs8",
"python310",
"python311",
"python312"
],
"type": "string"
},
Expand Down Expand Up @@ -678,7 +683,12 @@
"nodejs14",
"nodejs16",
"nodejs18",
"nodejs20"
"nodejs20",
"nodejs6",
"nodejs8",
"python310",
"python311",
"python312"
],
"type": "string"
},
Expand Down
2 changes: 1 addition & 1 deletion src/deploy/functions/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/deploy/functions/backend.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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<number>;
Expand Down
18 changes: 14 additions & 4 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions src/deploy/functions/runtimes/discovery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -20,7 +20,7 @@ export function yamlToBuild(
yaml: any,
project: string,
region: string,
runtime: runtimes.Runtime,
runtime: Runtime,
): build.Build {
try {
if (!yaml.specVersion) {
Expand All @@ -43,7 +43,7 @@ export function yamlToBuild(
export async function detectFromYaml(
directory: string,
project: string,
runtime: runtimes.Runtime,
runtime: Runtime,
): Promise<build.Build | undefined> {
let text: string;
try {
Expand All @@ -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<build.Build> {
let res: Response;
Expand Down
6 changes: 3 additions & 3 deletions src/deploy/functions/runtimes/discovery/v1alpha1.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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)) {
Expand Down
83 changes: 13 additions & 70 deletions src/deploy/functions/runtimes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Runtime | DeprecatedRuntime, string> = {
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.
Expand Down Expand Up @@ -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<RuntimeDelegate | undefined>;
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<RuntimeDelegate> {
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);
Expand All @@ -150,5 +93,5 @@ export async function getRuntimeDelegate(context: DelegateContext): Promise<Runt
}
}

throw new FirebaseError(`Could not detect language for functions at ${sourceDir}`);
throw new FirebaseError(`Could not detect runtime for functions at ${sourceDir}`);
}
13 changes: 6 additions & 7 deletions src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { logLabeledSuccess, logLabeledWarning, randomInt } from "../../../../uti
import * as backend from "../../backend";
import * as build from "../../build";
import * as discovery from "../discovery";
import * as runtimes from "..";
import { DelegateContext } from "..";
import * as supported from "../supported";
import * as validate from "./validate";
import * as versioning from "./versioning";
import * as parseTriggers from "./parseTriggers";
Expand All @@ -24,9 +25,7 @@ const MIN_FUNCTIONS_SDK_VERSION = "3.20.0";
/**
*
*/
export async function tryCreateDelegate(
context: runtimes.DelegateContext,
): Promise<Delegate | undefined> {
export async function tryCreateDelegate(context: DelegateContext): Promise<Delegate | undefined> {
const packageJsonPath = path.join(context.sourceDir, "package.json");

if (!(await promisify(fs.exists)(packageJsonPath))) {
Expand All @@ -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",
);
Expand All @@ -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
Expand Down
Loading

0 comments on commit 1a3b885

Please sign in to comment.