diff --git a/src/deploy/functions/runtimes/discovery/index.ts b/src/deploy/functions/runtimes/discovery/index.ts index 809671fa93a..e5e0e8ff71e 100644 --- a/src/deploy/functions/runtimes/discovery/index.ts +++ b/src/deploy/functions/runtimes/discovery/index.ts @@ -72,7 +72,7 @@ export async function detectFromPort( while (true) { try { - res = await Promise.race([fetch(`http://localhost:${port}/__/functions.yaml`), timedOut]); + res = await Promise.race([fetch(`http://127.0.0.1:${port}/__/functions.yaml`), timedOut]); break; } catch (err: any) { // Allow us to wait until the server is listening. diff --git a/src/deploy/functions/runtimes/golang/gomod.ts b/src/deploy/functions/runtimes/golang/gomod.ts deleted file mode 100644 index c99a9df4226..00000000000 --- a/src/deploy/functions/runtimes/golang/gomod.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { logger } from "../../../../logger"; - -// A module can be much more complicated than this, but this is all we need so far. -// For a full reference, see https://golang.org/doc/modules/gomod-ref -export interface Module { - module: string; - version: string; - dependencies: Record; - replaces: Record; -} - -export function parseModule(mod: string): Module { - const module: Module = { - module: "", - version: "", - dependencies: {}, - replaces: {}, - }; - const lines = mod.split("\n"); - let inBlock: Record | undefined = undefined; - for (const line of lines) { - if (inBlock) { - const endRequireMatch = /\)/.exec(line); - if (endRequireMatch) { - inBlock = undefined; - continue; - } - - let regex: RegExp; - if (inBlock === module.dependencies) { - regex = /([^ ]+) ([^ ]+)/; - } else { - regex = /([^ ]+) => ([^ ]+)/; - } - const mapping = regex.exec(line); - if (mapping) { - (inBlock as Record)[mapping[1]] = mapping[2]; - continue; - } - - if (line.trim()) { - logger.debug("Don't know how to handle line", line, "inside a mod.go require block"); - } - continue; - } - const modMatch = /^module (.*)$/.exec(line); - if (modMatch) { - module.module = modMatch[1]; - continue; - } - const versionMatch = /^go (\d+\.\d+)$/.exec(line); - if (versionMatch) { - module.version = versionMatch[1]; - continue; - } - - const requireMatch = /^require ([^ ]+) ([^ ]+)/.exec(line); - if (requireMatch) { - module.dependencies[requireMatch[1]] = requireMatch[2]; - continue; - } - - const replaceMatch = /^replace ([^ ]+) => ([^ ]+)$/.exec(line); - if (replaceMatch) { - module.replaces[replaceMatch[1]] = replaceMatch[2]; - continue; - } - - const requireBlockMatch = /^require +\(/.exec(line); - if (requireBlockMatch) { - inBlock = module.dependencies; - continue; - } - - const replaceBlockMatch = /^replace +\(/.exec(line); - if (replaceBlockMatch) { - inBlock = module.replaces; - continue; - } - - if (line.trim()) { - logger.debug("Don't know how to handle line", line, "in mod.go"); - } - } - - return module; -} diff --git a/src/deploy/functions/runtimes/golang/index.ts b/src/deploy/functions/runtimes/golang/index.ts deleted file mode 100644 index d879dd81271..00000000000 --- a/src/deploy/functions/runtimes/golang/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { promisify } from "util"; -import fetch from "node-fetch"; -import * as fs from "fs"; -import * as path from "path"; -import * as spawn from "cross-spawn"; - -import { FirebaseError } from "../../../../error"; -import { logger } from "../../../../logger"; -import * as backend from "../../backend"; -import * as build from "../../build"; -import * as gomod from "./gomod"; -import * as runtimes from ".."; - -const VERSION_TO_RUNTIME: Record = { - "1.13": "go113", -}; -export const ADMIN_SDK = "firebase.google.com/go/v4"; -export const FUNCTIONS_SDK = "github.com/FirebaseExtended/firebase-functions-go"; - -// Because codegen is a separate binary we won't automatically import it -// when we import the library. -export const FUNCTIONS_CODEGEN = FUNCTIONS_SDK + "/support/codegen"; -export const FUNCTIONS_RUNTIME = FUNCTIONS_SDK + "/support/runtime"; - -/** - * - */ -export async function tryCreateDelegate( - context: runtimes.DelegateContext -): Promise { - const goModPath = path.join(context.sourceDir, "go.mod"); - - let module: gomod.Module; - try { - const modBuffer = await promisify(fs.readFile)(goModPath); - module = gomod.parseModule(modBuffer.toString("utf8")); - } catch (err: any) { - logger.debug("Customer code is not Golang code (or they aren't using gomod)"); - return; - } - - let runtime = context.runtime; - if (!runtime) { - if (!module.version) { - throw new FirebaseError("Could not detect Golang version from go.mod"); - } - if (!VERSION_TO_RUNTIME[module.version]) { - throw new FirebaseError( - `go.mod specifies Golang version ${ - module.version - } which is unsupported by Google Cloud Functions. Valid values are ${Object.keys( - VERSION_TO_RUNTIME - ).join(", ")}` - ); - } - runtime = VERSION_TO_RUNTIME[module.version]; - } - - return new Delegate(context.projectId, context.sourceDir, runtime, module); -} - -export class Delegate { - public readonly name = "golang"; - - constructor( - private readonly projectId: string, - private readonly sourceDir: string, - public readonly runtime: runtimes.Runtime, - private readonly module: gomod.Module - ) {} - validate(): Promise { - return Promise.resolve(); - } - - async build(): Promise { - try { - await promisify(fs.mkdir)(path.join(this.sourceDir, "autogen")); - } catch (err: any) { - if (err?.code !== "EEXIST") { - throw new FirebaseError("Failed to create codegen directory", { children: [err] }); - } - } - const genBinary = spawn.sync("go", ["run", FUNCTIONS_CODEGEN, this.module.module], { - cwd: this.sourceDir, - env: { - ...process.env, - HOME: process.env.HOME, - PATH: process.env.PATH, - GOPATH: process.env.GOPATH, - }, - stdio: [/* stdin=*/ "ignore", /* stdout=*/ "pipe", /* stderr=*/ "pipe"], - }); - if (genBinary.status !== 0) { - throw new FirebaseError("Failed to run codegen", { - children: [new Error(genBinary.stderr.toString())], - }); - } - await promisify(fs.writeFile)( - path.join(this.sourceDir, "autogen", "main.go"), - genBinary.stdout - ); - } - - // Watch isn't supported for Go - watch(): Promise<() => Promise> { - return Promise.resolve(() => Promise.resolve()); - } - - serve( - port: number, - adminPort: number, - envs: backend.EnvironmentVariables - ): Promise<() => Promise> { - const childProcess = spawn("go", ["run", "./autogen"], { - env: { - ...process.env, - ...envs, - PORT: port.toString(), - ADMIN_PORT: adminPort.toString(), - HOME: process.env.HOME, - PATH: process.env.PATH, - GOPATH: process.env.GOPATH, - }, - cwd: this.sourceDir, - stdio: [/* stdin=*/ "ignore", /* stdout=*/ "pipe", /* stderr=*/ "inherit"], - }); - childProcess.stdout?.on("data", (chunk) => { - logger.debug(chunk.toString()); - }); - return Promise.resolve(async () => { - const p = new Promise((resolve, reject) => { - childProcess.once("exit", resolve); - childProcess.once("error", reject); - }); - - // If we SIGKILL the child process we're actually going to kill the go - // runner and the webserver it launched will keep running. - await fetch(`http://localhost:${adminPort}/__/quitquitquit`); - setTimeout(() => { - if (!childProcess.killed) { - childProcess.kill("SIGKILL"); - } - }, 10_000); - return p; - }); - } - - async discoverBuild(): Promise { - // Unimplemented. Build discovery is not currently supported in Go. - return Promise.resolve({ requiredAPIs: [], endpoints: {}, params: [] }); - } -} diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index 68bfb4125a0..c7a01cf6bff 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -1,5 +1,6 @@ import * as backend from "../backend"; import * as build from "../build"; +import * as python from "./python"; import * as node from "./node"; import * as validate from "../validate"; import { FirebaseError } from "../../../error"; @@ -9,7 +10,7 @@ const RUNTIMES: string[] = ["nodejs10", "nodejs12", "nodejs14", "nodejs16", "nod // 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 = ["go113"]; +const EXPERIMENTAL_RUNTIMES: string[] = ["python310", "python311"]; export type Runtime = typeof RUNTIMES[number] | typeof EXPERIMENTAL_RUNTIMES[number]; /** Runtimes that can be found in existing backends but not used for new functions. */ @@ -34,7 +35,8 @@ const MESSAGE_FRIENDLY_RUNTIMES: Record = { nodejs14: "Node.js 14", nodejs16: "Node.js 16", nodejs18: "Node.js 18", - go113: "Go 1.13", + python310: "Python 3.10", + python311: "Python 3.11 (Preview)", }; /** @@ -63,6 +65,11 @@ export interface RuntimeDelegate { */ runtime: Runtime; + /** + * Path to the bin used to run the source code. + */ + bin: string; + /** * Validate makes sure the customers' code is actually viable. * This includes checks like making sure a package.json file is @@ -109,9 +116,7 @@ export interface DelegateContext { } type Factory = (context: DelegateContext) => Promise; -// Note: golang has been removed from delegates because it does not work and it -// is not worth having an experiment for yet. -const factories: Factory[] = [node.tryCreateDelegate]; +const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate]; /** * diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 8241118caeb..633cacdcfeb 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -9,7 +9,7 @@ import fetch from "node-fetch"; import { FirebaseError } from "../../../../error"; import { getRuntimeChoice } from "./parseRuntimeAndValidateSDK"; import { logger } from "../../../../logger"; -import { logLabeledWarning } from "../../../../utils"; +import { logLabeledSuccess, logLabeledWarning } from "../../../../utils"; import * as backend from "../../backend"; import * as build from "../../build"; import * as discovery from "../discovery"; @@ -66,13 +66,75 @@ export class Delegate { // to decide whether to use the JS export method of discovery or the HTTP container contract // method of discovery. _sdkVersion: string | undefined = undefined; - get sdkVersion() { + get sdkVersion(): string { if (this._sdkVersion === undefined) { this._sdkVersion = versioning.getFunctionsSDKVersion(this.sourceDir) || ""; } return this._sdkVersion; } + _bin = ""; + get bin(): string { + if (this._bin === "") { + this._bin = this.getNodeBinary(); + } + return this._bin; + } + + getNodeBinary(): string { + const requestedVersion = semver.coerce(this.runtime); + if (!requestedVersion) { + throw new FirebaseError( + `Could not determine version of the requested runtime: ${this.runtime}` + ); + } + const hostVersion = process.versions.node; + + const localNodePath = path.join(this.sourceDir, "node_modules/node"); + const localNodeVersion = versioning.findModuleVersion("node", localNodePath); + + if (localNodeVersion) { + if (semver.major(requestedVersion) === semver.major(localNodeVersion)) { + logLabeledSuccess( + "functions", + `Using node@${semver.major(localNodeVersion)} from local cache.` + ); + return localNodePath; + } + } + + // If the requested version is the same as the host, let's use that + if (semver.major(requestedVersion) === semver.major(hostVersion)) { + logLabeledSuccess("functions", `Using node@${semver.major(hostVersion)} from host.`); + } else { + // Otherwise we'll warn and use the version that is currently running this process. + if (process.env.FIREPIT_VERSION) { + logLabeledWarning( + "functions", + `You've requested "node" version "${semver.major( + requestedVersion + )}", but the standalone Firebase CLI comes with bundled Node "${semver.major( + hostVersion + )}".` + ); + logLabeledSuccess( + "functions", + `To use a different Node.js version, consider removing the standalone Firebase CLI and switching to "firebase-tools" on npm.` + ); + } else { + logLabeledWarning( + "functions", + `Your requested "node" version "${semver.major( + requestedVersion + )}" doesn't match your global version "${semver.major( + hostVersion + )}". Using node@${semver.major(hostVersion)} from host.` + ); + } + } + return process.execPath; + } + validate(): Promise { versioning.checkFunctionsSDKVersion(this.sdkVersion); @@ -92,14 +154,14 @@ export class Delegate { return Promise.resolve(() => Promise.resolve()); } - serve( - port: number, + serveAdmin( + port: string, config: backend.RuntimeConfigValues, envs: backend.EnvironmentVariables ): Promise<() => Promise> { const env: NodeJS.ProcessEnv = { ...envs, - PORT: port.toString(), + PORT: port, FUNCTIONS_CONTROL_API: "true", HOME: process.env.HOME, PATH: process.env.PATH, @@ -161,7 +223,7 @@ export class Delegate { if (!discovered) { const getPort = promisify(portfinder.getPort) as () => Promise; const port = await getPort(); - const kill = await this.serve(port, config, env); + const kill = await this.serveAdmin(port.toString(), config, env); try { discovered = await discovery.detectFromPort(port, this.projectId, this.runtime); } finally { diff --git a/src/deploy/functions/runtimes/python/index.ts b/src/deploy/functions/runtimes/python/index.ts new file mode 100644 index 00000000000..38248d32ed6 --- /dev/null +++ b/src/deploy/functions/runtimes/python/index.ts @@ -0,0 +1,157 @@ +import * as fs from "fs"; +import * as path from "path"; +import fetch from "node-fetch"; +import { promisify } from "util"; + +import * as portfinder from "portfinder"; + +import * as runtimes from ".."; +import * as backend from "../../backend"; +import * as discovery from "../discovery"; +import { logger } from "../../../../logger"; +import { runWithVirtualEnv } from "../../../../functions/python"; +import { FirebaseError } from "../../../../error"; +import { Build } from "../../build"; + +export const LATEST_VERSION: runtimes.Runtime = "python310"; + +/** + * This function is used to create a runtime delegate for the Python runtime. + * @param context runtimes.DelegateContext + * @return Delegate Python runtime delegate + */ +export async function tryCreateDelegate( + context: runtimes.DelegateContext +): Promise { + // TODO this can be done better by passing Options to tryCreateDelegate and + // reading the "functions.source" and ""functions.runtime" values from there + // to determine the runtime. For the sake of keeping changes to python only + // this has not been done for now. + const requirementsTextPath = path.join(context.sourceDir, "requirements.txt"); + + if (!(await promisify(fs.exists)(requirementsTextPath))) { + 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`); + } + return Promise.resolve(new Delegate(context.projectId, context.sourceDir, runtime)); +} + +class Delegate implements runtimes.RuntimeDelegate { + public readonly name = "python"; + constructor( + private readonly projectId: string, + private readonly sourceDir: string, + public readonly runtime: runtimes.Runtime + ) {} + + private _bin = ""; + private _modulesDir = ""; + + get bin(): string { + if (this._bin === "") { + this._bin = this.getPythonBinary(); + } + return this._bin; + } + + async modulesDir(): Promise { + if (!this._modulesDir) { + const child = runWithVirtualEnv( + [ + this.bin, + "-c", + '"import firebase_functions; import os; print(os.path.dirname(firebase_functions.__file__))"', + ], + this.sourceDir, + {} + ); + let out = ""; + child.stdout?.on("data", (chunk: Buffer) => { + const chunkString = chunk.toString(); + out = out + chunkString; + logger.debug(`stdout: ${chunkString}`); + }); + await new Promise((resolve, reject) => { + child.on("exit", resolve); + child.on("error", reject); + }); + this._modulesDir = out.trim(); + } + return this._modulesDir; + } + + getPythonBinary(): string { + if (process.platform === "win32") { + // There is no easy way to get specific version of python executable in Windows. + return "python.exe"; + } + if (this.runtime === "python310") { + return "python3.10"; + } else if (this.runtime === "python311") { + return "python3.11"; + } + return "python"; + } + + validate(): Promise { + // TODO: make sure firebase-functions is included as a dep + return Promise.resolve(); + } + + // Watch isn't supported for Python. + watch(): Promise<() => Promise> { + return Promise.resolve(() => Promise.resolve()); + } + + async build(): Promise { + // No-op. + } + + async serveAdmin(port: number, envs: backend.EnvironmentVariables): Promise<() => Promise> { + const modulesDir = await this.modulesDir(); + const envWithAdminPort = { + ...envs, + ADMIN_PORT: port.toString(), + }; + const args = [this.bin, path.join(modulesDir, "private", "serving.py")]; + logger.debug( + `Running admin server with args: ${JSON.stringify(args)} and env: ${JSON.stringify( + envWithAdminPort + )} in ${this.sourceDir}` + ); + const childProcess = runWithVirtualEnv(args, this.sourceDir, envWithAdminPort); + return Promise.resolve(async () => { + await fetch(`http://127.0.0.1:${port}/__/quitquitquit`); + const quitTimeout = setTimeout(() => { + if (!childProcess.killed) { + childProcess.kill("SIGKILL"); + } + }, 10_000); + clearTimeout(quitTimeout); + }); + } + + async discoverBuild( + _configValues: backend.RuntimeConfigValues, + envs: backend.EnvironmentVariables + ): Promise { + let discovered = await discovery.detectFromYaml(this.sourceDir, this.projectId, this.runtime); + if (!discovered) { + const adminPort = await portfinder.getPortPromise({ + port: 8081, + }); + const killProcess = await this.serveAdmin(adminPort, envs); + try { + discovered = await discovery.detectFromPort(adminPort, this.projectId, this.runtime); + } finally { + await killProcess(); + } + } + return discovered; + } +} diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 2411374e52f..b2bb0a91ee4 100644 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -17,7 +17,6 @@ import { } from "./types"; import { Constants, FIND_AVAILBLE_PORT_BY_DEFAULT } from "./constants"; import { EmulatableBackend, FunctionsEmulator } from "./functionsEmulator"; -import { parseRuntimeVersion } from "./functionsEmulatorUtils"; import { AuthEmulator, SingleProjectMode } from "./auth"; import { DatabaseEmulator, DatabaseEmulatorArgs } from "./databaseEmulator"; import { FirestoreEmulator, FirestoreEmulatorArgs } from "./firestoreEmulator"; @@ -476,8 +475,10 @@ 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; emulatableBackends.push({ functionsDir, + runtime, codebase: cfg.codebase, env: { ...options.extDevEnv, @@ -486,7 +487,6 @@ export async function startAll( // TODO(b/213335255): predefinedTriggers and nodeMajorVersion are here to support ext:dev:emulators:* commands. // Ideally, we should handle that case via ExtensionEmulator. predefinedTriggers: options.extDevTriggers as ParsedTriggerDefinition[] | undefined, - nodeMajorVersion: parseRuntimeVersion((options.extDevNodeVersion as string) || cfg.runtime), }); } } diff --git a/src/emulator/extensionsEmulator.ts b/src/emulator/extensionsEmulator.ts index d62a1c53ec0..4aeb6e7b0c6 100644 --- a/src/emulator/extensionsEmulator.ts +++ b/src/emulator/extensionsEmulator.ts @@ -230,15 +230,16 @@ export class ExtensionsEmulator implements EmulatorInstance { const functionsDir = path.join(extensionDir, "functions"); // TODO(b/213335255): For local extensions, this should include extensionSpec instead of extensionVersion const env = Object.assign(this.autoPopulatedParams(instance), instance.params); - const { extensionTriggers, nodeMajorVersion, nonSecretEnv, secretEnvVariables } = + const { extensionTriggers, runtime, nonSecretEnv, secretEnvVariables } = await getExtensionFunctionInfo(instance, env); const emulatableBackend: EmulatableBackend = { functionsDir, + runtime, + bin: process.execPath, env: nonSecretEnv, codebase: instance.instanceId, // Give each extension its own codebase name so that they don't share workerPools. secretEnv: secretEnvVariables, predefinedTriggers: extensionTriggers, - nodeMajorVersion: nodeMajorVersion, extensionInstanceId: instance.instanceId, }; if (instance.ref) { diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 984a94955cc..85d1ee3b964 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -5,6 +5,7 @@ import * as clc from "colorette"; import * as http from "http"; import * as jwt from "jsonwebtoken"; import * as cors from "cors"; +import * as semver from "semver"; import { URL } from "url"; import { EventEmitter } from "events"; @@ -14,6 +15,7 @@ import { track, trackEmulator } from "../track"; import { Constants } from "./constants"; import { EmulatorInfo, EmulatorInstance, Emulators, FunctionsExecutionMode } from "./types"; import * as chokidar from "chokidar"; +import * as portfinder from "portfinder"; import * as spawn from "cross-spawn"; import { ChildProcess } from "child_process"; @@ -59,6 +61,7 @@ import { AUTH_BLOCKING_EVENTS, BEFORE_CREATE_EVENT } from "../functions/events/v import { BlockingFunctionsConfig } from "../gcp/identityPlatform"; import { resolveBackend } from "../deploy/functions/build"; import { setEnvVarsForEmulators } from "./env"; +import { runWithVirtualEnv } from "../functions/python"; const EVENT_INVOKE = "functions:invoke"; // event name for UA const EVENT_INVOKE_GA4 = "functions_invoke"; // event name GA4 (alphanumertic) @@ -85,8 +88,8 @@ export interface EmulatableBackend { secretEnv: backend.SecretEnvVar[]; codebase: string; predefinedTriggers?: ParsedTriggerDefinition[]; - nodeMajorVersion?: number; - nodeBinary?: string; + runtime?: string; + bin?: string; extensionInstanceId?: string; extension?: Extension; // Only present for published extensions extensionVersion?: ExtensionVersion; // Only present for published extensions @@ -121,15 +124,41 @@ export interface FunctionsEmulatorArgs { projectAlias?: string; } -// FunctionsRuntimeInstance is the handler for a running function invocation +/** + * IPC connection info of a Function Runtime. + */ +export class IPCConn { + constructor(readonly socketPath: string) {} + + httpReqOpts(): http.RequestOptions { + return { + socketPath: this.socketPath, + }; + } +} + +/** + * TCP/IP connection info of a Function Runtime. + */ +export class TCPConn { + constructor(readonly host: string, readonly port: number) {} + + httpReqOpts(): http.RequestOptions { + return { + host: this.host, + port: this.port, + }; + } +} + export interface FunctionsRuntimeInstance { process: ChildProcess; // An emitter which sends our EmulatorLog events from the runtime. events: EventEmitter; // A cwd of the process cwd: string; - // Path to socket file used for HTTP-over-IPC comms. - socketPath: string; + // Communication info for the runtime + conn: IPCConn | TCPConn; } export interface InvokeRuntimeOpts { @@ -336,7 +365,12 @@ export class FunctionsEmulator implements EmulatorInstance { const record = this.getTriggerRecordByKey(this.getTriggerKey(trigger)); const pool = this.workerPools[record.backend.codebase]; if (!pool.readyForWork(trigger.id)) { - await this.startRuntime(record.backend, trigger); + try { + await this.startRuntime(record.backend, trigger); + } catch (e: any) { + this.logger.logLabeled("ERROR", `Failed to start runtime for ${trigger.id}: ${e}`); + return; + } } const worker = pool.getIdleWorker(trigger.id)!; const reqBody = JSON.stringify(body); @@ -347,8 +381,8 @@ export class FunctionsEmulator implements EmulatorInstance { return new Promise((resolve, reject) => { const req = http.request( { + ...worker.runtime.conn.httpReqOpts(), path: `/`, - socketPath: worker.runtime.socketPath, headers: headers, }, resolve @@ -360,9 +394,6 @@ export class FunctionsEmulator implements EmulatorInstance { } async start(): Promise { - for (const backend of this.args.emulatableBackends) { - backend.nodeBinary = this.getNodeBinary(backend); - } const credentialEnv = await this.getCredentialsEnvironment(); for (const e of this.args.emulatableBackends) { e.env = { ...credentialEnv, ...e.env }; @@ -452,16 +483,18 @@ export class FunctionsEmulator implements EmulatorInstance { projectId: this.args.projectId, projectDir: this.args.projectDir, sourceDir: emulatableBackend.functionsDir, + runtime: emulatableBackend.runtime, }; - if (emulatableBackend.nodeMajorVersion) { - runtimeDelegateContext.runtime = `nodejs${emulatableBackend.nodeMajorVersion}`; - } const runtimeDelegate = await runtimes.getRuntimeDelegate(runtimeDelegateContext); logger.debug(`Validating ${runtimeDelegate.name} source`); await runtimeDelegate.validate(); logger.debug(`Building ${runtimeDelegate.name} source`); await runtimeDelegate.build(); + // Retrieve information from the runtime delegate. + emulatableBackend.runtime = runtimeDelegate.runtime; + emulatableBackend.bin = runtimeDelegate.bin; + // Don't include user envs when parsing triggers. Do include user envs when resolving parameter values const firebaseConfig = this.getFirebaseConfig(); const environment = { @@ -499,12 +532,6 @@ export class FunctionsEmulator implements EmulatorInstance { * TODO(b/216167890): Gracefully handle removal of deleted function definitions */ async loadTriggers(emulatableBackend: EmulatableBackend, force = false): Promise { - if (!emulatableBackend.nodeBinary) { - throw new FirebaseError( - `No node binary for ${emulatableBackend.functionsDir}. This should never happen.` - ); - } - let triggerDefinitions: EmulatedTriggerDefinition[] = []; try { triggerDefinitions = await this.discoverTriggers(emulatableBackend); @@ -674,7 +701,14 @@ export class FunctionsEmulator implements EmulatorInstance { {} ) ); - await this.startRuntime(emulatableBackend); + try { + await this.startRuntime(emulatableBackend); + } catch (e: any) { + this.logger.logLabeled( + "ERROR", + `Failed to start functions in ${emulatableBackend.functionsDir}: ${e}` + ); + } } } @@ -1029,72 +1063,6 @@ export class FunctionsEmulator implements EmulatorInstance { triggers.forEach((def) => this.addTriggerRecord(def, { backend, ignored: false })); } - getNodeBinary(backend: EmulatableBackend): string { - const pkg = require(path.join(backend.functionsDir, "package.json")); - // If the developer hasn't specified a Node to use, inform them that it's an option and use default - if ((!pkg.engines || !pkg.engines.node) && !backend.nodeMajorVersion) { - this.logger.log( - "WARN", - `Your functions directory ${backend.functionsDir} does not specify a Node version.\n ` + - "- Learn more at https://firebase.google.com/docs/functions/manage-functions#set_runtime_options" - ); - return process.execPath; - } - - const hostMajorVersion = process.versions.node.split(".")[0]; - const requestedMajorVersion: string = backend.nodeMajorVersion - ? `${backend.nodeMajorVersion}` - : pkg.engines.node; - let localMajorVersion = "0"; - const localNodePath = path.join(backend.functionsDir, "node_modules/.bin/node"); - - // Next check if we have a Node install in the node_modules folder - try { - const localNodeOutput = spawn.sync(localNodePath, ["--version"]).stdout.toString(); - localMajorVersion = localNodeOutput.slice(1).split(".")[0]; - } catch (err: any) { - // Will happen if we haven't asked about local version yet - } - - // If the requested version is already locally available, let's use that - if (requestedMajorVersion === localMajorVersion) { - this.logger.logLabeled( - "SUCCESS", - "functions", - `Using node@${requestedMajorVersion} from local cache.` - ); - return localNodePath; - } - - // If the requested version is the same as the host, let's use that - if (requestedMajorVersion === hostMajorVersion) { - this.logger.logLabeled( - "SUCCESS", - "functions", - `Using node@${requestedMajorVersion} from host.` - ); - } else { - // Otherwise we'll warn and use the version that is currently running this process. - if (process.env.FIREPIT_VERSION) { - this.logger.log( - "WARN", - `You've requested "node" version "${requestedMajorVersion}", but the standalone Firebase CLI comes with bundled Node "${hostMajorVersion}".` - ); - this.logger.log( - "INFO", - `To use a different Node.js version, consider removing the standalone Firebase CLI and switching to "firebase-tools" on npm.` - ); - } else { - this.logger.log( - "WARN", - `Your requested "node" version "${requestedMajorVersion}" doesn't match your global version "${hostMajorVersion}". Using node@${hostMajorVersion} from host.` - ); - } - } - - return process.execPath; - } - getRuntimeConfig(backend: EmulatableBackend): Record { const configPath = `${backend.functionsDir}/.runtimeconfig.json`; try { @@ -1268,18 +1236,18 @@ export class FunctionsEmulator implements EmulatorInstance { return secretEnvs; } - async startRuntime( + async startNode( backend: EmulatableBackend, - trigger?: EmulatedTriggerDefinition - ): Promise { - const emitter = new EventEmitter(); + envs: Record + ): Promise { const args = [path.join(__dirname, "functionsEmulatorRuntime")]; - if (this.args.debugPort) { - if (process.env.FIREPIT_VERSION && process.execPath === backend.nodeBinary) { + if (process.env.FIREPIT_VERSION) { this.logger.log( "WARN", - `To enable function inspection, please run "${process.execPath} is:npm i node@${backend.nodeMajorVersion} --save-dev" in your functions directory` + `To enable function inspection, please run "npm i node@${semver.coerce( + backend.runtime || "18.0.0" + )} --save-dev" in your functions directory` ); } else { const { host } = this.getInfo(); @@ -1301,28 +1269,78 @@ export class FunctionsEmulator implements EmulatorInstance { "See https://yarnpkg.com/getting-started/migration#step-by-step for more information." ); } - const runtimeEnv = this.getRuntimeEnvs(backend, trigger); - const secretEnvs = await this.resolveSecretEnvs(backend, trigger); - const socketPath = getTemporarySocketPath(); - const childProcess = spawn(backend.nodeBinary!, args, { + const bin = backend.bin; + if (!bin) { + throw new Error( + `No binary associated with ${backend.functionsDir}. ` + + "Make sure function runtime is configured correctly in firebase.json." + ); + } + + const socketPath = getTemporarySocketPath(); + const childProcess = spawn(bin, args, { cwd: backend.functionsDir, env: { - node: backend.nodeBinary, + node: backend.bin, ...process.env, - ...runtimeEnv, - ...secretEnvs, + ...envs, PORT: socketPath, }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); - const runtime: FunctionsRuntimeInstance = { + return Promise.resolve({ + process: childProcess, + events: new EventEmitter(), + cwd: backend.functionsDir, + conn: new IPCConn(socketPath), + }); + } + + async startPython( + backend: EmulatableBackend, + envs: Record + ): Promise { + const args = ["functions-framework"]; + + if (this.args.debugPort) { + this.logger.log("WARN", "--inspect-functions not supported for Python functions. Ignored."); + } + + // No support generic socket interface for Unix Domain Socket/Named Pipe in the python. + // Use TCP/IP stack instead. + const port = await portfinder.getPortPromise({ + port: 8081, + }); + const childProcess = runWithVirtualEnv(args, backend.functionsDir, { + ...process.env, + ...envs, + HOST: "127.0.0.1", + PORT: port.toString(), + }); + + return { process: childProcess, - events: emitter, + events: new EventEmitter(), cwd: backend.functionsDir, - socketPath, + conn: new TCPConn("127.0.0.1", port), }; + } + + async startRuntime( + backend: EmulatableBackend, + trigger?: EmulatedTriggerDefinition + ): Promise { + const runtimeEnv = this.getRuntimeEnvs(backend, trigger); + const secretEnvs = await this.resolveSecretEnvs(backend, trigger); + + let runtime; + if (backend.runtime!.startsWith("python")) { + runtime = await this.startPython(backend, { ...runtimeEnv, ...secretEnvs }); + } else { + runtime = await this.startNode(backend, { ...runtimeEnv, ...secretEnvs }); + } const extensionLogInfo = { instanceId: backend.extensionInstanceId, ref: backend.extensionVersion?.ref, @@ -1482,7 +1500,16 @@ export class FunctionsEmulator implements EmulatorInstance { const pool = this.workerPools[record.backend.codebase]; if (!pool.readyForWork(trigger.id)) { - await this.startRuntime(record.backend, trigger); + try { + await this.startRuntime(record.backend, trigger); + } catch (e: any) { + this.logger.logLabeled("ERROR", `Failed to handle request for function ${trigger.id}`); + this.logger.logLabeled( + "ERROR", + `Failed to start functions in ${record.backend.functionsDir}: ${e}` + ); + return; + } } const debugBundle = this.args.debugPort ? { diff --git a/src/emulator/functionsRuntimeWorker.ts b/src/emulator/functionsRuntimeWorker.ts index 3358ac68311..5365b7989d2 100644 --- a/src/emulator/functionsRuntimeWorker.ts +++ b/src/emulator/functionsRuntimeWorker.ts @@ -8,6 +8,7 @@ import { EventEmitter } from "events"; import { EmulatorLogger, ExtensionLogInfo } from "./emulatorLogger"; import { FirebaseError } from "../error"; import { Serializable } from "child_process"; +import { IncomingMessage } from "http"; type LogListener = (el: EmulatorLog) => any; @@ -118,12 +119,12 @@ export class RuntimeWorker { return new Promise((resolve) => { const proxy = http.request( { + ...this.runtime.conn.httpReqOpts(), method: req.method, path: req.path, headers: req.headers, - socketPath: this.runtime.socketPath, }, - (_resp) => { + (_resp: IncomingMessage) => { resp.writeHead(_resp.statusCode || 200, _resp.headers); const piped = _resp.pipe(resp); piped.on("finish", () => { @@ -178,20 +179,19 @@ export class RuntimeWorker { isSocketReady(): Promise { return new Promise((resolve, reject) => { - const req = http - .request( - { - method: "GET", - path: "/__/health", - socketPath: this.runtime.socketPath, - }, - () => { - // Set the worker state to IDLE for new work - this.readyForWork(); - resolve(); - } - ) - .end(); + const req = http.request( + { + ...this.runtime.conn.httpReqOpts(), + method: "GET", + path: "/__/health", + }, + () => { + // Set the worker state to IDLE for new work + this.readyForWork(); + resolve(); + } + ); + req.end(); req.on("error", (error) => { reject(error); }); diff --git a/src/extensions/emulator/optionsHelper.ts b/src/extensions/emulator/optionsHelper.ts index b5721991359..9d48aa6f42c 100644 --- a/src/extensions/emulator/optionsHelper.ts +++ b/src/extensions/emulator/optionsHelper.ts @@ -15,6 +15,9 @@ import { needProjectId } from "../../projectUtils"; import { Emulators } from "../../emulator/types"; import { SecretEnvVar } from "../../deploy/functions/backend"; +/** + * Build firebase options based on the extension configuration. + */ export async function buildOptions(options: any): Promise { const extDevDir = localHelper.findExtensionYaml(process.cwd()); options.extDevDir = extDevDir; @@ -37,16 +40,18 @@ export async function buildOptions(options: any): Promise { triggerHelper.functionResourceToEmulatedTriggerDefintion(r) ); options.extDevTriggers = functionEmuTriggerDefs; - options.extDevNodeVersion = specHelper.getNodeVersion(functionResources); + options.extDevRuntime = specHelper.getRuntime(functionResources); return options; } -// TODO: Better name? Also, should this be in extensionsEmulator instead? +/** + * TODO: Better name? Also, should this be in extensionsEmulator instead? + */ export async function getExtensionFunctionInfo( instance: planner.InstanceSpec, paramValues: Record ): Promise<{ - nodeMajorVersion: number; + runtime: string; extensionTriggers: ParsedTriggerDefinition[]; nonSecretEnv: Record; secretEnvVariables: SecretEnvVar[]; @@ -59,12 +64,12 @@ export async function getExtensionFunctionInfo( trigger.name = `ext-${instance.instanceId}-${trigger.name}`; return trigger; }); - const nodeMajorVersion = specHelper.getNodeVersion(functionResources); + const runtime = specHelper.getRuntime(functionResources); const nonSecretEnv = getNonSecretEnv(spec.params, paramValues); const secretEnvVariables = getSecretEnvVars(spec.params, paramValues); return { extensionTriggers, - nodeMajorVersion, + runtime, nonSecretEnv, secretEnvVariables, }; @@ -116,7 +121,9 @@ export function getSecretEnvVars( return secretEnvVar; } -// Exported for testing +/** + * Exported for testing + */ export function getParams(options: any, extensionSpec: ExtensionSpec) { const projectId = needProjectId(options); const userParams = paramHelper.readEnvFile(options.testParams); diff --git a/src/extensions/emulator/specHelper.ts b/src/extensions/emulator/specHelper.ts index 139a1c2c60e..5a6f63e6594 100644 --- a/src/extensions/emulator/specHelper.ts +++ b/src/extensions/emulator/specHelper.ts @@ -6,7 +6,6 @@ import { ExtensionSpec, Resource } from "../types"; import { FirebaseError } from "../../error"; import { substituteParams } from "../extensionsHelper"; import { getResourceRuntime } from "../utils"; -import { parseRuntimeVersion } from "../../emulator/functionsEmulatorUtils"; const SPEC_FILE = "extension.yaml"; const POSTINSTALL_FILE = "POSTINSTALL.md"; @@ -94,21 +93,28 @@ export function getFunctionProperties(resources: Resource[]) { return resources.map((r) => r.properties); } -export function getNodeVersion(resources: Resource[]): number { +const DEFAULT_RUNTIME = "nodejs14"; + +/** + * Get runtime associated with the resources. If conflicting, arbitrarily pick one. + */ +export function getRuntime(resources: Resource[]): string { + if (resources.length === 0) { + return DEFAULT_RUNTIME; + } + const invalidRuntimes: string[] = []; - const versions = resources.map((r: Resource) => { - if (getResourceRuntime(r)) { - const runtimeName = getResourceRuntime(r) as string; - const runtime = parseRuntimeVersion(runtimeName); - if (!runtime) { - invalidRuntimes.push(runtimeName); - } else { - return runtime; - } + const runtimes = resources.map((r: Resource) => { + const runtime = getResourceRuntime(r); + if (!runtime) { + return DEFAULT_RUNTIME; } - return 14; + if (!/(nodejs)?([0-9]+)/.test(runtime)) { + invalidRuntimes.push(runtime); + return DEFAULT_RUNTIME; + } + return runtime; }); - if (invalidRuntimes.length) { throw new FirebaseError( `The following runtimes are not supported by the Emulator Suite: ${invalidRuntimes.join( @@ -116,5 +122,5 @@ export function getNodeVersion(resources: Resource[]): number { )}. \n Only Node runtimes are supported.` ); } - return Math.max(...versions); + return runtimes[0]; } diff --git a/src/functions/python.ts b/src/functions/python.ts new file mode 100644 index 00000000000..372faf535c8 --- /dev/null +++ b/src/functions/python.ts @@ -0,0 +1,32 @@ +import * as path from "path"; +import * as spawn from "cross-spawn"; +import * as cp from "child_process"; +import { logger } from "../logger"; + +const DEFAULT_VENV_DIR = "venv"; + +/** + * Spawn a process inside the Python virtual environment if found. + */ +export function runWithVirtualEnv( + commandAndArgs: string[], + cwd: string, + envs: Record, + venvDir = DEFAULT_VENV_DIR +): cp.ChildProcess { + const activateScriptPath = + process.platform === "win32" ? ["Scripts", "activate.bat"] : ["bin", "activate"]; + const venvActivate = path.join(cwd, venvDir, ...activateScriptPath); + const command = process.platform === "win32" ? venvActivate : "source"; + const args = [process.platform === "win32" ? "" : venvActivate, "&&", ...commandAndArgs]; + logger.debug(`Running command with virtualenv: command=${command}, args=${JSON.stringify(args)}`); + + return spawn(command, args, { + shell: true, + cwd, + stdio: [/* stdin= */ "ignore", /* stdout= */ "pipe", /* stderr= */ "inherit"], + // Linting disabled since internal types expect NODE_ENV which does not apply to Python runtimes. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + env: envs as any, + }); +} diff --git a/src/init/features/functions/golang.ts b/src/init/features/functions/golang.ts deleted file mode 100644 index 18a8b92ee04..00000000000 --- a/src/init/features/functions/golang.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { promisify } from "util"; -import * as fs from "fs"; -import * as path from "path"; -import * as spawn from "cross-spawn"; -import * as clc from "colorette"; - -import { FirebaseError } from "../../../error"; -import { Config } from "../../../config"; -import { promptOnce } from "../../../prompt"; -import * as utils from "../../../utils"; -import * as go from "../../../deploy/functions/runtimes/golang"; -import { logger } from "../../../logger"; - -const RUNTIME_VERSION = "1.13"; - -const TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/golang"); -const MAIN_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "functions.go"), "utf8"); -const GITIGNORE_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "_gitignore"), "utf8"); - -async function init(setup: unknown, config: Config) { - await writeModFile(config); - - const modName = config.get("functions.go.module") as string; - const [pkg] = modName.split("/").slice(-1); - await config.askWriteProjectFile("functions/functions.go", MAIN_TEMPLATE.replace("PACKAGE", pkg)); - await config.askWriteProjectFile("functions/.gitignore", GITIGNORE_TEMPLATE); -} - -// writeModFile is meant to look like askWriteProjectFile but it generates the contents -// dynamically using the go tool -async function writeModFile(config: Config) { - const modPath = config.path("functions/go.mod"); - if (await promisify(fs.exists)(modPath)) { - const shoudlWriteModFile = await promptOnce({ - type: "confirm", - message: "File " + clc.underline("functions/go.mod") + " already exists. Overwrite?", - default: false, - }); - if (!shoudlWriteModFile) { - return; - } - - // Go will refuse to overwrite an existing mod file. - await promisify(fs.unlink)(modPath); - } - - // Nit(inlined) can we look at functions code and see if there's a domain mapping? - const modName = await promptOnce({ - type: "input", - message: "What would you like to name your module?", - default: "acme.com/functions", - }); - config.set("functions.go.module", modName); - - // Manually create a go mod file because (A) it's easier this way and (B) it seems to be the only - // way to set the min Go version to anything but what the user has installed. - config.writeProjectFile("functions/go.mod", `module ${modName} \n\ngo ${RUNTIME_VERSION}\n\n`); - utils.logSuccess("Wrote " + clc.bold("functions/go.mod")); - - for (const dep of [go.FUNCTIONS_SDK, go.ADMIN_SDK, go.FUNCTIONS_CODEGEN, go.FUNCTIONS_RUNTIME]) { - const result = spawn.sync("go", ["get", dep], { - cwd: config.path("functions"), - stdio: "inherit", - }); - if (result.error) { - logger.debug("Full output from go get command:", JSON.stringify(result, null, 2)); - throw new FirebaseError("Error installing dependencies", { children: [result.error] }); - } - } - utils.logSuccess("Installed dependencies"); -} - -module.exports = init; diff --git a/src/init/features/functions/index.ts b/src/init/features/functions/index.ts index f80875a5e67..1740104e467 100644 --- a/src/init/features/functions/index.ts +++ b/src/init/features/functions/index.ts @@ -13,6 +13,7 @@ import { assertUnique, } from "../../../functions/projectConfig"; import { FirebaseError } from "../../../error"; +import { isEnabled } from "../../../experiments"; const MAX_ATTEMPTS = 5; @@ -167,6 +168,12 @@ async function languageSetup(setup: any, config: Config): Promise { value: "typescript", }, ]; + if (isEnabled("pythonfunctions")) { + choices.push({ + name: "Python", + value: "python", + }); + } const language = await promptOnce({ type: "list", message: "What language would you like to use to write Cloud Functions?", diff --git a/src/init/features/functions/python.ts b/src/init/features/functions/python.ts new file mode 100644 index 00000000000..42f0a3eead9 --- /dev/null +++ b/src/init/features/functions/python.ts @@ -0,0 +1,63 @@ +import * as fs from "fs"; +import * as path from "path"; + +import { Config } from "../../../config"; +import { LATEST_VERSION } from "../../../deploy/functions/runtimes/python"; +import { runWithVirtualEnv } from "../../../functions/python"; + +const TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/python"); +const MAIN_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "main.py"), "utf8"); +const REQUIREMENTS_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "requirements.txt"), "utf8"); +const GITIGNORE_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "_gitignore"), "utf8"); + +/** + * Create a Python Firebase Functions project. + */ +export async function setup(_setup: unknown, config: Config): Promise { + await config.askWriteProjectFile( + `${Config.DEFAULT_FUNCTIONS_SOURCE}/requirements.txt`, + REQUIREMENTS_TEMPLATE + ); + await config.askWriteProjectFile( + `${Config.DEFAULT_FUNCTIONS_SOURCE}/.gitignore`, + GITIGNORE_TEMPLATE + ); + await config.askWriteProjectFile(`${Config.DEFAULT_FUNCTIONS_SOURCE}/main.py`, MAIN_TEMPLATE); + + // Write the latest supported runtime version to the config. + config.set("functions.runtime", LATEST_VERSION); + // Add python specific ignores to config. + config.set("functions.ignore", ["venv", "__pycache__"]); + // Setup VENV. + const venvProcess = runWithVirtualEnv( + ["python3.10", "-m", "venv", "venv"], + config.path(Config.DEFAULT_FUNCTIONS_SOURCE), + {} + ); + await new Promise((resolve, reject) => { + venvProcess.on("exit", resolve); + venvProcess.on("error", reject); + }); + + // Update pip to support dependencies like pyyaml. + const upgradeProcess = runWithVirtualEnv( + ["pip3", "install", "--upgrade", "pip"], + config.path(Config.DEFAULT_FUNCTIONS_SOURCE), + {} + ); + await new Promise((resolve, reject) => { + upgradeProcess.on("exit", resolve); + upgradeProcess.on("error", reject); + }); + + // Install dependencies. + const installProcess = runWithVirtualEnv( + ["python3.10", "-m", "pip", "install", "-r", "requirements.txt"], + config.path(Config.DEFAULT_FUNCTIONS_SOURCE), + {} + ); + await new Promise((resolve, reject) => { + installProcess.on("exit", resolve); + installProcess.on("error", reject); + }); +} diff --git a/src/serve/functions.ts b/src/serve/functions.ts index 6424eed384c..921ee9f4c06 100644 --- a/src/serve/functions.ts +++ b/src/serve/functions.ts @@ -4,7 +4,6 @@ import { FunctionsEmulator, FunctionsEmulatorArgs, } from "../emulator/functionsEmulator"; -import { parseRuntimeVersion } from "../emulator/functionsEmulatorUtils"; import { needProjectId } from "../projectUtils"; import { getProjectDefaultAccount } from "../auth"; import { Options } from "../options"; @@ -30,11 +29,10 @@ export class FunctionsServer { const backends: EmulatableBackend[] = []; for (const cfg of config) { const functionsDir = path.join(options.config.projectDir, cfg.source); - const nodeMajorVersion = parseRuntimeVersion(cfg.runtime); backends.push({ functionsDir, codebase: cfg.codebase, - nodeMajorVersion, + runtime: cfg.runtime, env: {}, secretEnv: [], }); diff --git a/src/test/deploy/functions/runtimes/discovery/index.spec.ts b/src/test/deploy/functions/runtimes/discovery/index.spec.ts index 266cce9997a..0a6f07b3f62 100644 --- a/src/test/deploy/functions/runtimes/discovery/index.spec.ts +++ b/src/test/deploy/functions/runtimes/discovery/index.spec.ts @@ -96,12 +96,12 @@ describe("detectFromPort", () => { }); it("passes as smoke test", async () => { - nock("http://localhost:8080").get("/__/functions.yaml").times(5).replyWithError({ + nock("http://127.0.0.1:8080").get("/__/functions.yaml").times(5).replyWithError({ message: "Still booting", code: "ECONNREFUSED", }); - nock("http://localhost:8080").get("/__/functions.yaml").reply(200, YAML_TEXT); + nock("http://127.0.0.1:8080").get("/__/functions.yaml").reply(200, YAML_TEXT); const parsed = await discovery.detectFromPort(8080, "project", "nodejs16"); expect(parsed).to.deep.equal(BUILD); diff --git a/src/test/deploy/functions/runtimes/golang/gomod.spec.ts b/src/test/deploy/functions/runtimes/golang/gomod.spec.ts deleted file mode 100644 index e2e61d62e36..00000000000 --- a/src/test/deploy/functions/runtimes/golang/gomod.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect } from "chai"; -import * as gomod from "../../../../../deploy/functions/runtimes/golang/gomod"; -import * as go from "../../../../../deploy/functions/runtimes/golang"; - -const MOD_NAME = "acme.com/fucntions"; -const GO_VERSION = "1.13"; -const FUNCTIONS_MOD = "firebase.google.com/firebase-functions-go"; -const MIN_MODULE = `module ${MOD_NAME} - -go ${GO_VERSION} -`; - -const INLINE_MODULE = `${MIN_MODULE} -require ${go.ADMIN_SDK} v4.6.0 // indirect - -replace ${FUNCTIONS_MOD} => ${go.FUNCTIONS_SDK} -`; - -const BLOCK_MODULE = `${MIN_MODULE} - -require ( - ${go.ADMIN_SDK} v4.6.0 // indirect -) - -replace ( - ${FUNCTIONS_MOD} => ${go.FUNCTIONS_SDK} -) -`; - -describe("Modules", () => { - it("Should parse a bare minimum module", () => { - const mod = gomod.parseModule(MIN_MODULE); - expect(mod.module).to.equal(MOD_NAME); - expect(mod.version).to.equal(GO_VERSION); - }); - - it("Should parse inline statements", () => { - const mod = gomod.parseModule(INLINE_MODULE); - expect(mod.module).to.equal(MOD_NAME); - expect(mod.version).to.equal(GO_VERSION); - expect(mod.dependencies).to.deep.equal({ - [go.ADMIN_SDK]: "v4.6.0", - }); - expect(mod.replaces).to.deep.equal({ - [FUNCTIONS_MOD]: go.FUNCTIONS_SDK, - }); - }); - - it("Should parse block statements", () => { - const mod = gomod.parseModule(BLOCK_MODULE); - expect(mod.module).to.equal(MOD_NAME); - expect(mod.version).to.equal(GO_VERSION); - expect(mod.dependencies).to.deep.equal({ - [go.ADMIN_SDK]: "v4.6.0", - }); - expect(mod.replaces).to.deep.equal({ - [FUNCTIONS_MOD]: go.FUNCTIONS_SDK, - }); - }); -}); diff --git a/src/test/emulators/extensionsEmulator.spec.ts b/src/test/emulators/extensionsEmulator.spec.ts index 93093146ea8..2b6db2488f3 100644 --- a/src/test/emulators/extensionsEmulator.spec.ts +++ b/src/test/emulators/extensionsEmulator.spec.ts @@ -109,7 +109,7 @@ describe("Extensions Emulator", () => { // so test also runs on win machines // eslint-disable-next-line prettier/prettier functionsDir: join("src/test/emulators/extensions/firebase/storage-resize-images@0.1.18/functions"), - nodeMajorVersion: 10, + runtime: "nodejs10", predefinedTriggers: [ { entryPoint: "generateResizedImage", @@ -140,6 +140,8 @@ describe("Extensions Emulator", () => { }); const result = await e.toEmulatableBackend(testCase.input); + // ignore result.bin, as it is platform dependent + delete result.bin; expect(result).to.deep.equal(testCase.expected); }); } diff --git a/src/test/emulators/functionsRuntimeWorker.spec.ts b/src/test/emulators/functionsRuntimeWorker.spec.ts index d0cb0ca557d..00bc983930c 100644 --- a/src/test/emulators/functionsRuntimeWorker.spec.ts +++ b/src/test/emulators/functionsRuntimeWorker.spec.ts @@ -1,7 +1,7 @@ import * as httpMocks from "node-mocks-http"; import * as nock from "nock"; import { expect } from "chai"; -import { FunctionsRuntimeInstance } from "../../emulator/functionsEmulator"; +import { FunctionsRuntimeInstance, IPCConn } from "../../emulator/functionsEmulator"; import { EventEmitter } from "events"; import { RuntimeWorker, @@ -21,7 +21,7 @@ class MockRuntimeInstance implements FunctionsRuntimeInstance { events: EventEmitter = new EventEmitter(); exit: Promise; cwd = "/home/users/dir"; - socketPath = "/path/to/socket/foo.sock"; + conn = new IPCConn("/path/to/socket/foo.sock"); constructor() { this.exit = new Promise((resolve) => { diff --git a/templates/init/functions/golang/_gitignore b/templates/init/functions/golang/_gitignore deleted file mode 100644 index f2dd9554a12..00000000000 --- a/templates/init/functions/golang/_gitignore +++ /dev/null @@ -1,12 +0,0 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out diff --git a/templates/init/functions/golang/functions.go b/templates/init/functions/golang/functions.go deleted file mode 100644 index b8795b7a4d3..00000000000 --- a/templates/init/functions/golang/functions.go +++ /dev/null @@ -1,38 +0,0 @@ -package PACKAGE - -// Welcome to Cloud Functions for Firebase for Golang! -// To get started, uncomment the below code or create your own. -// Deploy with `firebase deploy` - -/* -import ( - "context" - "fmt" - - "github.com/FirebaseExtended/firebase-functions-go/https" - "github.com/FirebaseExtended/firebase-functions-go/pubsub" - "github.com/FirebaseExtended/firebase-functions-go/runwith" -) - -var HelloWorld = https.Function{ - RunWith: https.Options{ - AvailableMemoryMB: 256, - }, - Callback: func(w https.ResponseWriter, req *https.Request) { - fmt.Println("Hello, world!") - fmt.Fprintf(w, "Hello, world!\n") - }, -} - -var PubSubFunction = pubsub.Function{ - EventType: pubsub.MessagePublished, - Topic: "topic", - RunWith: runwith.Options{ - AvailableMemoryMB: 256, - }, - Callback: func(ctx context.Context, message pubsub.Message) error { - fmt.Printf("Got Pub/Sub event %+v", message) - return nil - }, -} -*/ diff --git a/templates/init/functions/python/_gitignore b/templates/init/functions/python/_gitignore new file mode 100644 index 00000000000..34cef6b829a --- /dev/null +++ b/templates/init/functions/python/_gitignore @@ -0,0 +1,13 @@ +# pyenv +.python-version + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Environments +.env +.venv +venv/ +venv.bak/ +__pycache__ diff --git a/templates/init/functions/python/main.py b/templates/init/functions/python/main.py new file mode 100644 index 00000000000..43bedd6f259 --- /dev/null +++ b/templates/init/functions/python/main.py @@ -0,0 +1,18 @@ +# Welcome to Cloud Functions for Firebase for Python! +# To get started, simply uncomment the below code or create your own. +# Deploy with `firebase deploy` + +# from firebase_functions import https +# from firebase_admin import initialize_app + +# initialize_app() + + +# @https.on_request() +# def on_request_example(req: https.Request) -> https.Response: +# return https.Response("Hello world!") + + +# @https.on_call() +# def on_call_example(req: https.CallableRequest): +# return "Hello world!" diff --git a/templates/init/functions/python/requirements.txt b/templates/init/functions/python/requirements.txt new file mode 100644 index 00000000000..c244ae08a8f --- /dev/null +++ b/templates/init/functions/python/requirements.txt @@ -0,0 +1,2 @@ +# firebase-functions-python +git+https://github.com/firebase/firebase-functions-python.git@main#egg=firebase-functions