From 19547e95ba170b43506e8ce50c7e392d0aa61ccf Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Sun, 13 Sep 2026 01:49:56 +0500 Subject: [PATCH 1/2] cli/compose: honor service profiles on stack deploy stack config currently rejects profiles as an additional property. parse the field and only enable those services when --profile or COMPOSE_PROFILES is set. Fixes #4721 Fixes #3178 Signed-off-by: Dean Chen <862469039@qq.com> --- cli/command/stack/config.go | 7 ++- cli/command/stack/config_test.go | 31 +++++++++- cli/command/stack/deploy.go | 2 + cli/command/stack/loader.go | 4 +- cli/compose/loader/loader.go | 60 ++++++++++++++++++- cli/compose/loader/loader_test.go | 34 +++++++++++ .../schema/data/config_schema_v3.0.json | 1 + .../schema/data/config_schema_v3.1.json | 1 + .../schema/data/config_schema_v3.10.json | 1 + .../schema/data/config_schema_v3.11.json | 1 + .../schema/data/config_schema_v3.12.json | 1 + .../schema/data/config_schema_v3.13.json | 1 + .../schema/data/config_schema_v3.2.json | 1 + .../schema/data/config_schema_v3.3.json | 1 + .../schema/data/config_schema_v3.4.json | 1 + .../schema/data/config_schema_v3.5.json | 1 + .../schema/data/config_schema_v3.6.json | 1 + .../schema/data/config_schema_v3.7.json | 1 + .../schema/data/config_schema_v3.8.json | 1 + .../schema/data/config_schema_v3.9.json | 1 + cli/compose/schema/schema_test.go | 14 +++++ cli/compose/types/types.go | 1 + 22 files changed, 162 insertions(+), 5 deletions(-) diff --git a/cli/command/stack/config.go b/cli/command/stack/config.go index ebcbb3f2511e..14b9ee0efab3 100644 --- a/cli/command/stack/config.go +++ b/cli/command/stack/config.go @@ -16,6 +16,7 @@ import ( type configOptions struct { composeFiles []string skipInterpolation bool + profiles []string } func newConfigCommand(dockerCLI command.Cli) *cobra.Command { @@ -31,7 +32,7 @@ func newConfigCommand(dockerCLI command.Cli) *cobra.Command { return err } - cfg, err := outputConfig(configDetails, opts.skipInterpolation) + cfg, err := outputConfig(configDetails, opts.skipInterpolation, opts.profiles) if err != nil { return err } @@ -46,13 +47,15 @@ func newConfigCommand(dockerCLI command.Cli) *cobra.Command { flags := cmd.Flags() flags.StringSliceVarP(&opts.composeFiles, "compose-file", "c", []string{}, `Path to a Compose file, or "-" to read from stdin`) flags.BoolVar(&opts.skipInterpolation, "skip-interpolation", false, "Skip interpolation and output only merged config") + flags.StringArrayVar(&opts.profiles, "profile", []string{}, "Specify a profile to enable") return cmd } // outputConfig returns the merged and interpolated config file -func outputConfig(configFiles composetypes.ConfigDetails, skipInterpolation bool) (string, error) { +func outputConfig(configFiles composetypes.ConfigDetails, skipInterpolation bool, profiles []string) (string, error) { optsFunc := func(opts *composeLoader.Options) { opts.SkipInterpolation = skipInterpolation + opts.Profiles = profiles } config, err := composeLoader.Load(configFiles, optsFunc) if err != nil { diff --git a/cli/command/stack/config_test.go b/cli/command/stack/config_test.go index b4243567e709..8198f9f8b1e0 100644 --- a/cli/command/stack/config_test.go +++ b/cli/command/stack/config_test.go @@ -2,6 +2,7 @@ package stack import ( "io" + "strings" "testing" "github.com/docker/cli/cli/compose/loader" @@ -91,9 +92,37 @@ services: Environment: map[string]string{ "VERSION": "1.0", }, - }, tc.skipInterpolation) + }, tc.skipInterpolation, nil) assert.Check(t, err) assert.Equal(t, tc.expected, actual) }) } } + +func TestConfigProfiles(t *testing.T) { + dict, err := loader.ParseYAML([]byte(`version: "3.8" +services: + web: + image: busybox:latest + debug: + image: busybox:latest + profiles: + - debug +`)) + assert.NilError(t, err) + details := composetypes.ConfigDetails{ + ConfigFiles: []composetypes.ConfigFile{ + {Config: dict, Filename: "compose.yaml"}, + }, + } + + withoutProfile, err := outputConfig(details, false, nil) + assert.NilError(t, err) + assert.Check(t, !strings.Contains(withoutProfile, "debug:")) + assert.Check(t, strings.Contains(withoutProfile, "web:")) + + withProfile, err := outputConfig(details, false, []string{"debug"}) + assert.NilError(t, err) + assert.Check(t, strings.Contains(withProfile, "debug:")) + assert.Check(t, strings.Contains(withProfile, "web:")) +} diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go index 2e8a2b09530a..ea43638b6c9d 100644 --- a/cli/command/stack/deploy.go +++ b/cli/command/stack/deploy.go @@ -24,6 +24,7 @@ type deployOptions struct { prune bool detach bool quiet bool + profiles []string } func newDeployCommand(dockerCLI command.Cli) *cobra.Command { @@ -65,6 +66,7 @@ func newDeployCommand(dockerCLI command.Cli) *cobra.Command { flags.SetAnnotation("resolve-image", "version", []string{"1.30"}) flags.BoolVarP(&opts.detach, "detach", "d", true, "Exit immediately instead of waiting for the stack services to converge") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress progress output") + flags.StringArrayVar(&opts.profiles, "profile", []string{}, "Specify a profile to enable") return cmd } diff --git a/cli/command/stack/loader.go b/cli/command/stack/loader.go index 10579a08d89d..baadb54c4898 100644 --- a/cli/command/stack/loader.go +++ b/cli/command/stack/loader.go @@ -27,7 +27,9 @@ func loadComposeFile(streams command.Streams, opts deployOptions) (*composetypes return nil, err } - config, err := loader.Load(configDetails) + config, err := loader.Load(configDetails, func(o *loader.Options) { + o.Profiles = opts.profiles + }) if err != nil { if fpe, ok := errors.AsType[*loader.ForbiddenPropertiesError](err); ok { // this error is intentionally formatted multi-line diff --git a/cli/compose/loader/loader.go b/cli/compose/loader/loader.go index 0a9cb03ae0a5..071e3d28ecf1 100644 --- a/cli/compose/loader/loader.go +++ b/cli/compose/loader/loader.go @@ -42,6 +42,8 @@ type Options struct { Interpolate *interp.Options // Discard 'env_file' entries after resolving to 'environment' section discardEnvFiles bool + // Profiles to enable, in addition to COMPOSE_PROFILES from the environment. + Profiles []string } // ParseVolume parses a volume spec without any knowledge of the target platform. @@ -137,7 +139,63 @@ func Load(configDetails types.ConfigDetails, opt ...func(*Options)) (*types.Conf configs = append(configs, cfg) } - return merge(configs) + cfg, err := merge(configs) + if err != nil { + return nil, err + } + cfg.Services = filterServicesByProfile(cfg.Services, profilesFrom(configDetails, options)) + return cfg, nil +} + +func profilesFrom(configDetails types.ConfigDetails, options *Options) []string { + var out []string + seen := map[string]struct{}{} + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" { + return + } + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + if v := configDetails.Environment["COMPOSE_PROFILES"]; v != "" { + for _, p := range strings.Split(v, ",") { + add(p) + } + } + for _, p := range options.Profiles { + add(p) + } + return out +} + +func filterServicesByProfile(services []types.ServiceConfig, enabled []string) []types.ServiceConfig { + active := make(map[string]struct{}, len(enabled)) + for _, p := range enabled { + active[p] = struct{}{} + } + out := make([]types.ServiceConfig, 0, len(services)) + for _, svc := range services { + if serviceEnabledForProfiles(svc, active) { + out = append(out, svc) + } + } + return out +} + +func serviceEnabledForProfiles(svc types.ServiceConfig, active map[string]struct{}) bool { + if len(svc.Profiles) == 0 { + return true + } + for _, p := range svc.Profiles { + if _, ok := active[p]; ok { + return true + } + } + return false } func validateForbidden(configDict map[string]any) error { diff --git a/cli/compose/loader/loader_test.go b/cli/compose/loader/loader_test.go index 41f70c7cb23c..72aebadac8e9 100644 --- a/cli/compose/loader/loader_test.go +++ b/cli/compose/loader/loader_test.go @@ -900,6 +900,40 @@ func TestInvalidResource(t *testing.T) { assert.Check(t, is.ErrorContains(err, "additional property 'impossible' is not allowed")) } +func TestLoadProfiles(t *testing.T) { + yaml := ` +version: "3.8" +services: + web: + image: busybox + debug: + image: busybox + profiles: + - debug +` + config, err := loadYAML(yaml) + assert.NilError(t, err) + assert.Equal(t, len(config.Services), 1) + assert.Equal(t, config.Services[0].Name, "web") + + config, err = loadYAMLWithEnv(yaml, map[string]string{"COMPOSE_PROFILES": "debug"}) + assert.NilError(t, err) + assert.Equal(t, len(config.Services), 2) + byName := map[string]types.ServiceConfig{} + for _, svc := range config.Services { + byName[svc.Name] = svc + } + assert.Check(t, is.DeepEqual(byName["debug"].Profiles, []string{"debug"})) + + dict, err := ParseYAML([]byte(yaml)) + assert.NilError(t, err) + config, err = Load(buildConfigDetails(dict, nil), func(o *Options) { + o.Profiles = []string{"debug"} + }) + assert.NilError(t, err) + assert.Equal(t, len(config.Services), 2) +} + func TestInvalidExternalAndDriverCombination(t *testing.T) { _, err := loadYAML(` version: "3" diff --git a/cli/compose/schema/data/config_schema_v3.0.json b/cli/compose/schema/data/config_schema_v3.0.json index f39344cfbe74..1410e65d4998 100644 --- a/cli/compose/schema/data/config_schema_v3.0.json +++ b/cli/compose/schema/data/config_schema_v3.0.json @@ -163,6 +163,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.1.json b/cli/compose/schema/data/config_schema_v3.1.json index 719c0fa7acc5..24ae7dca0804 100644 --- a/cli/compose/schema/data/config_schema_v3.1.json +++ b/cli/compose/schema/data/config_schema_v3.1.json @@ -174,6 +174,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.10.json b/cli/compose/schema/data/config_schema_v3.10.json index 7c032cf54b31..b33180d14d01 100644 --- a/cli/compose/schema/data/config_schema_v3.10.json +++ b/cli/compose/schema/data/config_schema_v3.10.json @@ -234,6 +234,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.11.json b/cli/compose/schema/data/config_schema_v3.11.json index fb2c9fd84b0c..bea6b14f9f96 100644 --- a/cli/compose/schema/data/config_schema_v3.11.json +++ b/cli/compose/schema/data/config_schema_v3.11.json @@ -234,6 +234,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.12.json b/cli/compose/schema/data/config_schema_v3.12.json index 2a548a38163d..e7b926a2d145 100644 --- a/cli/compose/schema/data/config_schema_v3.12.json +++ b/cli/compose/schema/data/config_schema_v3.12.json @@ -234,6 +234,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.13.json b/cli/compose/schema/data/config_schema_v3.13.json index 8daa8892d625..6ad508cae376 100644 --- a/cli/compose/schema/data/config_schema_v3.13.json +++ b/cli/compose/schema/data/config_schema_v3.13.json @@ -240,6 +240,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.2.json b/cli/compose/schema/data/config_schema_v3.2.json index 6e0e0e747da9..833f8d363920 100644 --- a/cli/compose/schema/data/config_schema_v3.2.json +++ b/cli/compose/schema/data/config_schema_v3.2.json @@ -187,6 +187,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.3.json b/cli/compose/schema/data/config_schema_v3.3.json index 13a58044d843..d43dcf9091ad 100644 --- a/cli/compose/schema/data/config_schema_v3.3.json +++ b/cli/compose/schema/data/config_schema_v3.3.json @@ -225,6 +225,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.4.json b/cli/compose/schema/data/config_schema_v3.4.json index 8660c98da42b..2222fe604131 100644 --- a/cli/compose/schema/data/config_schema_v3.4.json +++ b/cli/compose/schema/data/config_schema_v3.4.json @@ -228,6 +228,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.5.json b/cli/compose/schema/data/config_schema_v3.5.json index bf9c56c02e37..ebea7628b96a 100644 --- a/cli/compose/schema/data/config_schema_v3.5.json +++ b/cli/compose/schema/data/config_schema_v3.5.json @@ -230,6 +230,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.6.json b/cli/compose/schema/data/config_schema_v3.6.json index cd6a638ceb7f..50423622d862 100644 --- a/cli/compose/schema/data/config_schema_v3.6.json +++ b/cli/compose/schema/data/config_schema_v3.6.json @@ -230,6 +230,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.7.json b/cli/compose/schema/data/config_schema_v3.7.json index 69d5c52f87f5..cbc1aed0e248 100644 --- a/cli/compose/schema/data/config_schema_v3.7.json +++ b/cli/compose/schema/data/config_schema_v3.7.json @@ -231,6 +231,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.8.json b/cli/compose/schema/data/config_schema_v3.8.json index 059c0bcf76d8..eaf3ff48ee7f 100644 --- a/cli/compose/schema/data/config_schema_v3.8.json +++ b/cli/compose/schema/data/config_schema_v3.8.json @@ -232,6 +232,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/data/config_schema_v3.9.json b/cli/compose/schema/data/config_schema_v3.9.json index c6f63fda46d3..4d70b76ee79d 100644 --- a/cli/compose/schema/data/config_schema_v3.9.json +++ b/cli/compose/schema/data/config_schema_v3.9.json @@ -234,6 +234,7 @@ }, "privileged": {"type": "boolean"}, + "profiles": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/cli/compose/schema/schema_test.go b/cli/compose/schema/schema_test.go index cef3d55223b9..e4bb86638a96 100644 --- a/cli/compose/schema/schema_test.go +++ b/cli/compose/schema/schema_test.go @@ -148,6 +148,20 @@ func TestValidatePorts(t *testing.T) { } } +func TestValidateProfiles(t *testing.T) { + config := dict{ + "version": "3.8", + "services": dict{ + "foo": dict{ + "image": "busybox", + "profiles": []any{"debug", "dev"}, + }, + }, + } + assert.NilError(t, Validate(config, "3.8")) + assert.NilError(t, Validate(config, "3")) +} + func TestValidateUndefinedTopLevelOption(t *testing.T) { config := dict{ "version": "3.0", diff --git a/cli/compose/types/types.go b/cli/compose/types/types.go index ad59ce052b00..bd79c922b0a4 100644 --- a/cli/compose/types/types.go +++ b/cli/compose/types/types.go @@ -194,6 +194,7 @@ type ServiceConfig struct { Pid string `yaml:",omitempty" json:"pid,omitempty"` Ports []ServicePortConfig `yaml:",omitempty" json:"ports,omitempty"` Privileged bool `yaml:",omitempty" json:"privileged,omitempty"` + Profiles []string `yaml:",omitempty" json:"profiles,omitempty"` ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty" json:"read_only,omitempty"` Restart string `yaml:",omitempty" json:"restart,omitempty"` Secrets []ServiceSecretConfig `yaml:",omitempty" json:"secrets,omitempty"` From e95b85ebc5034e4622a1bcb65b0a1acc526866f0 Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Sun, 13 Sep 2026 07:30:16 +0500 Subject: [PATCH 2/2] cli/compose: refresh stack help goldens for --profile Signed-off-by: Dean Chen <862469039@qq.com> --- docs/reference/commandline/stack_config.md | 1 + docs/reference/commandline/stack_deploy.md | 1 + e2e/stack/testdata/stack-deploy-help.golden | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/reference/commandline/stack_config.md b/docs/reference/commandline/stack_config.md index 0ad91cb8137c..2596846b924f 100644 --- a/docs/reference/commandline/stack_config.md +++ b/docs/reference/commandline/stack_config.md @@ -8,6 +8,7 @@ Outputs the final config file, after doing merges and interpolations | Name | Type | Default | Description | |:-----------------------|:--------------|:--------|:--------------------------------------------------| | `-c`, `--compose-file` | `stringSlice` | | Path to a Compose file, or `-` to read from stdin | +| `--profile` | `stringArray` | | Specify a profile to enable | | `--skip-interpolation` | `bool` | | Skip interpolation and output only merged config | diff --git a/docs/reference/commandline/stack_deploy.md b/docs/reference/commandline/stack_deploy.md index 01ccf278875f..7a7f1cc1fbfe 100644 --- a/docs/reference/commandline/stack_deploy.md +++ b/docs/reference/commandline/stack_deploy.md @@ -13,6 +13,7 @@ Deploy a new stack or update an existing stack |:---------------------------------------------------------|:--------------|:---------|:--------------------------------------------------------------------------------------------------| | [`-c`](#compose-file), [`--compose-file`](#compose-file) | `stringSlice` | | Path to a Compose file, or `-` to read from stdin | | `-d`, `--detach` | `bool` | `true` | Exit immediately instead of waiting for the stack services to converge | +| `--profile` | `stringArray` | | Specify a profile to enable | | `--prune` | `bool` | | Prune services that are no longer referenced | | `-q`, `--quiet` | `bool` | | Suppress progress output | | `--resolve-image` | `string` | `always` | Query the registry to resolve image digest and supported platforms (`always`, `changed`, `never`) | diff --git a/e2e/stack/testdata/stack-deploy-help.golden b/e2e/stack/testdata/stack-deploy-help.golden index 91e9c8300a5d..da400ed3bd74 100644 --- a/e2e/stack/testdata/stack-deploy-help.golden +++ b/e2e/stack/testdata/stack-deploy-help.golden @@ -10,6 +10,7 @@ Options: from stdin -d, --detach Exit immediately instead of waiting for the stack services to converge (default true) + --profile stringArray Specify a profile to enable --prune Prune services that are no longer referenced -q, --quiet Suppress progress output --resolve-image string Query the registry to resolve image digest