Skip to content
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

support --env-file CLI option (#274) #491

Merged
merged 16 commits into from
Nov 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
dossy marked this conversation as resolved.
Show resolved Hide resolved
- `--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 @@ -211,15 +221,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())
dossy marked this conversation as resolved.
Show resolved Hide resolved
// 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"))
})
}