Skip to content

Commit

Permalink
Adding support for project specific .env files (firebase#3904)
Browse files Browse the repository at this point in the history
  • Loading branch information
joehan committed Nov 17, 2021
1 parent 4f1764c commit 0048a10
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 23 deletions.
1 change: 1 addition & 0 deletions 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`.
54 changes: 54 additions & 0 deletions 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<string, string> {
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<string, string> {
const paramPath = path.join(projectDir, ENV_DIRECTORY, fileName);
const params = readEnvFile(paramPath);
return params as Record<string, string>;
}
34 changes: 17 additions & 17 deletions src/deploy/extensions/planner.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -46,8 +46,6 @@ export async function getExtension(i: InstanceSpec): Promise<extensionsApi.Exten
return i.extension;
}

const ENV_DIRECTORY = "extensions";

/**
* have checks a project for what extension instances are currently installed,
* and returns them as a list of instanceSpecs.
Expand Down Expand Up @@ -76,21 +74,29 @@ export async function have(projectId: string): Promise<InstanceSpec[]> {
* @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<string, string>
): Promise<InstanceSpec[]> {
export async function want(args: {
projectId: string;
projectNumber: string;
aliases: string[];
projectDir: string;
extensions: Record<string, string>;
}): Promise<InstanceSpec[]> {
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({
Expand Down Expand Up @@ -134,9 +140,3 @@ export async function resolveVersion(ref: refs.Ref): Promise<string> {
}
return maxSatisfying;
}

function readParams(projectDir: string, instanceId: string): Record<string, string> {
const paramPath = path.join(projectDir, ENV_DIRECTORY, `${instanceId}.env`);
const params = readEnvFile(paramPath);
return params as Record<string, string>;
}
14 changes: 9 additions & 5 deletions src/deploy/extensions/prepare.ts
Expand Up @@ -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";
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/projectUtils.ts
Expand Up @@ -90,3 +90,17 @@ export async function needProjectNumber(options: any): Promise<string> {
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 [];
}
104 changes: 104 additions & 0 deletions 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" });
});
});
31 changes: 30 additions & 1 deletion 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";

Expand Down Expand Up @@ -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([]);
});
});

0 comments on commit 0048a10

Please sign in to comment.