Add stack config command

Make use of existing modules and functions in order to output the merged configs.
Added skip interpolation flag of variables, so that you can pipe the output back to stack deploy without much hassle.

Signed-off-by: Stoica-Marcu Floris-Andrei <floris.sm@gmail.com>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Stoica-Marcu Floris-Andrei 2020-09-22 12:16:05 +03:00 committed by Sebastiaan van Stijn
parent 429d716fbc
commit dfc214115b
No known key found for this signature in database
GPG Key ID: 76698F39D527CE8C
10 changed files with 267 additions and 11 deletions

View File

@ -33,6 +33,7 @@ func NewStackCommand(dockerCli command.Cli) *cobra.Command {
newPsCommand(dockerCli), newPsCommand(dockerCli),
newRemoveCommand(dockerCli), newRemoveCommand(dockerCli),
newServicesCommand(dockerCli), newServicesCommand(dockerCli),
newConfigCommand(dockerCli),
) )
flags := cmd.PersistentFlags() flags := cmd.PersistentFlags()
flags.String("orchestrator", "", "Orchestrator to use (swarm|all)") flags.String("orchestrator", "", "Orchestrator to use (swarm|all)")

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -18,7 +18,7 @@ import (
// LoadComposefile parse the composefile specified in the cli and returns its Config and version. // 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -68,7 +68,8 @@ func propertyWarnings(properties map[string]string) string {
return strings.Join(msgs, "\n\n") 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 var details composetypes.ConfigDetails
if len(composefiles) == 0 { if len(composefiles) == 0 {

View File

@ -21,7 +21,7 @@ services:
file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content)) file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content))
defer file.Remove() defer file.Remove()
details, err := getConfigDetails([]string{file.Path()}, nil) details, err := GetConfigDetails([]string{file.Path()}, nil)
assert.NilError(t, err) assert.NilError(t, err)
assert.Check(t, is.Equal(filepath.Dir(file.Path()), details.WorkingDir)) assert.Check(t, is.Equal(filepath.Dir(file.Path()), details.WorkingDir))
assert.Assert(t, is.Len(details.ConfigFiles, 1)) assert.Assert(t, is.Len(details.ConfigFiles, 1))
@ -36,7 +36,7 @@ services:
foo: foo:
image: alpine:3.5 image: alpine:3.5
` `
details, err := getConfigDetails([]string{"-"}, strings.NewReader(content)) details, err := GetConfigDetails([]string{"-"}, strings.NewReader(content))
assert.NilError(t, err) assert.NilError(t, err)
cwd, err := os.Getwd() cwd, err := os.Getwd()
assert.NilError(t, err) assert.NilError(t, err)

View File

@ -11,6 +11,12 @@ type Deploy struct {
Prune bool Prune bool
} }
// Config holds docker stack config options
type Config struct {
Composefiles []string
SkipInterpolation bool
}
// List holds docker stack ls options // List holds docker stack ls options
type List struct { type List struct {
Format string Format string

View File

@ -152,13 +152,14 @@ read the [`dockerd`](dockerd.md) reference page.
### Swarm stack commands ### Swarm stack commands
| Command | Description | | Command | Description |
|:------------------------------------|:-----------------------------------------------| |:------------------------------------|:--------------------------------------------------------|
| [stack deploy](stack_deploy.md) | Deploy a new stack or update an existing stack | | [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 ls](stack_ls.md) | List stacks in the swarm |
| [stack ps](stack_ps.md) | List the tasks in the stack | | [stack ps](stack_ps.md) | List the tasks in the stack |
| [stack rm](stack_rm.md) | Remove the stack from the swarm | | [stack rm](stack_rm.md) | Remove the stack from the swarm |
| [stack services](stack_services.md) | List the services in the stack | | [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 ### Plugin commands

View File

@ -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)

View File

@ -111,3 +111,4 @@ axqh55ipl40h vossibility_vossibility-collector replicated 1/1 icecrime/
* [stack ps](stack_ps.md) * [stack ps](stack_ps.md)
* [stack rm](stack_rm.md) * [stack rm](stack_rm.md)
* [stack services](stack_services.md) * [stack services](stack_services.md)
* [stack config](stack_config.md)

12
e2e/stack/config_test.go Normal file
View File

@ -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)
}