diff --git a/cli/command/stack/cmd.go b/cli/command/stack/cmd.go index 4f59b99f4a..e2027568bc 100644 --- a/cli/command/stack/cmd.go +++ b/cli/command/stack/cmd.go @@ -33,6 +33,7 @@ func NewStackCommand(dockerCli command.Cli) *cobra.Command { newPsCommand(dockerCli), newRemoveCommand(dockerCli), newServicesCommand(dockerCli), + newConfigCommand(dockerCli), ) flags := cmd.PersistentFlags() flags.String("orchestrator", "", "Orchestrator to use (swarm|all)") diff --git a/cli/command/stack/config.go b/cli/command/stack/config.go new file mode 100644 index 0000000000..c6bf74f1e3 --- /dev/null +++ b/cli/command/stack/config.go @@ -0,0 +1,60 @@ +package stack + +import ( + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/stack/loader" + "github.com/docker/cli/cli/command/stack/options" + composeLoader "github.com/docker/cli/cli/compose/loader" + composetypes "github.com/docker/cli/cli/compose/types" + "github.com/spf13/cobra" + yaml "gopkg.in/yaml.v2" +) + +func newConfigCommand(dockerCli command.Cli) *cobra.Command { + var opts options.Config + + cmd := &cobra.Command{ + Use: "config [OPTIONS]", + Short: "Outputs the final config file, after doing merges and interpolations", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + configDetails, err := loader.GetConfigDetails(opts.Composefiles, dockerCli.In()) + if err != nil { + return err + } + + cfg, err := outputConfig(configDetails, opts.SkipInterpolation) + if err != nil { + return err + } + + _, err = fmt.Fprintf(dockerCli.Out(), "%s", cfg) + return err + }, + } + + 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") + return cmd +} + +// outputConfig returns the merged and interpolated config file +func outputConfig(configFiles composetypes.ConfigDetails, skipInterpolation bool) (string, error) { + optsFunc := func(options *composeLoader.Options) { + options.SkipInterpolation = skipInterpolation + } + config, err := composeLoader.Load(configFiles, optsFunc) + if err != nil { + return "", err + } + + d, err := yaml.Marshal(&config) + if err != nil { + return "", err + } + return string(d), nil +} diff --git a/cli/command/stack/config_test.go b/cli/command/stack/config_test.go new file mode 100644 index 0000000000..80b121f5a9 --- /dev/null +++ b/cli/command/stack/config_test.go @@ -0,0 +1,106 @@ +package stack + +import ( + "io" + "testing" + + "github.com/docker/cli/cli/compose/loader" + composetypes "github.com/docker/cli/cli/compose/types" + "github.com/docker/cli/internal/test" + "gotest.tools/v3/assert" +) + +func TestConfigWithEmptyComposeFile(t *testing.T) { + cmd := newConfigCommand(test.NewFakeCli(&fakeClient{})) + cmd.SetOut(io.Discard) + + assert.ErrorContains(t, cmd.Execute(), `Please specify a Compose file`) +} + +var configMergeTests = []struct { + name string + skipInterpolation bool + first string + second string + merged string +}{ + { + name: "With Interpolation", + skipInterpolation: false, + first: `version: "3.7" +services: + foo: + image: busybox:latest + command: cat file1.txt +`, + second: `version: "3.7" +services: + foo: + image: busybox:${VERSION} + command: cat file2.txt +`, + merged: `version: "3.7" +services: + foo: + command: + - cat + - file2.txt + image: busybox:1.0 +`, + }, + { + name: "Without Interpolation", + skipInterpolation: true, + first: `version: "3.7" +services: + foo: + image: busybox:latest + command: cat file1.txt +`, + second: `version: "3.7" +services: + foo: + image: busybox:${VERSION} + command: cat file2.txt +`, + merged: `version: "3.7" +services: + foo: + command: + - cat + - file2.txt + image: busybox:${VERSION} +`, + }, +} + +func TestConfigMergeInterpolation(t *testing.T) { + + for _, tt := range configMergeTests { + t.Run(tt.name, func(t *testing.T) { + firstConfig := []byte(tt.first) + secondConfig := []byte(tt.second) + + firstConfigData, err := loader.ParseYAML(firstConfig) + assert.NilError(t, err) + secondConfigData, err := loader.ParseYAML(secondConfig) + assert.NilError(t, err) + + env := map[string]string{ + "VERSION": "1.0", + } + + cfg, err := outputConfig(composetypes.ConfigDetails{ + ConfigFiles: []composetypes.ConfigFile{ + {Config: firstConfigData, Filename: "firstConfig"}, + {Config: secondConfigData, Filename: "secondConfig"}, + }, + Environment: env, + }, tt.skipInterpolation) + assert.NilError(t, err) + + assert.Equal(t, cfg, tt.merged) + }) + } + +} diff --git a/cli/command/stack/loader/loader.go b/cli/command/stack/loader/loader.go index a61efc7bd7..22352f434e 100644 --- a/cli/command/stack/loader/loader.go +++ b/cli/command/stack/loader/loader.go @@ -18,7 +18,7 @@ import ( // LoadComposefile parse the composefile specified in the cli and returns its Config and version. func LoadComposefile(dockerCli command.Cli, opts options.Deploy) (*composetypes.Config, error) { - configDetails, err := getConfigDetails(opts.Composefiles, dockerCli.In()) + configDetails, err := GetConfigDetails(opts.Composefiles, dockerCli.In()) if err != nil { return nil, err } @@ -68,7 +68,8 @@ func propertyWarnings(properties map[string]string) string { return strings.Join(msgs, "\n\n") } -func getConfigDetails(composefiles []string, stdin io.Reader) (composetypes.ConfigDetails, error) { +// GetConfigDetails parse the composefiles specified in the cli and returns their ConfigDetails +func GetConfigDetails(composefiles []string, stdin io.Reader) (composetypes.ConfigDetails, error) { var details composetypes.ConfigDetails if len(composefiles) == 0 { diff --git a/cli/command/stack/loader/loader_test.go b/cli/command/stack/loader/loader_test.go index fd504bc9d1..6ddca65bb8 100644 --- a/cli/command/stack/loader/loader_test.go +++ b/cli/command/stack/loader/loader_test.go @@ -21,7 +21,7 @@ services: file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content)) defer file.Remove() - details, err := getConfigDetails([]string{file.Path()}, nil) + details, err := GetConfigDetails([]string{file.Path()}, nil) assert.NilError(t, err) assert.Check(t, is.Equal(filepath.Dir(file.Path()), details.WorkingDir)) assert.Assert(t, is.Len(details.ConfigFiles, 1)) @@ -36,7 +36,7 @@ services: foo: image: alpine:3.5 ` - details, err := getConfigDetails([]string{"-"}, strings.NewReader(content)) + details, err := GetConfigDetails([]string{"-"}, strings.NewReader(content)) assert.NilError(t, err) cwd, err := os.Getwd() assert.NilError(t, err) diff --git a/cli/command/stack/options/opts.go b/cli/command/stack/options/opts.go index 9842b4995c..9c0cece153 100644 --- a/cli/command/stack/options/opts.go +++ b/cli/command/stack/options/opts.go @@ -11,6 +11,12 @@ type Deploy struct { Prune bool } +// Config holds docker stack config options +type Config struct { + Composefiles []string + SkipInterpolation bool +} + // List holds docker stack ls options type List struct { Format string diff --git a/docs/reference/commandline/index.md b/docs/reference/commandline/index.md index 9cd7ea2ad7..0dc074d676 100644 --- a/docs/reference/commandline/index.md +++ b/docs/reference/commandline/index.md @@ -152,13 +152,14 @@ read the [`dockerd`](dockerd.md) reference page. ### Swarm stack commands -| Command | Description | -|:------------------------------------|:-----------------------------------------------| -| [stack deploy](stack_deploy.md) | Deploy a new stack or update an existing stack | -| [stack ls](stack_ls.md) | List stacks in the swarm | -| [stack ps](stack_ps.md) | List the tasks in the stack | -| [stack rm](stack_rm.md) | Remove the stack from the swarm | -| [stack services](stack_services.md) | List the services in the stack | +| Command | Description | +|:------------------------------------|:--------------------------------------------------------| +| [stack deploy](stack_deploy.md) | Deploy a new stack or update an existing stack | +| [stack ls](stack_ls.md) | List stacks in the swarm | +| [stack ps](stack_ps.md) | List the tasks in the stack | +| [stack rm](stack_rm.md) | Remove the stack from the swarm | +| [stack services](stack_services.md) | List the services in the stack | +| [stack config](stack_config.md) | Output the Compose file after merges and interpolations | ### Plugin commands diff --git a/docs/reference/commandline/stack_config.md b/docs/reference/commandline/stack_config.md new file mode 100644 index 0000000000..0d3ff490ea --- /dev/null +++ b/docs/reference/commandline/stack_config.md @@ -0,0 +1,68 @@ +--- +title: "stack config" +description: "The stack config command description and usage" +keywords: "stack, config" +--- + +# stack config + +```markdown +Usage: docker stack config [OPTIONS] + +Outputs the final config file, after doing merges and interpolations + +Aliases: + config, cfg + +Options: + -c, --compose-file strings Path to a Compose file, or "-" to read from stdin + --orchestrator string Orchestrator to use (swarm|kubernetes|all) + --skip-interpolation Skip interpolation and output only merged config +``` + +## Description + +Outputs the final Compose file, after doing the merges and interpolations of the input Compose files. + +## Examples + +The following command outputs the result of the merge and interpolation of two Compose files. + +```bash +$ docker stack config --compose-file docker-compose.yml --compose-file docker-compose.prod.yml +``` + +The Compose file can also be provided as standard input with `--compose-file -`: + +```bash +$ cat docker-compose.yml | docker stack config --compose-file - +``` + +### Skipping interpolation + +In some cases, it might be useful to skip interpolation of environment variables. +For example, when you want to pipe the output of this command back to `stack deploy`. + +If you have a regex for a redirect route in an environment variable for your webserver you would use two `$` signs to prevent `stack deploy` from interpolating `${1}`. + +```bash + service: webserver + environment: + REDIRECT_REGEX=http://host/redirect/$${1} +``` + +With interpolation, the `stack config` command will replace the environment variable in the Compose file +with `REDIRECT_REGEX=http://host/redirect/${1}`, but then when piping it back to the `stack deploy` +command it will be interpolated again and result in undefined behavior. +That is why, when piping the output back to `stack deploy` one should always prefer the `--skip-interpolation` option. + +``` +$ docker stack config --compose-file web.yml --compose-file web.prod.yml --skip-interpolation | docker stack deploy --compose-file - +``` + +## Related commands + +* [stack deploy](stack_deploy.md) +* [stack ps](stack_ps.md) +* [stack rm](stack_rm.md) +* [stack services](stack_services.md) diff --git a/docs/reference/commandline/stack_deploy.md b/docs/reference/commandline/stack_deploy.md index fcce61643f..f1989aecd7 100644 --- a/docs/reference/commandline/stack_deploy.md +++ b/docs/reference/commandline/stack_deploy.md @@ -111,3 +111,4 @@ axqh55ipl40h vossibility_vossibility-collector replicated 1/1 icecrime/ * [stack ps](stack_ps.md) * [stack rm](stack_rm.md) * [stack services](stack_services.md) +* [stack config](stack_config.md) diff --git a/e2e/stack/config_test.go b/e2e/stack/config_test.go new file mode 100644 index 0000000000..1d56ae7019 --- /dev/null +++ b/e2e/stack/config_test.go @@ -0,0 +1,12 @@ +package stack + +import ( + "testing" + + "gotest.tools/v3/icmd" +) + +func TestConfigFullStack(t *testing.T) { + result := icmd.RunCommand("docker", "stack", "config", "--compose-file=./testdata/full-stack.yml") + result.Assert(t, icmd.Success) +}