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
31 changes: 26 additions & 5 deletions cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ const (
)

// rawEnv load a dot env file using docker/cli key=value parser, without attempt to interpolate or evaluate values
// removeOrphansFromEnv resolves the effective --remove-orphans value for a
// command: an explicit flag always wins; otherwise COMPOSE_REMOVE_ORPHANS is
// read from the process environment. Meant to be called from a command's
// PreRunE — after the root PersistentPreRunE completed the process
// environment with the COMPOSE_* keys of the project's local .env (see
// setEnvWithDotEnv) — so every command carrying the flag resolves the
// variable identically, whether it is exported in the shell or declared in
// the local .env.
func removeOrphansFromEnv(flags *pflag.FlagSet, current bool) bool {
if flags.Changed("remove-orphans") {
return current
}
return utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
}

func rawEnv(r io.Reader, filename string, vars map[string]string, lookup func(key string) (string, bool)) error {
lines, err := kvfile.ParseFromReader(r, lookup)
if err != nil {
Expand Down Expand Up @@ -728,16 +743,22 @@ func selectEventProcessor(dockerCli command.Cli, progress, ansi string, detached
}
}

// setEnvWithDotEnv completes the process environment with the COMPOSE_*
// keys declared in the project's local .env (and explicit --env-file files),
// so they act as per-project defaults for the matching CLI flags. Keys
// already present in the process environment win, and an explicit flag wins
// over both — see removeOrphansFromEnv.
//
// Remote configs (OCI, Git) are deliberately excluded: COMPOSE_* variables
// exist so a local user doesn't have to repeat a flag on every command.
// They are the local user's choice, and a remote model must not steer the
// behavior of the CLI consuming it. This is a product decision, not a
// technical limitation.
func setEnvWithDotEnv(opts ProjectOptions, dockerCli command.Cli) error {
// Check if we're using a remote config (OCI or Git)
// If so, skip env loading as remote loaders haven't been initialized yet
// and trying to process the path would fail
remoteLoaders := opts.remoteLoaders(dockerCli)
for _, path := range opts.ConfigPaths {
for _, loader := range remoteLoaders {
if loader.Accept(path) {
// Remote config - skip env loading for now
// It will be loaded later when the project is fully initialized
return nil
}
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (

"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/compose"
"github.com/docker/compose/v5/pkg/utils"
)

type createOptions struct {
Expand Down Expand Up @@ -62,6 +63,7 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac
Short: "Creates containers for a service",
PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
opts.pullChanged = cmd.Flags().Changed("pull")
opts.removeOrphans = removeOrphansFromEnv(cmd.Flags(), opts.removeOrphans)
if opts.Build && opts.noBuild {
return fmt.Errorf("--build and --no-build are incompatible")
}
Expand Down Expand Up @@ -97,6 +99,10 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac
}

func runCreate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, createOpts createOptions, buildOpts buildOptions, project *types.Project, services []string) error {
createOpts.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans])
if createOpts.ignoreOrphans && createOpts.removeOrphans {
return fmt.Errorf("cannot combine %s and --remove-orphans", ComposeIgnoreOrphans)
}
if err := createOpts.Apply(project); err != nil {
return err
}
Expand Down
6 changes: 2 additions & 4 deletions cmd/compose/down.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package compose
import (
"context"
"fmt"
"os"
"time"

"github.com/docker/cli/cli/command"
Expand All @@ -29,7 +28,6 @@ import (

"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/compose"
"github.com/docker/compose/v5/pkg/utils"
)

type downOptions struct {
Expand All @@ -50,6 +48,7 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe
Short: "Stop and remove containers, networks",
PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
opts.timeChanged = cmd.Flags().Changed("timeout")
opts.removeOrphans = removeOrphansFromEnv(cmd.Flags(), opts.removeOrphans)
if opts.images != "" {
if opts.images != "all" && opts.images != "local" {
return fmt.Errorf("invalid value for --rmi: %q", opts.images)
Expand All @@ -63,8 +62,7 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe
ValidArgsFunction: completeServiceNames(dockerCli, p),
}
flags := downCmd.Flags()
removeOrphans := utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
flags.BoolVar(&opts.removeOrphans, "remove-orphans", removeOrphans, "Remove containers for services not defined in the Compose file")
flags.BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file")
flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds")
flags.BoolVarP(&opts.volumes, "volumes", "v", false, `Remove named volumes declared in the "volumes" section of the Compose file and anonymous volumes attached to containers`)
flags.StringVar(&opts.images, "rmi", "", `Remove images used by services. "local" remove only images that don't have a custom tag ("local"|"all")`)
Expand Down
9 changes: 5 additions & 4 deletions cmd/compose/kill.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@ import (
"context"
"errors"
"fmt"
"os"

"github.com/docker/cli/cli/command"
"github.com/spf13/cobra"

"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/utils"
)

type killOptions struct {
Expand All @@ -42,15 +40,18 @@ func killCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe
cmd := &cobra.Command{
Use: "kill [OPTIONS] [SERVICE...]",
Short: "Force stop service containers",
PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
opts.removeOrphans = removeOrphansFromEnv(cmd.Flags(), opts.removeOrphans)
return nil
}),
RunE: Adapt(func(ctx context.Context, args []string) error {
return runKill(ctx, dockerCli, backendOptions, opts, args)
}),
ValidArgsFunction: completeServiceNames(dockerCli, p),
}

flags := cmd.Flags()
removeOrphans := utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
flags.BoolVar(&opts.removeOrphans, "remove-orphans", removeOrphans, "Remove containers for services not defined in the Compose file")
flags.BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file")
flags.StringVarP(&opts.signal, "signal", "s", "SIGKILL", "SIGNAL to send to the container")

return cmd
Expand Down
133 changes: 133 additions & 0 deletions cmd/compose/orphans_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
Copyright 2020 Docker Compose CLI authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package compose

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

"github.com/compose-spec/compose-go/v2/loader"
"github.com/spf13/pflag"
"gotest.tools/v3/assert"
)

func removeOrphansFlagSet(t *testing.T, args ...string) *pflag.FlagSet {
t.Helper()
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
var v bool
flags.BoolVar(&v, "remove-orphans", false, "")
assert.NilError(t, flags.Parse(args))
return flags
}

// An explicit --remove-orphans flag always wins; without it the value comes
// from COMPOSE_REMOVE_ORPHANS in the process environment — which the root
// PersistentPreRunE completed with the project's local .env beforehand.
func TestRemoveOrphansFromEnv(t *testing.T) {
t.Run("explicit flag wins over the environment", func(t *testing.T) {
t.Setenv(ComposeRemoveOrphans, "true")
flags := removeOrphansFlagSet(t, "--remove-orphans=false")
assert.Equal(t, removeOrphansFromEnv(flags, false), false)
})

t.Run("environment applies when the flag is not passed", func(t *testing.T) {
t.Setenv(ComposeRemoveOrphans, "true")
flags := removeOrphansFlagSet(t)
assert.Equal(t, removeOrphansFromEnv(flags, false), true)
})

t.Run("defaults to false when neither is set", func(t *testing.T) {
t.Setenv(ComposeRemoveOrphans, "")
assert.NilError(t, os.Unsetenv(ComposeRemoveOrphans))
flags := removeOrphansFlagSet(t)
assert.Equal(t, removeOrphansFromEnv(flags, false), false)
})
}

func writeProjectWithDotEnv(t *testing.T, dotEnv string) ProjectOptions {
t.Helper()
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
assert.NilError(t, os.WriteFile(composePath, []byte("services: {}\n"), 0o600))
assert.NilError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(dotEnv), 0o600))
return ProjectOptions{
ConfigPaths: []string{composePath},
ProjectDir: dir,
}
}

// setEnvWithDotEnv turns the COMPOSE_* keys of the project's local .env into
// per-project defaults by completing the process environment — process
// values win, non-COMPOSE_ keys are left alone, and remote configs are
// deliberately excluded (COMPOSE_* variables are the local user's choice; a
// remote model must not steer the CLI consuming it).
func TestSetEnvWithDotEnv(t *testing.T) {
unset := func(t *testing.T, keys ...string) {
t.Helper()
for _, k := range keys {
t.Setenv(k, "") // registers restoration
assert.NilError(t, os.Unsetenv(k))
}
}

t.Run("COMPOSE_ keys of the local .env are injected", func(t *testing.T) {
unset(t, ComposeRemoveOrphans, "NOT_COMPOSE_VAR")
opts := writeProjectWithDotEnv(t, "COMPOSE_REMOVE_ORPHANS=true\nNOT_COMPOSE_VAR=x\n")

assert.NilError(t, setEnvWithDotEnv(opts, nil))

assert.Equal(t, os.Getenv(ComposeRemoveOrphans), "true")
_, injected := os.LookupEnv("NOT_COMPOSE_VAR")
assert.Check(t, !injected, "non-COMPOSE_ keys must not leak into the process environment")
})

t.Run("process environment wins over the .env", func(t *testing.T) {
t.Setenv(ComposeRemoveOrphans, "false")
opts := writeProjectWithDotEnv(t, "COMPOSE_REMOVE_ORPHANS=true\n")

assert.NilError(t, setEnvWithDotEnv(opts, nil))

assert.Equal(t, os.Getenv(ComposeRemoveOrphans), "false")
})

t.Run("remote configs are excluded", func(t *testing.T) {
unset(t, ComposeRemoveOrphans)
opts := writeProjectWithDotEnv(t, "COMPOSE_REMOVE_ORPHANS=true\n")
opts.ConfigPaths = []string{"test://remote/compose.yaml"}
opts.remoteLoadersOverride = []loader.ResourceLoader{testRemoteLoader{}}

assert.NilError(t, setEnvWithDotEnv(opts, nil))

_, injected := os.LookupEnv(ComposeRemoveOrphans)
assert.Check(t, !injected, "a remote model must not inject COMPOSE_* variables")
})
}

// G.3 of epic #14074: the full resolution order, .env → process env → flag.
func TestRemoveOrphansResolutionOrder(t *testing.T) {
t.Setenv(ComposeRemoveOrphans, "")
assert.NilError(t, os.Unsetenv(ComposeRemoveOrphans))
opts := writeProjectWithDotEnv(t, "COMPOSE_REMOVE_ORPHANS=true\n")

// .env applies when nothing else is set
assert.NilError(t, setEnvWithDotEnv(opts, nil))
assert.Equal(t, removeOrphansFromEnv(removeOrphansFlagSet(t), false), true)

// an explicit flag beats both
assert.Equal(t, removeOrphansFromEnv(removeOrphansFlagSet(t, "--remove-orphans=false"), false), false)
}
1 change: 1 addition & 0 deletions cmd/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen
display.Mode = display.ModeQuiet
backendOptions.Add(compose.WithEventProcessor(display.Quiet()))
}
options.removeOrphans = removeOrphansFromEnv(cmd.Flags(), options.removeOrphans)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[medium] Missing ignoreOrphans vs removeOrphans conflict guard in run subcommand

This PR correctly adds removeOrphansFromEnv to run's PreRunE so COMPOSE_REMOVE_ORPHANS is now resolved for run — but the corresponding conflict guard that both up and create have was not added to run's RunE.

In up.go (line 129) and create.go (line 103):

if create.ignoreOrphans && create.removeOrphans {
    return fmt.Errorf("cannot combine %s and --remove-orphans", ComposeIgnoreOrphans)
}

In run.go's RunE, after options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans]) (line 217), there is no equivalent check. Now that removeOrphans is properly resolved for run, a user with both COMPOSE_IGNORE_ORPHANS=true in their project .env and COMPOSE_REMOVE_ORPHANS=true (or --remove-orphans) will get conflicting options silently accepted for run, while up and create would return an error.

Fix: Add the same guard in run.go's RunE after options.ignoreOrphans is assigned:

options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans])
if options.ignoreOrphans && options.removeOrphans {
    return fmt.Errorf("cannot combine %s and --remove-orphans", ComposeIgnoreOrphans)
}
Confidence Score
🟢 strong 97/100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agreed and confirmed: run is the only command missing this guard; the fix above resolves it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for confirming! Adding the conflict guard to run will ensure consistent validation across all commands that support orphan handling.

createOpts.pullChanged = cmd.Flags().Changed("pull")
return nil
}),
Expand Down
4 changes: 1 addition & 3 deletions cmd/compose/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend
create.pullChanged = cmd.Flags().Changed("pull")
create.timeChanged = cmd.Flags().Changed("timeout")
up.navigationMenuChanged = cmd.Flags().Changed("menu")
if !cmd.Flags().Changed("remove-orphans") {
create.removeOrphans = utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
}
create.removeOrphans = removeOrphansFromEnv(cmd.Flags(), create.removeOrphans)
return validateFlags(&up, &create)
}),
RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
Expand Down
Loading