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
39 changes: 38 additions & 1 deletion cmd/compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
Expand All @@ -38,6 +39,7 @@ import (
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/compose"
"github.com/docker/compose/v5/pkg/filter"
)

type configOptions struct {
Expand All @@ -51,6 +53,7 @@ type configOptions struct {
noResolvePath bool
noResolveEnv bool
services bool
filter []string
volumes bool
networks bool
models bool
Expand All @@ -74,6 +77,20 @@ func (o *configOptions) ToModel(ctx context.Context, dockerCli command.Cli, serv
return o.ProjectOptions.ToModel(ctx, dockerCli, services, po...)
}

// validateFilter checks the flag combinations --filter can be used with.
func (o *configOptions) validateFilter() error {
if len(o.filter) == 0 {
return nil
}
if !o.services {
return errors.New("--filter requires --services")
}
if o.noInterpolate {
return errors.New("--filter cannot be combined with --no-interpolate")
}
return nil
}

// toProjectOptionsFns converts config options to cli.ProjectOptionsFn
func (o *configOptions) toProjectOptionsFns() []cli.ProjectOptionsFn {
fns := []cli.ProjectOptionsFn{
Expand Down Expand Up @@ -111,7 +128,7 @@ func configCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
if opts.lockImageDigests {
opts.resolveImageDigests = true
}
return nil
return opts.validateFilter()
}),
RunE: Adapt(func(ctx context.Context, args []string) error {
if opts.services {
Expand Down Expand Up @@ -161,6 +178,7 @@ func configCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
flags.BoolVar(&opts.noResolveEnv, "no-env-resolution", false, "Don't resolve service env files")

flags.BoolVar(&opts.services, "services", false, "Print the service names, one per line.")
flags.StringArrayVar(&opts.filter, "filter", nil, `With --services, only print services matching a criteria=value expression ("profile=NAME", "label=KEY[=VALUE]"). Repeat to combine criteria.`)
flags.BoolVar(&opts.volumes, "volumes", false, "Print the volume names, one per line.")
flags.BoolVar(&opts.networks, "networks", false, "Print the network names, one per line.")
flags.BoolVar(&opts.models, "models", false, "Print the model names, one per line.")
Expand Down Expand Up @@ -495,6 +513,25 @@ func runServices(ctx context.Context, dockerCli command.Cli, opts configOptions)
if err != nil {
return err
}

if len(opts.filter) > 0 {
serviceFilter, err := filter.Parse(opts.filter)
if err != nil {
return err
}
// Filtering on a profile implies activating it.
if profiles := serviceFilter.Profiles(); len(profiles) > 0 {

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] profile=* filter silently produces incomplete results — profiled services are never activated

The PR description states that profile=* "selects every service declaring at least one profile" with no need for a separate --profile flag, but the implementation contradicts this.

Filter.Profiles() explicitly excludes the wildcard with e.Value != "*", so WithProfiles is never called for the profile=* case. Since SelectNames only iterates over project.Services (active services), any profiled service that was not already activated at load time silently stays in DisabledServices and is invisible to the filter.

The e2e test (pkg/e2e/config_test.go line 134) actually confirms the workaround: it passes --profile "*" as a separate CLI flag alongside --filter profile=* to activate all profiles first. Without that extra flag, the filter returns an empty or incomplete list — the opposite of "no separate --profile is needed."

Trigger path: A user runs

docker compose config --services --filter profile=*

without any --profile flag and expects all profiled services to appear. Instead they get only those whose profile happens to already be active (typically none beyond the default set).

Fix: detect the wildcard in runServices (or in Filter.Profiles()) and call project.WithProfiles with all profiles the project declares before filtering:

if profiles := serviceFilter.Profiles(); len(profiles) > 0 {
    project, err = project.WithProfiles(append(project.Profiles, profiles...))
    if err != nil {
        return err
    }
}
// NEW: handle profile=* wildcard — activate every declared profile so that
// SelectNames can see all profiled services.
if serviceFilter.HasWildcardProfile() {
    project, err = project.WithProfiles(project.AllProfiles())
    if err != nil {
        return err
    }
}

Or, simpler, change Profiles() to return the sentinel "*" and let the caller pass it to WithProfiles (if WithProfiles handles "*" already via the existing --profile "*" mechanism).

Confidence Score
🟢 strong 100/100

project, err = project.WithProfiles(append(project.Profiles, profiles...))
if err != nil {
return err
}
}
for _, name := range serviceFilter.SelectNames(project) {
_, _ = fmt.Fprintln(dockerCli.Out(), name)
}
return nil
}

err = project.ForEachService(project.ServiceNames(), func(serviceName string, _ *types.ServiceConfig) error {
_, _ = fmt.Fprintln(dockerCli.Out(), serviceName)
return nil
Expand Down
45 changes: 23 additions & 22 deletions docs/reference/compose_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,29 @@ the canonical format.

### Options

| Name | Type | Default | Description |
|:--------------------------|:---------|:--------|:----------------------------------------------------------------------------|
| `--dry-run` | `bool` | | Execute command in dry run mode |
| `--environment` | `bool` | | Print environment used for interpolation. |
| `--format` | `string` | | Format the output. Values: [yaml \| json] |
| `--hash` | `string` | | Print the service config hash, one per line. |
| `--images` | `bool` | | Print the image names, one per line. |
| `--lock-image-digests` | `bool` | | Produces an override file with image digests |
| `--models` | `bool` | | Print the model names, one per line. |
| `--networks` | `bool` | | Print the network names, one per line. |
| `--no-consistency` | `bool` | | Don't check model consistency - warning: may produce invalid Compose output |
| `--no-env-resolution` | `bool` | | Don't resolve service env files |
| `--no-interpolate` | `bool` | | Don't interpolate environment variables |
| `--no-normalize` | `bool` | | Don't normalize compose model |
| `--no-path-resolution` | `bool` | | Don't resolve file paths |
| `-o`, `--output` | `string` | | Save to file (default to stdout) |
| `--profiles` | `bool` | | Print the profile names, one per line. |
| `-q`, `--quiet` | `bool` | | Only validate the configuration, don't print anything |
| `--resolve-image-digests` | `bool` | | Pin image tags to digests |
| `--services` | `bool` | | Print the service names, one per line. |
| `--variables` | `bool` | | Print model variables and default values. |
| `--volumes` | `bool` | | Print the volume names, one per line. |
| Name | Type | Default | Description |
|:--------------------------|:--------------|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------|
| `--dry-run` | `bool` | | Execute command in dry run mode |
| `--environment` | `bool` | | Print environment used for interpolation. |
| `--filter` | `stringArray` | | With --services, only print services matching a criteria=value expression ("profile=NAME", "label=KEY[=VALUE]"). Repeat to combine criteria. |
| `--format` | `string` | | Format the output. Values: [yaml \| json] |
| `--hash` | `string` | | Print the service config hash, one per line. |
| `--images` | `bool` | | Print the image names, one per line. |
| `--lock-image-digests` | `bool` | | Produces an override file with image digests |
| `--models` | `bool` | | Print the model names, one per line. |
| `--networks` | `bool` | | Print the network names, one per line. |
| `--no-consistency` | `bool` | | Don't check model consistency - warning: may produce invalid Compose output |
| `--no-env-resolution` | `bool` | | Don't resolve service env files |
| `--no-interpolate` | `bool` | | Don't interpolate environment variables |
| `--no-normalize` | `bool` | | Don't normalize compose model |
| `--no-path-resolution` | `bool` | | Don't resolve file paths |
| `-o`, `--output` | `string` | | Save to file (default to stdout) |
| `--profiles` | `bool` | | Print the profile names, one per line. |
| `-q`, `--quiet` | `bool` | | Only validate the configuration, don't print anything |
| `--resolve-image-digests` | `bool` | | Pin image tags to digests |
| `--services` | `bool` | | Print the service names, one per line. |
| `--variables` | `bool` | | Print model variables and default values. |
| `--volumes` | `bool` | | Print the volume names, one per line. |


<!---MARKER_GEN_END-->
Expand Down
11 changes: 11 additions & 0 deletions docs/reference/docker_compose_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ options:
experimentalcli: false
kubernetes: false
swarm: false
- option: filter
value_type: stringArray
default_value: '[]'
description: |
With --services, only print services matching a criteria=value expression ("profile=NAME", "label=KEY[=VALUE]"). Repeat to combine criteria.
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: format
value_type: string
description: 'Format the output. Values: [yaml | json]'
Expand Down
48 changes: 48 additions & 0 deletions pkg/e2e/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,54 @@ func TestLocalComposeConfig(t *testing.T) {
})
}

func TestConfigServicesFilter(t *testing.T) {
c := NewParallelCLI(t)

const projectName = "compose-e2e-config-filter"

t.Run("--filter profile activates and selects the profile", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--services", "--filter", "profile=workers")
assert.Equal(t, res.Stdout(), "monitor\nworker\n")
})

t.Run("--filter rejects profile wildcard", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--services", "--filter", "profile=*")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: "profiles must be selected explicitly"})
})

t.Run("--filter label", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--services", "--filter", "label=tier=backend")
assert.Equal(t, res.Stdout(), "core\n")
})

t.Run("--filter combines criteria", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--services", "--filter", "profile=workers", "--filter", "label=tier=backend")
assert.Equal(t, res.Stdout(), "worker\n")
})

t.Run("--filter no match prints nothing", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--services", "--filter", "profile=unknown")
assert.Equal(t, res.Stdout(), "")
})

t.Run("--filter requires --services", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--filter", "profile=workers")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: "--filter requires --services"})
})

t.Run("--filter rejects unknown criteria", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/config-filter/compose.yaml", "--project-name", projectName,
"config", "--services", "--filter", "state=running")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: `unknown criteria "state"`})
})
}

func TestConfigHashMatchesContainerLabel(t *testing.T) {
c := NewParallelCLI(t)

Expand Down
20 changes: 20 additions & 0 deletions pkg/e2e/fixtures/config-filter/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
services:
core:
image: alpine
labels:
tier: backend

worker:
image: alpine
profiles:
- workers
labels:
tier: backend

monitor:
image: alpine
profiles:
- monitoring
- workers
labels:
tier: ops
132 changes: 132 additions & 0 deletions pkg/filter/filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
Copyright 2026 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 filter implements criteria=value expressions selecting a subset of
// a Compose application's services, so that the selection logic can be
// shared by every command operating on a subset of services.
package filter

import (
"fmt"
"slices"
"strings"

"github.com/compose-spec/compose-go/v2/types"
)

const (
// CriteriaProfile selects services declaring the given profile in their
// `profiles` attribute. Filtering on a profile implies activating it:
// matching services are searched among all the services of the model,
// whether or not their profile is otherwise active.
CriteriaProfile = "profile"
// CriteriaLabel selects services carrying the given label, expressed
// either as a bare KEY (any value) or as KEY=VALUE.
CriteriaLabel = "label"
)

// Expression is a single parsed criteria=value selection expression.
type Expression struct {
Criteria string
Value string
}

// Filter is a set of selection expressions. Expressions with the same
// criteria are alternatives (OR); distinct criteria must all be satisfied
// (AND), following the `docker --filter` conventions.
type Filter []Expression

// Parse parses raw criteria=value expressions into a Filter.
func Parse(expressions []string) (Filter, error) {
var f Filter
for _, raw := range expressions {
criteria, value, ok := strings.Cut(raw, "=")
if !ok || value == "" {
return nil, fmt.Errorf("invalid filter %q: must be a criteria=value expression", raw)
}
switch criteria {
case CriteriaProfile:
if value == "*" {
return nil, fmt.Errorf("invalid filter %q: profiles must be selected explicitly", raw)
}
f = append(f, Expression{Criteria: criteria, Value: value})
case CriteriaLabel:
f = append(f, Expression{Criteria: criteria, Value: value})
default:
return nil, fmt.Errorf("invalid filter %q: unknown criteria %q (supported: %s, %s)", raw, criteria, CriteriaProfile, CriteriaLabel)
}
}
return f, nil
}

// Profiles returns the profiles named by profile= expressions, so that
// callers can activate them before matching: filtering on a profile implies
// activating it.
func (f Filter) Profiles() []string {
var profiles []string
for _, e := range f {
if e.Criteria == CriteriaProfile && !slices.Contains(profiles, e.Value) {
profiles = append(profiles, e.Value)
}
}
return profiles
}

// Match reports whether service satisfies every criteria of the filter, any
// expression of a criteria being sufficient for that criteria.
func (f Filter) Match(service types.ServiceConfig) bool {
byCriteria := map[string]bool{}
for _, e := range f {
byCriteria[e.Criteria] = byCriteria[e.Criteria] || e.match(service)
}
for _, matched := range byCriteria {
if !matched {
return false
}
}
return true
}

func (e Expression) match(service types.ServiceConfig) bool {
switch e.Criteria {
case CriteriaProfile:
return slices.Contains(service.Profiles, e.Value)
case CriteriaLabel:
key, value, hasValue := strings.Cut(e.Value, "=")
label, ok := service.Labels[key]
if !ok {
return false
}
return !hasValue || label == value
default:
return false
}
}

// SelectNames returns the sorted names of the project's enabled services
// satisfying the filter. Callers are expected to have activated the profiles
// returned by [Filter.Profiles] beforehand, e.g. with
// [types.Project.WithProfiles].
func (f Filter) SelectNames(project *types.Project) []string {
var names []string
for name, service := range project.Services {
if f.Match(service) {
names = append(names, name)
}
}
slices.Sort(names)
return names
}
Loading
Loading