From f2762a1f8ee55edcb73ce5c40ff9bd0a1b4d4cbe Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 14 Aug 2026 12:04:02 +0200 Subject: [PATCH] config: add --filter to select services by profile or label Add a --filter criteria=value option to docker compose config --services, restricting the printed service names to those matching the given criteria: - profile=NAME selects services declaring the profile. Filtering on a profile implies activating it, so the services need not be otherwise enabled with --profile. Profiles must be selected explicitly: the "*" wildcard is rejected. - label=KEY[=VALUE] selects services carrying the label. Expressions with the same criteria are alternatives; distinct criteria must all be satisfied, following the docker --filter conventions. This gives a first-class way to script service selection, e.g. stopping only the services of a profile without stopping the profile-less ones: services=$(docker compose config --services --filter profile=workers) [ -n "$services" ] && docker compose stop $services The expression parsing and matching live in a new pkg/filter package, deliberately decoupled from the config command so the same selection mechanism can later be offered by commands operating on a subset of services (stop, restart, down, ...). Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- cmd/compose/config.go | 39 +++++- docs/reference/compose_config.md | 45 +++--- docs/reference/docker_compose_config.yaml | 11 ++ pkg/e2e/config_test.go | 48 +++++++ pkg/e2e/fixtures/config-filter/compose.yaml | 20 +++ pkg/filter/filter.go | 132 ++++++++++++++++++ pkg/filter/filter_test.go | 143 ++++++++++++++++++++ 7 files changed, 415 insertions(+), 23 deletions(-) create mode 100644 pkg/e2e/fixtures/config-filter/compose.yaml create mode 100644 pkg/filter/filter.go create mode 100644 pkg/filter/filter_test.go diff --git a/cmd/compose/config.go b/cmd/compose/config.go index 331a0b0ea41..1a13dac239c 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -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 { @@ -51,6 +53,7 @@ type configOptions struct { noResolvePath bool noResolveEnv bool services bool + filter []string volumes bool networks bool models bool @@ -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{ @@ -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 { @@ -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.") @@ -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 { + 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 diff --git a/docs/reference/compose_config.md b/docs/reference/compose_config.md index e2e773feae5..63f3edc2e77 100644 --- a/docs/reference/compose_config.md +++ b/docs/reference/compose_config.md @@ -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. | diff --git a/docs/reference/docker_compose_config.yaml b/docs/reference/docker_compose_config.yaml index 3efc922b219..e8bdf11b4a3 100644 --- a/docs/reference/docker_compose_config.yaml +++ b/docs/reference/docker_compose_config.yaml @@ -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]' diff --git a/pkg/e2e/config_test.go b/pkg/e2e/config_test.go index 76e8ad0110d..8c2047e0a41 100644 --- a/pkg/e2e/config_test.go +++ b/pkg/e2e/config_test.go @@ -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) diff --git a/pkg/e2e/fixtures/config-filter/compose.yaml b/pkg/e2e/fixtures/config-filter/compose.yaml new file mode 100644 index 00000000000..2b6f23a2bcb --- /dev/null +++ b/pkg/e2e/fixtures/config-filter/compose.yaml @@ -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 diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go new file mode 100644 index 00000000000..83e5da96391 --- /dev/null +++ b/pkg/filter/filter.go @@ -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 +} diff --git a/pkg/filter/filter_test.go b/pkg/filter/filter_test.go new file mode 100644 index 00000000000..1172f2e97e1 --- /dev/null +++ b/pkg/filter/filter_test.go @@ -0,0 +1,143 @@ +/* + 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 + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestParse(t *testing.T) { + tests := []struct { + expressions []string + expected Filter + expectedErr string + }{ + { + expressions: nil, + expected: nil, + }, + { + expressions: []string{"profile=foo"}, + expected: Filter{{Criteria: "profile", Value: "foo"}}, + }, + { + expressions: []string{"profile=foo", "label=tier=backend"}, + expected: Filter{ + {Criteria: "profile", Value: "foo"}, + {Criteria: "label", Value: "tier=backend"}, + }, + }, + { + expressions: []string{"profile"}, + expectedErr: `invalid filter "profile": must be a criteria=value expression`, + }, + { + expressions: []string{"profile="}, + expectedErr: `invalid filter "profile=": must be a criteria=value expression`, + }, + { + expressions: []string{"state=running"}, + expectedErr: `invalid filter "state=running": unknown criteria "state" (supported: profile, label)`, + }, + { + expressions: []string{"profile=*"}, + expectedErr: `invalid filter "profile=*": profiles must be selected explicitly`, + }, + } + for _, tc := range tests { + t.Run("", func(t *testing.T) { + f, err := Parse(tc.expressions) + if tc.expectedErr != "" { + assert.Error(t, err, tc.expectedErr) + return + } + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(f, tc.expected)) + }) + } +} + +func TestProfiles(t *testing.T) { + f, err := Parse([]string{"profile=foo", "profile=bar", "profile=foo", "label=x"}) + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(f.Profiles(), []string{"foo", "bar"})) +} + +func TestSelectNames(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + "default": {Name: "default"}, + "labeled": {Name: "labeled", Labels: types.Labels{"tier": "backend"}}, + "foo1": {Name: "foo1", Profiles: []string{"foo"}}, + "foo2": {Name: "foo2", Profiles: []string{"foo", "bar"}, Labels: types.Labels{"tier": "backend"}}, + "bar1": {Name: "bar1", Profiles: []string{"bar"}}, + }, + } + + tests := []struct { + name string + expressions []string + expected []string + }{ + { + name: "single profile", + expressions: []string{"profile=foo"}, + expected: []string{"foo1", "foo2"}, + }, + { + name: "profiles are alternatives", + expressions: []string{"profile=foo", "profile=bar"}, + expected: []string{"bar1", "foo1", "foo2"}, + }, + { + name: "label key", + expressions: []string{"label=tier"}, + expected: []string{"foo2", "labeled"}, + }, + { + name: "label key value", + expressions: []string{"label=tier=backend"}, + expected: []string{"foo2", "labeled"}, + }, + { + name: "label wrong value", + expressions: []string{"label=tier=frontend"}, + expected: nil, + }, + { + name: "criteria combine", + expressions: []string{"profile=foo", "label=tier=backend"}, + expected: []string{"foo2"}, + }, + { + name: "no match", + expressions: []string{"profile=unknown"}, + expected: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f, err := Parse(tc.expressions) + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(f.SelectNames(project), tc.expected)) + }) + } +}