diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 52daddcb36..89c2da6225 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -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 { @@ -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 } } diff --git a/cmd/compose/create.go b/cmd/compose/create.go index 41cac7de96..c08a19fcff 100644 --- a/cmd/compose/create.go +++ b/cmd/compose/create.go @@ -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 { @@ -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") } @@ -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 } diff --git a/cmd/compose/down.go b/cmd/compose/down.go index d74c817529..89c23bba15 100644 --- a/cmd/compose/down.go +++ b/cmd/compose/down.go @@ -19,7 +19,6 @@ package compose import ( "context" "fmt" - "os" "time" "github.com/docker/cli/cli/command" @@ -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 { @@ -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) @@ -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")`) diff --git a/cmd/compose/kill.go b/cmd/compose/kill.go index 1ec83153bc..f068f60143 100644 --- a/cmd/compose/kill.go +++ b/cmd/compose/kill.go @@ -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 { @@ -42,6 +40,10 @@ 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) }), @@ -49,8 +51,7 @@ func killCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe } 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 diff --git a/cmd/compose/orphans_env_test.go b/cmd/compose/orphans_env_test.go new file mode 100644 index 0000000000..2c2a103606 --- /dev/null +++ b/cmd/compose/orphans_env_test.go @@ -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) +} diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 7d5f522b43..34a91b31dc 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -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) createOpts.pullChanged = cmd.Flags().Changed("pull") return nil }), diff --git a/cmd/compose/up.go b/cmd/compose/up.go index e69e455e27..c7241a03ec 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -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 {