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
78 changes: 78 additions & 0 deletions cmd/compose/profiles_only.go
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions cmd/compose/profiles_only_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
18 changes: 15 additions & 3 deletions cmd/compose/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
15 changes: 13 additions & 2 deletions cmd/compose/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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),
Expand Down
11 changes: 6 additions & 5 deletions docs/reference/compose_restart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


<!---MARKER_GEN_END-->
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/compose_stop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


<!---MARKER_GEN_END-->
Expand Down
11 changes: 11 additions & 0 deletions docs/reference/docker_compose_restart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/reference/docker_compose_stop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions pkg/e2e/profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"})
})
}
Loading