From 64ba96a4ed75e44efc4ce438a4e81f9d26dd9be9 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 4 Aug 2026 11:45:48 +0200 Subject: [PATCH] feat: add --profiles-only flag to stop and restart commands Acting on only the services of a profile currently requires naming each service explicitly, as `--profile x stop` or `--profile x restart` also target services without profiles (#13993, previously #12648). The new flag restricts both commands to services enabled by a profile: the active profiles when some are set via --profile or COMPOSE_PROFILES, otherwise services from all profiles. Services without profiles are never affected. `down` intentionally keeps its whole-application semantics. Signed-off-by: Guillaume Lours --- cmd/compose/profiles_only.go | 78 ++++++++++++++++ cmd/compose/profiles_only_test.go | 103 +++++++++++++++++++++ cmd/compose/restart.go | 18 +++- cmd/compose/stop.go | 15 ++- docs/reference/compose_restart.md | 11 ++- docs/reference/compose_stop.md | 9 +- docs/reference/docker_compose_restart.yaml | 11 +++ docs/reference/docker_compose_stop.yaml | 11 +++ pkg/e2e/profiles_test.go | 60 ++++++++++++ 9 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 cmd/compose/profiles_only.go create mode 100644 cmd/compose/profiles_only_test.go diff --git a/cmd/compose/profiles_only.go b/cmd/compose/profiles_only.go new file mode 100644 index 00000000000..6685469e950 --- /dev/null +++ b/cmd/compose/profiles_only.go @@ -0,0 +1,78 @@ +/* + 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 ( + "errors" + "fmt" + "io" + "slices" + "strings" + + "github.com/compose-spec/compose-go/v2/types" +) + +// profilesOnlyServices resolves the service names targeted by the --profiles-only +// flag: services enabled by one of the active profiles, or services belonging to +// any profile when no profile is active. It returns the project to run the +// command with, which has all profiles enabled when none was active. An empty +// service list with a nil error means there is nothing to do. +func profilesOnlyServices(project *types.Project, services []string, action string, w io.Writer) (*types.Project, []string, error) { + if len(services) > 0 { + return nil, nil, errors.New("--profiles-only cannot be combined with service names, naming a service already activates its profiles") + } + if project == nil { + return nil, nil, errors.New("--profiles-only requires the project's compose file(s), pass --file or run the command from the project directory") + } + + // COMPOSE_PROFILES being unset yields a single blank profile name, filter + // such entries out so it is treated as "no active profile" + activeProfiles := slices.DeleteFunc(slices.Clone(project.Profiles), func(p string) bool { + return p == "" + }) + if len(activeProfiles) == 0 { + var err error + project, err = project.WithProfiles([]string{"*"}) + if err != nil { + return nil, nil, err + } + } + + var names, allProfiles []string + for name, service := range project.Services { + if len(service.Profiles) == 0 { + continue + } + names = append(names, name) + allProfiles = append(allProfiles, service.Profiles...) + } + slices.Sort(names) + slices.Sort(allProfiles) + allProfiles = slices.Compact(allProfiles) + + switch { + case len(names) == 0 && len(activeProfiles) > 0: + _, _ = fmt.Fprintf(w, "no services matched the active profiles [%s]\n", strings.Join(activeProfiles, " ")) + case len(names) == 0: + _, _ = fmt.Fprintln(w, "no service in this project uses profiles") + case len(activeProfiles) > 0: + _, _ = fmt.Fprintf(w, "%s services in profiles [%s]\n", action, strings.Join(activeProfiles, " ")) + default: + _, _ = fmt.Fprintf(w, "%s services from all profiles [%s] as none is active\n", action, strings.Join(allProfiles, " ")) + } + return project, names, nil +} diff --git a/cmd/compose/profiles_only_test.go b/cmd/compose/profiles_only_test.go new file mode 100644 index 00000000000..b10376ad343 --- /dev/null +++ b/cmd/compose/profiles_only_test.go @@ -0,0 +1,103 @@ +/* + 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 ( + "bytes" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" +) + +func TestProfilesOnlyServices(t *testing.T) { + newProject := func(activeProfiles ...string) *types.Project { + project := &types.Project{ + Name: "test", + Services: types.Services{ + "core": types.ServiceConfig{Name: "core"}, + }, + DisabledServices: types.Services{ + "svc-a": types.ServiceConfig{Name: "svc-a", Profiles: []string{"a"}}, + "svc-ab": types.ServiceConfig{Name: "svc-ab", Profiles: []string{"a", "b"}}, + "svc-b": types.ServiceConfig{Name: "svc-b", Profiles: []string{"b"}}, + }, + } + if len(activeProfiles) == 0 { + // mimic the loader behavior when no profile is set: COMPOSE_PROFILES + // being unset yields a single blank profile name + project.Profiles = []string{""} + return project + } + withProfiles, err := project.WithProfiles(activeProfiles) + assert.NilError(t, err) + return withProfiles + } + + t.Run("rejects explicit service names", func(t *testing.T) { + _, _, err := profilesOnlyServices(newProject("a"), []string{"svc-a"}, "Stopping", &bytes.Buffer{}) + assert.ErrorContains(t, err, "cannot be combined with service names") + }) + + t.Run("requires a project", func(t *testing.T) { + _, _, err := profilesOnlyServices(nil, nil, "Stopping", &bytes.Buffer{}) + assert.ErrorContains(t, err, "requires the project's compose file(s)") + }) + + t.Run("restricts to services of the active profiles", func(t *testing.T) { + out := &bytes.Buffer{} + _, services, err := profilesOnlyServices(newProject("a"), nil, "Stopping", out) + assert.NilError(t, err) + assert.DeepEqual(t, services, []string{"svc-a", "svc-ab"}) + assert.Equal(t, out.String(), "Stopping services in profiles [a]\n") + }) + + t.Run("no active profile targets all profiled services", func(t *testing.T) { + out := &bytes.Buffer{} + project, services, err := profilesOnlyServices(newProject(), nil, "Stopping", out) + assert.NilError(t, err) + assert.DeepEqual(t, services, []string{"svc-a", "svc-ab", "svc-b"}) + assert.Equal(t, out.String(), "Stopping services from all profiles [a b] as none is active\n") + // the returned project must have the targeted services enabled, as the + // backend only acts on enabled services + for _, name := range services { + _, enabled := project.Services[name] + assert.Assert(t, enabled, "service %s should be enabled in the returned project", name) + } + }) + + t.Run("active profile matching no service is a no-op", func(t *testing.T) { + out := &bytes.Buffer{} + _, services, err := profilesOnlyServices(newProject("unknown"), nil, "Stopping", out) + assert.NilError(t, err) + assert.Equal(t, len(services), 0) + assert.Equal(t, out.String(), "no services matched the active profiles [unknown]\n") + }) + + t.Run("project without profiles is a no-op", func(t *testing.T) { + project := &types.Project{ + Name: "test", + Services: types.Services{"core": types.ServiceConfig{Name: "core"}}, + Profiles: []string{""}, + } + out := &bytes.Buffer{} + _, services, err := profilesOnlyServices(project, nil, "Stopping", out) + assert.NilError(t, err) + assert.Equal(t, len(services), 0) + assert.Equal(t, out.String(), "no service in this project uses profiles\n") + }) +} diff --git a/cmd/compose/restart.go b/cmd/compose/restart.go index a9d97c50263..c7d1713f784 100644 --- a/cmd/compose/restart.go +++ b/cmd/compose/restart.go @@ -27,9 +27,10 @@ import ( type restartOptions struct { *ProjectOptions - timeChanged bool - timeout int - noDeps bool + timeChanged bool + timeout int + noDeps bool + profilesOnly bool } func restartCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { @@ -50,6 +51,7 @@ func restartCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Ba flags := restartCmd.Flags() flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds") flags.BoolVar(&opts.noDeps, "no-deps", false, "Don't restart dependent services") + flags.BoolVar(&opts.profilesOnly, "profiles-only", false, "Only restart services enabled by a profile, leaving other services untouched (all profiles if none is active)") return restartCmd } @@ -60,6 +62,16 @@ func runRestart(ctx context.Context, dockerCli command.Cli, backendOptions *Back return err } + if opts.profilesOnly { + project, services, err = profilesOnlyServices(project, services, "Restarting", dockerCli.Err()) + if err != nil { + return err + } + if len(services) == 0 { + return nil + } + } + if project != nil && len(services) > 0 { project, err = project.WithServicesEnabled(services...) if err != nil { diff --git a/cmd/compose/stop.go b/cmd/compose/stop.go index be5dec26fc2..9da1527a9a9 100644 --- a/cmd/compose/stop.go +++ b/cmd/compose/stop.go @@ -27,8 +27,9 @@ import ( type stopOptions struct { *ProjectOptions - timeChanged bool - timeout int + timeChanged bool + timeout int + profilesOnly bool } func stopCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { @@ -48,6 +49,7 @@ func stopCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe } flags := cmd.Flags() flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds") + flags.BoolVar(&opts.profilesOnly, "profiles-only", false, "Only stop services enabled by a profile, leaving other services running (all profiles if none is active)") return cmd } @@ -57,6 +59,15 @@ func runStop(ctx context.Context, dockerCli command.Cli, backendOptions *Backend if err != nil { return err } + if opts.profilesOnly { + project, services, err = profilesOnlyServices(project, services, "Stopping", dockerCli.Err()) + if err != nil { + return err + } + if len(services) == 0 { + return nil + } + } return withBackend(dockerCli, backendOptions, func(backend api.Compose) error { return backend.Stop(ctx, name, api.StopOptions{ Timeout: optionalTimeout(opts.timeout, opts.timeChanged), diff --git a/docs/reference/compose_restart.md b/docs/reference/compose_restart.md index e57f346a81a..d35b688d534 100644 --- a/docs/reference/compose_restart.md +++ b/docs/reference/compose_restart.md @@ -14,11 +14,12 @@ or [restart_policy](https://github.com/compose-spec/compose-spec/blob/main/deplo ### Options -| Name | Type | Default | Description | -|:------------------|:-------|:--------|:--------------------------------------| -| `--dry-run` | `bool` | | Execute command in dry run mode | -| `--no-deps` | `bool` | | Don't restart dependent services | -| `-t`, `--timeout` | `int` | `0` | Specify a shutdown timeout in seconds | +| Name | Type | Default | Description | +|:------------------|:-------|:--------|:--------------------------------------------------------------------------------------------------------------| +| `--dry-run` | `bool` | | Execute command in dry run mode | +| `--no-deps` | `bool` | | Don't restart dependent services | +| `--profiles-only` | `bool` | | Only restart services enabled by a profile, leaving other services untouched (all profiles if none is active) | +| `-t`, `--timeout` | `int` | `0` | Specify a shutdown timeout in seconds | diff --git a/docs/reference/compose_stop.md b/docs/reference/compose_stop.md index fe84f24f8f5..ac04709561c 100644 --- a/docs/reference/compose_stop.md +++ b/docs/reference/compose_stop.md @@ -5,10 +5,11 @@ Stops running containers without removing them. They can be started again with ` ### Options -| Name | Type | Default | Description | -|:------------------|:-------|:--------|:--------------------------------------| -| `--dry-run` | `bool` | | Execute command in dry run mode | -| `-t`, `--timeout` | `int` | `0` | Specify a shutdown timeout in seconds | +| Name | Type | Default | Description | +|:------------------|:-------|:--------|:---------------------------------------------------------------------------------------------------------| +| `--dry-run` | `bool` | | Execute command in dry run mode | +| `--profiles-only` | `bool` | | Only stop services enabled by a profile, leaving other services running (all profiles if none is active) | +| `-t`, `--timeout` | `int` | `0` | Specify a shutdown timeout in seconds | diff --git a/docs/reference/docker_compose_restart.yaml b/docs/reference/docker_compose_restart.yaml index 3bc0a3ad83a..459b266d2c8 100644 --- a/docs/reference/docker_compose_restart.yaml +++ b/docs/reference/docker_compose_restart.yaml @@ -25,6 +25,17 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: profiles-only + value_type: bool + default_value: "false" + description: | + Only restart services enabled by a profile, leaving other services untouched (all profiles if none is active) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: timeout shorthand: t value_type: int diff --git a/docs/reference/docker_compose_stop.yaml b/docs/reference/docker_compose_stop.yaml index f2ec34ccb3d..62c6d38e02e 100644 --- a/docs/reference/docker_compose_stop.yaml +++ b/docs/reference/docker_compose_stop.yaml @@ -6,6 +6,17 @@ usage: docker compose stop [OPTIONS] [SERVICE...] pname: docker compose plink: docker_compose.yaml options: + - option: profiles-only + value_type: bool + default_value: "false" + description: | + Only stop services enabled by a profile, leaving other services running (all profiles if none is active) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: timeout shorthand: t value_type: int diff --git a/pkg/e2e/profiles_test.go b/pkg/e2e/profiles_test.go index dffc209d00e..e00bbe020b0 100644 --- a/pkg/e2e/profiles_test.go +++ b/pkg/e2e/profiles_test.go @@ -205,3 +205,63 @@ func TestDotEnvProfileUsage(t *testing.T) { res.Assert(t, icmd.Expected{Out: profiledService}) }) } + +func TestProfilesOnlyFlag(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "compose-e2e-profiles-only" + const profileName = "test-profile" + + t.Cleanup(func() { + _ = c.RunDockerComposeCmd(t, "--project-name", projectName, "down") + }) + + assertOnlyRegularRunning := func(t *testing.T) { + t.Helper() + res := c.RunDockerComposeCmd(t, "-p", projectName, "ps", "--status", "running") + res.Assert(t, icmd.Expected{Out: regularService}) + assert.Assert(t, !strings.Contains(res.Combined(), profiledService)) + } + + t.Run("compose up with profile", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/profiles/compose.yaml", + "-p", projectName, "--profile", profileName, "up", "-d") + res.Assert(t, icmd.Expected{ExitCode: 0}) + }) + + t.Run("stop --profiles-only stops only profiled services", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/profiles/compose.yaml", + "-p", projectName, "--profile", profileName, "stop", "--profiles-only") + res.Assert(t, icmd.Expected{ExitCode: 0}) + assert.Assert(t, strings.Contains(res.Combined(), "Stopping services in profiles [test-profile]"), res.Combined()) + assertOnlyRegularRunning(t) + }) + + t.Run("restart --profiles-only restarts only profiled services", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/profiles/compose.yaml", + "-p", projectName, "--profile", profileName, "restart", "--profiles-only") + res.Assert(t, icmd.Expected{ExitCode: 0}) + assert.Assert(t, strings.Contains(res.Combined(), "Restarting services in profiles [test-profile]"), res.Combined()) + res = c.RunDockerComposeCmd(t, "-p", projectName, "ps", "--status", "running") + res.Assert(t, icmd.Expected{Out: regularService}) + res.Assert(t, icmd.Expected{Out: profiledService}) + }) + + t.Run("stop --profiles-only without active profile targets all profiles", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/profiles/compose.yaml", + "-p", projectName, "stop", "--profiles-only") + res.Assert(t, icmd.Expected{ExitCode: 0}) + assert.Assert(t, strings.Contains(res.Combined(), "Stopping services from all profiles [test-profile] as none is active"), res.Combined()) + assertOnlyRegularRunning(t) + }) + + t.Run("stop --profiles-only rejects service names", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/profiles/compose.yaml", + "-p", projectName, "stop", "--profiles-only", profiledService) + res.Assert(t, icmd.Expected{ExitCode: 1, Err: "cannot be combined with service names"}) + }) + + t.Run("stop --profiles-only requires the compose files", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "stop", "--profiles-only") + res.Assert(t, icmd.Expected{ExitCode: 1, Err: "requires the project's compose file(s)"}) + }) +}