diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 2f2445261e8..c75d80de4b0 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -123,8 +123,9 @@ export async function prepare( const config = configForCodebase(context.config, codebase); const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId); const userEnvOpt: functionsEnv.UserEnvsOpts = { + projectId, + codebase, functionsSource: options.config.path(config.source), - projectId: projectId, projectAlias: options.projectAlias, }; const userEnvs = functionsEnv.loadUserEnvs(userEnvOpt); diff --git a/src/deploy/functions/runtimes/node/parseTriggers.spec.ts b/src/deploy/functions/runtimes/node/parseTriggers.spec.ts index 37331061c21..ed6edad5916 100644 --- a/src/deploy/functions/runtimes/node/parseTriggers.spec.ts +++ b/src/deploy/functions/runtimes/node/parseTriggers.spec.ts @@ -17,7 +17,7 @@ async function resolveBackend(bd: build.Build): Promise { storageBucket: "foo.appspot.com", databaseURL: "https://foo.firebaseio.com", }, - userEnvOpt: { functionsSource: "", projectId: "PROJECT" }, + userEnvOpt: { functionsSource: "", projectId: "PROJECT", codebase: "default" }, userEnvs: {}, }) ).backend; diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index c8f69de5343..e736a9b3774 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -553,6 +553,7 @@ export class FunctionsEmulator implements EmulatorInstance { projectId: this.args.projectId, projectAlias: this.args.projectAlias, isEmulator: true, + codebase: emulatableBackend.codebase, }; const userEnvs = functionsEnv.loadUserEnvs(userEnvOpt); const discoveredBuild = await runtimeDelegate.discoverBuild(runtimeConfig, environment); @@ -1378,6 +1379,7 @@ export class FunctionsEmulator implements EmulatorInstance { projectId: this.args.projectId, projectAlias: this.args.projectAlias, isEmulator: true, + codebase: backend.codebase, }; if (functionsEnv.hasUserEnvs(projectInfo)) { diff --git a/src/functions/env.spec.ts b/src/functions/env.spec.ts index 6dc63351caa..6c9d3316b41 100644 --- a/src/functions/env.spec.ts +++ b/src/functions/env.spec.ts @@ -310,11 +310,22 @@ FOO=foo it("never affects the filesystem if the list of keys to write is empty", () => { env.writeUserEnvs( {}, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ); env.writeUserEnvs( {}, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir, isEmulator: true }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + isEmulator: true, + codebase: "default", + }, ); expect(() => fs.statSync(path.join(tmpdir, ".env.alias"))).to.throw; expect(() => fs.statSync(path.join(tmpdir, ".env.project"))).to.throw; @@ -322,7 +333,10 @@ FOO=foo }); it("touches .env.projectId if it doesn't already exist", () => { - env.writeUserEnvs({ FOO: "bar" }, { projectId: "project", functionsSource: tmpdir }); + env.writeUserEnvs( + { FOO: "bar" }, + { projectId: "project", functionsSource: tmpdir, codebase: "default" }, + ); expect(() => fs.statSync(path.join(tmpdir, ".env.alias"))).to.throw; expect(!!fs.statSync(path.join(tmpdir, ".env.project"))).to.be.true; expect(() => fs.statSync(path.join(tmpdir, ".env.local"))).to.throw; @@ -331,7 +345,7 @@ FOO=foo it("touches .env.local if it doesn't already exist in emulator mode", () => { env.writeUserEnvs( { FOO: "bar" }, - { projectId: "project", functionsSource: tmpdir, isEmulator: true }, + { projectId: "project", functionsSource: tmpdir, isEmulator: true, codebase: "default" }, ); expect(() => fs.statSync(path.join(tmpdir, ".env.alias"))).to.throw; expect(() => fs.statSync(path.join(tmpdir, ".env.project"))).to.throw; @@ -343,7 +357,10 @@ FOO=foo [".env.project"]: "FOO=foo", }); expect(() => - env.writeUserEnvs({ FOO: "bar" }, { projectId: "project", functionsSource: tmpdir }), + env.writeUserEnvs( + { FOO: "bar" }, + { projectId: "project", functionsSource: tmpdir, codebase: "default" }, + ), ).to.throw(FirebaseError); }); @@ -353,7 +370,7 @@ FOO=foo }); env.writeUserEnvs( { FOO: "bar" }, - { projectId: "project", functionsSource: tmpdir, isEmulator: true }, + { projectId: "project", functionsSource: tmpdir, isEmulator: true, codebase: "default" }, ); expect( env.loadUserEnvs({ @@ -361,6 +378,7 @@ FOO=foo projectAlias: "alias", functionsSource: tmpdir, isEmulator: true, + codebase: "default", })["FOO"], ).to.equal("bar"); }); @@ -372,7 +390,12 @@ FOO=foo expect(() => env.writeUserEnvs( { FOO: "baz" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ), ).to.throw(FirebaseError); }); @@ -383,7 +406,13 @@ FOO=foo }); env.writeUserEnvs( { FOO: "baz" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir, isEmulator: true }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + isEmulator: true, + codebase: "default", + }, ); expect( env.loadUserEnvs({ @@ -391,6 +420,7 @@ FOO=foo projectAlias: "alias", functionsSource: tmpdir, isEmulator: true, + codebase: "default", })["FOO"], ).to.equal("baz"); }); @@ -402,7 +432,7 @@ FOO=foo expect(() => env.writeUserEnvs( { ASDF: "bar" }, - { projectId: "project", functionsSource: tmpdir, isEmulator: true }, + { projectId: "project", functionsSource: tmpdir, isEmulator: true, codebase: "default" }, ), ).to.throw(FirebaseError); }); @@ -411,19 +441,34 @@ FOO=foo expect(() => env.writeUserEnvs( { lowercase: "bar" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ), ).to.throw(env.KeyValidationError); expect(() => env.writeUserEnvs( { GCP_PROJECT: "bar" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ), ).to.throw(env.KeyValidationError); expect(() => env.writeUserEnvs( { FIREBASE_KEY: "bar" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ), ).to.throw(env.KeyValidationError); }); @@ -431,12 +476,20 @@ FOO=foo it("writes the specified key to a .env.projectId that it created", () => { env.writeUserEnvs( { FOO: "bar" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ); expect( - env.loadUserEnvs({ projectId: "project", projectAlias: "alias", functionsSource: tmpdir })[ - "FOO" - ], + env.loadUserEnvs({ + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + })["FOO"], ).to.equal("bar"); }); @@ -446,24 +499,38 @@ FOO=foo }); env.writeUserEnvs( { FOO: "bar" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ); expect( - env.loadUserEnvs({ projectId: "project", projectAlias: "alias", functionsSource: tmpdir })[ - "FOO" - ], + env.loadUserEnvs({ + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + })["FOO"], ).to.equal("bar"); }); it("writes multiple keys at once", () => { env.writeUserEnvs( { FOO: "foo", BAR: "bar" }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ); const envs = env.loadUserEnvs({ projectId: "project", projectAlias: "alias", functionsSource: tmpdir, + codebase: "default", }); expect(envs["FOO"]).to.equal("foo"); expect(envs["BAR"]).to.equal("bar"); @@ -476,12 +543,18 @@ FOO=foo WITH_SLASHES: "\n\\\r\\\t\\\v", QUOTES: "'\"'", }, - { projectId: "project", projectAlias: "alias", functionsSource: tmpdir }, + { + projectId: "project", + projectAlias: "alias", + functionsSource: tmpdir, + codebase: "default", + }, ); const envs = env.loadUserEnvs({ projectId: "project", projectAlias: "alias", functionsSource: tmpdir, + codebase: "default", }); expect(envs["ESCAPES"]).to.equal("\n\r\t\v"); expect(envs["WITH_SLASHES"]).to.equal("\n\\\r\\\t\\\v"); @@ -492,13 +565,16 @@ FOO=foo try { env.writeUserEnvs( { FOO: "bar", lowercase: "bar" }, - { projectId: "project", functionsSource: tmpdir }, + { projectId: "project", functionsSource: tmpdir, codebase: "default" }, ); } catch (err: any) { // no-op } - expect(env.loadUserEnvs({ projectId: "project", functionsSource: tmpdir })["FOO"]).to.be - .undefined; + expect( + env.loadUserEnvs({ projectId: "project", functionsSource: tmpdir, codebase: "default" })[ + "FOO" + ], + ).to.be.undefined; }); }); @@ -511,6 +587,7 @@ FOO=foo const projectInfo: Omit = { projectId: "my-project", projectAlias: "dev", + codebase: "default", }; let tmpdir: string; @@ -695,6 +772,79 @@ FOO=foo env.loadUserEnvs({ ...projectInfo, functionsSource: tmpdir }); }).to.throw("Failed to load"); }); + + describe("codebase environment variables", () => { + it("loads envs from .env. file", () => { + createEnvFiles(tmpdir, { + [`.env.${projectInfo.codebase}`]: "FOO=codebase-foo\nBAR=codebase-bar", + }); + + expect(env.loadUserEnvs({ ...projectInfo, functionsSource: tmpdir })).to.be.deep.equal({ + FOO: "codebase-foo", + BAR: "codebase-bar", + }); + }); + + it("loads envs with correct precedence: .env < .env. < .env.", () => { + createEnvFiles(tmpdir, { + ".env": "FOO=global\nBAR=global\nBAZ=global", + [`.env.${projectInfo.codebase}`]: "FOO=codebase\nBAR=codebase", + [`.env.${projectInfo.projectId}`]: "FOO=project", + }); + + expect(env.loadUserEnvs({ ...projectInfo, functionsSource: tmpdir })).to.be.deep.equal({ + FOO: "project", // project overrides codebase + BAR: "codebase", // codebase overrides global + BAZ: "global", // only defined in global + }); + }); + + it("loads envs with correct precedence: .env < .env. < .env.", () => { + createEnvFiles(tmpdir, { + ".env": "FOO=global\nBAR=global\nBAZ=global", + [`.env.${projectInfo.codebase}`]: "FOO=codebase\nBAR=codebase", + [`.env.${projectInfo.projectAlias}`]: "FOO=alias", + }); + + expect(env.loadUserEnvs({ ...projectInfo, functionsSource: tmpdir })).to.be.deep.equal({ + FOO: "alias", // alias overrides codebase + BAR: "codebase", // codebase overrides global + BAZ: "global", // only defined in global + }); + }); + + it("works with custom codebase names", () => { + const customProjectInfo = { ...projectInfo, codebase: "profile-pics-resizer" }; + createEnvFiles(tmpdir, { + ".env": "FOO=global", + ".env.profile-pics-resizer": "FOO=custom-codebase\nCUSTOM=value", + }); + + expect( + env.loadUserEnvs({ ...customProjectInfo, functionsSource: tmpdir }), + ).to.be.deep.equal({ + FOO: "custom-codebase", + CUSTOM: "value", + }); + }); + + it("loads envs correctly for emulator with .env.local precedence", () => { + createEnvFiles(tmpdir, { + ".env": "FOO=global\nBAR=global\nBAZ=global", + [`.env.${projectInfo.codebase}`]: "FOO=codebase\nBAR=codebase", + [`.env.${projectInfo.projectId}`]: "FOO=project", + ".env.local": "FOO=local", + }); + + expect( + env.loadUserEnvs({ ...projectInfo, functionsSource: tmpdir, isEmulator: true }), + ).to.be.deep.equal({ + FOO: "local", // .env.local has highest precedence in emulator + BAR: "codebase", // codebase overrides global + BAZ: "global", // only defined in global + }); + }); + }); }); describe("parseStrict", () => { diff --git a/src/functions/env.ts b/src/functions/env.ts index d00b2056735..d74f4235881 100644 --- a/src/functions/env.ts +++ b/src/functions/env.ts @@ -220,10 +220,12 @@ export function parseStrict(data: string): Record { function findEnvfiles( functionsSource: string, projectId: string, + codebase: string, projectAlias?: string, isEmulator?: boolean, ): string[] { const files: string[] = [".env"]; + files.push(`.env.${codebase}`); files.push(`.env.${projectId}`); if (projectAlias) { files.push(`.env.${projectAlias}`); @@ -243,6 +245,7 @@ export interface UserEnvsOpts { projectId: string; projectAlias?: string; isEmulator?: boolean; + codebase: string; } /** @@ -255,8 +258,9 @@ export function hasUserEnvs({ projectId, projectAlias, isEmulator, + codebase, }: UserEnvsOpts): boolean { - return findEnvfiles(functionsSource, projectId, projectAlias, isEmulator).length > 0; + return findEnvfiles(functionsSource, projectId, codebase, projectAlias, isEmulator).length > 0; } /** @@ -269,10 +273,10 @@ export function writeUserEnvs(toWrite: Record, envOpts: UserEnvs if (Object.keys(toWrite).length === 0) { return; } - const { functionsSource, projectId, projectAlias, isEmulator } = envOpts; + const { functionsSource, projectId, projectAlias, isEmulator, codebase } = envOpts; // Determine which .env file to write to, and create it if it doesn't exist - const allEnvFiles = findEnvfiles(functionsSource, projectId, projectAlias, isEmulator); + const allEnvFiles = findEnvfiles(functionsSource, projectId, codebase, projectAlias, isEmulator); const targetEnvFile = envOpts.isEmulator ? FUNCTIONS_EMULATOR_DOTENV : `.env.${envOpts.projectId}`; @@ -356,8 +360,9 @@ function formatUserEnvForWrite(key: string, value: string): string { * * .env files are searched and merged in the following order: * - * 1. .env - * 2. .env. + * 1. .env (global defaults for all functions in source) + * 2. .env. (codebase-specific settings) + * 3. .env. (project-specific overrides) * * If both .env. and .env. files are found, an error is thrown. * @@ -368,8 +373,9 @@ export function loadUserEnvs({ projectId, projectAlias, isEmulator, + codebase, }: UserEnvsOpts): Record { - const envFiles = findEnvfiles(functionsSource, projectId, projectAlias, isEmulator); + const envFiles = findEnvfiles(functionsSource, projectId, codebase, projectAlias, isEmulator); if (envFiles.length === 0) { return {}; }