-
Notifications
You must be signed in to change notification settings - Fork 5.7k
feat(env loader): add basic support for autoloading env variables #8413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(env loader): add basic support for autoloading env variables #8413
Conversation
65bc9bb to
bcdedc9
Compare
medikoo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @pgrzesik that looks really good! Please see my comments
lib/classes/EnvLoader.js
Outdated
| if (doesStageEnvFileExists) { | ||
| dotenv.config({ path: stageEnvFilePath }); | ||
| } else if (doesDefaultEnvFileExists) { | ||
| dotenv.config({ path: defaultEnvFilePath }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't tested but I assume that dotenv.config() is safe to call when env file at path doesn't exist.
If it's the case I would just run it unconditionally
Technically we need a file exists check only to eventually display deprecation message so with v3 it'll be nice to remove this checks.
I take it can be constructed as follows
if (useDotEnv) {
dotenv.config(...)
} else {
cost hasDotEnvFile = ...
if (hasDotEnvFile) showDeprecation()
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like that approach, however, that suggests that you'd like the behavior to be a bit different than the one in serverless/components - please see my question from the above
By following the similar approach as serverless/components, only one .env file can be loaded - would it make sense to load both .env and .env.stage if there're present in a way that .env.stage will be loaded later to override overlapping variables from .env?
So, do you think that we should load both env files (default one first, then stage) if they exist?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pgrzesik what about that one
If I read correctly from dotenv implementation, they return object with error property if file was not found: https://github.com/motdotla/dotenv/blob/7301ac9be0b2c766f865bbe24280bf82586d25aa/lib/main.js#L108
This also reminds me, that it'll be good to signal any parsing errors to the users. I think it'll be good to crash then with meaningful error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I read correctly from dotenv implementation, they return object with error property if file was not found: motdotla/dotenv@7301ac9/lib/main.js#L108
Do you suggest using that to avoid explicit checks if the file exists? If yes, then, unfortunately, we still have to check for the existence of files in case the useDotenv is not set to true.
This also reminds me, that it'll be good to signal any parsing errors to the users. I think it'll be good to crash then with meaningful error.
That's a good idea, however, I couldn't really come up with any actual possible parsing errors from dotenv.config that are not related to nonexistent file under provided path. I still think it's a good idea to check for any error. How would you like the framework to handle it? Crash or rather emit a warning to the user and continue processing as "normal"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If yes, then, unfortunately, we still have to check for the existence of files in case the useDotenv is not set to true
Yes, but it's temporary, it's only for deprecation reporting and will be removed with next major
How would you like the framework to handle it? Crash or rather emit a warning to the user and continue processing as "normal"?
I think we should crash. If env setup is broken it's unlikely things will go well
|
Thank you @medikoo for a swift review 🙇 There are still a few questions that I'm not sure about and I was hoping if you would be able to help make a proper decision:
Thanks in advance 🙇 |
|
@pgrzesik great questions, sorry I forgot to answer them in initial review:
Yes, let's put them top-level as
Simply against
I imagined supporting both (as you describe), but let me confirm with Components authors on why it works differently there. |
Codecov Report
@@ Coverage Diff @@
## master #8413 +/- ##
==========================================
+ Coverage 87.25% 87.27% +0.02%
==========================================
Files 255 256 +1
Lines 9501 9516 +15
==========================================
+ Hits 8290 8305 +15
Misses 1211 1211
Continue to review full report at Codecov.
|
|
Hello @medikoo 👋 Did you hear back from |
|
@pgrzesik very sorry for late response. After looking into it deeper, I think we should support only one env file. It's also recommended approach by So |
|
Hello @medikoo, thanks a lot for your response 🙇 I'll apply the changes to once again use only one env file and push the changes, hopefully later today 💯 |
702c8af to
274ac08
Compare
|
Updated, any feedback will be much appreciated 🙇 |
medikoo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @pgrzesik ! It looks very promising. Please see my suggestions and let me know what yo think
docs/environment-variables.md
Outdated
| layout: Doc | ||
| --> | ||
|
|
||
| # Preloading environment variables |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's maybe name it as "Resolution of environment variables"
And add another section header as:
## Support for `.env` filesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed 👍
docs/environment-variables.md
Outdated
|
|
||
| # Preloading environment variables | ||
|
|
||
| The framework automatically loads environment variables from `.env` files with the help of [dotenv](https://www.npmjs.com/package/dotenv). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'll be good to inform upfront that it's an opt-in. So maybe let's put it as:
With useDotenv: true set in your serverless.yml file, framework automatically... (from next major version .env files will be loaded by default and useDotenv setting will be ignored)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed 👍
docs/environment-variables.md
Outdated
|
|
||
| The framework automatically loads environment variables from `.env` files with the help of [dotenv](https://www.npmjs.com/package/dotenv). | ||
|
|
||
| The framework looks for `.env` and `.env.{stage}` files in current directory and then tries to load them using `dotenv`. If `.env.{stage}` is found, `.env` will not be loaded. If stage is not explicitly defined, it defaults to `dev`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's maybe change current directory to service directory, it'll be a bit more specific
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed 👍
docs/environment-variables.md
Outdated
|
|
||
| The framework looks for `.env` and `.env.{stage}` files in current directory and then tries to load them using `dotenv`. If `.env.{stage}` is found, `.env` will not be loaded. If stage is not explicitly defined, it defaults to `dev`. | ||
|
|
||
| **Note**: There are a few differences between above functionality and [serverless-dotenv-plugin](https://github.com/colynb/serverless-dotenv-plugin): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's maybe make it a subsection as:
### Differences against `serverless-dotenv-plugin`There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed 👍
lib/classes/EnvLoader.js
Outdated
| const path = require('path'); | ||
| const _ = require('lodash'); | ||
|
|
||
| class EnvLoader { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if creating a class is justified in this case. It looks as relatively simple loadEnv function encapsulated into class, which doesn't feel right.
Let's host it as function, same as e.g. eventuallyUpdate.js logic
Still I notice that utils folder also doesn't seem right, to me it can also be placed directly in lib (we anyway need at some point to improve internal organization)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was also not sure but I decided to follow similar approach as with YamlParser class. I currently changed implementation from class-based to function-based, however, it still depends on serverless instance. I was thinking on how to separate it, but it actually requires a few dependencies such as access to serverlessConfigFile, options and logDeprecation. I'm on the fence here and I see a few options:
- keep the dependency on
serverless - keep the dependency on specific pieces - instead of passing
serverlessinstance, just pass the needed pieces - resolve everything in
Serverlessand only passstage,useDotenvtoloadEnvfunction
Which one do you think makes the most sense here? I personally like the option #3, but that moves some of the responsibilities of loadEnv directly to Serverless class, which is not ideal
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was also not sure but I decided to follow similar approach as with YamlParser class
Totally understood, we have a lot of legacy conventions. Still it's good to abort some of them, especially when they resemble noisy not well justified practices.
it still depends on
serverlessinstance
That's true. I think the cleanest approach for now, would be to follow with something as:
InServerless.js (inline in init or in new method):
if (serviceContext && serviceConfig.useDotEnv) {
const stage = // resolve stage
loadDotEnv(stage);
} else {
isDotFileEnvExisting = // fs exists checks
if (isDotFileEnvExisting) showDeprecation();
}And in lib/resolveDotEnv.js just load eventually existing config for given stage
Note, that if we do it that way, then we would be able to use lib/resolveDotEnv.js in as is form, in refactor we plan to do here: #8364 (it is mostly about separating CLI and programmatic logic, so Framework core is purely programmatic (there will be then no dotenv resolution in its core), it'll also allow to integrate Components better)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So yes, option 3 as you pointed, seems best :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But without passing useDotenv (let's call the util when we know we want to resolve env from .env)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for a comprehensive explanation, I wasn't sure if moving some of the env-related logic makes sense in this case, but thanks to your explanation I see the reasoning and I also think that this is the most appropriate solution moving forward 🙇
lib/classes/EnvLoader.js
Outdated
| if (doesStageEnvFileExists) { | ||
| dotenv.config({ path: stageEnvFilePath }); | ||
| } else if (doesDefaultEnvFileExists) { | ||
| dotenv.config({ path: defaultEnvFilePath }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pgrzesik what about that one
If I read correctly from dotenv implementation, they return object with error property if file was not found: https://github.com/motdotla/dotenv/blob/7301ac9be0b2c766f865bbe24280bf82586d25aa/lib/main.js#L108
This also reminds me, that it'll be good to signal any parsing errors to the users. I think it'll be good to crash then with meaningful error.
lib/classes/EnvLoader.test.js
Outdated
| describe('EnvLoader', () => { | ||
| describe('with useDotenv', () => { | ||
| it('should load matching stage env file if present', async () => { | ||
| const result = await runServerless({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having it as util (which just takes servicePath as an input) we may write simpler test not involving runServerless.
Then we can write temporary files to home dir (it's a temporary folder in test run, not real user home dir), and handle process.env with process-uils/override-env as:
overrideEnv(() => {
// Load env from .env
// Confirm on `process.env` it's effective
});It'll be also helpful to have it detached from Serverless instance, as it's likely we will refactor whole process follow up to this proposal: #8364
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Depending on the decision on dependencies of loadEnv func, I will either refactor it to not depend on serverless or keep it in current form
|
Hello @medikoo - I'm really sorry about radio silence - I was not available for pretty much whole last week and I'm only now catching up. I hope to address all the comments at the beginning of the week. |
|
No worries, and great thanks for heads up @pgrzesik ! |
d7b545b to
b14d97b
Compare
medikoo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great thanks @pgrzesik for valuable input I hope my answers helped to clarify my point.
Sorry, if it's too confusing. We plan a bigger refactor improvement to the Framework (so it's more composable and fits along Components better), and that roadmap also influences implementation choices for PR's like this.
|
Thanks a lot @medikoo for additional clarification - that was definitely very valuable to me and helped me understand a bit better the vision for upcoming changes/refactoring of the framework. My initial gut feeling was to encapsulate the whole logic related to loading env (including all needed checks, etc) in separate util to keep |
b14d97b to
c26ae95
Compare
lib/loadEnv.test.js
Outdated
| let restoreEnv; | ||
|
|
||
| beforeEach(() => { | ||
| const tmpDir = createTmpDir(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a place that I would love to get your feedback @medikoo - I was trying to just setup .env files once in before, however, after first test, the files were no longer accessible, so I switched to beforeEach/afterEach combo that I'm not super happy about due to unnecessary creation of .env files before each test. Do you have any suggestions on how to properly set it up so these files setup in before will be visible to all tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pgrzesik As we're preparing it as standalone module (that takes stage), the unit test may simply put needed .env files to process.cwd(), and confirm that after running util expected env vars are seen on process.env
Important notes:
process.envis reset of each test file, therefore we don't have to worry about side effects (it's ensured by:restore-envMocha extension we have loadedprocess.cwd()is set to home dir, which is set to temporary dir (again ensured by Mocha extensions:mock-cwdandmock-homedir- note that homedir is cleanup up for other test files, so we also do not need to take care of cleaning it.
Additionally if you'd like to have process.env or process.cwd isolated for specific test runs (so there are no side effects for following tests in same test file). check: process-utils/override-env an process-utils/override-cwd
When having it isolated, and tested as above with unit test, not sure if it's worth testing that resolution works in context of Serverless instance. We might have some one sanity test, and such can be constructed as:
- Setup a chosen fixture (like here: )
const { servicePath, updateConfig } = await fixtures.setup('function'); - Then in returned
servicePathsetup needed.envfiles - Invoke
runServerlessrun on fixture (as here:)serverless/lib/plugins/aws/package/compile/functions/index.test.js
Lines 2933 to 2936 in c4c57b0
const { cfTemplate: originalTemplate } = await runServerless({ cwd: servicePath, cliArgs: ['package'], }); - Confirm on the outcome
If that doesn't answer your questions, let me know. I know I've provided an indirect answer, but I hope that above information clarifies how to setup test efficiently
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've posted above comment, without looking into test file, which looks very good imo.
I believe you don't need to create tmp dir, you may just put files in process.cwd() (once) as is, and all that you need to reset is process.env (which you already do with process-utils/override-env).
.env files should definitely not be removed between single tests, so you should be able to setup them once in before (they will however be cleaned after whole test file runs)
|
|
||
| const { error: stageEnvResultError } = dotenv.config({ path: stageEnvFilePath }); | ||
|
|
||
| if (!stageEnvResultError) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here, as suggested, I changed the approach to try to load files and based on error returned by dotenv.config decide how to proceed further. I'd love to hear your opinion on the chosen approach and I'm open to suggestions on how to improve it further
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks great!
medikoo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pgrzesik that looks great! I've posted just few clarifications towards proposed tests
lib/loadEnv.test.js
Outdated
| let restoreEnv; | ||
|
|
||
| beforeEach(() => { | ||
| const tmpDir = createTmpDir(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've posted above comment, without looking into test file, which looks very good imo.
I believe you don't need to create tmp dir, you may just put files in process.cwd() (once) as is, and all that you need to reset is process.env (which you already do with process-utils/override-env).
.env files should definitely not be removed between single tests, so you should be able to setup them once in before (they will however be cleaned after whole test file runs)
|
Thanks a lot for very valuable suggestions @medikoo. Initially, I wanted to setup files in Here, I added simple logging statements to log I'm not very familiar with As for the changes relevant to the main topic of this PR I did the following:
|
|
@pgrzesik thanks for this insight. Still I'm not able to reproduce the race condition you're describing. I've created following test file: 'use strict';
console.log('MODULE', process.cwd());
describe('test', () => {
console.log('DESCRIBE', process.cwd());
before(() => {
console.log('BEFORE', process.cwd());
});
beforeEach(() => {
console.log('BEFORE EACH', process.cwd());
});
it('test', () => {
console.log('IT', process.cwd());
});
});I've put it in Framework folder into and then I've simply run While in scope of module body cwd is not yet tweaked (and that's known limitation), it's in place in Are you sure you're relying on Mocha v6 as referenced in Concerning Mocha v8. Upgrade is pending, I've already started work on it (check: https://github.com/serverless/test/pull/59 - it'll be ready if not today, than in next days) |
|
Thanks for checking that out @medikoo. I've confirmed on my side once again, and you're right that with Running with Running with Running with As can be seen above, the test uses non-mocked cwd if ran with Running it with After waiting for a second, Another example of this behavior, not related to my machine surfaced in Would you be able to verify it on your side as well? |
|
@pgrzesik indeed, I was able to reproduce it locally, great thanks for thorough explanations. Interestingly it can be reproduced already by Therefore after you update against master issue should be gone. |
e6d9df3 to
7c5e72a
Compare
|
Thanks a lot @medikoo 🙇 Indeed, your changes resolved the issue. I've pushed version rebased on top of current master and changed the approach to setting up the files in |
medikoo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pgrzesik that looks great! I've proposed just few minor, mostly style improvements, and we're good to go!
lib/Serverless.test.js
Outdated
| ).servicePath; | ||
|
|
||
| const defaultFileContent = 'DEFAULT_ENV_VARIABLE=valuefromdefault'; | ||
| writeFileSync(path.join(servicePath, '.env'), defaultFileContent); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use async function (and rely on fs.promises directly).
(Unfortunately in Framework we have some not great old patterns that dates Node.js v4 and before, we will slowly recover from that, and ideally if new code is free from them)
lib/loadEnv.test.js
Outdated
| before(() => { | ||
| const stage = 'testing'; | ||
| const stageFileContent = 'FROM_STAGE=valuefromstage'; | ||
| writeFileSync(path.join(process.cwd(), `.env.${stage}`), stageFileContent); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here let's rely on fs.promises.writeFile
lib/loadEnv.test.js
Outdated
|
|
||
| it('should load matching stage env file if present', async () => { | ||
| await loadEnv('testing'); | ||
| expect(process.env.FROM_DEFAULT).to.be.undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More logical check is to.not.have.property() (theoretically undefined can be assigned as value, and that should be not expected)
docs/environment-variables.md
Outdated
|
|
||
| There are a few differences between above functionality and [serverless-dotenv-plugin](https://github.com/colynb/serverless-dotenv-plugin): | ||
|
|
||
| - the framework only loads environments variables locally and does not pass them to your functions' environment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd probably start each sentence capitalized.
also let's fix functions' into function's
lib/loadEnv.js
Outdated
| `Encountered: "${error}" while trying to load environment variables`, | ||
| ` from filepath: ${filePath}.`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd probably reword it to:
Failed to load environment variables from "${filePath}": ${error}"
- Original error message can be verbose, so it's better to put this at the end
- It's also convention in few other places, to first provide short context info, and then trail it with original error message.
|
@medikoo thank you, all should be fixed now 🙇 |
medikoo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @pgrzesik ! It's great to have this long waited functionality finally in
Add support for autoloading env variables from
.envand.env.${stage}files.The support is pretty basic and is meant to be similar to support implemented in
serverless/components- https://github.com/serverless/components/blob/master/src/cli/index.js#L64-L76There are still a few small questions that I think are better to discuss directly on a PR:
configuration-validation.md?.envfiles be resolved? Should it take into accountservicePathif one is set/provider?serverless/components, only one.envfile can be loaded - would it make sense to load both.envand.env.stageif there're present in a way that.env.stagewill be loaded later to override overlapping variables from.env?Closes: #7907