Skip to content

Commit 97c70c1

Browse files
Support for multiple compose files (#603)
Fixes #509 In #500, we were concerned that the presence of both a `compose.yaml` and ` docker-compose.yaml` file could be confusing, so we decided to return an error and exit early. However, `compose-go` _does_ log out a warning in this scenario, as does `docker compose`. We've changed our mind and decided to aim for closer parity with docker here by reverting to logging the warning and continuing with `compose.yaml`. While implementing this, in an effort to bring us to closer parity with docker compose, we decided to go ahead and add support for passing `-f` multiple times—adding support for multiple compose files. - [x] Add support for multiple compose files with `-f`. - [x] Modify `compose.LoadCompose` to leverage the `compose-go` library to find default compose file paths if one isn't passed explicitly. - [x] Avoid returning an error if multiple default compose files are found. `compose-go` will always pick `compose.yaml` over `docker-compose.yaml`. It will also log a warning message explaining this to stderr. - [x] Remove `toomany` test case since there is nothing meaningful to test anymore. - [x] Add a simple test at the command level for something like `defang version` to make sure it doesn't try to load a compose file during intialization. In time, we should add command-level tests for everything with a mock server. - [x] lazily verify compose path, to avoid requiring it for commands like `defang version` which do not use it.
1 parent 8e4ee01 commit 97c70c1

File tree

12 files changed

+129
-92
lines changed

12 files changed

+129
-92
lines changed

src/cmd/cli/command/commands.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func SetupCommands(version string) {
136136
RootCmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "T", !hasTty, "disable interactive prompts / no TTY")
137137
RootCmd.PersistentFlags().StringP("cwd", "C", "", "change directory before running the command")
138138
_ = RootCmd.MarkPersistentFlagDirname("cwd")
139-
RootCmd.PersistentFlags().StringP("file", "f", "", `compose file path`)
139+
RootCmd.PersistentFlags().StringArrayP("file", "f", []string{}, `compose file path`)
140140
_ = RootCmd.MarkPersistentFlagFilename("file", "yml", "yaml")
141141

142142
// Bootstrap command
@@ -183,6 +183,7 @@ func SetupCommands(version string) {
183183
// Config Command (was: secrets)
184184
configSetCmd.Flags().BoolP("name", "n", false, "name of the config (backwards compat)")
185185
_ = configSetCmd.Flags().MarkHidden("name")
186+
186187
configCmd.AddCommand(configSetCmd)
187188

188189
configDeleteCmd.Flags().BoolP("name", "n", false, "name of the config(s) (backwards compat)")
@@ -320,9 +321,7 @@ var RootCmd = &cobra.Command{
320321
return err
321322
}
322323
}
323-
324-
composeFilePath, _ := cmd.Flags().GetString("file")
325-
loader := compose.Loader{ComposeFilePath: composeFilePath}
324+
loader := configureLoader(cmd)
326325
client = cli.NewClient(cmd.Context(), cluster, provider, loader)
327326

328327
if v, err := client.GetVersions(cmd.Context()); err == nil {
@@ -583,7 +582,11 @@ var generateCmd = &cobra.Command{
583582
}
584583

585584
// Load the project and check for empty environment variables
586-
loader := compose.Loader{ComposeFilePath: filepath.Join(prompt.Folder, "compose.yaml")}
585+
loaderOptions := compose.LoaderOptions{
586+
WorkingDir: prompt.Folder,
587+
ConfigPaths: []string{filepath.Join(prompt.Folder, "compose.yaml")},
588+
}
589+
loader := compose.NewLoaderWithOptions(loaderOptions)
587590
project, _ := loader.LoadCompose(cmd.Context())
588591

589592
var envInstructions []string
@@ -1268,6 +1271,24 @@ var tosCmd = &cobra.Command{
12681271
},
12691272
}
12701273

1274+
func configureLoader(cmd *cobra.Command) compose.Loader {
1275+
f := cmd.Flags()
1276+
o := compose.LoaderOptions{}
1277+
var err error
1278+
1279+
o.ConfigPaths, err = f.GetStringArray("file")
1280+
if err != nil {
1281+
panic(err)
1282+
}
1283+
1284+
o.WorkingDir, err = f.GetString("cwd")
1285+
if err != nil {
1286+
panic(err)
1287+
}
1288+
1289+
return compose.NewLoaderWithOptions(o)
1290+
}
1291+
12711292
func awsInEnv() bool {
12721293
return os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_SECRET_ACCESS_KEY") != ""
12731294
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package command
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestVersion(t *testing.T) {
9+
err := testCommand([]string{"version"})
10+
if err != nil {
11+
t.Fatalf("Version() failed: %v", err)
12+
}
13+
}
14+
15+
func testCommand(args []string) error {
16+
ctx := context.Background()
17+
SetupCommands("test")
18+
RootCmd.SetArgs(args)
19+
return RootCmd.ExecuteContext(ctx)
20+
}

src/pkg/cli/compose/compose_test.go

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"bytes"
55
"context"
66
"os"
7-
"strings"
87
"testing"
98

109
"github.com/DefangLabs/defang/src/pkg/term"
@@ -14,7 +13,7 @@ func TestLoadCompose(t *testing.T) {
1413
term.SetDebug(testing.Verbose())
1514

1615
t.Run("no project name defaults to parent directory name", func(t *testing.T) {
17-
loader := Loader{"../../../tests/noprojname/compose.yaml"}
16+
loader := NewLoaderWithPath("../../../tests/noprojname/compose.yaml")
1817
p, err := loader.LoadCompose(context.Background())
1918
if err != nil {
2019
t.Fatalf("LoadCompose() failed: %v", err)
@@ -25,7 +24,7 @@ func TestLoadCompose(t *testing.T) {
2524
})
2625

2726
t.Run("no project name defaults to fancy parent directory name", func(t *testing.T) {
28-
loader := Loader{"../../../tests/Fancy-Proj_Dir/compose.yaml"}
27+
loader := NewLoaderWithPath("../../../tests/Fancy-Proj_Dir/compose.yaml")
2928
p, err := loader.LoadCompose(context.Background())
3029
if err != nil {
3130
t.Fatalf("LoadCompose() failed: %v", err)
@@ -36,7 +35,7 @@ func TestLoadCompose(t *testing.T) {
3635
})
3736

3837
t.Run("use project name in compose file", func(t *testing.T) {
39-
loader := Loader{"../../../tests/testproj/compose.yaml"}
38+
loader := NewLoaderWithPath("../../../tests/testproj/compose.yaml")
4039
p, err := loader.LoadCompose(context.Background())
4140
if err != nil {
4241
t.Fatalf("LoadCompose() failed: %v", err)
@@ -48,7 +47,7 @@ func TestLoadCompose(t *testing.T) {
4847

4948
t.Run("COMPOSE_PROJECT_NAME env var should override project name", func(t *testing.T) {
5049
t.Setenv("COMPOSE_PROJECT_NAME", "overridename")
51-
loader := Loader{"../../../tests/testproj/compose.yaml"}
50+
loader := NewLoaderWithPath("../../../tests/testproj/compose.yaml")
5251
p, err := loader.LoadCompose(context.Background())
5352
if err != nil {
5453
t.Fatalf("LoadCompose() failed: %v", err)
@@ -59,7 +58,7 @@ func TestLoadCompose(t *testing.T) {
5958
})
6059

6160
t.Run("use project name should not be overriden by tenantID", func(t *testing.T) {
62-
loader := Loader{"../../../tests/testproj/compose.yaml"}
61+
loader := NewLoaderWithPath("../../../tests/testproj/compose.yaml")
6362
p, err := loader.LoadCompose(context.Background())
6463
if err != nil {
6564
t.Fatalf("LoadCompose() failed: %v", err)
@@ -88,7 +87,7 @@ func TestLoadCompose(t *testing.T) {
8887
t.Cleanup(teardown)
8988

9089
// execute test
91-
loader := Loader{}
90+
loader := NewLoaderWithPath("")
9291
p, err := loader.LoadCompose(context.Background())
9392
if err != nil {
9493
t.Fatalf("LoadCompose() failed: %v", err)
@@ -99,7 +98,7 @@ func TestLoadCompose(t *testing.T) {
9998
})
10099

101100
t.Run("load alternative compose file", func(t *testing.T) {
102-
loader := Loader{"../../../tests/alttestproj/altcomp.yaml"}
101+
loader := NewLoaderWithPath("../../../tests/alttestproj/altcomp.yaml")
103102
p, err := loader.LoadCompose(context.Background())
104103
if err != nil {
105104
t.Fatalf("LoadCompose() failed: %v", err)
@@ -120,7 +119,7 @@ func TestComposeGoNoDoubleWarningLog(t *testing.T) {
120119
var warnings bytes.Buffer
121120
term.DefaultTerm = term.NewTerm(&warnings, &warnings)
122121

123-
loader := Loader{"../../../tests/compose-go-warn/compose.yaml"}
122+
loader := NewLoaderWithPath("../../../tests/compose-go-warn/compose.yaml")
124123
_, err := loader.LoadCompose(context.Background())
125124
if err != nil {
126125
t.Fatalf("LoadCompose() failed: %v", err)
@@ -138,15 +137,36 @@ func TestComposeOnlyOneFile(t *testing.T) {
138137
})
139138
os.Chdir("../../../tests/toomany")
140139

141-
loader := Loader{}
142-
_, err := loader.LoadCompose(context.Background())
143-
if err == nil {
144-
t.Fatalf("LoadCompose() failed: expected error, got nil")
140+
loader := NewLoaderWithPath("")
141+
project, err := loader.LoadCompose(context.Background())
142+
if err != nil {
143+
t.Errorf("LoadCompose() failed: %v", err)
144+
}
145+
146+
if len(project.ComposeFiles) != 1 {
147+
t.Errorf("LoadCompose() failed: expected only one config file, got %d", len(project.ComposeFiles))
148+
}
149+
}
150+
151+
func TestComposeMultipleFiles(t *testing.T) {
152+
cwd, _ := os.Getwd()
153+
t.Cleanup(func() {
154+
os.Chdir(cwd)
155+
})
156+
os.Chdir("../../../tests/multiple")
157+
158+
composeFiles := []string{"compose1.yaml", "compose2.yaml"}
159+
loader := NewLoaderWithOptions(LoaderOptions{ConfigPaths: composeFiles})
160+
project, err := loader.LoadCompose(context.Background())
161+
if err != nil {
162+
t.Fatalf("LoadCompose() failed: %v", err)
163+
}
164+
165+
if len(project.ComposeFiles) != 2 {
166+
t.Errorf("LoadCompose() failed: expected 2 compose files, got %d", len(project.ComposeFiles))
145167
}
146168

147-
const expected = `multiple Compose files found: ["./compose.yaml" "./docker-compose.yml"]; use -f to specify which one to use`
148-
newCwd, _ := os.Getwd() // make the error message independent of the current working directory
149-
if got := strings.ReplaceAll(err.Error(), newCwd, "."); got != expected {
150-
t.Errorf("LoadCompose() failed: expected error %q, got: %s", expected, got)
169+
if len(project.Services) != 2 {
170+
t.Errorf("LoadCompose() failed: expected 2 services, got %d", len(project.Services))
151171
}
152172
}

src/pkg/cli/compose/convert_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func TestConvertPort(t *testing.T) {
149149

150150
func TestConvert(t *testing.T) {
151151
testRunCompose(t, func(t *testing.T, path string) {
152-
loader := Loader{path}
152+
loader := NewLoaderWithPath(path)
153153
proj, err := loader.LoadCompose(context.Background())
154154
if err != nil {
155155
t.Fatal(err)

src/pkg/cli/compose/loader.go

Lines changed: 31 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,47 @@ package compose
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
7-
"path/filepath"
88

99
"github.com/DefangLabs/defang/src/pkg/logs"
1010
"github.com/DefangLabs/defang/src/pkg/term"
1111
"github.com/DefangLabs/defang/src/pkg/types"
1212
"github.com/compose-spec/compose-go/v2/cli"
13+
"github.com/compose-spec/compose-go/v2/errdefs"
1314
compose "github.com/compose-spec/compose-go/v2/types"
1415
"github.com/sirupsen/logrus"
1516
"gopkg.in/yaml.v3"
1617
)
1718

19+
type LoaderOptions struct {
20+
ConfigPaths []string
21+
WorkingDir string
22+
}
23+
1824
type Loader struct {
19-
ComposeFilePath string
25+
options LoaderOptions
2026
}
2127

22-
func (c Loader) LoadCompose(ctx context.Context) (*compose.Project, error) {
23-
composeFilePath, err := getComposeFilePath(c.ComposeFilePath)
24-
if err != nil {
25-
return nil, err
28+
func NewLoaderWithOptions(options LoaderOptions) Loader {
29+
return Loader{options: options}
30+
}
31+
32+
func NewLoaderWithPath(path string) Loader {
33+
configPaths := []string{}
34+
if path != "" {
35+
configPaths = append(configPaths, path)
2636
}
27-
term.Debug("Loading compose file", composeFilePath)
37+
return NewLoaderWithOptions(LoaderOptions{ConfigPaths: configPaths})
38+
}
2839

40+
func (c Loader) LoadCompose(ctx context.Context) (*compose.Project, error) {
2941
// Set logrus send logs via the term package
3042
termLogger := logs.TermLogFormatter{Term: term.DefaultTerm}
3143
logrus.SetFormatter(termLogger)
3244

33-
projOpts, err := getDefaultProjectOptions(composeFilePath)
45+
projOpts, err := c.projectOptions()
3446
if err != nil {
3547
return nil, err
3648
}
@@ -42,23 +54,27 @@ func (c Loader) LoadCompose(ctx context.Context) (*compose.Project, error) {
4254

4355
project, err := projOpts.LoadProject(ctx)
4456
if err != nil {
57+
if errors.Is(err, errdefs.ErrNotFound) {
58+
return nil, types.ErrComposeFileNotFound
59+
}
60+
4561
return nil, err
4662
}
4763

4864
if term.DoDebug() {
4965
b, _ := yaml.Marshal(project)
5066
fmt.Println(string(b))
5167
}
68+
5269
return project, nil
5370
}
5471

55-
func getDefaultProjectOptions(composeFilePath string, extraOpts ...cli.ProjectOptionsFn) (*cli.ProjectOptions, error) {
56-
workingDir := filepath.Dir(composeFilePath)
57-
72+
func (c *Loader) projectOptions() (*cli.ProjectOptions, error) {
73+
options := c.options
5874
// Based on how docker compose setup its own project options
5975
// https://github.com/docker/compose/blob/1a14fcb1e6645dd92f5a4f2da00071bd59c2e887/cmd/compose/compose.go#L326-L346
60-
opts := []cli.ProjectOptionsFn{
61-
cli.WithWorkingDirectory(workingDir),
76+
optFns := []cli.ProjectOptionsFn{
77+
cli.WithWorkingDirectory(options.WorkingDir),
6278
// First apply os.Environment, always win
6379
// -- DISABLED -- cli.WithOsEnv,
6480
// Load PWD/.env if present and no explicit --env-file has been set
@@ -68,7 +84,7 @@ func getDefaultProjectOptions(composeFilePath string, extraOpts ...cli.ProjectOp
6884
// get compose file path set by COMPOSE_FILE
6985
cli.WithConfigFileEnv,
7086
// if none was selected, get default compose.yaml file from current dir or parent folder
71-
// cli.WithDefaultConfigPath, NO: this ends up picking the "first" when more than one file is found
87+
cli.WithDefaultConfigPath,
7288
// cli.WithName(o.ProjectName)
7389

7490
// Calling the 2 functions below the 2nd time as the loaded env in first call modifies the behavior of the 2nd call
@@ -81,55 +97,6 @@ func getDefaultProjectOptions(composeFilePath string, extraOpts ...cli.ProjectOp
8197
cli.WithDiscardEnvFile,
8298
cli.WithConsistency(false), // TODO: check fails if secrets are used but top-level 'secrets:' is missing
8399
}
84-
opts = append(opts, extraOpts...)
85-
projOpts, err := cli.NewProjectOptions([]string{composeFilePath}, opts...)
86-
if err != nil {
87-
return nil, err
88-
}
89100

90-
return projOpts, nil
91-
}
92-
93-
func getComposeFilePath(userSpecifiedComposeFile string) (string, error) {
94-
// The Compose file is compose.yaml (preferred) or compose.yml that is placed in the current directory or higher.
95-
// Compose also supports docker-compose.yaml and docker-compose.yml for backwards compatibility.
96-
// Users can override the file by specifying file name
97-
const DEFAULT_COMPOSE_FILE_PATTERN = "*compose.y*ml"
98-
99-
path, err := os.Getwd()
100-
if err != nil {
101-
return path, err
102-
}
103-
104-
searchPattern := DEFAULT_COMPOSE_FILE_PATTERN
105-
if len(userSpecifiedComposeFile) > 0 {
106-
path = ""
107-
searchPattern = userSpecifiedComposeFile
108-
}
109-
110-
// iterate through this loop at least once to find the compose file.
111-
// if the user did not specify a specific file (i.e. userSpecifiedComposeFile == "")
112-
// then walk the tree up to the root directory looking for a compose file.
113-
term.Debug("Looking for compose file - searching for", searchPattern)
114-
for {
115-
if files, _ := filepath.Glob(filepath.Join(path, searchPattern)); len(files) > 1 {
116-
return "", fmt.Errorf("multiple Compose files found: %q; use -f to specify which one to use", files)
117-
} else if len(files) == 1 {
118-
// found compose file, we're done
119-
return files[0], nil
120-
}
121-
122-
if len(userSpecifiedComposeFile) > 0 {
123-
return "", fmt.Errorf("no Compose file found at %q: %w", userSpecifiedComposeFile, os.ErrNotExist)
124-
}
125-
126-
// compose file not found, try parent directory
127-
nextPath := filepath.Dir(path)
128-
if nextPath == path {
129-
// previous search was of root, we're done
130-
return "", types.ErrComposeFileNotFound
131-
}
132-
133-
path = nextPath
134-
}
101+
return cli.NewProjectOptions(options.ConfigPaths, optFns...)
135102
}

src/pkg/cli/compose/loader_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ import (
1616

1717
func TestLoader(t *testing.T) {
1818
testRunCompose(t, func(t *testing.T, path string) {
19-
loader := Loader{path}
19+
loader := NewLoaderWithPath(path)
2020
proj, err := loader.LoadCompose(context.Background())
2121
if err != nil {
2222
t.Fatal(err)
2323
}
24+
2425
yaml, err := proj.MarshalYAML()
2526
if err != nil {
2627
t.Fatal(err)

src/pkg/cli/compose/validation_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ func TestValidationAndConvert(t *testing.T) {
2222
logs := new(bytes.Buffer)
2323
term.DefaultTerm = term.NewTerm(logs, logs)
2424

25-
loader := Loader{path}
25+
options := LoaderOptions{ConfigPaths: []string{path}}
26+
loader := Loader{options: options}
2627
proj, err := loader.LoadCompose(context.Background())
2728
if err != nil {
2829
t.Fatal(err)

0 commit comments

Comments
 (0)