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
13 changes: 13 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,17 @@ 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 {
// Deliberate source asymmetry with removeOrphans: the destructive
// variable (COMPOSE_REMOVE_ORPHANS) resolves through the process
// environment, which setEnvWithDotEnv completes from the local .env
// only — a remote model cannot enable container removal. The benign
// COMPOSE_IGNORE_ORPHANS reads project.Environment, remote configs
// included: the worst a remote model can do there is suppress a
// warning.
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)
}
11 changes: 11 additions & 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)
Comment thread
ndeloof marked this conversation as resolved.
createOpts.pullChanged = cmd.Flags().Changed("pull")
return nil
}),
Expand All @@ -213,7 +214,17 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen
buildOpts.Progress = string(xprogress.QuietMode)
}

// Deliberate source asymmetry with removeOrphans: the destructive
// variable (COMPOSE_REMOVE_ORPHANS) resolves through the process
// environment, which setEnvWithDotEnv completes from the local .env
// only — a remote model cannot enable container removal. The benign
// COMPOSE_IGNORE_ORPHANS reads project.Environment, remote configs
// included: the worst a remote model can do there is suppress a
// warning.
options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans])
if options.ignoreOrphans && options.removeOrphans {
return fmt.Errorf("cannot combine %s and --remove-orphans", ComposeIgnoreOrphans)
}
return runRun(ctx, backend, project, options, createOpts, buildOpts, dockerCli)
}),
ValidArgsFunction: completeServiceNames(dockerCli, p),
Expand Down
11 changes: 8 additions & 3 deletions cmd/compose/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,17 @@ 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 {
// Deliberate source asymmetry with removeOrphans: the destructive
// variable (COMPOSE_REMOVE_ORPHANS) resolves through the process
// environment, which setEnvWithDotEnv completes from the local .env
// only — a remote model cannot enable container removal. The benign
// COMPOSE_IGNORE_ORPHANS reads project.Environment, remote configs
// included: the worst a remote model can do there is suppress a
// warning.
create.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans])
if create.ignoreOrphans && create.removeOrphans {
return fmt.Errorf("cannot combine %s and --remove-orphans", ComposeIgnoreOrphans)
Expand Down