Skip to content
Open
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
113 changes: 113 additions & 0 deletions src/pkg/cli/compose/load_content_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package compose

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRoundTrip(t *testing.T) {
Expand Down Expand Up @@ -117,3 +120,113 @@ func TestLoadFromContent(t *testing.T) {
})
}
}

func TestLoadProjectWithStackEnvFile(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
stackEnvPath := filepath.Join(dir, ".env.mystack")

require.NoError(t, os.WriteFile(composePath, []byte(`name: envfiles
services:
app:
image: ${IMAGE}
environment:
SHARED: ${SHARED}
STACK_VALUE: ${STACK_VALUE}
UNRESOLVED: ${UNRESOLVED}
INTERPOLATED: ${INTERPOLATED}
`), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("IMAGE=nginx\nSHARED=base\nBASE=basevalue\n"), 0o600))
require.NoError(t, os.WriteFile(stackEnvPath, []byte("SHARED=stack\nSTACK_VALUE=fromstack\nINTERPOLATED=${BASE}-stack\nUNUSED=unused\n"), 0o600))

project, err := NewLoader(WithPath(composePath), WithStackName("mystack")).LoadProject(t.Context())
require.NoError(t, err)

service := project.Services["app"]
assert.Equal(t, "nginx", service.Image)
assert.Equal(t, "stack", *service.Environment["SHARED"])
assert.Equal(t, "fromstack", *service.Environment["STACK_VALUE"])
assert.Equal(t, "${UNRESOLVED}", *service.Environment["UNRESOLVED"])
assert.Equal(t, "basevalue-stack", *service.Environment["INTERPOLATED"])
}

func TestLoadProjectWithOnlyStackEnvFile(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
stackEnvPath := filepath.Join(dir, ".env.mystack")

require.NoError(t, os.WriteFile(composePath, []byte(`name: envfiles
services:
app:
image: ${IMAGE}
`), 0o600))
require.NoError(t, os.WriteFile(stackEnvPath, []byte("IMAGE=busybox\nUNUSED=unused\n"), 0o600))

project, err := NewLoader(WithPath(composePath), WithStackName("mystack")).LoadProject(t.Context())
require.NoError(t, err)
assert.Equal(t, "busybox", project.Services["app"].Image)
}

func TestLoadProjectWithStackEnvFileFromDiscoveredProjectDir(t *testing.T) {
dir := t.TempDir()
childDir := filepath.Join(dir, "child")
require.NoError(t, os.Mkdir(childDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(`name: envfiles
services:
app:
image: ${IMAGE}
`), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("IMAGE=base\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.mystack"), []byte("IMAGE=stack\n"), 0o600))

t.Chdir(childDir)
project, err := NewLoader(WithStackName("mystack")).LoadProject(t.Context())
require.NoError(t, err)
assert.Equal(t, "stack", project.Services["app"].Image)
}

func TestLoadProjectWithEmptyStackEnvFile(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
stackEnvPath := filepath.Join(dir, ".env.mystack")

require.NoError(t, os.WriteFile(composePath, []byte(`name: envfiles
services:
app:
image: ${IMAGE}
`), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("IMAGE=nginx\n"), 0o600))
require.NoError(t, os.WriteFile(stackEnvPath, nil, 0o600))

project, err := NewLoader(WithPath(composePath), WithStackName("mystack")).LoadProject(t.Context())
require.NoError(t, err)
assert.Equal(t, "nginx", project.Services["app"].Image)
}

func TestLoadProjectWithStackEnvDirectory(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
require.NoError(t, os.WriteFile(composePath, []byte(`name: envfiles
services:
app:
image: nginx
`), 0o600))
require.NoError(t, os.Mkdir(filepath.Join(dir, ".env.mystack"), 0o700))

_, err := NewLoader(WithPath(composePath), WithStackName("mystack")).LoadProject(t.Context())
require.ErrorContains(t, err, "is a directory")
}

func TestLoadProjectWithEnvStackFixture(t *testing.T) {
project, err := NewLoader(
WithPath("testdata/envstackfixture/compose.yaml"),
WithStackName("teststackname"),
).LoadProject(t.Context())
require.NoError(t, err)

service := project.Services["app"]
assert.Equal(t, "nginx", service.Image)
assert.Equal(t, "stack", *service.Environment["SHARED"])
assert.Equal(t, "fromstack", *service.Environment["STACK_ONLY"])
assert.Equal(t, "${UNRESOLVED}", *service.Environment["UNRESOLVED"])
}
64 changes: 53 additions & 11 deletions src/pkg/cli/compose/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/DefangLabs/defang/src/pkg/term"
"github.com/DefangLabs/defang/src/pkg/types"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/errdefs"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/template"
Expand All @@ -33,6 +34,7 @@ type BuildConfig = composeTypes.BuildConfig
type LoaderOptions struct {
ConfigPaths []string
ProjectName string
StackName string
}

type Loader struct {
Expand All @@ -48,6 +50,12 @@ func WithPath(paths ...string) LoaderOption {
}
}

func WithStackName(name string) LoaderOption {
return func(o *LoaderOptions) {
o.StackName = name
}
}

func WithProjectName(name string) LoaderOption {
return func(o *LoaderOptions) {
o.ProjectName = name
Expand Down Expand Up @@ -146,17 +154,17 @@ func (l *Loader) newProjectOptions(suppressWarn bool) (*cli.ProjectOptions, erro
// -- DISABLED FOR DEFANG -- cli.WithOsEnv,
cli.WithEnv(onlyComposeEnv),
// Load PWD/.env if present and no explicit --env-file has been set
cli.WithEnvFiles(), // TODO: Support --env-file to be added as param to this call
cli.WithEnvFiles(),
// read dot env file to populate project environment
cli.WithDotEnv,
// get compose file path set by COMPOSE_FILE
cli.WithConfigFileEnv,
// if none was selected, get default compose.yaml file from current dir or parent folder
cli.WithDefaultConfigPath,
// Calling the 2 functions below the 2nd time as the loaded env in first call modifies the behavior of the 2nd call:
// .. and then, a project directory != PWD maybe has been set so let's load .env file
cli.WithEnvFiles(), // TODO: Support --env-file to be added as param to this call
// Load project-directory .env after COMPOSE_FILE/default discovery, then apply stack-specific overrides.
cli.WithEnvFiles(),
cli.WithDotEnv,
l.withStackEnvFile,
// eventually COMPOSE_PROFILES should have been set
// cli.WithDefaultProfiles(c.Profiles...), TODO: Support --profile to be added as param to this call
cli.WithName(l.options.ProjectName),
Expand Down Expand Up @@ -199,6 +207,40 @@ func (l *Loader) newProjectOptions(suppressWarn bool) (*cli.ProjectOptions, erro
)
}

// withStackEnvFile loads .env.<stack> from the project working directory after
// .env has populated the compose environment. compose-go treats existing keys as
// higher precedence, so stack values are merged explicitly to override defaults.
func (l *Loader) withStackEnvFile(o *cli.ProjectOptions) error {
if l.options.StackName == "" || len(o.ConfigPaths) == 0 {
return nil
}
wd, err := o.GetWorkingDir()
if err != nil {
return err
}
envFile := filepath.Join(wd, ".env."+l.options.StackName)
info, err := os.Stat(envFile)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("failed to inspect stack env file %s: %w", envFile, err)
}
if info.IsDir() {
return fmt.Errorf("stack env file %s is a directory", envFile)
}

env, err := dotenv.GetEnvFromFile(o.Environment, []string{envFile})
if err != nil {
return fmt.Errorf("failed to load stack env file %s: %w", envFile, err)
}
term.Infof("Loading stack environment from %s", filepath.Base(envFile))
for key, value := range env {
o.Environment[key] = value
}
return nil
}

func hasSubstitution(s, key string) bool {
// Check in the original `templ` string if the variable uses any substitution patterns like - :- + :+ ? :?
pattern := regexp.MustCompile(`(^|[^$])\$\{` + regexp.QuoteMeta(key) + `:?[-+?]`)
Expand All @@ -211,18 +253,18 @@ func (l *Loader) CreateProjectForDebug() (*Project, error) {
return nil, err
}

workingDir, err := projOpts.GetWorkingDir()
if err != nil {
return nil, err
}

// get the project name
if projOpts.Name == "" {
dir, err := os.Getwd()
if err != nil {
return nil, err
}

projOpts.Name = filepath.Base(dir)
projOpts.Name = filepath.Base(workingDir)
}
project := &Project{
Name: projOpts.Name,
WorkingDir: projOpts.WorkingDir,
WorkingDir: workingDir,
Environment: projOpts.Environment,
ComposeFiles: projOpts.ConfigPaths,
}
Expand Down
2 changes: 2 additions & 0 deletions src/pkg/cli/compose/testdata/envstackfixture/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
IMAGE=nginx
SHARED=base
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SHARED=stack
STACK_ONLY=fromstack
UNUSED=unused
8 changes: 8 additions & 0 deletions src/pkg/cli/compose/testdata/envstackfixture/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: envstackfixture
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename folder to envstackfixture to avoid another level of indirection.

services:
app:
image: ${IMAGE}
environment:
SHARED: ${SHARED}
STACK_ONLY: ${STACK_ONLY}
UNRESOLVED: ${UNRESOLVED}
4 changes: 3 additions & 1 deletion src/pkg/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ func (sl *SessionLoader) LoadSession(ctx context.Context) (*Session, error) {
}
// load provider with selected stack
provider := cli.NewProvider(ctx, stack.Provider, sl.client, stack.Name)
loaderOptions := sl.opts.LoaderOptions
loaderOptions.StackName = stack.Name
session := &Session{
Stack: stack,
Loader: compose.NewLoaderFromOptions(sl.opts.LoaderOptions),
Loader: compose.NewLoaderFromOptions(loaderOptions),
Provider: provider,
}

Expand Down
65 changes: 65 additions & 0 deletions src/pkg/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package session
import (
"context"
"os"
"path/filepath"
"testing"

"github.com/DefangLabs/defang/src/pkg/cli/client"
"github.com/DefangLabs/defang/src/pkg/cli/client/byoc/aws"
"github.com/DefangLabs/defang/src/pkg/cli/client/byoc/gcp"
"github.com/DefangLabs/defang/src/pkg/cli/compose"
"github.com/DefangLabs/defang/src/pkg/modes"
"github.com/DefangLabs/defang/src/pkg/stacks"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -241,3 +243,66 @@ func TestLoadSession(t *testing.T) {
})
}
}

func TestLoadSessionStackEnvFile(t *testing.T) {
tests := []struct {
name string
createStackEnv bool
stackEnv string
expectedImage string
}{
{
name: "uses stack env file when it exists",
createStackEnv: true,
stackEnv: "IMAGE=stack\n",
expectedImage: "stack",
},
{
name: "ignores missing stack env file",
expectedImage: "base",
},
{
name: "allows empty stack env file",
createStackEnv: true,
stackEnv: "",
expectedImage: "base",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
require.NoError(t, os.WriteFile(composePath, []byte(`name: envfiles
services:
app:
image: ${IMAGE}
`), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("IMAGE=base\n"), 0o600))
if tt.createStackEnv {
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.existingstack"), []byte(tt.stackEnv), 0o600))
}

ctx := t.Context()
sm := &mockStacksManager{}
sm.On("GetStack", ctx, mock.Anything).Return(&stacks.Parameters{
Name: "existingstack",
Provider: client.ProviderDefang,
}, "local", nil)

loader := NewSessionLoader(client.MockFabricClient{}, sm, SessionLoaderOptions{
LoaderOptions: compose.LoaderOptions{ConfigPaths: []string{composePath}},
GetStackOpts: stacks.GetStackOpts{
Default: stacks.Parameters{Name: "existingstack"},
},
})
session, err := loader.LoadSession(ctx)
require.NoError(t, err)

project, err := session.Loader.LoadProject(ctx)
require.NoError(t, err)
assert.Equal(t, tt.expectedImage, project.Services["app"].Image)
sm.AssertExpectations(t)
})
}
}
Loading