Skip to content

Commit

Permalink
Allow multiple-config form for functions config in firebase.json (#4269)
Browse files Browse the repository at this point in the history
This change extends `functions` config in `firebase.json` so that you can write it in multi-config form. E.g.

```json
// This has always been valid
{
  "functions": {
    "source": "source"
  }
}

// This is also valid now
{
  "functions": [
    {
      "source": "source"
    }
  ]
}
```

Since having multiple configuration for function doesn't make sense (yet), we add runtime checks to make sure that there is exactly 1 functions source if we have an array of function config. I had fun trying to add better types around configs and consolidated config validations to remove redundant config checks throughout the codebase.
  • Loading branch information
taeold committed Mar 17, 2022
1 parent a95694f commit 089bea2
Show file tree
Hide file tree
Showing 13 changed files with 301 additions and 113 deletions.
129 changes: 93 additions & 36 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,54 +326,111 @@
"type": "object"
},
"functions": {
"additionalProperties": false,
"properties": {
"ignore": {
"items": {
"type": "string"
},
"type": "array"
},
"postdeploy": {
"anyOf": [
{
"anyOf": [
{
"additionalProperties": false,
"properties": {
"ignore": {
"items": {
"type": "string"
},
"type": "array"
},
{
"postdeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"predeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"runtime": {
"enum": [
"nodejs10",
"nodejs12",
"nodejs14",
"nodejs16"
],
"type": "string"
},
"source": {
"type": "string"
}
]
},
"type": "object"
},
"predeploy": {
"anyOf": [
{
"items": {
{
"items": {
"additionalProperties": false,
"properties": {
"ignore": {
"items": {
"type": "string"
},
"type": "array"
},
"postdeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"predeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"runtime": {
"enum": [
"nodejs10",
"nodejs12",
"nodejs14",
"nodejs16"
],
"type": "string"
},
"type": "array"
"source": {
"type": "string"
}
},
{
"type": "string"
}
]
},
"runtime": {
"enum": [
"nodejs10",
"nodejs12",
"nodejs14",
"nodejs16"
],
"type": "string"
},
"source": {
"type": "string"
"type": "object"
},
"type": "array"
}
},
"type": "object"
]
},
"hosting": {
"anyOf": [
Expand Down
5 changes: 4 additions & 1 deletion src/commands/functions-config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as configExport from "../functions/runtimeConfigExport";
import { requireConfig } from "../requireConfig";

import type { Options } from "../options";
import { normalizeAndValidate } from "../functions/projectConfig";

const REQUIRED_PERMISSIONS = [
"runtimeconfig.configs.list",
Expand Down Expand Up @@ -103,6 +104,9 @@ export default new Command("functions:config:export")
.before(requireConfig)
.before(requireInteractive)
.action(async (options: Options) => {
const config = normalizeAndValidate(options.config.src.functions)[0];
const functionsDir = config.source;

let pInfos = configExport.getProjectInfos(options);
checkReservedAliases(pInfos);

Expand Down Expand Up @@ -145,7 +149,6 @@ export default new Command("functions:config:export")
".env"
] = `${header}# .env file contains environment variables that applies to all projects.\n`;

const functionsDir = options.config.get("functions.source", ".");
for (const [filename, content] of Object.entries(filesToWrite)) {
await options.config.askWriteProjectFile(path.join(functionsDir, filename), content);
}
Expand Down
18 changes: 11 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,17 @@ export class Config {
}
});

// Auto-detect functions from package.json in directory
if (
this.projectDir &&
!this.get("functions.source") &&
fsutils.dirExistsSync(this.path("functions"))
) {
this.set("functions.source", Config.DEFAULT_FUNCTIONS_SOURCE);
// Inject default functions config and source if missing.
if (this.projectDir && fsutils.dirExistsSync(this.path(Config.DEFAULT_FUNCTIONS_SOURCE))) {
if (Array.isArray(this.get("functions"))) {
if (!this.get("functions.[0].source")) {
this.set("functions.[0].source", Config.DEFAULT_FUNCTIONS_SOURCE);
}
} else {
if (!this.get("functions.source")) {
this.set("functions.source", Config.DEFAULT_FUNCTIONS_SOURCE);
}
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/deploy/functions/args.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as backend from "./backend";
import * as gcfV2 from "../../gcp/cloudfunctionsv2";
import * as projectConfig from "../../functions/projectConfig";

// These types should proably be in a root deploy.ts, but we can only boil the ocean one bit at a time.

Expand All @@ -18,6 +19,7 @@ export interface Context {
filters: string[][];

// Filled in the "prepare" phase.
config?: projectConfig.ValidatedSingle;
functionsSourceV1?: string;
functionsSourceV2?: string;
runtimeConfigEnabled?: boolean;
Expand Down
12 changes: 3 additions & 9 deletions src/deploy/functions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function deploy(
options: Options,
payload: args.Payload
): Promise<void> {
if (!options.config.src.functions) {
if (!context.config) {
return;
}

Expand Down Expand Up @@ -77,16 +77,10 @@ export async function deploy(
}
await Promise.all(uploads);

utils.assertDefined(
options.config.src.functions.source,
"Error: 'functions.source' is not defined"
);
const source = context.config.source;
if (uploads.length) {
logSuccess(
clc.green.bold("functions:") +
" " +
clc.bold(options.config.src.functions.source) +
" folder uploaded successfully"
`${clc.green.bold("functions:")} ${clc.bold(source)} folder uploaded successfully`
);
}
} catch (err: any) {
Expand Down
20 changes: 11 additions & 9 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import { functionMatchesAnyGroup, getFilterGroups } from "./functionsDeployHelpe
import { logBullet } from "../../utils";
import { getFunctionsConfig, prepareFunctionsUpload } from "./prepareFunctionsUpload";
import { promptForFailurePolicies, promptForMinInstances } from "./prompts";
import { previews } from "../../previews";
import { needProjectId, needProjectNumber } from "../../projectUtils";
import { track } from "../../track";
import { logger } from "../../logger";
import { ensureTriggerRegions } from "./triggerRegionHelper";
import { ensureServiceAgentRoles } from "./checkIam";
import { FirebaseError } from "../../error";
import { normalizeAndValidate } from "../../functions/projectConfig";

function hasUserConfig(config: Record<string, unknown>): boolean {
// "firebase" key is always going to exist in runtime config.
Expand All @@ -39,10 +39,11 @@ export async function prepare(
const projectId = needProjectId(options);
const projectNumber = await needProjectNumber(options);

const sourceDirName = options.config.get("functions.source") as string;
context.config = normalizeAndValidate(options.config.src.functions)[0];
const sourceDirName = context.config.source;
if (!sourceDirName) {
throw new FirebaseError(
`No functions code detected at default location (./functions), and no functions.source defined in firebase.json`
`No functions code detected at default location (./functions), and no functions source defined in firebase.json`
);
}
const sourceDir = options.config.path(sourceDirName);
Expand All @@ -51,7 +52,7 @@ export async function prepare(
projectId,
sourceDir,
projectDir: options.config.projectDir,
runtime: (options.config.get("functions.runtime") as string) || "",
runtime: context.config.runtime || "",
};
const runtimeDelegate = await runtimes.getRuntimeDelegate(delegateContext);
logger.debug(`Validating ${runtimeDelegate.name} source`);
Expand Down Expand Up @@ -127,13 +128,14 @@ export async function prepare(
);
}
if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv1")) {
context.functionsSourceV1 = await prepareFunctionsUpload(runtimeConfig, options);
context.functionsSourceV1 = await prepareFunctionsUpload(
sourceDir,
context.config,
runtimeConfig
);
}
if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2")) {
context.functionsSourceV2 = await prepareFunctionsUpload(
/* runtimeConfig= */ undefined,
options
);
context.functionsSourceV2 = await prepareFunctionsUpload(sourceDir, context.config);
}

// Setup environment variables on each function.
Expand Down
36 changes: 14 additions & 22 deletions src/deploy/functions/prepareFunctionsUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import * as functionsConfig from "../../functionsConfig";
import * as utils from "../../utils";
import * as fsAsync from "../../fsAsync";
import * as args from "./args";
import { Options } from "../../options";
import { Config } from "../../config";
import * as projectConfig from "../../functions/projectConfig";

const CONFIG_DEST_FILE = ".runtimeconfig.json";

Expand Down Expand Up @@ -55,7 +54,11 @@ async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream) {
});
}

async function packageSource(options: Options, sourceDir: string, configValues: any) {
async function packageSource(
sourceDir: string,
config: projectConfig.ValidatedSingle,
runtimeConfig: any
) {
const tmpFile = tmp.fileSync({ prefix: "firebase-functions-", postfix: ".zip" }).name;
const fileStream = fs.createWriteStream(tmpFile, {
flags: "w",
Expand All @@ -67,7 +70,7 @@ async function packageSource(options: Options, sourceDir: string, configValues:
// you're in the public dir when you deploy.
// We ignore any CONFIG_DEST_FILE that already exists, and write another one
// with current config values into the archive in the "end" handler for reader
const ignore = options.config.src.functions?.ignore || ["node_modules", ".git"];
const ignore = config.ignore || ["node_modules", ".git"];
ignore.push(
"firebase-debug.log",
"firebase-debug.*.log",
Expand All @@ -81,8 +84,8 @@ async function packageSource(options: Options, sourceDir: string, configValues:
mode: file.mode,
});
});
if (typeof configValues !== "undefined") {
archive.append(JSON.stringify(configValues, null, 2), {
if (typeof runtimeConfig !== "undefined") {
archive.append(JSON.stringify(runtimeConfig, null, 2), {
name: CONFIG_DEST_FILE,
mode: 420 /* 0o644 */,
});
Expand All @@ -99,15 +102,10 @@ async function packageSource(options: Options, sourceDir: string, configValues:
);
}

utils.assertDefined(options.config.src.functions);
utils.assertDefined(
options.config.src.functions.source,
"Error: 'functions.source' is not defined"
);
utils.logBullet(
clc.cyan.bold("functions:") +
" packaged " +
clc.bold(options.config.src.functions.source) +
clc.bold(sourceDir) +
" (" +
filesize(archive.pointer()) +
") for uploading"
Expand All @@ -116,15 +114,9 @@ async function packageSource(options: Options, sourceDir: string, configValues:
}

export async function prepareFunctionsUpload(
runtimeConfig: backend.RuntimeConfigValues | undefined,
options: Options
sourceDir: string,
config: projectConfig.ValidatedSingle,
runtimeConfig?: backend.RuntimeConfigValues
): Promise<string | undefined> {
utils.assertDefined(options.config.src.functions);
utils.assertDefined(
options.config.src.functions.source,
"Error: 'functions.source' is not defined"
);

const sourceDir = options.config.path(options.config.src.functions.source);
return packageSource(options, sourceDir, runtimeConfig);
return packageSource(sourceDir, config, runtimeConfig);
}
2 changes: 1 addition & 1 deletion src/deploy/functions/release/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function release(
options: Options,
payload: args.Payload
): Promise<void> {
if (!options.config.has("functions")) {
if (!context.config) {
return;
}

Expand Down

0 comments on commit 089bea2

Please sign in to comment.