diff --git a/.gitignore b/.gitignore index 350c4412..b9cb0b61 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ TODO.md __debug_bin* # Added by goreleaser init: dist/ +.env diff --git a/cmd/lets/main.go b/cmd/lets/main.go index 6084dac5..3317a73e 100644 --- a/cmd/lets/main.go +++ b/cmd/lets/main.go @@ -124,7 +124,7 @@ func main() { if errors.As(err, &depErr) { executor.PrintDependencyTree(depErr, os.Stderr) } - log.Error(err.Error()) + log.Errorf("lets: %s", err.Error()) os.Exit(getExitCode(err, 1)) } } diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index fdc3fc9d..4b4b56ee 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -10,6 +10,7 @@ title: Changelog * `[Added]` Expose `LETS_OS` and `LETS_ARCH` environment variables at command runtime. * `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead. * `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted +* `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`. ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/docs/docs/config.md b/docs/docs/config.md index 84646f66..85387e6a 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -101,6 +101,58 @@ env: sh: echo "${ENGINE}-compose" ``` +### Global env_file + +`key: env_file` + +`type: string | map | list` + +Load one or more dotenv-style files and expose their values to all commands. + +Supported forms: + +```yaml +env_file: .env +env_file: -.env.local +env_file: + name: .env.${TARGET} + required: false +env_file: + - .env + - -.env.local + - name: .env.${LETS_OS} + required: true +``` + +Rules: + +- `-filename` is a short form of `required: false` +- files are resolved relative to the config directory +- file names are expanded after global `env` is resolved, so `env_file` can depend on global `env` +- values loaded from `env_file` have higher precedence than values from `env` +- missing files fail by default +- invalid env file syntax reports an error with the file name + +Example: + +```yaml +shell: bash + +env: + TARGET: dev + ENGINE: docker + +env_file: + - .env.${TARGET} + - -.env.local + +commands: + echo-env: + cmd: | + echo ENGINE=${ENGINE} + echo API_URL=${API_URL} +``` + ### Global before `key: before` @@ -696,6 +748,43 @@ commands: ``` +### `env_file` + +`key: env_file` + +`type: string | map | list` + +Load dotenv-style env files for a single command. + +Rules: + +- command `env` is resolved first +- command `env_file` file names are expanded using builtin lets vars, merged global env, and resolved command `env` +- values loaded from command `env_file` override values from command `env` +- paths are resolved relative to the config directory, not `work_dir` + +Example: + +```yaml +shell: bash + +env: + TARGET: dev + +commands: + up: + env: + SUFFIX: local + ENGINE: docker + env_file: + - .env.${TARGET}.${SUFFIX} + - -.env.override + cmd: | + echo ENGINE=${ENGINE} + echo API_URL=${API_URL} +``` + + ### `checksum` `key: checksum` diff --git a/docs/docs/env.md b/docs/docs/env.md index 94ac973b..a6c56c4f 100644 --- a/docs/docs/env.md +++ b/docs/docs/env.md @@ -27,6 +27,28 @@ title: Environment ### Override command env with -E flag +### `env_file` precedence + +`env_file` loads dotenv-style files from config and command definitions. + +Precedence order is: + +* process env +* builtin lets vars +* global `env` +* global `env_file` +* command `env` +* command `env_file` +* command options, `-E` / `--env`, checksum vars + +When the same key is present in both directives, `env_file` wins over `env` at the same scope. + +`env_file` file names are expanded after `env` is resolved. This means: + +* global `env_file` can depend on global `env` +* command `env_file` can depend on merged global env and command `env` +* `env.sh` still does not read values from `env_file` + You can override environment for command with `-E` flag: ```yaml diff --git a/docs/static/schema.json b/docs/static/schema.json index 77cc0c70..0127f7fd 100644 --- a/docs/static/schema.json +++ b/docs/static/schema.json @@ -42,6 +42,9 @@ "env": { "$ref": "#/definitions/env" }, + "env_file": { + "$ref": "#/definitions/env_file" + }, "before": { "type": "string", "description": "Commands to run before the main script." @@ -137,6 +140,9 @@ "env": { "$ref": "#/definitions/env" }, + "env_file": { + "$ref": "#/definitions/env_file" + }, "after": { "type": "string", "description": "A shell sctipt to run after the command." @@ -192,6 +198,45 @@ } }, "additionalProperties": false + }, + "env_file_entry": { + "oneOf": [ + { + "type": "string", + "description": "Path to an env file. Prefix with '-' to make it optional." + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Path to an env file." + }, + "required": { + "type": "boolean", + "description": "Whether the env file must exist. Defaults to true." + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + ] + }, + "env_file": { + "description": "Env file or list of env files to load.", + "oneOf": [ + { + "$ref": "#/definitions/env_file_entry" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/env_file_entry" + } + } + ] } }, "additionalProperties": false diff --git a/go.mod b/go.mod index 1db7d1bb..42579383 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( require ( github.com/h2non/filetype v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/joho/godotenv v1.5.1 github.com/juju/errors v0.0.0-20200330140219-3fe23663418f // indirect github.com/juju/testing v0.0.0-20201216035041-2be42bba85f3 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 diff --git a/go.sum b/go.sum index a42168e8..715fdbf4 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSAS github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/juju/ansiterm v0.0.0-20160907234532-b99631de12cf/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/juju/clock v0.0.0-20190205081909-9c5c9712527c/go.mod h1:nD0vlnrUjcjJhqN5WuCWZyzfd5AHZAC9/ajvbSx69xA= github.com/juju/cmd v0.0.0-20171107070456-e74f39857ca0/go.mod h1:yWJQHl73rdSX4DHVKGqkAip+huBslxRwS8m9CrOLq18= diff --git a/internal/config/config/command.go b/internal/config/config/command.go index e32d9204..c3ebdd2c 100644 --- a/internal/config/config/command.go +++ b/internal/config/config/command.go @@ -25,6 +25,8 @@ type Command struct { Description string // env from command Env *Envs + // env files from command + EnvFiles *EnvFiles // store docopts from options directive Docopts string SkipDocopts bool // default false @@ -62,6 +64,7 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { Description string Shell string Env *Envs + EnvFiles *EnvFiles `yaml:"env_file"` Options string Depends *Deps WorkDir string `yaml:"work_dir"` @@ -87,6 +90,10 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { if c.Env == nil { c.Env = &Envs{} } + c.EnvFiles = cmd.EnvFiles + if c.EnvFiles == nil { + c.EnvFiles = &EnvFiles{} + } c.Shell = cmd.Shell c.Docopts = cmd.Options @@ -126,12 +133,37 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } -func (c *Command) GetEnv(cfg Config) (map[string]string, error) { - if err := c.Env.Execute(cfg, cfg.GetEnv()); err != nil { +func (c *Command) GetEnv(cfg Config, builtinEnv map[string]string) (map[string]string, error) { + baseEnv := cloneMap(builtinEnv) + if baseEnv == nil { + baseEnv = make(map[string]string) + } + for key, value := range cfg.GetEnv() { + baseEnv[key] = value + } + + envs := c.Env.Clone() + if err := envs.Execute(cfg, baseEnv); err != nil { return nil, err } - return c.Env.Dump(), nil + filenameEnv := cloneMap(baseEnv) + for key, value := range envs.Dump() { + filenameEnv[key] = value + } + + envFiles := c.EnvFiles.Clone() + envFileEnv, err := envFiles.Load(cfg, filenameEnv) + if err != nil { + return nil, fmt.Errorf("lets: failed to resolve env_file for command '%s': %w", c.Name, err) + } + + resolvedEnv := envs.Dump() + for key, value := range envFileEnv { + resolvedEnv[key] = value + } + + return resolvedEnv, nil } func (c *Command) Clone() *Command { @@ -144,6 +176,7 @@ func (c *Command) Clone() *Command { WorkDir: c.WorkDir, Description: c.Description, Env: c.Env.Clone(), + EnvFiles: c.EnvFiles.Clone(), Docopts: c.Docopts, SkipDocopts: c.SkipDocopts, Options: cloneMap(c.Options), diff --git a/internal/config/config/config.go b/internal/config/config/config.go index 9897b8bb..99cf0244 100644 --- a/internal/config/config/config.go +++ b/internal/config/config/config.go @@ -18,6 +18,7 @@ var keywords = set.NewSet[string]( "version", "shell", "env", + "env_file", "init", "before", "mixins", @@ -35,16 +36,20 @@ type Config struct { // before is a script which will be included before every cmd Before string // init is a script which will be called exactly once before any command calls - Init string - Env *Envs - Version string - isMixin bool // if true, we consider config as mixin and apply different parsing and validation + Init string + Env *Envs + EnvFiles *EnvFiles + Version string // absolute path to .lets DotLetsDir string // absolute path to .lets/checksums ChecksumsDir string // absolute path to .lets/mixins MixinsDir string + + // cached env after config.SetupEnv, used in config.GetEnv + cachedEnv map[string]string + isMixin bool // if true, we consider config as mixin and apply different parsing and validation } func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -68,6 +73,7 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { Before string Init string Env *Envs + EnvFiles *EnvFiles `yaml:"env_file"` } if err := unmarshal(&config); err != nil { @@ -92,6 +98,10 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { if c.Env == nil { c.Env = &Envs{} } + c.EnvFiles = config.EnvFiles + if c.EnvFiles == nil { + c.EnvFiles = &EnvFiles{} + } for name, cmd := range c.Commands { cmd.Name = name @@ -188,6 +198,7 @@ func (c *Config) mergeMixin(mixin *Config) error { c.Before, mixin.Before, ) + c.EnvFiles.Append(mixin.EnvFiles) return nil } @@ -281,6 +292,11 @@ func (c *Config) readMixins(mixins []*Mixin) error { } func (c *Config) GetEnv() map[string]string { + if c.cachedEnv != nil { + // clone to avoid mutating config env + return cloneMap(c.cachedEnv) + } + return c.Env.Dump() } @@ -291,12 +307,28 @@ func (c *Config) SetupEnv() error { return err } + filenameEnv := c.BuiltinEnv(c.Shell) + for key, value := range c.Env.Dump() { + filenameEnv[key] = value + } + + envFileEnv, err := c.EnvFiles.Load(*c, filenameEnv) + if err != nil { + return fmt.Errorf("failed to resolve global env_file: %w", err) + } + + c.cachedEnv = c.Env.Dump() + for key, value := range envFileEnv { + c.cachedEnv[key] = value + } + // expand env for args for _, cmd := range c.Commands { for idx, arg := range cmd.Args { // we have to expand env here on our own, since this args not came from users tty, and not expanded before lets cmd.Args[idx] = os.Expand(arg, func(key string) string { - return c.Env.Mapping[key].Value + // safe to access own cached env + return c.cachedEnv[key] }) } } diff --git a/internal/config/config/deps.go b/internal/config/config/deps.go index 6af2c388..f6dc0b1d 100644 --- a/internal/config/config/deps.go +++ b/internal/config/config/deps.go @@ -21,7 +21,7 @@ type Deps struct { // UnmarshalYAML implements the yaml.Unmarshaler interface. func (d *Deps) UnmarshalYAML(node *yaml.Node) error { if node.Kind != yaml.SequenceNode { - return errors.New("lets: 'depends' must be a sequence") + return errors.New("'depends' must be a sequence") } for i := range len(node.Content) { diff --git a/internal/config/config/env.go b/internal/config/config/env.go index ae492871..17e2d2b1 100644 --- a/internal/config/config/env.go +++ b/internal/config/config/env.go @@ -28,7 +28,7 @@ type Env struct { // UnmarshalYAML implements the yaml.Unmarshaler interface. func (e *Envs) UnmarshalYAML(node *yaml.Node) error { if node.Kind != yaml.MappingNode { - return errors.New("lets: env is not a map") + return errors.New("env is not a map") } // keys accessed under even indexes @@ -42,7 +42,7 @@ func (e *Envs) UnmarshalYAML(node *yaml.Node) error { aliasedEnv := &Envs{} err := aliasedEnv.UnmarshalYAML(valueNode.Alias) if err != nil { - return errors.New("lets: can not parse aliased env") + return errors.New("can not parse aliased env") } e.Merge(aliasedEnv) continue @@ -72,11 +72,11 @@ func (e *Envs) UnmarshalYAML(node *yaml.Node) error { } if envAsMap.Sh == nil && envAsMap.Checksum == nil { - return fmt.Errorf("lets: environment variable '%s' must have value or 'sh' or 'checksum'", keyNode.Value) + return fmt.Errorf("environment variable '%s' must have value or 'sh' or 'checksum'", keyNode.Value) } if envAsMap.Sh != nil && envAsMap.Checksum != nil { - return fmt.Errorf("lets: environment variable '%s' must have only 'sh' or 'checksum'", keyNode.Value) + return fmt.Errorf("environment variable '%s' must have only 'sh' or 'checksum'", keyNode.Value) } if envAsMap.Sh != nil { diff --git a/internal/config/config/env_file.go b/internal/config/config/env_file.go new file mode 100644 index 00000000..8f690712 --- /dev/null +++ b/internal/config/config/env_file.go @@ -0,0 +1,177 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + "github.com/lets-cli/lets/internal/util" + "gopkg.in/yaml.v3" +) + +type EnvFile struct { + Name string + Required bool +} + +type EnvFiles struct { + Items []EnvFile + loaded map[string]string + ready bool +} + +func (e *EnvFile) UnmarshalYAML(unmarshal func(interface{}) error) error { + var filename string + // try parse as scalar + if err := unmarshal(&filename); err == nil { + e.Name = normalizeEnvFilename(filename) + e.Required = !isOptionalEnvFilename(filename) + if e.Name == "" { + return errors.New("env_file name can not be empty") + } + + return nil + } + + var raw struct { + Name string + Required *bool + } + // try parse as map + if err := unmarshal(&raw); err != nil { + return err + } + + if raw.Name == "" { + return errors.New("env_file name can not be empty") + } + + if isOptionalEnvFilename(raw.Name) { + return errors.New("env_file map form does not support '-' prefix in name; use required: false instead") + } + + e.Name = raw.Name + e.Required = true + if raw.Required != nil { + e.Required = *raw.Required + } + + return nil +} + +func (e *EnvFiles) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode, yaml.MappingNode: + var item EnvFile + if err := node.Decode(&item); err != nil { + return err + } + + e.Items = []EnvFile{item} + return nil + case yaml.SequenceNode: + items := make([]EnvFile, 0, len(node.Content)) + for _, itemNode := range node.Content { + var item EnvFile + if err := itemNode.Decode(&item); err != nil { + return err + } + items = append(items, item) + } + + e.Items = items + return nil + default: + return errors.New("env_file must be a string, map, or sequence") + } +} + +func (e *EnvFiles) Clone() *EnvFiles { + if e == nil { + return nil + } + + items := make([]EnvFile, len(e.Items)) + copy(items, e.Items) + + return &EnvFiles{ + Items: items, + } +} + +func (e *EnvFiles) Append(other *EnvFiles) { + if other == nil || len(other.Items) == 0 { + return + } + + e.Items = append(e.Items, other.Items...) +} + +func (e *EnvFiles) Load(cfg Config, envMap map[string]string) (map[string]string, error) { + if e == nil { + return map[string]string{}, nil + } + + if e.ready { + return cloneMap(e.loaded), nil + } + + loaded := make(map[string]string) + + for _, item := range e.Items { + filename := expandWithEnv(item.Name, envMap) + if strings.TrimSpace(filename) == "" { + return nil, fmt.Errorf("env_file %q resolved to empty path", item.Name) + } + + if !filepath.IsAbs(filename) { + filename = filepath.Join(cfg.WorkDir, filename) + } + + if !util.FileExists(filename) { + if item.Required { + return nil, fmt.Errorf("env_file %q does not exist", filename) + } + + continue + } + + values, err := godotenv.Read(filename) + if err != nil { + return nil, fmt.Errorf("failed to parse env_file %q: %w", filename, err) + } + + for key, value := range values { + loaded[key] = value + } + } + + e.loaded = loaded + e.ready = true + + return cloneMap(loaded), nil +} + +func normalizeEnvFilename(filename string) string { + return strings.TrimPrefix(filename, "-") +} + +func isOptionalEnvFilename(filename string) bool { + return strings.HasPrefix(filename, "-") +} + +func expandWithEnv(value string, envMap map[string]string) string { + return os.Expand(value, func(key string) string { + if envMap != nil { + if value, exists := envMap[key]; exists { + return value + } + } + + // If lets own env does not have declared env var, fallback to os env + return os.Getenv(key) + }) +} diff --git a/internal/config/config/env_file_test.go b/internal/config/config/env_file_test.go new file mode 100644 index 00000000..5ab61e5c --- /dev/null +++ b/internal/config/config/env_file_test.go @@ -0,0 +1,357 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/lithammer/dedent" + "gopkg.in/yaml.v3" +) + +func loadConfigFixture(t *testing.T, workDir string, text string) *Config { + t.Helper() + + cfg := NewConfig(workDir, filepath.Join(workDir, "lets.yaml"), filepath.Join(workDir, ".lets")) + buf := bytes.NewBufferString(text) + if err := yaml.NewDecoder(buf).Decode(cfg); err != nil { + t.Fatalf("config fixture decode error: %s", err) + } + + return cfg +} + +func writeFixtureFile(t *testing.T, dir string, name string, content string) { + t.Helper() + + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write fixture %s: %s", path, err) + } +} + +func TestParseEnvFiles(t *testing.T) { + t.Run("parses scalar env_file form", func(t *testing.T) { + text := "env_file: .env\n" + + var raw struct { + EnvFiles *EnvFiles `yaml:"env_file"` + } + if err := yaml.NewDecoder(bytes.NewBufferString(text)).Decode(&raw); err != nil { + t.Fatalf("unexpected decode error: %s", err) + } + + if len(raw.EnvFiles.Items) != 1 { + t.Fatalf("expected 1 env file, got %d", len(raw.EnvFiles.Items)) + } + + if got := raw.EnvFiles.Items[0]; got.Name != ".env" || !got.Required { + t.Fatalf("unexpected env file: %#v", got) + } + }) + + t.Run("parses map env_file form", func(t *testing.T) { + text := dedent.Dedent(` + env_file: + name: .env.prod + required: false + `) + + var raw struct { + EnvFiles *EnvFiles `yaml:"env_file"` + } + if err := yaml.NewDecoder(bytes.NewBufferString(text)).Decode(&raw); err != nil { + t.Fatalf("unexpected decode error: %s", err) + } + + if len(raw.EnvFiles.Items) != 1 { + t.Fatalf("expected 1 env file, got %d", len(raw.EnvFiles.Items)) + } + + if got := raw.EnvFiles.Items[0]; got.Name != ".env.prod" || got.Required { + t.Fatalf("unexpected env file: %#v", got) + } + }) + + t.Run("parses mixed env_file forms", func(t *testing.T) { + text := dedent.Dedent(` + env_file: + - .env + - -.env.local + - name: .env.prod + required: false + `) + + var raw struct { + EnvFiles *EnvFiles `yaml:"env_file"` + } + if err := yaml.NewDecoder(bytes.NewBufferString(text)).Decode(&raw); err != nil { + t.Fatalf("unexpected decode error: %s", err) + } + + if len(raw.EnvFiles.Items) != 3 { + t.Fatalf("expected 3 env files, got %d", len(raw.EnvFiles.Items)) + } + + if got := raw.EnvFiles.Items[0]; got.Name != ".env" || !got.Required { + t.Fatalf("unexpected first env file: %#v", got) + } + + if got := raw.EnvFiles.Items[1]; got.Name != ".env.local" || got.Required { + t.Fatalf("unexpected second env file: %#v", got) + } + + if got := raw.EnvFiles.Items[2]; got.Name != ".env.prod" || got.Required { + t.Fatalf("unexpected third env file: %#v", got) + } + }) + + t.Run("rejects env_file item without name", func(t *testing.T) { + text := dedent.Dedent(` + env_file: + - required: false + `) + + var raw struct { + EnvFiles *EnvFiles `yaml:"env_file"` + } + err := yaml.NewDecoder(bytes.NewBufferString(text)).Decode(&raw) + if err == nil { + t.Fatal("expected decode error") + } + + if !strings.Contains(err.Error(), "env_file name can not be empty") { + t.Fatalf("unexpected error: %s", err) + } + }) + + t.Run("rejects map form with optional dash prefix", func(t *testing.T) { + text := dedent.Dedent(` + env_file: + name: -.env.local + `) + + var raw struct { + EnvFiles *EnvFiles `yaml:"env_file"` + } + err := yaml.NewDecoder(bytes.NewBufferString(text)).Decode(&raw) + if err == nil { + t.Fatal("expected decode error") + } + + if !strings.Contains(err.Error(), "use required: false instead") { + t.Fatalf("unexpected error: %s", err) + } + }) + + t.Run("rejects invalid top-level env_file kind", func(t *testing.T) { + envFiles := &EnvFiles{} + err := envFiles.UnmarshalYAML(&yaml.Node{Kind: yaml.AliasNode}) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "env_file must be a string, map, or sequence") { + t.Fatalf("unexpected error: %s", err) + } + }) +} + +func TestEnvFilesLoad(t *testing.T) { + workDir := t.TempDir() + writeFixtureFile(t, workDir, ".env.first", "VALUE=first\n") + writeFixtureFile(t, workDir, ".env.second", "VALUE=second\nSECOND=two\n") + writeFixtureFile(t, workDir, ".env.invalid", "NOT VALID\n") + + cfg := Config{WorkDir: workDir} + + t.Run("later files override earlier files", func(t *testing.T) { + envFiles := &EnvFiles{ + Items: []EnvFile{ + {Name: ".env.first", Required: true}, + {Name: ".env.second", Required: true}, + }, + } + + got, err := envFiles.Load(cfg, nil) + if err != nil { + t.Fatalf("unexpected load error: %s", err) + } + + if got["VALUE"] != "second" || got["SECOND"] != "two" { + t.Fatalf("unexpected env map: %#v", got) + } + }) + + t.Run("skips optional missing files", func(t *testing.T) { + envFiles := &EnvFiles{ + Items: []EnvFile{ + {Name: ".env.first", Required: true}, + {Name: ".env.missing", Required: false}, + }, + } + + got, err := envFiles.Load(cfg, nil) + if err != nil { + t.Fatalf("unexpected load error: %s", err) + } + + if got["VALUE"] != "first" { + t.Fatalf("expected VALUE from existing file, got %#v", got) + } + }) + + t.Run("fails on missing required file", func(t *testing.T) { + envFiles := &EnvFiles{ + Items: []EnvFile{{Name: ".env.missing", Required: true}}, + } + + _, err := envFiles.Load(cfg, nil) + if err == nil { + t.Fatal("expected load error") + } + + if !strings.Contains(err.Error(), filepath.Join(workDir, ".env.missing")) { + t.Fatalf("unexpected error: %s", err) + } + }) + + t.Run("fails on invalid env file format with filename", func(t *testing.T) { + envFiles := &EnvFiles{ + Items: []EnvFile{{Name: ".env.invalid", Required: true}}, + } + + _, err := envFiles.Load(cfg, nil) + if err == nil { + t.Fatal("expected load error") + } + + if !strings.Contains(err.Error(), filepath.Join(workDir, ".env.invalid")) { + t.Fatalf("unexpected error: %s", err) + } + }) +} + +func TestConfigSetupEnvWithEnvFile(t *testing.T) { + workDir := t.TempDir() + writeFixtureFile(t, workDir, ".env.global", "FROM_FILE=global-file\nOVERRIDE=from-file\n") + writeFixtureFile(t, workDir, ".env."+runtime.GOOS, "OS_FILE=os-file\n") + + cfg := loadConfigFixture(t, workDir, dedent.Dedent(` + shell: bash + env: + TARGET: global + OVERRIDE: from-env + env_file: + - .env.${TARGET} + - .env.${LETS_OS} + commands: + echo: + cmd: echo ok + `)) + + if err := cfg.SetupEnv(); err != nil { + t.Fatalf("unexpected setup error: %s", err) + } + + got := cfg.GetEnv() + if got["FROM_FILE"] != "global-file" { + t.Fatalf("expected FROM_FILE from env_file, got %#v", got) + } + + if got["OVERRIDE"] != "from-file" { + t.Fatalf("expected env_file to override env, got %#v", got) + } + + if got["OS_FILE"] != "os-file" { + t.Fatalf("expected OS_FILE from LETS_OS interpolation, got %#v", got) + } + + got["FROM_FILE"] = "mutated" + gotAgain := cfg.GetEnv() + if gotAgain["FROM_FILE"] != "global-file" { + t.Fatalf("expected cached env to be isolated from caller mutations, got %#v", gotAgain) + } +} + +func TestCommandGetEnvWithEnvFile(t *testing.T) { + workDir := t.TempDir() + writeFixtureFile(t, workDir, ".env.global", "GLOBAL_FROM_FILE=global-file\n") + writeFixtureFile(t, workDir, ".env.command.dev", "COMMAND_FROM_FILE=command-file\nCOMMAND_OVERRIDE=from-file\n") + + cfg := loadConfigFixture(t, workDir, dedent.Dedent(` + shell: bash + env: + TARGET: global + COMMAND_TARGET: command + env_file: .env.${TARGET} + commands: + echo: + env: + SUFFIX: dev + COMMAND_OVERRIDE: from-env + env_file: .env.${COMMAND_TARGET}.${SUFFIX} + cmd: echo ok + `)) + + if err := cfg.SetupEnv(); err != nil { + t.Fatalf("unexpected setup error: %s", err) + } + + cmd := cfg.Commands["echo"] + got, err := cmd.GetEnv(*cfg, cfg.CommandBuiltinEnv(cmd, cfg.Shell, cfg.WorkDir)) + if err != nil { + t.Fatalf("unexpected command env error: %s", err) + } + + if got["COMMAND_FROM_FILE"] != "command-file" { + t.Fatalf("expected command env_file value, got %#v", got) + } + + if got["COMMAND_OVERRIDE"] != "from-file" { + t.Fatalf("expected command env_file to override env, got %#v", got) + } +} + +func TestCommandGetEnvDoesNotReuseBuiltinEnvCache(t *testing.T) { + workDir := t.TempDir() + writeFixtureFile(t, workDir, ".env.one", "VALUE=from-one\n") + writeFixtureFile(t, workDir, ".env.two", "VALUE=from-two\n") + + cfg := loadConfigFixture(t, workDir, dedent.Dedent(` + shell: bash + commands: + echo: + env_file: .env.${LETS_COMMAND_ARGS} + cmd: echo ok + `)) + + if err := cfg.SetupEnv(); err != nil { + t.Fatalf("unexpected setup error: %s", err) + } + + cmd := cfg.Commands["echo"] + + cmd.Args = []string{"one"} + gotOne, err := cmd.GetEnv(*cfg, cfg.CommandBuiltinEnv(cmd, cfg.Shell, cfg.WorkDir)) + if err != nil { + t.Fatalf("unexpected command env error: %s", err) + } + + cmd.Args = []string{"two"} + gotTwo, err := cmd.GetEnv(*cfg, cfg.CommandBuiltinEnv(cmd, cfg.Shell, cfg.WorkDir)) + if err != nil { + t.Fatalf("unexpected command env error: %s", err) + } + + if gotOne["VALUE"] != "from-one" { + t.Fatalf("expected first env file to be used, got %#v", gotOne) + } + + if gotTwo["VALUE"] != "from-two" { + t.Fatalf("expected second env file to be used, got %#v", gotTwo) + } +} diff --git a/internal/config/config/runtime_env.go b/internal/config/config/runtime_env.go new file mode 100644 index 00000000..3159f3ea --- /dev/null +++ b/internal/config/config/runtime_env.go @@ -0,0 +1,26 @@ +package config + +import ( + "path/filepath" + "runtime" + "strings" +) + +func (c *Config) BuiltinEnv(shell string) map[string]string { + return map[string]string{ + "LETS_CONFIG": filepath.Base(c.FilePath), + "LETS_CONFIG_DIR": filepath.Dir(c.FilePath), + "LETS_OS": runtime.GOOS, + "LETS_ARCH": runtime.GOARCH, + "LETS_SHELL": shell, + } +} + +func (c *Config) CommandBuiltinEnv(command *Command, shell string, workDir string) map[string]string { + envMap := c.BuiltinEnv(shell) + envMap["LETS_COMMAND_NAME"] = command.Name + envMap["LETS_COMMAND_ARGS"] = strings.Join(command.Args, " ") + envMap["LETS_COMMAND_WORK_DIR"] = workDir + + return envMap +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 8ba08ff6..5e9f8e30 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -7,8 +7,6 @@ import ( "io" "os" "os/exec" - "path/filepath" - "runtime" "strings" "github.com/lets-cli/lets/internal/checksum" @@ -209,16 +207,7 @@ func joinBeforeAndScript(before string, script string) string { // Setup env for cmd. func (e *Executor) setupEnv(osCmd *exec.Cmd, command *config.Command, shell string) error { - defaultEnv := map[string]string{ - "LETS_COMMAND_NAME": command.Name, - "LETS_COMMAND_ARGS": strings.Join(command.Args, " "), - "LETS_COMMAND_WORK_DIR": osCmd.Dir, - "LETS_CONFIG": filepath.Base(e.cfg.FilePath), - "LETS_CONFIG_DIR": filepath.Dir(e.cfg.FilePath), - "LETS_OS": runtime.GOOS, - "LETS_ARCH": runtime.GOARCH, - "LETS_SHELL": shell, - } + defaultEnv := e.cfg.CommandBuiltinEnv(command, shell, osCmd.Dir) checksumEnvMap := getChecksumEnvMap(command.ChecksumMap) @@ -230,7 +219,7 @@ func (e *Executor) setupEnv(osCmd *exec.Cmd, command *config.Command, shell stri ) } - cmdEnv, err := command.GetEnv(*e.cfg) + cmdEnv, err := command.GetEnv(*e.cfg, defaultEnv) if err != nil { return err } diff --git a/tests/command_options.bats b/tests/command_options.bats index 3c757d60..4d0c11ff 100644 --- a/tests/command_options.bats +++ b/tests/command_options.bats @@ -108,7 +108,7 @@ setup() { run lets test-options --kv-opt assert_failure - assert_line --index 1 "failed to parse docopt options for cmd test-options: --kv-opt requires argument" + assert_line --index 1 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument" assert_line --index 2 "Usage:" assert_line --index 3 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" assert_line --index 4 "Options:" @@ -122,7 +122,7 @@ setup() { run lets options-wrong-usage assert_failure - assert_line --index 1 "failed to parse docopt options for cmd options-wrong-usage: no such option" + assert_line --index 1 "lets: failed to parse docopt options for cmd options-wrong-usage: no such option" assert_line --index 2 "Usage: lets options-wrong-usage-xxx" } diff --git a/tests/default_env.bats b/tests/default_env.bats index 365710de..6f329a24 100644 --- a/tests/default_env.bats +++ b/tests/default_env.bats @@ -61,5 +61,5 @@ setup() { LETS_CONFIG_DIR=./a run lets print-workdir assert_failure - assert_line "failed to run command 'print-workdir': chdir ${TEST_DIR}/b: no such file or directory" + assert_line "lets: failed to run command 'print-workdir': chdir ${TEST_DIR}/b: no such file or directory" } diff --git a/tests/env_file.bats b/tests/env_file.bats new file mode 100644 index 00000000..6c6fc20b --- /dev/null +++ b/tests/env_file.bats @@ -0,0 +1,50 @@ +load test_helpers + +setup() { + load "${BATS_UTILS_PATH}/bats-support/load.bash" + load "${BATS_UTILS_PATH}/bats-assert/load.bash" + cd ./tests/env_file +} + +@test "env_file: should load global env files and keep env precedence" { + run lets print-global + assert_success + assert_line --index 0 "GLOBAL_FROM_FILE=from-global-file" + assert_line --index 1 "GLOBAL_OVERRIDE=from-global-file" + assert_line --index 2 "OS_FILE=1" +} + +@test "env_file: should load command env files and keep env precedence" { + run lets print-command + assert_success + assert_line --index 0 "COMMAND_FROM_FILE=from-command-file" + assert_line --index 1 "COMMAND_OVERRIDE=from-command-file" +} + +@test "env_file: should ignore optional missing file" { + run lets print-optional + assert_success + assert_line --index 0 "OPTIONAL_OK=1" +} + +@test "env_file: should fail on required missing global env file" { + run lets -c lets.global-missing.yaml noop + assert_failure + assert_line --partial "env_file" + assert_line --partial ".env.required.global.missing" +} + +@test "env_file: should fail on required missing command env file" { + run lets -c lets.command-missing.yaml fail + assert_failure + assert_line --partial "command 'fail'" + assert_line --partial "env_file" + assert_line --partial ".env.required.command.missing" +} + +@test "env_file: should report invalid env file with filename" { + run lets -c lets.global-invalid.yaml noop + assert_failure + assert_line --partial "failed to parse env_file" + assert_line --partial ".env.invalid" +} diff --git a/tests/env_file/.env.command.dev b/tests/env_file/.env.command.dev new file mode 100644 index 00000000..82e1cd25 --- /dev/null +++ b/tests/env_file/.env.command.dev @@ -0,0 +1,2 @@ +COMMAND_FROM_FILE=from-command-file +COMMAND_OVERRIDE=from-command-file diff --git a/tests/env_file/.env.darwin b/tests/env_file/.env.darwin new file mode 100644 index 00000000..1f320ff3 --- /dev/null +++ b/tests/env_file/.env.darwin @@ -0,0 +1 @@ +OS_FILE=1 diff --git a/tests/env_file/.env.global b/tests/env_file/.env.global new file mode 100644 index 00000000..3852c510 --- /dev/null +++ b/tests/env_file/.env.global @@ -0,0 +1,2 @@ +GLOBAL_FROM_FILE=from-global-file +GLOBAL_OVERRIDE=from-global-file diff --git a/tests/env_file/.env.invalid b/tests/env_file/.env.invalid new file mode 100644 index 00000000..9d8455bd --- /dev/null +++ b/tests/env_file/.env.invalid @@ -0,0 +1 @@ +NOT VALID diff --git a/tests/env_file/.env.linux b/tests/env_file/.env.linux new file mode 100644 index 00000000..1f320ff3 --- /dev/null +++ b/tests/env_file/.env.linux @@ -0,0 +1 @@ +OS_FILE=1 diff --git a/tests/env_file/lets.command-missing.yaml b/tests/env_file/lets.command-missing.yaml new file mode 100644 index 00000000..0052dbaf --- /dev/null +++ b/tests/env_file/lets.command-missing.yaml @@ -0,0 +1,6 @@ +shell: bash + +commands: + fail: + env_file: .env.required.command.missing + cmd: echo fail diff --git a/tests/env_file/lets.global-invalid.yaml b/tests/env_file/lets.global-invalid.yaml new file mode 100644 index 00000000..0f269022 --- /dev/null +++ b/tests/env_file/lets.global-invalid.yaml @@ -0,0 +1,7 @@ +shell: bash + +env_file: .env.invalid + +commands: + noop: + cmd: echo noop diff --git a/tests/env_file/lets.global-missing.yaml b/tests/env_file/lets.global-missing.yaml new file mode 100644 index 00000000..4349d460 --- /dev/null +++ b/tests/env_file/lets.global-missing.yaml @@ -0,0 +1,7 @@ +shell: bash + +env_file: .env.required.global.missing + +commands: + noop: + cmd: echo noop diff --git a/tests/env_file/lets.yaml b/tests/env_file/lets.yaml new file mode 100644 index 00000000..90e990e4 --- /dev/null +++ b/tests/env_file/lets.yaml @@ -0,0 +1,30 @@ +shell: bash + +env: + TARGET: global + COMMAND_TARGET: command + GLOBAL_OVERRIDE: from-global-env + +env_file: + - .env.${TARGET} + - .env.${LETS_OS} + +commands: + print-global: + cmd: | + echo GLOBAL_FROM_FILE=${GLOBAL_FROM_FILE} + echo GLOBAL_OVERRIDE=${GLOBAL_OVERRIDE} + echo OS_FILE=${OS_FILE} + + print-command: + env: + SUFFIX: dev + COMMAND_OVERRIDE: from-command-env + env_file: .env.${COMMAND_TARGET}.${SUFFIX} + cmd: | + echo COMMAND_FROM_FILE=${COMMAND_FROM_FILE} + echo COMMAND_OVERRIDE=${COMMAND_OVERRIDE} + + print-optional: + env_file: -.env.optional.missing + cmd: echo OPTIONAL_OK=1