From 0048a1087db54f61e6d864fc2808d40aaba2010a Mon Sep 17 00:00:00 2001 From: joehan Date: Wed, 17 Nov 2021 11:47:31 -0800 Subject: [PATCH] Adding support for project specific .env files (#3904) --- CHANGELOG.md | 1 + src/deploy/extensions/params.ts | 54 +++++++++++ src/deploy/extensions/planner.ts | 34 +++---- src/deploy/extensions/prepare.ts | 14 +-- src/projectUtils.ts | 14 +++ src/test/deploy/extensions/params.spec.ts | 104 ++++++++++++++++++++++ src/test/projectUtils.spec.ts | 31 ++++++- 7 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 src/deploy/extensions/params.ts create mode 100644 src/test/deploy/extensions/params.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..3325681400f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- `firebase deploy --only extensions` now supports project specifc .env files. When deploying to multiple projects, param values that are different between projects can be put in `extensions/${extensionInstanceId}.env.${projectIdOrAlias}` and common param values can be put in `extensions/${extensionInstanceId}.env`. diff --git a/src/deploy/extensions/params.ts b/src/deploy/extensions/params.ts new file mode 100644 index 00000000000..b04bfa4612d --- /dev/null +++ b/src/deploy/extensions/params.ts @@ -0,0 +1,54 @@ +import * as path from "path"; +import { logger } from "../../logger"; + +import { readEnvFile } from "../../extensions/paramHelper"; +import { FirebaseError } from "../../error"; + +const ENV_DIRECTORY = "extensions"; + +/** + * readParams gets the params for an extension instance from the `extensions` folder, + * checking for project specific env files, then falling back to generic env files. + * This checks the following locations & if a param is defined in multiple places, it prefers + * whichever is higher on this list: + * - extensions/{instanceId}.env.{projectID} + * - extensions/{instanceId}.env.{projectNumber} + * - extensions/{instanceId}.env.{projectAlias} + * - extensions/{instanceId}.env + */ +export function readParams(args: { + projectDir: string; + projectId: string; + projectNumber: string; + aliases: string[]; + instanceId: string; +}): Record { + const filesToCheck = [ + `${args.instanceId}.env`, + ...args.aliases.map((alias) => `${args.instanceId}.env.${alias}`), + `${args.instanceId}.env.${args.projectNumber}`, + `${args.instanceId}.env.${args.projectId}`, + ]; + let noFilesFound = true; + const combinedParams = {}; + for (const fileToCheck of filesToCheck) { + try { + const params = readParamsFile(args.projectDir, fileToCheck); + logger.debug(`Successfully read params from ${fileToCheck}`); + noFilesFound = false; + Object.assign(combinedParams, params); + } catch (err) { + logger.debug(`${err}`); + } + } + if (noFilesFound) { + throw new FirebaseError(`No params file found for ${args.instanceId}`); + } + return combinedParams; +} + +function readParamsFile(projectDir: string, fileName: string): Record { + const paramPath = path.join(projectDir, ENV_DIRECTORY, fileName); + const params = readEnvFile(paramPath); + return params as Record; +} diff --git a/src/deploy/extensions/planner.ts b/src/deploy/extensions/planner.ts index 781bf1fcb1c..10221b5e514 100644 --- a/src/deploy/extensions/planner.ts +++ b/src/deploy/extensions/planner.ts @@ -5,7 +5,7 @@ import { FirebaseError } from "../../error"; import * as extensionsApi from "../../extensions/extensionsApi"; import { getFirebaseProjectParams, substituteParams } from "../../extensions/extensionsHelper"; import * as refs from "../../extensions/refs"; -import { readEnvFile } from "../../extensions/paramHelper"; +import { readParams } from "./params"; import { logger } from "../../logger"; export interface InstanceSpec { @@ -46,8 +46,6 @@ export async function getExtension(i: InstanceSpec): Promise { * @param projectDir The directory containing firebase.json and extensions/ * @param extensions The extensions section of firebase.jsonm */ -export async function want( - projectId: string, - projectDir: string, - extensions: Record -): Promise { +export async function want(args: { + projectId: string; + projectNumber: string; + aliases: string[]; + projectDir: string; + extensions: Record; +}): Promise { const instanceSpecs: InstanceSpec[] = []; const errors: FirebaseError[] = []; - for (const e of Object.entries(extensions)) { + for (const e of Object.entries(args.extensions)) { try { const instanceId = e[0]; const ref = refs.parse(e[1]); ref.version = await resolveVersion(ref); - const params = readParams(projectDir, instanceId); - const autoPopulatedParams = await getFirebaseProjectParams(projectId); + const params = readParams({ + projectDir: args.projectDir, + instanceId, + projectId: args.projectId, + projectNumber: args.projectNumber, + aliases: args.aliases, + }); + const autoPopulatedParams = await getFirebaseProjectParams(args.projectId); const subbedParams = substituteParams(params, autoPopulatedParams); instanceSpecs.push({ @@ -134,9 +140,3 @@ export async function resolveVersion(ref: refs.Ref): Promise { } return maxSatisfying; } - -function readParams(projectDir: string, instanceId: string): Record { - const paramPath = path.join(projectDir, ENV_DIRECTORY, `${instanceId}.env`); - const params = readEnvFile(paramPath); - return params as Record; -} diff --git a/src/deploy/extensions/prepare.ts b/src/deploy/extensions/prepare.ts index 1a2f7652037..a2e53413fcf 100644 --- a/src/deploy/extensions/prepare.ts +++ b/src/deploy/extensions/prepare.ts @@ -3,7 +3,7 @@ import * as deploymentSummary from "./deploymentSummary"; import * as prompt from "../../prompt"; import * as refs from "../../extensions/refs"; import { Options } from "../../options"; -import { needProjectId } from "../../projectUtils"; +import { getAliases, needProjectId, needProjectNumber } from "../../projectUtils"; import { logger } from "../../logger"; import { Context, Payload } from "./args"; import { FirebaseError } from "../../error"; @@ -15,16 +15,20 @@ import { displayWarningsForDeploy } from "../../extensions/warnings"; export async function prepare(context: Context, options: Options, payload: Payload) { const projectId = needProjectId(options); + const projectNumber = await needProjectNumber(options); + const aliases = getAliases(options, projectId); await ensureExtensionsApiEnabled(options); await requirePermissions(options, ["firebaseextensions.instances.list"]); context.have = await planner.have(projectId); - context.want = await planner.want( + context.want = await planner.want({ projectId, - options.config.projectDir, - options.config.get("extensions") - ); + projectNumber, + aliases, + projectDir: options.config.projectDir, + extensions: options.config.get("extensions"), + }); // Check if any extension instance that we want is using secrets, // and ensure the API is enabled if so. diff --git a/src/projectUtils.ts b/src/projectUtils.ts index e4d291af9c3..f6973164e9f 100644 --- a/src/projectUtils.ts +++ b/src/projectUtils.ts @@ -90,3 +90,17 @@ export async function needProjectNumber(options: any): Promise { options.projectNumber = metadata.projectNumber; return options.projectNumber; } + +/** + * Looks up all aliases for projectId. + * @param options CLI options. + * @param projectId A project id to get the aliases for + */ +export function getAliases(options: any, projectId: string): string[] { + if (options.rc.hasProjects) { + return Object.entries(options.rc.projects) + .filter((entry) => entry[1] === projectId) + .map((entry) => entry[0]); + } + return []; +} diff --git a/src/test/deploy/extensions/params.spec.ts b/src/test/deploy/extensions/params.spec.ts new file mode 100644 index 00000000000..02f1f7869d7 --- /dev/null +++ b/src/test/deploy/extensions/params.spec.ts @@ -0,0 +1,104 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; + +import * as params from "../../../deploy/extensions/params"; +import * as paramHelper from "../../../extensions/paramHelper"; + +describe("readParams", () => { + let readEnvFileStub: sinon.SinonStub; + const testProjectDir = "test"; + const testProjectId = "my-project"; + const testProjectNumber = "123456"; + const testInstanceId = "extensionId"; + + beforeEach(() => { + readEnvFileStub = sinon.stub(paramHelper, "readEnvFile").returns({}); + }); + + afterEach(() => { + readEnvFileStub.restore(); + }); + + it("should read from generic .env file", () => { + readEnvFileStub + .withArgs("test/extensions/extensionId.env") + .returns({ param: "otherValue", param2: "value2" }); + + expect( + params.readParams({ + projectDir: testProjectDir, + instanceId: testInstanceId, + projectId: testProjectId, + projectNumber: testProjectNumber, + aliases: [], + }) + ).to.deep.equal({ param: "otherValue", param2: "value2" }); + }); + + it("should read from project id .env file", () => { + readEnvFileStub + .withArgs("test/extensions/extensionId.env.my-project") + .returns({ param: "otherValue", param2: "value2" }); + + expect( + params.readParams({ + projectDir: testProjectDir, + instanceId: testInstanceId, + projectId: testProjectId, + projectNumber: testProjectNumber, + aliases: [], + }) + ).to.deep.equal({ param: "otherValue", param2: "value2" }); + }); + + it("should read from project number .env file", () => { + readEnvFileStub + .withArgs("test/extensions/extensionId.env.123456") + .returns({ param: "otherValue", param2: "value2" }); + + expect( + params.readParams({ + projectDir: testProjectDir, + instanceId: testInstanceId, + projectId: testProjectId, + projectNumber: testProjectNumber, + aliases: [], + }) + ).to.deep.equal({ param: "otherValue", param2: "value2" }); + }); + + it("should read from an alias .env file", () => { + readEnvFileStub + .withArgs("test/extensions/extensionId.env.prod") + .returns({ param: "otherValue", param2: "value2" }); + + expect( + params.readParams({ + projectDir: testProjectDir, + instanceId: testInstanceId, + projectId: testProjectId, + projectNumber: testProjectNumber, + aliases: ["prod"], + }) + ).to.deep.equal({ param: "otherValue", param2: "value2" }); + }); + + it("should prefer values from project specific env files", () => { + readEnvFileStub + .withArgs("test/extensions/extensionId.env.my-project") + .returns({ param: "value" }); + readEnvFileStub + .withArgs("test/extensions/extensionId.env") + .returns({ param: "otherValue", param2: "value2" }); + + expect( + params.readParams({ + projectDir: testProjectDir, + instanceId: testInstanceId, + projectId: testProjectId, + projectNumber: testProjectNumber, + aliases: [], + }) + ).to.deep.equal({ param: "value", param2: "value2" }); + }); +}); diff --git a/src/test/projectUtils.spec.ts b/src/test/projectUtils.spec.ts index f1e273e26ae..187e34f9f74 100644 --- a/src/test/projectUtils.spec.ts +++ b/src/test/projectUtils.spec.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; -import { needProjectNumber, needProjectId, getProjectId } from "../projectUtils"; +import { needProjectNumber, needProjectId, getAliases, getProjectId } from "../projectUtils"; import * as projects from "../management/projects"; import { RC } from "../rc"; @@ -69,3 +69,32 @@ describe("needProjectNumber", () => { ); }); }); + +describe("getAliases", () => { + it("should return the aliases for a projectId", () => { + const testProjectId = "my-project"; + const testOptions = { + rc: { + hasProjects: true, + projects: { + prod: testProjectId, + prod2: testProjectId, + staging: "other-project", + }, + }, + }; + + expect(getAliases(testOptions, testProjectId).sort()).to.deep.equal(["prod", "prod2"]); + }); + + it("should return an empty array if there are no aliases in rc", () => { + const testProjectId = "my-project"; + const testOptions = { + rc: { + hasProjects: false, + }, + }; + + expect(getAliases(testOptions, testProjectId)).to.deep.equal([]); + }); +});