Repository: compose-spec/compose-go
Pull request #55 (2020-06-25 10:02:45)

introduce WorkingDir and Environment in ProjectOptions
Move definition of `WorkingDir` and `Environment` to `cli.ProjectOptions` so that `Loader` do not handle filepath default magic and is not responsible to CLI specific things


Patch:
diff --git a/cli/options.go b/cli/options.go
index 69a9f35..8600dd3 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -17,22 +17,73 @@
 package cli
 
 import (
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 
 	"github.com/compose-spec/compose-go/errdefs"
 	"github.com/compose-spec/compose-go/loader"
 	"github.com/compose-spec/compose-go/types"
+	"github.com/joho/godotenv"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
 
 // ProjectOptions groups the command line options recommended for a Compose implementation
 type ProjectOptions struct {
-	ConfigPaths []string
 	Name        string
+	WorkingDir  string
+	ConfigPaths []string
+	Environment []string
+}
+
+// WithOsEnv imports environment variables from OS until those have been overridden by ProjectOptions.Environment
+func (o ProjectOptions) WithOsEnv() ProjectOptions {
+	env := getAsEqualsMap(o.Environment)
+	for k, v := range getAsEqualsMap(os.Environ()) {
+		if _, ok := env[k]; !ok {
+			env[k] = v
+		}
+	}
+
+	return ProjectOptions{
+		Name:        o.Name,
+		WorkingDir:  o.WorkingDir,
+		ConfigPaths: o.ConfigPaths,
+		Environment: getAsStringList(env),
+	}
+}
+
+// WithDotEnv imports environment variables from .env file until those have been overridden by ProjectOptions.Environment
+func (o ProjectOptions) WithDotEnv() (ProjectOptions, error) {
+	dir, err := o.GetWorkingDir()
+	if err != nil {
+		return o, err
+	}
+	dotEnvFile := filepath.Join(dir, ".env")
+	if _, err := os.Stat(dotEnvFile); os.IsNotExist(err) {
+		return o, nil
+	}
+	file, err := os.Open(dotEnvFile)
+	if err != nil {
+		return o, err
+	}
+	defer file.Close()
+
+	env, err := godotenv.Parse(file)
+	if err != nil {
+		return o, err
+	}
+
+	return ProjectOptions{
+		Name:        o.Name,
+		WorkingDir:  o.WorkingDir,
+		ConfigPaths: o.ConfigPaths,
+		Environment: getAsStringList(env),
+	}, nil
 }
 
 // DefaultFileNames defines the Compose file names for auto-discovery (in order of preference)
@@ -44,6 +95,22 @@ const (
 	ComposeFilePath      = "COMPOSE_FILE"
 )
 
+func (o ProjectOptions) GetWorkingDir() (string, error) {
+	if o.WorkingDir != "" {
+		return o.WorkingDir, nil
+	}
+	for _, path := range o.ConfigPaths {
+		if path != "-" {
+			absPath, err := filepath.Abs(path)
+			if err != nil {
+				return "", err
+			}
+			return filepath.Dir(absPath), nil
+		}
+	}
+	return os.Getwd()
+}
+
 // ProjectFromOptions load a compose project based on command line options
 func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 	configPaths, err := getConfigPathsFromOptions(options)
@@ -56,14 +123,23 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 		return nil, err
 	}
 
+	workingDir, err := options.GetWorkingDir()
+	if err != nil {
+		return nil, err
+	}
+
 	return loader.Load(types.ConfigDetails{
 		ConfigFiles: configs,
-		Environment: environment(),
+		WorkingDir:  workingDir,
+		Environment: getAsEqualsMap(options.Environment),
 	}, func(opts *loader.Options) {
 		if options.Name != "" {
 			opts.Name = options.Name
 		} else if nameFromEnv, ok := os.LookupEnv(ComposeProjectName); ok {
 			opts.Name = nameFromEnv
+		} else {
+			opts.Name = regexp.MustCompile(`[^a-z0-9\\-_]+`).
+				ReplaceAllString(strings.ToLower(filepath.Base(workingDir)), "")
 		}
 	})
 }
@@ -71,9 +147,13 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 // getConfigPathsFromOptions retrieves the config files for project based on project options
 func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
 	paths := []string{}
-	pwd, err := os.Getwd()
-	if err != nil {
-		return nil, err
+	pwd := options.WorkingDir
+	if pwd == "" {
+		wd, err := os.Getwd()
+		if err != nil {
+			return nil, err
+		}
+		pwd = wd
 	}
 
 	if len(options.ConfigPaths) != 0 {
@@ -150,10 +230,6 @@ func parseConfigs(configPaths []string) ([]types.ConfigFile, error) {
 	return files, nil
 }
 
-func environment() map[string]string {
-	return getAsEqualsMap(os.Environ())
-}
-
 // getAsEqualsMap split key=value formatted strings into a key : value map
 func getAsEqualsMap(em []string) map[string]string {
 	m := make(map[string]string)
@@ -163,3 +239,12 @@ func getAsEqualsMap(em []string) map[string]string {
 	}
 	return m
 }
+
+// getAsEqualsMap format a key : value map into key=value strings
+func getAsStringList(em map[string]string) []string {
+	m := make([]string, 0, len(em))
+	for k, v := range em {
+		m = append(m, fmt.Sprintf("%s=%s", k, v))
+	}
+	return m
+}
diff --git a/go.mod b/go.mod
index 9b52032..6827819 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/docker/go-units v0.4.0
 	github.com/google/go-cmp v0.5.0
 	github.com/imdario/mergo v0.3.8
+	github.com/joho/godotenv v1.3.0
 	github.com/mattn/go-shellwords v1.0.10
 	github.com/mitchellh/mapstructure v1.3.2
 	github.com/pkg/errors v0.9.1
diff --git a/go.sum b/go.sum
index 6c1414e..f72240e 100644
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,8 @@ github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ=
 github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
+github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw=
diff --git a/loader/loader.go b/loader/loader.go
index 298fd7d..cabab79 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -22,7 +22,6 @@ import (
 	"path"
 	"path/filepath"
 	"reflect"
-	"regexp"
 	"sort"
 	"strings"
 	"time"
@@ -88,24 +87,12 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		return nil, errors.Errorf("No files specified")
 	}
 
-	workingDir := configDetails.WorkingDir
-	if workingDir == "" {
-		for _, path := range configDetails.ConfigFiles {
-			if path.Filename != "-" {
-				workingDir = filepath.Dir(path.Filename)
-				break
-			}
-		}
-	}
-
 	opts := &Options{
 		Interpolate: &interp.Options{
 			Substitute:      template.Substitute,
 			LookupValue:     configDetails.LookupEnv,
 			TypeCastMapping: interpolateTypeCastMapping,
 		},
-		Name: regexp.MustCompile(`[^a-z0-9\\-_]+`).
-			ReplaceAllString(strings.ToLower(filepath.Base(workingDir)), ""),
 	}
 
 	for _, op := range options {
@@ -113,8 +100,6 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 	}
 
 	configs := []*types.Config{}
-	var err error
-
 	for _, file := range configDetails.ConfigFiles {
 		configDict := file.Config
 		version := schema.Version(configDict)
@@ -126,6 +111,7 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		}
 
 		if !opts.SkipInterpolation {
+			var err error
 			configDict, err = interpolateConfig(configDict, *opts.Interpolate)
 			if err != nil {
 				return nil, err
@@ -161,7 +147,7 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 
 	project := &types.Project{
 		Name:       opts.Name,
-		WorkingDir: workingDir,
+		WorkingDir: configDetails.WorkingDir,
 		Services:   model.Services,
 		Networks:   model.Networks,
 		Volumes:    model.Volumes,


Test patch:
diff --git a/cli/options_test.go b/cli/options_test.go
index 4f86740..d90574a 100644
--- a/cli/options_test.go
+++ b/cli/options_test.go
@@ -59,3 +59,27 @@ func TestProjectFromSetOfFiles(t *testing.T) {
 	assert.NilError(t, err)
 	assert.Equal(t, service.Image, "haproxy")
 }
+
+func TestProjectWithDotEnv(t *testing.T) {
+	options, err := ProjectOptions{
+		Name: "my_project",
+		ConfigPaths: []string{
+			"testdata/simple/compose-with-variables.yaml",
+		},
+	}.WithDotEnv()
+	assert.NilError(t, err)
+	p, err := ProjectFromOptions(&options)
+	assert.NilError(t, err)
+	service, err := p.GetService("simple")
+	assert.NilError(t, err)
+	assert.Equal(t, service.Ports[0].Published, uint32(8000))
+}
+
+func TestEnvMap(t *testing.T) {
+	m := map[string]string{}
+	m["foo"] = "bar"
+	l := getAsStringList(m)
+	assert.Equal(t, l[0], "foo=bar")
+	m = getAsEqualsMap(l)
+	assert.Equal(t, m["foo"], "bar")
+}
diff --git a/cli/testdata/simple/.env b/cli/testdata/simple/.env
new file mode 100644
index 0000000..9f295a4
--- /dev/null
+++ b/cli/testdata/simple/.env
@@ -0,0 +1,1 @@
+PUBLIC_PORT=8000
\ No newline at end of file
diff --git a/cli/testdata/simple/compose-with-variables.yaml b/cli/testdata/simple/compose-with-variables.yaml
new file mode 100644
index 0000000..57c29d7
--- /dev/null
+++ b/cli/testdata/simple/compose-with-variables.yaml
@@ -0,0 +1,6 @@
+version: "3"
+services:
+  simple:
+    image: nginx
+    ports:
+    - ${PUBLIC_PORT}:80
diff --git a/loader/loader_test.go b/loader/loader_test.go
index b52f1c0..4e08017 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -645,7 +645,7 @@ networks:
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
 	expected := &types.Project{
-		Name:       "loader",
+		Name:       "",
 		WorkingDir: workingDir,
 		Services: []types.ServiceConfig{
 			{
@@ -1466,7 +1466,7 @@ networks:
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
 	expected := &types.Project{
-		Name:       "loader",
+		Name:       "",
 		WorkingDir: workingDir,
 		Services: types.Services{
 			{
@@ -1629,7 +1629,7 @@ secrets:
 	assert.NilError(t, err)
 
 	expected := &types.Project{
-		Name:       "loader",
+		Name:       "",
 		WorkingDir: workingDir,
 		Services: types.Services{
 			{
@@ -1695,7 +1695,7 @@ secrets:
 	assert.NilError(t, err)
 
 	expected := &types.Project{
-		Name:       "loader",
+		Name:       "",
 		WorkingDir: workingDir,
 		Services: types.Services{
 			{
diff --git a/loader/merge_test.go b/loader/merge_test.go
index 1e33cda..4e686c9 100644
--- a/loader/merge_test.go
+++ b/loader/merge_test.go
@@ -228,7 +228,7 @@ func TestLoadLogging(t *testing.T) {
 			assert.NilError(t, err)
 			assert.DeepEqual(t, &types.Project{
 				Name:       "",
-				WorkingDir: ".",
+				WorkingDir: "",
 				Services: []types.ServiceConfig{
 					{
 						Name:        "foo",
@@ -354,7 +354,7 @@ func TestLoadMultipleServicePorts(t *testing.T) {
 			assert.NilError(t, err)
 			assert.DeepEqual(t, &types.Project{
 				Name:       "",
-				WorkingDir: ".",
+				WorkingDir: "",
 				Services: []types.ServiceConfig{
 					{
 						Name:        "foo",
@@ -480,7 +480,7 @@ func TestLoadMultipleSecretsConfig(t *testing.T) {
 			assert.NilError(t, err)
 			assert.DeepEqual(t, &types.Project{
 				Name:       "",
-				WorkingDir: ".",
+				WorkingDir: "",
 				Services: []types.ServiceConfig{
 					{
 						Name:        "foo",
@@ -606,7 +606,7 @@ func TestLoadMultipleConfigobjsConfig(t *testing.T) {
 			assert.NilError(t, err)
 			assert.DeepEqual(t, &types.Project{
 				Name:       "",
-				WorkingDir: ".",
+				WorkingDir: "",
 				Services: []types.ServiceConfig{
 					{
 						Name:        "foo",
@@ -722,7 +722,7 @@ func TestLoadMultipleUlimits(t *testing.T) {
 			assert.NilError(t, err)
 			assert.DeepEqual(t, &types.Project{
 				Name:       "",
-				WorkingDir: ".",
+				WorkingDir: "",
 				Services: []types.ServiceConfig{
 					{
 						Name:        "foo",
@@ -841,7 +841,7 @@ func TestLoadMultipleServiceNetworks(t *testing.T) {
 			assert.NilError(t, err)
 			assert.DeepEqual(t, &types.Project{
 				Name:       "",
-				WorkingDir: ".",
+				WorkingDir: "",
 				Services: []types.ServiceConfig{
 					{
 						Name:        "foo",
@@ -929,7 +929,7 @@ func TestLoadMultipleConfigs(t *testing.T) {
 	assert.NilError(t, err)
 	assert.DeepEqual(t, &types.Project{
 		Name:       "",
-		WorkingDir: ".",
+		WorkingDir: "",
 		Services: []types.ServiceConfig{
 			{
 				Name:        "bar",
@@ -1022,7 +1022,7 @@ func TestLoadMultipleNetworks(t *testing.T) {
 	assert.NilError(t, err)
 	assert.DeepEqual(t, &types.Project{
 		Name:       "",
-		WorkingDir: ".",
+		WorkingDir: "",
 		Services: []types.ServiceConfig{
 			{
 				Name:        "foo",