diff --git a/src/config.ts b/src/config.ts index 1f998d16228..afef38ecd3c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,7 +79,7 @@ export class Config { if ( this.projectDir && !this.get("functions.source") && - fsutils.fileExistsSync(this.path("functions/package.json")) + fsutils.dirExistsSync(this.path("functions")) ) { this.set("functions.source", Config.DEFAULT_FUNCTIONS_SOURCE); } diff --git a/src/deploy/functions/runtimes/golang/index.ts b/src/deploy/functions/runtimes/golang/index.ts new file mode 100644 index 00000000000..ac310f1b349 --- /dev/null +++ b/src/deploy/functions/runtimes/golang/index.ts @@ -0,0 +1,168 @@ +import { promisify } from "util"; +import * as path from "path"; +import * as fs from "fs"; +import * as spawn from "cross-spawn"; + +import { FirebaseError } from "../../../../error"; +import { Options } from "../../../../options"; +import { logger } from "../../../../logger"; +import * as args from "../../args"; +import * as backend from "../../backend"; +import * as getProjectId from "../../../../getProjectId"; +import * as runtimes from ".."; + +export const ADMIN_SDK = "firebase.google.com/go/v4"; +export const FUNCTIONS_SDK = "github.com/FirebaseExtended/firebase-functions-go"; + +const VERSION_TO_RUNTIME: Record = { + "1.13": "go113", +}; + +export async function tryCreateDelegate( + context: args.Context, + options: Options +): Promise { + const relativeSourceDir = options.config.get("functions.source") as string; + const sourceDir = options.config.path(relativeSourceDir); + const goModPath = path.join(sourceDir, "go.mod"); + const projectId = getProjectId(options); + + let module: Module; + try { + const modBuffer = await promisify(fs.readFile)(goModPath); + module = parseModule(modBuffer.toString("utf8")); + } catch (err) { + logger.debug("Customer code is not Golang code (or they aren't using modules)"); + return; + } + + let runtime = options.config.get("functions.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(projectId, sourceDir, runtime, module); +} + +// 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 +interface Module { + module: string; + version: string; + dependencies: Record; +} + +export function parseModule(mod: string): Module { + const module: Module = { + module: "", + version: "", + dependencies: {}, + }; + const lines = mod.split("\n"); + let inRequire = false; + for (const line of lines) { + if (inRequire) { + const endRequireMatch = /\)/.exec(line); + if (endRequireMatch) { + inRequire = false; + continue; + } + + const requireMatch = /([^ ]+) (.*)/.exec(line); + if (requireMatch) { + module.dependencies[requireMatch[1]] = requireMatch[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 requireBlockMatch = /^require +\(/.exec(line); + if (requireBlockMatch) { + inRequire = true; + continue; + } + + if (line.trim()) { + logger.debug("Don't know how to handle line", line, "in mod.go"); + } + } + + if (!module.module) { + throw new FirebaseError("Module has no name"); + } + if (!module.version) { + throw new FirebaseError(`Module ${module.module} has no go version`); + } + + return 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: Module + ) {} + validate(): Promise { + throw new FirebaseError("Cannot yet analyze Go source code"); + } + + build(): Promise { + const res = spawn.sync("go", ["build"], { + cwd: this.sourceDir, + stdio: "inherit", + }); + if (res.error) { + logger.debug("Got error running go build", res); + throw new FirebaseError("Failed to build functions source", { children: [res.error] }); + } + + return Promise.resolve(); + } + + watch(): Promise<() => Promise> { + return Promise.resolve(() => Promise.resolve()); + } + + discoverSpec( + configValues: backend.RuntimeConfigValues, + envs: backend.EnvironmentVariables + ): Promise { + throw new FirebaseError("Cannot yet discover function specs"); + } +} diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index 1c8523ab5d3..28b62647be0 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -1,13 +1,18 @@ import { Options } from "../../../options"; import * as backend from "../backend"; import * as args from "../args"; +import * as golang from "./golang"; import * as node from "./node"; import * as validate from "../validate"; import { FirebaseError } from "../../../error"; /** Supported runtimes for new Cloud Functions. */ const RUNTIMES: string[] = ["nodejs10", "nodejs12", "nodejs14"]; -export type Runtime = typeof RUNTIMES[number]; +// 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"]; +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"]; @@ -20,7 +25,7 @@ export function isDeprecatedRuntime(runtime: string): runtime is DeprecatedRunti /** Type deduction helper for a runtime string. */ export function isValidRuntime(runtime: string): runtime is Runtime { - return RUNTIMES.includes(runtime); + return RUNTIMES.includes(runtime) || EXPERIMENTAL_RUNTIMES.includes(runtime); } const MESSAGE_FRIENDLY_RUNTIMES: Record = { @@ -29,6 +34,7 @@ const MESSAGE_FRIENDLY_RUNTIMES: Record = { nodejs10: "Node.js 10", nodejs12: "Node.js 12", nodejs14: "Node.js 14", + go113: "Go 1.13", }; /** @@ -94,7 +100,7 @@ export interface RuntimeDelegate { } type Factory = (context: args.Context, options: Options) => Promise; -const factories: Factory[] = [node.tryCreateDelegate]; +const factories: Factory[] = [node.tryCreateDelegate, golang.tryCreateDelegate]; export async function getRuntimeDelegate( context: args.Context, @@ -108,6 +114,13 @@ export async function getRuntimeDelegate( } validate.functionsDirectoryExists(options, sourceDirName); + // 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 + const runtime = options.config.get("functions.runtime"); + if (runtime && !isValidRuntime(runtime)) { + throw new FirebaseError("Cannot deploy function with runtime " + runtime); + } + for (const factory of factories) { const delegate = await factory(context, options); if (delegate) { diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index f40d5d7da65..941c450b2db 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -18,8 +18,8 @@ export async function tryCreateDelegate( context: args.Context, options: Options ): Promise { - const sourceDirName = options.config.get("functions.source") as string; - const sourceDir = options.config.path(sourceDirName); + const projectRelativeSourceDir = options.config.get("functions.source") as string; + const sourceDir = options.config.path(projectRelativeSourceDir); const packageJsonPath = path.join(sourceDir, "package.json"); if (!(await promisify(fs.exists)(packageJsonPath))) { @@ -40,13 +40,7 @@ export async function tryCreateDelegate( throw new FirebaseError(`Unexpected runtime ${runtime}`); } - return new Delegate( - getProjectId(options), - options.config.projectDir, - sourceDirName, - sourceDir, - runtime - ); + return new Delegate(getProjectId(options), options.config.projectDir, sourceDir, runtime); } // TODO(inlined): Consider moving contents in parseRuntimeAndValidateSDK and validate around. @@ -59,7 +53,6 @@ export class Delegate { constructor( private readonly projectId: string, private readonly projectDir: string, - private readonly sourceDirName: string, private readonly sourceDir: string, public readonly runtime: runtimes.Runtime ) {} @@ -78,7 +71,8 @@ export class Delegate { validate(): Promise { versioning.checkFunctionsSDKVersion(this.sdkVersion); - validate.packageJsonIsValid(this.sourceDirName, this.sourceDir, this.projectDir); + const relativeDir = path.relative(this.projectDir, this.sourceDir); + validate.packageJsonIsValid(relativeDir, this.sourceDir, this.projectDir); return Promise.resolve(); } diff --git a/src/init/features/functions/golang.ts b/src/init/features/functions/golang.ts new file mode 100644 index 00000000000..f2e680bc741 --- /dev/null +++ b/src/init/features/functions/golang.ts @@ -0,0 +1,74 @@ +import { promisify } from "util"; +import * as fs from "fs"; +import * as path from "path"; +import * as spawn from "cross-spawn"; + +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 clc = require("cli-color"); + +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]) { + 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.js b/src/init/features/functions/index.js deleted file mode 100644 index 3ff9dd357c5..00000000000 --- a/src/init/features/functions/index.js +++ /dev/null @@ -1,56 +0,0 @@ -"use strict"; - -var clc = require("cli-color"); - -var _ = require("lodash"); - -const { logger } = require("../../../logger"); -var { prompt } = require("../../../prompt"); -var enableApi = require("../../../ensureApiEnabled").enable; -var { requirePermissions } = require("../../../requirePermissions"); - -module.exports = function (setup, config, options) { - logger.info(); - logger.info( - "A " + clc.bold("functions") + " directory will be created in your project with a Node.js" - ); - logger.info( - "package pre-configured. Functions can be deployed with " + clc.bold("firebase deploy") + "." - ); - logger.info(); - - setup.functions = {}; - var projectId = _.get(setup, "rcfile.projects.default"); - /** @type {Promise<*>} */ - var enableApis = Promise.resolve(); - if (projectId) { - enableApis = requirePermissions({ ...options, project: projectId }).then(() => { - return Promise.all([ - enableApi(projectId, "cloudfunctions.googleapis.com"), - enableApi(projectId, "runtimeconfig.googleapis.com"), - ]); - }); - } - return enableApis.then(function () { - return prompt(setup.functions, [ - { - type: "list", - name: "language", - message: "What language would you like to use to write Cloud Functions?", - default: "javascript", - choices: [ - { - name: "JavaScript", - value: "javascript", - }, - { - name: "TypeScript", - value: "typescript", - }, - ], - }, - ]).then(function () { - return require("./" + setup.functions.language)(setup, config); - }); - }); -}; diff --git a/src/init/features/functions/index.ts b/src/init/features/functions/index.ts new file mode 100644 index 00000000000..eca63d2cbf9 --- /dev/null +++ b/src/init/features/functions/index.ts @@ -0,0 +1,52 @@ +import * as clc from "cli-color"; + +import { logger } from "../../../logger"; +import { promptOnce } from "../../../prompt"; +import { requirePermissions } from "../../../requirePermissions"; +import { previews } from "../../../previews"; +import { Options } from "../../../options"; +import * as ensureApiEnabled from "../../../ensureApiEnabled"; + +module.exports = async function (setup: any, config: any, options: Options) { + logger.info(); + logger.info( + "A " + clc.bold("functions") + " directory will be created in your project with sample code" + ); + logger.info( + "pre-configured. Functions can be deployed with " + clc.bold("firebase deploy") + "." + ); + logger.info(); + + setup.functions = {}; + const projectId = setup?.rcfile?.projects?.default; + if (projectId) { + await requirePermissions({ ...options, project: projectId }); + await Promise.all([ + ensureApiEnabled.enable(projectId, "cloudfunctions.googleapis.com"), + ensureApiEnabled.enable(projectId, "runtimeconfig.googleapis.com"), + ]); + } + const choices = [ + { + name: "JavaScript", + value: "javascript", + }, + { + name: "TypeScript", + value: "typescript", + }, + ]; + if (previews.golang) { + choices.push({ + name: "Go", + value: "golang", + }); + } + const language = await promptOnce({ + type: "list", + message: "What language would you like to use to write Cloud Functions?", + default: "javascript", + choices, + }); + return require("./" + language)(setup, config); +}; diff --git a/src/previews.ts b/src/previews.ts index d4ad0036a70..69128035642 100644 --- a/src/previews.ts +++ b/src/previews.ts @@ -7,6 +7,7 @@ interface PreviewFlags { extdev: boolean; rtdbmanagement: boolean; functionsv2: boolean; + golang: boolean; } export const previews: PreviewFlags = { @@ -16,6 +17,7 @@ export const previews: PreviewFlags = { extdev: false, rtdbmanagement: false, functionsv2: false, + golang: false, ...configstore.get("previews"), }; diff --git a/templates/init/functions/golang/_gitignore b/templates/init/functions/golang/_gitignore new file mode 100644 index 00000000000..f2dd9554a12 --- /dev/null +++ b/templates/init/functions/golang/_gitignore @@ -0,0 +1,12 @@ +# 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 new file mode 100644 index 00000000000..1596778ff14 --- /dev/null +++ b/templates/init/functions/golang/functions.go @@ -0,0 +1,38 @@ +package PACKAGE + +// Welcome to Cloud Functions for Firebase for Golang! +// To get started, simply uncomment the below code or create your own. +// Deploy with `firebase deploy` + +/* +import ( + "context" + "fmt" + + "github.com/inlined/go-functions/https" + "github.com/inlined/go-functions/pubsub" + "github.com/inlined/go-functions/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 + }, +} +*/