Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
168 changes: 168 additions & 0 deletions src/deploy/functions/runtimes/golang/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, runtimes.Runtime> = {
"1.13": "go113",
};

export async function tryCreateDelegate(
context: args.Context,
options: Options
): Promise<Delegate | undefined> {
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<string, string>;
}

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this error on cases where module.module or module.version doesn't get defined?

}

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<void> {
throw new FirebaseError("Cannot yet analyze Go source code");
}

build(): Promise<void> {
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<void>> {
return Promise.resolve(() => Promise.resolve());
}

discoverSpec(
configValues: backend.RuntimeConfigValues,
envs: backend.EnvironmentVariables
): Promise<backend.Backend> {
throw new FirebaseError("Cannot yet discover function specs");
}
}
19 changes: 16 additions & 3 deletions src/deploy/functions/runtimes/index.ts
Original file line number Diff line number Diff line change
@@ -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"];
Expand All @@ -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<Runtime | DeprecatedRuntime, string> = {
Expand All @@ -29,6 +34,7 @@ const MESSAGE_FRIENDLY_RUNTIMES: Record<Runtime | DeprecatedRuntime, string> = {
nodejs10: "Node.js 10",
nodejs12: "Node.js 12",
nodejs14: "Node.js 14",
go113: "Go 1.13",
};

/**
Expand Down Expand Up @@ -94,7 +100,7 @@ export interface RuntimeDelegate {
}

type Factory = (context: args.Context, options: Options) => Promise<RuntimeDelegate | undefined>;
const factories: Factory[] = [node.tryCreateDelegate];
const factories: Factory[] = [node.tryCreateDelegate, golang.tryCreateDelegate];

export async function getRuntimeDelegate(
context: args.Context,
Expand All @@ -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) {
Expand Down
16 changes: 5 additions & 11 deletions src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export async function tryCreateDelegate(
context: args.Context,
options: Options
): Promise<Delegate | undefined> {
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))) {
Expand All @@ -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.
Expand All @@ -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
) {}
Expand All @@ -78,7 +71,8 @@ export class Delegate {
validate(): Promise<void> {
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();
}
Expand Down
74 changes: 74 additions & 0 deletions src/init/features/functions/golang.ts
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just copy go's behavior and error here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript and JavaScript do a prompt to overwrite and we do above here. That seems correct to me.

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;
Loading