Skip to content

Commit

Permalink
support --env-file CLI option (#274) (#491)
Browse files Browse the repository at this point in the history
* support --env-file CLI option (#274)

* Passing errors when loading env
from file, remove --env-file alias

* Add --env-file flag documentation

* format errors messages in loadDotEnv function

* Fix returning error when not using .env,
change error message

* renaming loadDotEnv to loadEnvFiles,
change the logic of loadEnvFiles function,
change cli to more accurate doc
fix typo in readme

* support env-file flag to accept multiple files

* Call loadEnvFiles earlier so that cli.App can see the values.

* Make appending to FLAGS possible when invoking make.

* Add tests to document loadEnvFiles implementation.

* Add test fixture files.

* Fix setting Makefile FLAGS change error.

* Revert "Fix setting Makefile FLAGS change error."

This reverts commit 0d429f0.

Moving this to its own separate PR.

* Revert "Make appending to FLAGS possible when invoking make."

This reverts commit 3e846d3.

Moving this to its own separate PR.

* Add error checking to satisfy linter.

* Override top-level gitignore, so we can check in .env test fixture file.

---------

Co-authored-by: Dossy Shiobara <[email protected]>
  • Loading branch information
hamza512b and dossy authored Nov 26, 2023
1 parent 7320764 commit c641805
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 9 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ The following options are available with all commands. You must use command line

- `--url, -u "protocol://host:port/dbname"` - specify the database url directly. _(env: `DATABASE_URL`)_
- `--env, -e "DATABASE_URL"` - specify an environment variable to read the database connection URL from.
- `--env-file ".env"` - specify an alternate environment variables file(s) to load.
- `--migrations-dir, -d "./db/migrations"` - where to keep the migration files. _(env: `DBMATE_MIGRATIONS_DIR`)_
- `--migrations-table "schema_migrations"` - database table to record migrations in. _(env: `DBMATE_MIGRATIONS_TABLE`)_
- `--schema-file, -s "./db/schema.sql"` - a path to keep the schema.sql file. _(env: `DBMATE_SCHEMA_FILE`)_
Expand Down
1 change: 1 addition & 0 deletions fixtures/loadEnvFiles/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST_DOTENV=default
1 change: 1 addition & 0 deletions fixtures/loadEnvFiles/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!.env
1 change: 1 addition & 0 deletions fixtures/loadEnvFiles/first.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FIRST=one
1 change: 1 addition & 0 deletions fixtures/loadEnvFiles/invalid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INVALID ENV FILE
1 change: 1 addition & 0 deletions fixtures/loadEnvFiles/second.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SECOND=two
59 changes: 50 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package main

import (
"errors"
"fmt"
"log"
"net/url"
"os"
"regexp"
Expand All @@ -17,10 +17,14 @@ import (
)

func main() {
loadDotEnv()
err := loadEnvFiles(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(3)
}

app := NewApp()
err := app.Run(os.Args)
err = app.Run(os.Args)

if err != nil {
errText := redactLogString(fmt.Sprintf("Error: %s\n", err))
Expand All @@ -37,6 +41,7 @@ func NewApp() *cli.App {
app.Version = dbmate.Version

defaultDB := dbmate.New(nil)

app.Flags = []cli.Flag{
&cli.StringFlag{
Name: "url",
Expand All @@ -49,6 +54,11 @@ func NewApp() *cli.App {
Value: "DATABASE_URL",
Usage: "specify an environment variable containing the database URL",
},
&cli.StringSliceFlag{
Name: "env-file",
Value: cli.NewStringSlice(".env"),
Usage: "specify a file to load environment variables from",
},
&cli.StringSliceFlag{
Name: "migrations-dir",
Aliases: []string{"d"},
Expand Down Expand Up @@ -224,15 +234,46 @@ func NewApp() *cli.App {
return app
}

// load environment variables from .env file
func loadDotEnv() {
if _, err := os.Stat(".env"); err != nil {
return
// load environment variables from file(s)
func loadEnvFiles(args []string) error {
var envFiles []string

for i := 0; i < len(args); i++ {
if args[i] == "--env-file" {
if i+1 >= len(args) {
// returning nil here, even though it's an error
// because we want the caller to proceed anyway,
// and produce the actual arg parsing error response
return nil
}

envFiles = append(envFiles, args[i+1])
i++
}
}

if len(envFiles) == 0 {
envFiles = []string{".env"}
}

if err := godotenv.Load(); err != nil {
log.Fatalf("Error loading .env file: %s", err.Error())
// try to load all files in sequential order,
// ignoring any that do not exist
for _, file := range envFiles {
err := godotenv.Load([]string{file}...)
if err == nil {
continue
}

var perr *os.PathError
if errors.As(err, &perr) && errors.Is(perr, os.ErrNotExist) {
// Ignoring file not found error
continue
}

return fmt.Errorf("loading env file(s) %v: %v", envFiles, err)
}

return nil
}

// action wraps a cli.ActionFunc with dbmate initialization logic
Expand Down
120 changes: 120 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"flag"
"os"
"strings"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -58,3 +59,122 @@ func TestRedactLogString(t *testing.T) {
require.Equal(t, ex.expected, redactLogString(ex.in))
}
}

func TestLoadEnvFiles(t *testing.T) {
setup := func(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}

env := os.Environ()
os.Clearenv()

err = os.Chdir("fixtures/loadEnvFiles")
if err != nil {
t.Fatal(err)
}

t.Cleanup(func() {
err := os.Chdir(cwd)
if err != nil {
t.Fatal(err)
}

os.Clearenv()

for _, e := range env {
pair := strings.SplitN(e, "=", 2)
os.Setenv(pair[0], pair[1])
}
})
}

t.Run("default file is .env", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{})
require.NoError(t, err)

require.Equal(t, 1, len(os.Environ()))
require.Equal(t, "default", os.Getenv("TEST_DOTENV"))
})

t.Run("valid file", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "first.txt"})
require.NoError(t, err)
require.Equal(t, 1, len(os.Environ()))
require.Equal(t, "one", os.Getenv("FIRST"))
})

t.Run("two valid files", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "first.txt", "--env-file", "second.txt"})
require.NoError(t, err)
require.Equal(t, 2, len(os.Environ()))
require.Equal(t, "one", os.Getenv("FIRST"))
require.Equal(t, "two", os.Getenv("SECOND"))
})

t.Run("nonexistent file", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "nonexistent.txt"})
require.NoError(t, err)
require.Equal(t, 0, len(os.Environ()))
})

t.Run("no overload", func(t *testing.T) {
setup(t)

// we do not load values over existing values
os.Setenv("FIRST", "not one")

err := loadEnvFiles([]string{"--env-file", "first.txt"})
require.NoError(t, err)
require.Equal(t, 1, len(os.Environ()))
require.Equal(t, "not one", os.Getenv("FIRST"))
})

t.Run("invalid file", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "invalid.txt"})
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected character \"\\n\" in variable name near \"INVALID ENV FILE\\n\"")
require.Equal(t, 0, len(os.Environ()))
})

t.Run("invalid file followed by a valid file", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "invalid.txt", "--env-file", "first.txt"})
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected character \"\\n\" in variable name near \"INVALID ENV FILE\\n\"")
require.Equal(t, 0, len(os.Environ()))
})

t.Run("valid file followed by an invalid file", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "first.txt", "--env-file", "invalid.txt"})
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected character \"\\n\" in variable name near \"INVALID ENV FILE\\n\"")
require.Equal(t, 1, len(os.Environ()))
require.Equal(t, "one", os.Getenv("FIRST"))
})

t.Run("valid file followed by an invalid file followed by a valid file", func(t *testing.T) {
setup(t)

err := loadEnvFiles([]string{"--env-file", "first.txt", "--env-file", "invalid.txt", "--env-file", "second.txt"})
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected character \"\\n\" in variable name near \"INVALID ENV FILE\\n\"")
// files after an invalid file should not get loaded
require.Equal(t, 1, len(os.Environ()))
require.Equal(t, "one", os.Getenv("FIRST"))
})
}

0 comments on commit c641805

Please sign in to comment.