Implement .env file preload (#867)
* implement .env parsing and expanding * implement .env parsing and expanding * reload env on reload/rebuild as well * default to empty env file. remove cyclical dependency stuff * default to empty env file. remove cyclical dependency stuff * use godotenv to parse, dont override global vars * implement multiple env file support * update readme and example config * update readme some more * fix typo * dce in util.go * Avoid loading env files on cancelled runs * Clean up env load linting --------- Co-authored-by: sirkostya009 <kosta.tovstik@gmail.com>
This commit is contained in:
@@ -25,6 +25,7 @@ Note: This tool has nothing to do with hot-deploy for production.
|
||||
- Support excluding subdirectories
|
||||
- Allow watching new directories after Air started
|
||||
- Better building process
|
||||
- Automatic `.env` file loading
|
||||
|
||||
### Overwrite specify configuration from arguments
|
||||
|
||||
@@ -227,6 +228,17 @@ entrypoint = [
|
||||
]
|
||||
```
|
||||
|
||||
### Environment Files
|
||||
|
||||
Air can automatically load environment variables from `.env` files before both building and running.
|
||||
|
||||
```toml
|
||||
# Loads .env.development and then .env files.
|
||||
# Values in the lattermost file overwrite any preceding ones.
|
||||
# Does not overwrite variables that were present before running air.
|
||||
env_files = [".env.development", ".env"]
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
# Remove to not load any files whatsoever
|
||||
# Non-existing files are safely ignored
|
||||
env_files = [".env"]
|
||||
|
||||
[build]
|
||||
# Array of commands to run before each build
|
||||
pre_cmd = ["echo 'hello air' > pre_cmd.txt"]
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gohugoio/hugo v0.149.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pelletier/go-toml v1.9.5
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
@@ -94,6 +94,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
|
||||
@@ -31,6 +31,7 @@ type Config struct {
|
||||
Root string `toml:"root" usage:"Working directory, . or absolute path, please note that the directories following must be under root"`
|
||||
TmpDir string `toml:"tmp_dir" usage:"Temporary directory for air"`
|
||||
TestDataDir string `toml:"testdata_dir"`
|
||||
EnvFiles []string `toml:"env_files" usage:"Paths to .env files to load before build/run"`
|
||||
Build cfgBuild `toml:"build"`
|
||||
Color cfgColor `toml:"color"`
|
||||
Log cfgLog `toml:"log"`
|
||||
@@ -323,6 +324,7 @@ func defaultConfig() Config {
|
||||
Root: ".",
|
||||
TmpDir: "tmp",
|
||||
TestDataDir: "testdata",
|
||||
EnvFiles: []string{},
|
||||
Build: build,
|
||||
Color: color,
|
||||
Log: log,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hugo/watcher/filenotify"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Engine ...
|
||||
@@ -47,6 +48,12 @@ type Engine struct {
|
||||
fileChecksums *checksumMap
|
||||
|
||||
ll sync.Mutex // lock for logger
|
||||
|
||||
// globalEnv stores the original env values before air modified them
|
||||
// key:original value (empty string means it was unset)
|
||||
globalEnv map[string]*string
|
||||
// loadedEnv tracks env values that were set by the last env file load
|
||||
loadedEnv map[string]string
|
||||
}
|
||||
|
||||
// NewEngineWithConfig ...
|
||||
@@ -79,6 +86,7 @@ func NewEngineWithConfig(cfg *Config, debugMode bool) (*Engine, error) {
|
||||
exitCh: make(chan bool),
|
||||
fileChecksums: &checksumMap{m: make(map[string]string)},
|
||||
watchers: 0,
|
||||
globalEnv: map[string]*string{},
|
||||
}
|
||||
|
||||
return &e, nil
|
||||
@@ -426,6 +434,72 @@ func (e *Engine) start() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) loadEnvFile() {
|
||||
if len(e.config.EnvFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// assume refreshed env is as big as the loaded env
|
||||
newEnv := make(map[string]string, len(e.loadedEnv))
|
||||
|
||||
for _, envPath := range e.config.EnvFiles {
|
||||
if !filepath.IsAbs(envPath) {
|
||||
envPath = filepath.Join(e.config.Root, envPath)
|
||||
}
|
||||
file, err := os.Open(envPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
e.mainDebug("env file %q does not exist, skipping", envPath)
|
||||
} else {
|
||||
e.runnerLog("failed to open env file %q: %s", envPath, err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
defer file.Close()
|
||||
fileEnv, err := godotenv.Parse(file)
|
||||
if err != nil {
|
||||
e.runnerLog("failed to parse env file %q: %s", envPath, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range fileEnv {
|
||||
if v, tracked := e.globalEnv[k]; !tracked {
|
||||
origVal, exists := os.LookupEnv(k)
|
||||
if exists {
|
||||
// not used yet, but might be useful for a future "override" feature
|
||||
e.globalEnv[k] = &origVal
|
||||
continue // untracked env values are likely global - don't override them
|
||||
}
|
||||
// on first encounter of a key, if no global value exists, mark as nil so
|
||||
// that on next load of .env file, globalEnv map value will not be overwritten
|
||||
e.globalEnv[k] = nil
|
||||
} else if tracked && v != nil {
|
||||
// only set values from file if not already present in the environment
|
||||
e.mainDebug("key %q already exists in the environment, skipping", k)
|
||||
continue
|
||||
}
|
||||
if err := os.Setenv(k, v); err != nil {
|
||||
e.runnerLog("failed to set env key %q: %s", k, err.Error())
|
||||
}
|
||||
newEnv[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// unset any keys that were removed from .env file,
|
||||
// but ignore those that were set before air was run
|
||||
for k := range e.loadedEnv {
|
||||
if _, exists := newEnv[k]; !exists {
|
||||
if orig := e.globalEnv[k]; orig == nil {
|
||||
if err := os.Unsetenv(k); err != nil {
|
||||
e.runnerLog("failed to restore env key %q: %s", k, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.loadedEnv = newEnv
|
||||
}
|
||||
|
||||
func (e *Engine) buildRun() {
|
||||
// Create this build's unique stop channel
|
||||
myStopCh := make(chan struct{})
|
||||
@@ -446,6 +520,8 @@ func (e *Engine) buildRun() {
|
||||
default:
|
||||
}
|
||||
|
||||
e.loadEnvFile()
|
||||
|
||||
var err error
|
||||
if err = e.runPreCmd(); err != nil {
|
||||
e.runnerLog("failed to execute pre_cmd: %s", err.Error())
|
||||
|
||||
@@ -1334,3 +1334,56 @@ func TestBuildRunRaceConditionRapidChanges(t *testing.T) {
|
||||
// Clean up
|
||||
<-e.buildRunCh
|
||||
}
|
||||
|
||||
func TestEngineLoadEnvFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
envPath := filepath.Join(tmpDir, ".env")
|
||||
|
||||
originalValue := "original_global_value"
|
||||
t.Setenv("TEST_GLOBAL_VAR", originalValue)
|
||||
|
||||
const initialEnv = `TEST_VAR1=value1
|
||||
TEST_VAR2=value2
|
||||
TEST_GLOBAL_VAR=overridden_value
|
||||
`
|
||||
|
||||
err := os.WriteFile(envPath, []byte(initialEnv), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := defaultConfig()
|
||||
cfg.Root = tmpDir
|
||||
cfg.EnvFiles = []string{".env"}
|
||||
|
||||
engine, err := NewEngineWithConfig(&cfg, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
engine.loadEnvFile()
|
||||
|
||||
assert.Equal(t, "value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be set")
|
||||
assert.Equal(t, "value2", os.Getenv("TEST_VAR2"), "TEST_VAR2 should be set")
|
||||
assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden")
|
||||
|
||||
// remove TEST_VAR2
|
||||
const updatedEnv = `TEST_VAR1=updated_value1
|
||||
TEST_GLOBAL_VAR=still_overridden
|
||||
`
|
||||
err = os.WriteFile(envPath, []byte(updatedEnv), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
engine.loadEnvFile()
|
||||
|
||||
assert.Equal(t, "updated_value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be updated")
|
||||
// since TEST_VAR2 only exists in environment thanks to air, it should get unset on removal
|
||||
_, exists := os.LookupEnv("TEST_VAR2")
|
||||
assert.False(t, exists, "TEST_VAR2 should be unset after removal from .env")
|
||||
assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden")
|
||||
|
||||
const finalEnv = `TEST_VAR1=final_value`
|
||||
err = os.WriteFile(envPath, []byte(finalEnv), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
engine.loadEnvFile()
|
||||
|
||||
assert.Equal(t, "final_value", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be final")
|
||||
assert.Equal(t, originalValue, os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should be restored to original value")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user