diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index a797ebeaff..ca7cf187a6 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -61,7 +61,6 @@ func (manager *SwarmStackManager) Deploy( ProjectName: stack.Name, Host: url, Env: env, - WorkingDir: stack.ProjectPath, Registries: portainerRegistriesToAuthConfigs(registries), } diff --git a/pkg/libstack/swarm/swarm.go b/pkg/libstack/swarm/swarm.go index 64acf3051b..bc931c2548 100644 --- a/pkg/libstack/swarm/swarm.go +++ b/pkg/libstack/swarm/swarm.go @@ -52,7 +52,6 @@ type Options struct { ProjectName string Host string Env []string - WorkingDir string Registries []configtypes.AuthConfig } @@ -104,7 +103,7 @@ func (d *SwarmDeployer) Deploy(ctx context.Context, filePaths []string, options // Validate loads and parses the compose file(s), returning an error if they are invalid. func (d *SwarmDeployer) Validate(_ context.Context, filePaths []string, options Options) error { - _, err := getConfig(filePaths, options.WorkingDir, options.Env) + _, err := getConfig(filePaths, options.Env) return err } @@ -186,7 +185,7 @@ func deployStack(ctx context.Context, dockerCLI *command.DockerCli, filePaths [] return errors.New(`this node is not a swarm manager. Use "docker swarm init" or "docker swarm join" to connect this node to swarm and try again`) } - config, err := getConfig(filePaths, options.WorkingDir, options.Env) + config, err := getConfig(filePaths, options.Env) if err != nil { return fmt.Errorf("failed to load compose file: %w", err) } @@ -617,9 +616,9 @@ func isTerminalState(state swarm.TaskState) bool { return taskStateOrdinal[state] > taskStateOrdinal[swarm.TaskStateRunning] } -func getConfig(filePaths []string, workingDir string, env []string) (*composetypes.Config, error) { +func getConfig(filePaths []string, env []string) (*composetypes.Config, error) { // Load and parse the compose file(s). - configDetails, err := getConfigDetails(filePaths, workingDir, env) + configDetails, err := getConfigDetails(filePaths, env) if err != nil { return nil, fmt.Errorf("failed to load compose file: %w", err) } @@ -659,14 +658,17 @@ func getConfig(filePaths []string, workingDir string, env []string) (*composetyp return config, nil } -func getConfigDetails(filePaths []string, workingDir string, env []string) (composetypes.ConfigDetails, error) { +func getConfigDetails(filePaths []string, env []string) (composetypes.ConfigDetails, error) { var details composetypes.ConfigDetails if len(filePaths) == 0 { return details, errors.New("at least one compose file must be specified") } - details.WorkingDir = workingDir + // Resolve relative references against the compose file's own directory, matching + // Docker Compose semantics (the default project directory is the directory of the + // first compose file) and the compose (non-swarm) libstack deployer. + details.WorkingDir = filepath.Dir(filePaths[0]) details.ConfigFiles = make([]composetypes.ConfigFile, 0, len(filePaths)) for _, fp := range filePaths { @@ -680,7 +682,7 @@ func getConfigDetails(filePaths []string, workingDir string, env []string) (comp return details, err } - resolveEnvFilePaths(config, workingDir) + resolveEnvFilePaths(config, filepath.Dir(fp)) details.ConfigFiles = append(details.ConfigFiles, composetypes.ConfigFile{ Filename: fp, diff --git a/pkg/libstack/swarm/swarm_unit_test.go b/pkg/libstack/swarm/swarm_unit_test.go index 70959d2155..326a61d302 100644 --- a/pkg/libstack/swarm/swarm_unit_test.go +++ b/pkg/libstack/swarm/swarm_unit_test.go @@ -3,6 +3,7 @@ package swarm import ( "context" "os" + "path/filepath" "slices" "testing" @@ -260,6 +261,7 @@ func Test_getConfig(t *testing.T) { writeFile := func(name, content string) string { path := filesystem.JoinPaths(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) return path } @@ -270,7 +272,6 @@ func Test_getConfig(t *testing.T) { name string composeFiles map[string]string files map[string]string - workingDir string env []string osEnv map[string]string expectedCfg *composetypes.Config @@ -284,7 +285,6 @@ services: web: image: nginx:latest`, }, - workingDir: dir, expectedCfg: &composetypes.Config{ Filename: dir + "/valid.yml", Version: "3.13", @@ -306,7 +306,6 @@ services: composeFiles: map[string]string{ "invalid.yml": `not: valid: yaml: content`, }, - workingDir: dir, expectedErr: "failed to load compose file: yaml: mapping values are not allowed in this context", }, { @@ -321,7 +320,6 @@ services: web: command: echo hello`, }, - workingDir: dir, expectedErr: "invalid image reference for service web: no image specified", }, { @@ -336,7 +334,6 @@ services: worker: image: alpine:latest`, }, - workingDir: dir, expectedCfg: &composetypes.Config{ Filename: dir + "/base.yml", Version: "3.13", @@ -366,8 +363,7 @@ services: web: image: nginx:${TAG}`, }, - workingDir: dir, - env: []string{"TAG=1.25"}, + env: []string{"TAG=1.25"}, expectedCfg: &composetypes.Config{ Filename: dir + "/envvar.yml", Version: "3.13", @@ -392,7 +388,6 @@ services: web: image: nginx:${PORTAINER_TAG}`, }, - workingDir: dir, osEnv: map[string]string{ libstack.PortainerEnvVarsPrefix + "TAG": "1.25", }, @@ -413,7 +408,7 @@ services: }, }, { - name: "env_file with relative path is resolved using workingDir", + name: "env_file with relative path is resolved against the compose file directory", composeFiles: map[string]string{ "docker-compose.yaml": `services: configtest: @@ -424,7 +419,6 @@ services: files: map[string]string{ "stack.env": "A=junk", }, - workingDir: dir, expectedCfg: &composetypes.Config{ Filename: dir + "/docker-compose.yaml", Version: "3.13", @@ -456,7 +450,6 @@ services: files: map[string]string{ "stack.env": "A=junk", }, - workingDir: dir, expectedCfg: &composetypes.Config{ Filename: dir + "/docker-compose.yaml", Version: "3.13", @@ -476,6 +469,41 @@ services: Configs: map[string]composetypes.ConfigObjConfig{}, }, }, + { + // Regression test for BE-13157: a Git stack whose compose file lives in a + // sub-directory must resolve a sibling env_file relative to that sub-directory, + // not the project root. + name: "env_file with relative path is resolved within the compose file sub-directory", + composeFiles: map[string]string{ + "sub/docker-compose.yaml": `services: + configtest: + image: nginx:latest + env_file: + - stack.env`, + }, + files: map[string]string{ + "sub/stack.env": "A=junk", + "stack.env": "A=not-this-one", + }, + expectedCfg: &composetypes.Config{ + Filename: dir + "/sub/docker-compose.yaml", + Version: "3.13", + Services: composetypes.Services{ + composetypes.ServiceConfig{ + Name: "configtest", + Environment: composetypes.MappingWithEquals{ + "A": getStrPointer("junk"), + }, + Image: "nginx:latest", + EnvFile: []string{dir + "/sub/stack.env"}, + }, + }, + Networks: map[string]composetypes.NetworkConfig{}, + Volumes: map[string]composetypes.VolumeConfig{}, + Secrets: map[string]composetypes.SecretConfig{}, + Configs: map[string]composetypes.ConfigObjConfig{}, + }, + }, } for _, tt := range tests { @@ -494,7 +522,7 @@ services: t.Setenv(k, v) } - cfg, err := getConfig(filePaths, tt.workingDir, tt.env) + cfg, err := getConfig(filePaths, tt.env) if err != nil { if tt.expectedErr == "" { t.Fatalf("expected no error but got: %v", err)