diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index a4c00eee24..c6fc8d6ace 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -5,6 +5,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/checkpoint" + "github.com/docker/cli/cli/command/config" "github.com/docker/cli/cli/command/container" "github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/network" @@ -26,6 +27,9 @@ func AddCommands(cmd *cobra.Command, dockerCli *command.DockerCli) { // checkpoint checkpoint.NewCheckpointCommand(dockerCli), + // config + config.NewConfigCommand(dockerCli), + // container container.NewContainerCommand(dockerCli), container.NewRunCommand(dockerCli), diff --git a/cli/command/config/client_test.go b/cli/command/config/client_test.go new file mode 100644 index 0000000000..fdb1321847 --- /dev/null +++ b/cli/command/config/client_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" + "golang.org/x/net/context" +) + +type fakeClient struct { + client.Client + configCreateFunc func(swarm.ConfigSpec) (types.ConfigCreateResponse, error) + configInspectFunc func(string) (swarm.Config, []byte, error) + configListFunc func(types.ConfigListOptions) ([]swarm.Config, error) + configRemoveFunc func(string) error +} + +func (c *fakeClient) ConfigCreate(ctx context.Context, spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) { + if c.configCreateFunc != nil { + return c.configCreateFunc(spec) + } + return types.ConfigCreateResponse{}, nil +} + +func (c *fakeClient) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) { + if c.configInspectFunc != nil { + return c.configInspectFunc(id) + } + return swarm.Config{}, nil, nil +} + +func (c *fakeClient) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) { + if c.configListFunc != nil { + return c.configListFunc(options) + } + return []swarm.Config{}, nil +} + +func (c *fakeClient) ConfigRemove(ctx context.Context, name string) error { + if c.configRemoveFunc != nil { + return c.configRemoveFunc(name) + } + return nil +} diff --git a/cli/command/config/cmd.go b/cli/command/config/cmd.go new file mode 100644 index 0000000000..1f762596c2 --- /dev/null +++ b/cli/command/config/cmd.go @@ -0,0 +1,27 @@ +package config + +import ( + "github.com/spf13/cobra" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" +) + +// NewConfigCommand returns a cobra command for `config` subcommands +// nolint: interfacer +func NewConfigCommand(dockerCli *command.DockerCli) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage Docker configs", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + Tags: map[string]string{"version": "1.30"}, + } + cmd.AddCommand( + newConfigListCommand(dockerCli), + newConfigCreateCommand(dockerCli), + newConfigInspectCommand(dockerCli), + newConfigRemoveCommand(dockerCli), + ) + return cmd +} diff --git a/cli/command/config/create.go b/cli/command/config/create.go new file mode 100644 index 0000000000..fed2f207bf --- /dev/null +++ b/cli/command/config/create.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "io" + "io/ioutil" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/opts" + "github.com/docker/docker/pkg/system" + runconfigopts "github.com/docker/docker/runconfig/opts" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/net/context" +) + +type createOptions struct { + name string + file string + labels opts.ListOpts +} + +func newConfigCreateCommand(dockerCli command.Cli) *cobra.Command { + createOpts := createOptions{ + labels: opts.NewListOpts(opts.ValidateEnv), + } + + cmd := &cobra.Command{ + Use: "create [OPTIONS] CONFIG file|-", + Short: "Create a configuration file from a file or STDIN as content", + Args: cli.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + createOpts.name = args[0] + createOpts.file = args[1] + return runConfigCreate(dockerCli, createOpts) + }, + } + flags := cmd.Flags() + flags.VarP(&createOpts.labels, "label", "l", "Config labels") + + return cmd +} + +func runConfigCreate(dockerCli command.Cli, options createOptions) error { + client := dockerCli.Client() + ctx := context.Background() + + var in io.Reader = dockerCli.In() + if options.file != "-" { + file, err := system.OpenSequential(options.file) + if err != nil { + return err + } + in = file + defer file.Close() + } + + configData, err := ioutil.ReadAll(in) + if err != nil { + return errors.Errorf("Error reading content from %q: %v", options.file, err) + } + + spec := swarm.ConfigSpec{ + Annotations: swarm.Annotations{ + Name: options.name, + Labels: runconfigopts.ConvertKVStringsToMap(options.labels.GetAll()), + }, + Data: configData, + } + + r, err := client.ConfigCreate(ctx, spec) + if err != nil { + return err + } + + fmt.Fprintln(dockerCli.Out(), r.ID) + return nil +} diff --git a/cli/command/config/create_test.go b/cli/command/config/create_test.go new file mode 100644 index 0000000000..25b133836f --- /dev/null +++ b/cli/command/config/create_test.go @@ -0,0 +1,112 @@ +package config + +import ( + "bytes" + "io/ioutil" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/docker/cli/cli/internal/test" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/pkg/testutil" + "github.com/docker/docker/pkg/testutil/golden" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +const configDataFile = "config-create-with-name.golden" + +func TestConfigCreateErrors(t *testing.T) { + testCases := []struct { + args []string + configCreateFunc func(swarm.ConfigSpec) (types.ConfigCreateResponse, error) + expectedError string + }{ + { + args: []string{"too_few"}, + expectedError: "requires exactly 2 argument(s)", + }, + {args: []string{"too", "many", "arguments"}, + expectedError: "requires exactly 2 argument(s)", + }, + { + args: []string{"name", filepath.Join("testdata", configDataFile)}, + configCreateFunc: func(configSpec swarm.ConfigSpec) (types.ConfigCreateResponse, error) { + return types.ConfigCreateResponse{}, errors.Errorf("error creating config") + }, + expectedError: "error creating config", + }, + } + for _, tc := range testCases { + buf := new(bytes.Buffer) + cmd := newConfigCreateCommand( + test.NewFakeCli(&fakeClient{ + configCreateFunc: tc.configCreateFunc, + }, buf), + ) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestConfigCreateWithName(t *testing.T) { + name := "foo" + buf := new(bytes.Buffer) + var actual []byte + cli := test.NewFakeCli(&fakeClient{ + configCreateFunc: func(spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) { + if spec.Name != name { + return types.ConfigCreateResponse{}, errors.Errorf("expected name %q, got %q", name, spec.Name) + } + + actual = spec.Data + + return types.ConfigCreateResponse{ + ID: "ID-" + spec.Name, + }, nil + }, + }, buf) + + cmd := newConfigCreateCommand(cli) + cmd.SetArgs([]string{name, filepath.Join("testdata", configDataFile)}) + assert.NoError(t, cmd.Execute()) + expected := golden.Get(t, actual, configDataFile) + assert.Equal(t, string(expected), string(actual)) + assert.Equal(t, "ID-"+name, strings.TrimSpace(buf.String())) +} + +func TestConfigCreateWithLabels(t *testing.T) { + expectedLabels := map[string]string{ + "lbl1": "Label-foo", + "lbl2": "Label-bar", + } + name := "foo" + + buf := new(bytes.Buffer) + cli := test.NewFakeCli(&fakeClient{ + configCreateFunc: func(spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) { + if spec.Name != name { + return types.ConfigCreateResponse{}, errors.Errorf("expected name %q, got %q", name, spec.Name) + } + + if !reflect.DeepEqual(spec.Labels, expectedLabels) { + return types.ConfigCreateResponse{}, errors.Errorf("expected labels %v, got %v", expectedLabels, spec.Labels) + } + + return types.ConfigCreateResponse{ + ID: "ID-" + spec.Name, + }, nil + }, + }, buf) + + cmd := newConfigCreateCommand(cli) + cmd.SetArgs([]string{name, filepath.Join("testdata", configDataFile)}) + cmd.Flags().Set("label", "lbl1=Label-foo") + cmd.Flags().Set("label", "lbl2=Label-bar") + assert.NoError(t, cmd.Execute()) + assert.Equal(t, "ID-"+name, strings.TrimSpace(buf.String())) +} diff --git a/cli/command/config/inspect.go b/cli/command/config/inspect.go new file mode 100644 index 0000000000..fdf3bc5e7e --- /dev/null +++ b/cli/command/config/inspect.go @@ -0,0 +1,41 @@ +package config + +import ( + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/inspect" + "github.com/spf13/cobra" + "golang.org/x/net/context" +) + +type inspectOptions struct { + names []string + format string +} + +func newConfigInspectCommand(dockerCli command.Cli) *cobra.Command { + opts := inspectOptions{} + cmd := &cobra.Command{ + Use: "inspect [OPTIONS] CONFIG [CONFIG...]", + Short: "Display detailed information on one or more configuration files", + Args: cli.RequiresMinArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.names = args + return runConfigInspect(dockerCli, opts) + }, + } + + cmd.Flags().StringVarP(&opts.format, "format", "f", "", "Format the output using the given Go template") + return cmd +} + +func runConfigInspect(dockerCli command.Cli, opts inspectOptions) error { + client := dockerCli.Client() + ctx := context.Background() + + getRef := func(id string) (interface{}, []byte, error) { + return client.ConfigInspectWithRaw(ctx, id) + } + + return inspect.Inspect(dockerCli.Out(), opts.names, opts.format, getRef) +} diff --git a/cli/command/config/inspect_test.go b/cli/command/config/inspect_test.go new file mode 100644 index 0000000000..13ef40549c --- /dev/null +++ b/cli/command/config/inspect_test.go @@ -0,0 +1,150 @@ +package config + +import ( + "bytes" + "fmt" + "io/ioutil" + "testing" + + "github.com/docker/cli/cli/internal/test" + "github.com/docker/docker/api/types/swarm" + "github.com/pkg/errors" + // Import builders to get the builder function as package function + . "github.com/docker/cli/cli/internal/test/builders" + "github.com/docker/docker/pkg/testutil" + "github.com/docker/docker/pkg/testutil/golden" + "github.com/stretchr/testify/assert" +) + +func TestConfigInspectErrors(t *testing.T) { + testCases := []struct { + args []string + flags map[string]string + configInspectFunc func(configID string) (swarm.Config, []byte, error) + expectedError string + }{ + { + expectedError: "requires at least 1 argument", + }, + { + args: []string{"foo"}, + configInspectFunc: func(configID string) (swarm.Config, []byte, error) { + return swarm.Config{}, nil, errors.Errorf("error while inspecting the config") + }, + expectedError: "error while inspecting the config", + }, + { + args: []string{"foo"}, + flags: map[string]string{ + "format": "{{invalid format}}", + }, + expectedError: "Template parsing error", + }, + { + args: []string{"foo", "bar"}, + configInspectFunc: func(configID string) (swarm.Config, []byte, error) { + if configID == "foo" { + return *Config(ConfigName("foo")), nil, nil + } + return swarm.Config{}, nil, errors.Errorf("error while inspecting the config") + }, + expectedError: "error while inspecting the config", + }, + } + for _, tc := range testCases { + buf := new(bytes.Buffer) + cmd := newConfigInspectCommand( + test.NewFakeCli(&fakeClient{ + configInspectFunc: tc.configInspectFunc, + }, buf), + ) + cmd.SetArgs(tc.args) + for key, value := range tc.flags { + cmd.Flags().Set(key, value) + } + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestConfigInspectWithoutFormat(t *testing.T) { + testCases := []struct { + name string + args []string + configInspectFunc func(configID string) (swarm.Config, []byte, error) + }{ + { + name: "single-config", + args: []string{"foo"}, + configInspectFunc: func(name string) (swarm.Config, []byte, error) { + if name != "foo" { + return swarm.Config{}, nil, errors.Errorf("Invalid name, expected %s, got %s", "foo", name) + } + return *Config(ConfigID("ID-foo"), ConfigName("foo")), nil, nil + }, + }, + { + name: "multiple-configs-with-labels", + args: []string{"foo", "bar"}, + configInspectFunc: func(name string) (swarm.Config, []byte, error) { + return *Config(ConfigID("ID-"+name), ConfigName(name), ConfigLabels(map[string]string{ + "label1": "label-foo", + })), nil, nil + }, + }, + } + for _, tc := range testCases { + buf := new(bytes.Buffer) + cmd := newConfigInspectCommand( + test.NewFakeCli(&fakeClient{ + configInspectFunc: tc.configInspectFunc, + }, buf), + ) + cmd.SetArgs(tc.args) + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), fmt.Sprintf("config-inspect-without-format.%s.golden", tc.name)) + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) + } +} + +func TestConfigInspectWithFormat(t *testing.T) { + configInspectFunc := func(name string) (swarm.Config, []byte, error) { + return *Config(ConfigName("foo"), ConfigLabels(map[string]string{ + "label1": "label-foo", + })), nil, nil + } + testCases := []struct { + name string + format string + args []string + configInspectFunc func(name string) (swarm.Config, []byte, error) + }{ + { + name: "simple-template", + format: "{{.Spec.Name}}", + args: []string{"foo"}, + configInspectFunc: configInspectFunc, + }, + { + name: "json-template", + format: "{{json .Spec.Labels}}", + args: []string{"foo"}, + configInspectFunc: configInspectFunc, + }, + } + for _, tc := range testCases { + buf := new(bytes.Buffer) + cmd := newConfigInspectCommand( + test.NewFakeCli(&fakeClient{ + configInspectFunc: tc.configInspectFunc, + }, buf), + ) + cmd.SetArgs(tc.args) + cmd.Flags().Set("format", tc.format) + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), fmt.Sprintf("config-inspect-with-format.%s.golden", tc.name)) + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) + } +} diff --git a/cli/command/config/ls.go b/cli/command/config/ls.go new file mode 100644 index 0000000000..e3ca82ad50 --- /dev/null +++ b/cli/command/config/ls.go @@ -0,0 +1,63 @@ +package config + +import ( + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/docker/api/types" + "github.com/docker/docker/opts" + "github.com/spf13/cobra" + "golang.org/x/net/context" +) + +type listOptions struct { + quiet bool + format string + filter opts.FilterOpt +} + +func newConfigListCommand(dockerCli command.Cli) *cobra.Command { + opts := listOptions{filter: opts.NewFilterOpt()} + + cmd := &cobra.Command{ + Use: "ls [OPTIONS]", + Aliases: []string{"list"}, + Short: "List configs", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigList(dockerCli, opts) + }, + } + + flags := cmd.Flags() + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs") + flags.StringVarP(&opts.format, "format", "", "", "Pretty-print configs using a Go template") + flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided") + + return cmd +} + +func runConfigList(dockerCli command.Cli, opts listOptions) error { + client := dockerCli.Client() + ctx := context.Background() + + configs, err := client.ConfigList(ctx, types.ConfigListOptions{Filters: opts.filter.Value()}) + if err != nil { + return err + } + + format := opts.format + if len(format) == 0 { + if len(dockerCli.ConfigFile().ConfigFormat) > 0 && !opts.quiet { + format = dockerCli.ConfigFile().ConfigFormat + } else { + format = formatter.TableFormatKey + } + } + + configCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewConfigFormat(format, opts.quiet), + } + return formatter.ConfigWrite(configCtx, configs) +} diff --git a/cli/command/config/ls_test.go b/cli/command/config/ls_test.go new file mode 100644 index 0000000000..b43c764a4d --- /dev/null +++ b/cli/command/config/ls_test.go @@ -0,0 +1,173 @@ +package config + +import ( + "bytes" + "io/ioutil" + "testing" + "time" + + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/internal/test" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/pkg/errors" + // Import builders to get the builder function as package function + . "github.com/docker/cli/cli/internal/test/builders" + "github.com/docker/docker/pkg/testutil" + "github.com/docker/docker/pkg/testutil/golden" + "github.com/stretchr/testify/assert" +) + +func TestConfigListErrors(t *testing.T) { + testCases := []struct { + args []string + configListFunc func(types.ConfigListOptions) ([]swarm.Config, error) + expectedError string + }{ + { + args: []string{"foo"}, + expectedError: "accepts no argument", + }, + { + configListFunc: func(options types.ConfigListOptions) ([]swarm.Config, error) { + return []swarm.Config{}, errors.Errorf("error listing configs") + }, + expectedError: "error listing configs", + }, + } + for _, tc := range testCases { + buf := new(bytes.Buffer) + cmd := newConfigListCommand( + test.NewFakeCli(&fakeClient{ + configListFunc: tc.configListFunc, + }, buf), + ) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestConfigList(t *testing.T) { + buf := new(bytes.Buffer) + cli := test.NewFakeCli(&fakeClient{ + configListFunc: func(options types.ConfigListOptions) ([]swarm.Config, error) { + return []swarm.Config{ + *Config(ConfigID("ID-foo"), + ConfigName("foo"), + ConfigVersion(swarm.Version{Index: 10}), + ConfigCreatedAt(time.Now().Add(-2*time.Hour)), + ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), + ), + *Config(ConfigID("ID-bar"), + ConfigName("bar"), + ConfigVersion(swarm.Version{Index: 11}), + ConfigCreatedAt(time.Now().Add(-2*time.Hour)), + ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), + ), + }, nil + }, + }, buf) + cli.SetConfigfile(&configfile.ConfigFile{}) + cmd := newConfigListCommand(cli) + cmd.SetOutput(buf) + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), "config-list.golden") + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) +} + +func TestConfigListWithQuietOption(t *testing.T) { + buf := new(bytes.Buffer) + cli := test.NewFakeCli(&fakeClient{ + configListFunc: func(options types.ConfigListOptions) ([]swarm.Config, error) { + return []swarm.Config{ + *Config(ConfigID("ID-foo"), ConfigName("foo")), + *Config(ConfigID("ID-bar"), ConfigName("bar"), ConfigLabels(map[string]string{ + "label": "label-bar", + })), + }, nil + }, + }, buf) + cli.SetConfigfile(&configfile.ConfigFile{}) + cmd := newConfigListCommand(cli) + cmd.Flags().Set("quiet", "true") + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), "config-list-with-quiet-option.golden") + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) +} + +func TestConfigListWithConfigFormat(t *testing.T) { + buf := new(bytes.Buffer) + cli := test.NewFakeCli(&fakeClient{ + configListFunc: func(options types.ConfigListOptions) ([]swarm.Config, error) { + return []swarm.Config{ + *Config(ConfigID("ID-foo"), ConfigName("foo")), + *Config(ConfigID("ID-bar"), ConfigName("bar"), ConfigLabels(map[string]string{ + "label": "label-bar", + })), + }, nil + }, + }, buf) + cli.SetConfigfile(&configfile.ConfigFile{ + ConfigFormat: "{{ .Name }} {{ .Labels }}", + }) + cmd := newConfigListCommand(cli) + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), "config-list-with-config-format.golden") + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) +} + +func TestConfigListWithFormat(t *testing.T) { + buf := new(bytes.Buffer) + cli := test.NewFakeCli(&fakeClient{ + configListFunc: func(options types.ConfigListOptions) ([]swarm.Config, error) { + return []swarm.Config{ + *Config(ConfigID("ID-foo"), ConfigName("foo")), + *Config(ConfigID("ID-bar"), ConfigName("bar"), ConfigLabels(map[string]string{ + "label": "label-bar", + })), + }, nil + }, + }, buf) + cmd := newConfigListCommand(cli) + cmd.Flags().Set("format", "{{ .Name }} {{ .Labels }}") + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), "config-list-with-format.golden") + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) +} + +func TestConfigListWithFilter(t *testing.T) { + buf := new(bytes.Buffer) + cli := test.NewFakeCli(&fakeClient{ + configListFunc: func(options types.ConfigListOptions) ([]swarm.Config, error) { + assert.Equal(t, "foo", options.Filters.Get("name")[0]) + assert.Equal(t, "lbl1=Label-bar", options.Filters.Get("label")[0]) + return []swarm.Config{ + *Config(ConfigID("ID-foo"), + ConfigName("foo"), + ConfigVersion(swarm.Version{Index: 10}), + ConfigCreatedAt(time.Now().Add(-2*time.Hour)), + ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), + ), + *Config(ConfigID("ID-bar"), + ConfigName("bar"), + ConfigVersion(swarm.Version{Index: 11}), + ConfigCreatedAt(time.Now().Add(-2*time.Hour)), + ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), + ), + }, nil + }, + }, buf) + cli.SetConfigfile(&configfile.ConfigFile{}) + cmd := newConfigListCommand(cli) + cmd.Flags().Set("filter", "name=foo") + cmd.Flags().Set("filter", "label=lbl1=Label-bar") + assert.NoError(t, cmd.Execute()) + actual := buf.String() + expected := golden.Get(t, []byte(actual), "config-list-with-filter.golden") + testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) +} diff --git a/cli/command/config/remove.go b/cli/command/config/remove.go new file mode 100644 index 0000000000..5512986d90 --- /dev/null +++ b/cli/command/config/remove.go @@ -0,0 +1,53 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/net/context" +) + +type removeOptions struct { + names []string +} + +func newConfigRemoveCommand(dockerCli command.Cli) *cobra.Command { + return &cobra.Command{ + Use: "rm CONFIG [CONFIG...]", + Aliases: []string{"remove"}, + Short: "Remove one or more configuration files", + Args: cli.RequiresMinArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts := removeOptions{ + names: args, + } + return runConfigRemove(dockerCli, opts) + }, + } +} + +func runConfigRemove(dockerCli command.Cli, opts removeOptions) error { + client := dockerCli.Client() + ctx := context.Background() + + var errs []string + + for _, name := range opts.names { + if err := client.ConfigRemove(ctx, name); err != nil { + errs = append(errs, err.Error()) + continue + } + + fmt.Fprintln(dockerCli.Out(), name) + } + + if len(errs) > 0 { + return errors.Errorf("%s", strings.Join(errs, "\n")) + } + + return nil +} diff --git a/cli/command/config/remove_test.go b/cli/command/config/remove_test.go new file mode 100644 index 0000000000..84423c0254 --- /dev/null +++ b/cli/command/config/remove_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "bytes" + "io/ioutil" + "strings" + "testing" + + "github.com/docker/cli/cli/internal/test" + "github.com/docker/docker/pkg/testutil" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestConfigRemoveErrors(t *testing.T) { + testCases := []struct { + args []string + configRemoveFunc func(string) error + expectedError string + }{ + { + args: []string{}, + expectedError: "requires at least 1 argument(s).", + }, + { + args: []string{"foo"}, + configRemoveFunc: func(name string) error { + return errors.Errorf("error removing config") + }, + expectedError: "error removing config", + }, + } + for _, tc := range testCases { + buf := new(bytes.Buffer) + cmd := newConfigRemoveCommand( + test.NewFakeCli(&fakeClient{ + configRemoveFunc: tc.configRemoveFunc, + }, buf), + ) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestConfigRemoveWithName(t *testing.T) { + names := []string{"foo", "bar"} + buf := new(bytes.Buffer) + var removedConfigs []string + cli := test.NewFakeCli(&fakeClient{ + configRemoveFunc: func(name string) error { + removedConfigs = append(removedConfigs, name) + return nil + }, + }, buf) + cmd := newConfigRemoveCommand(cli) + cmd.SetArgs(names) + assert.NoError(t, cmd.Execute()) + assert.Equal(t, names, strings.Split(strings.TrimSpace(buf.String()), "\n")) + assert.Equal(t, names, removedConfigs) +} + +func TestConfigRemoveContinueAfterError(t *testing.T) { + names := []string{"foo", "bar"} + buf := new(bytes.Buffer) + var removedConfigs []string + + cli := test.NewFakeCli(&fakeClient{ + configRemoveFunc: func(name string) error { + removedConfigs = append(removedConfigs, name) + if name == "foo" { + return errors.Errorf("error removing config: %s", name) + } + return nil + }, + }, buf) + + cmd := newConfigRemoveCommand(cli) + cmd.SetArgs(names) + assert.EqualError(t, cmd.Execute(), "error removing config: foo") + assert.Equal(t, names, removedConfigs) +} diff --git a/cli/command/config/testdata/config-create-with-name.golden b/cli/command/config/testdata/config-create-with-name.golden new file mode 100644 index 0000000000..7b28bb3f30 --- /dev/null +++ b/cli/command/config/testdata/config-create-with-name.golden @@ -0,0 +1 @@ +config_foo_bar diff --git a/cli/command/config/testdata/config-inspect-with-format.json-template.golden b/cli/command/config/testdata/config-inspect-with-format.json-template.golden new file mode 100644 index 0000000000..aab678f85d --- /dev/null +++ b/cli/command/config/testdata/config-inspect-with-format.json-template.golden @@ -0,0 +1 @@ +{"label1":"label-foo"} diff --git a/cli/command/config/testdata/config-inspect-with-format.simple-template.golden b/cli/command/config/testdata/config-inspect-with-format.simple-template.golden new file mode 100644 index 0000000000..257cc5642c --- /dev/null +++ b/cli/command/config/testdata/config-inspect-with-format.simple-template.golden @@ -0,0 +1 @@ +foo diff --git a/cli/command/config/testdata/config-inspect-without-format.multiple-configs-with-labels.golden b/cli/command/config/testdata/config-inspect-without-format.multiple-configs-with-labels.golden new file mode 100644 index 0000000000..6887c185f1 --- /dev/null +++ b/cli/command/config/testdata/config-inspect-without-format.multiple-configs-with-labels.golden @@ -0,0 +1,26 @@ +[ + { + "ID": "ID-foo", + "Version": {}, + "CreatedAt": "0001-01-01T00:00:00Z", + "UpdatedAt": "0001-01-01T00:00:00Z", + "Spec": { + "Name": "foo", + "Labels": { + "label1": "label-foo" + } + } + }, + { + "ID": "ID-bar", + "Version": {}, + "CreatedAt": "0001-01-01T00:00:00Z", + "UpdatedAt": "0001-01-01T00:00:00Z", + "Spec": { + "Name": "bar", + "Labels": { + "label1": "label-foo" + } + } + } +] diff --git a/cli/command/config/testdata/config-inspect-without-format.single-config.golden b/cli/command/config/testdata/config-inspect-without-format.single-config.golden new file mode 100644 index 0000000000..ea42ec6f4f --- /dev/null +++ b/cli/command/config/testdata/config-inspect-without-format.single-config.golden @@ -0,0 +1,12 @@ +[ + { + "ID": "ID-foo", + "Version": {}, + "CreatedAt": "0001-01-01T00:00:00Z", + "UpdatedAt": "0001-01-01T00:00:00Z", + "Spec": { + "Name": "foo", + "Labels": null + } + } +] diff --git a/cli/command/config/testdata/config-list-with-config-format.golden b/cli/command/config/testdata/config-list-with-config-format.golden new file mode 100644 index 0000000000..9a47538804 --- /dev/null +++ b/cli/command/config/testdata/config-list-with-config-format.golden @@ -0,0 +1,2 @@ +foo +bar label=label-bar diff --git a/cli/command/config/testdata/config-list-with-filter.golden b/cli/command/config/testdata/config-list-with-filter.golden new file mode 100644 index 0000000000..29983de8e9 --- /dev/null +++ b/cli/command/config/testdata/config-list-with-filter.golden @@ -0,0 +1,3 @@ +ID NAME CREATED UPDATED +ID-foo foo 2 hours ago About an hour ago +ID-bar bar 2 hours ago About an hour ago diff --git a/cli/command/config/testdata/config-list-with-format.golden b/cli/command/config/testdata/config-list-with-format.golden new file mode 100644 index 0000000000..9a47538804 --- /dev/null +++ b/cli/command/config/testdata/config-list-with-format.golden @@ -0,0 +1,2 @@ +foo +bar label=label-bar diff --git a/cli/command/config/testdata/config-list-with-quiet-option.golden b/cli/command/config/testdata/config-list-with-quiet-option.golden new file mode 100644 index 0000000000..83fb6e8979 --- /dev/null +++ b/cli/command/config/testdata/config-list-with-quiet-option.golden @@ -0,0 +1,2 @@ +ID-foo +ID-bar diff --git a/cli/command/config/testdata/config-list.golden b/cli/command/config/testdata/config-list.golden new file mode 100644 index 0000000000..29983de8e9 --- /dev/null +++ b/cli/command/config/testdata/config-list.golden @@ -0,0 +1,3 @@ +ID NAME CREATED UPDATED +ID-foo foo 2 hours ago About an hour ago +ID-bar bar 2 hours ago About an hour ago diff --git a/cli/command/formatter/config.go b/cli/command/formatter/config.go new file mode 100644 index 0000000000..69b30e9010 --- /dev/null +++ b/cli/command/formatter/config.go @@ -0,0 +1,100 @@ +package formatter + +import ( + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types/swarm" + units "github.com/docker/go-units" +) + +const ( + defaultConfigTableFormat = "table {{.ID}}\t{{.Name}}\t{{.CreatedAt}}\t{{.UpdatedAt}}" + configIDHeader = "ID" + configCreatedHeader = "CREATED" + configUpdatedHeader = "UPDATED" +) + +// NewConfigFormat returns a Format for rendering using a config Context +func NewConfigFormat(source string, quiet bool) Format { + switch source { + case TableFormatKey: + if quiet { + return defaultQuietFormat + } + return defaultConfigTableFormat + } + return Format(source) +} + +// ConfigWrite writes the context +func ConfigWrite(ctx Context, configs []swarm.Config) error { + render := func(format func(subContext subContext) error) error { + for _, config := range configs { + configCtx := &configContext{c: config} + if err := format(configCtx); err != nil { + return err + } + } + return nil + } + return ctx.Write(newConfigContext(), render) +} + +func newConfigContext() *configContext { + cCtx := &configContext{} + + cCtx.header = map[string]string{ + "ID": configIDHeader, + "Name": nameHeader, + "CreatedAt": configCreatedHeader, + "UpdatedAt": configUpdatedHeader, + "Labels": labelsHeader, + } + return cCtx +} + +type configContext struct { + HeaderContext + c swarm.Config +} + +func (c *configContext) MarshalJSON() ([]byte, error) { + return marshalJSON(c) +} + +func (c *configContext) ID() string { + return c.c.ID +} + +func (c *configContext) Name() string { + return c.c.Spec.Annotations.Name +} + +func (c *configContext) CreatedAt() string { + return units.HumanDuration(time.Now().UTC().Sub(c.c.Meta.CreatedAt)) + " ago" +} + +func (c *configContext) UpdatedAt() string { + return units.HumanDuration(time.Now().UTC().Sub(c.c.Meta.UpdatedAt)) + " ago" +} + +func (c *configContext) Labels() string { + mapLabels := c.c.Spec.Annotations.Labels + if mapLabels == nil { + return "" + } + var joinLabels []string + for k, v := range mapLabels { + joinLabels = append(joinLabels, fmt.Sprintf("%s=%s", k, v)) + } + return strings.Join(joinLabels, ",") +} + +func (c *configContext) Label(name string) string { + if c.c.Spec.Annotations.Labels == nil { + return "" + } + return c.c.Spec.Annotations.Labels[name] +} diff --git a/cli/command/formatter/config_test.go b/cli/command/formatter/config_test.go new file mode 100644 index 0000000000..227f454ffa --- /dev/null +++ b/cli/command/formatter/config_test.go @@ -0,0 +1,63 @@ +package formatter + +import ( + "bytes" + "testing" + "time" + + "github.com/docker/docker/api/types/swarm" + "github.com/stretchr/testify/assert" +) + +func TestConfigContextFormatWrite(t *testing.T) { + // Check default output format (verbose and non-verbose mode) for table headers + cases := []struct { + context Context + expected string + }{ + // Errors + { + Context{Format: "{{InvalidFunction}}"}, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + Context{Format: "{{nil}}"}, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table format + {Context{Format: NewConfigFormat("table", false)}, + `ID NAME CREATED UPDATED +1 passwords Less than a second ago Less than a second ago +2 id_rsa Less than a second ago Less than a second ago +`}, + {Context{Format: NewConfigFormat("table {{.Name}}", true)}, + `NAME +passwords +id_rsa +`}, + {Context{Format: NewConfigFormat("{{.ID}}-{{.Name}}", false)}, + `1-passwords +2-id_rsa +`}, + } + + configs := []swarm.Config{ + {ID: "1", + Meta: swarm.Meta{CreatedAt: time.Now(), UpdatedAt: time.Now()}, + Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "passwords"}}}, + {ID: "2", + Meta: swarm.Meta{CreatedAt: time.Now(), UpdatedAt: time.Now()}, + Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "id_rsa"}}}, + } + for _, testcase := range cases { + out := bytes.NewBufferString("") + testcase.context.Output = out + if err := ConfigWrite(testcase.context, configs); err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} diff --git a/cli/command/formatter/secret.go b/cli/command/formatter/secret.go index 46c97e2393..b3e53e8f78 100644 --- a/cli/command/formatter/secret.go +++ b/cli/command/formatter/secret.go @@ -16,7 +16,7 @@ const ( secretUpdatedHeader = "UPDATED" ) -// NewSecretFormat returns a Format for rendering using a network Context +// NewSecretFormat returns a Format for rendering using a secret Context func NewSecretFormat(source string, quiet bool) Format { switch source { case TableFormatKey: diff --git a/cli/command/secret/create_test.go b/cli/command/secret/create_test.go index cc77fd7fac..197483cf70 100644 --- a/cli/command/secret/create_test.go +++ b/cli/command/secret/create_test.go @@ -4,6 +4,7 @@ import ( "bytes" "io/ioutil" "path/filepath" + "reflect" "strings" "testing" @@ -92,7 +93,7 @@ func TestSecretCreateWithLabels(t *testing.T) { return types.SecretCreateResponse{}, errors.Errorf("expected name %q, got %q", name, spec.Name) } - if !compareMap(spec.Labels, expectedLabels) { + if !reflect.DeepEqual(spec.Labels, expectedLabels) { return types.SecretCreateResponse{}, errors.Errorf("expected labels %v, got %v", expectedLabels, spec.Labels) } @@ -109,19 +110,3 @@ func TestSecretCreateWithLabels(t *testing.T) { assert.NoError(t, cmd.Execute()) assert.Equal(t, "ID-"+name, strings.TrimSpace(buf.String())) } - -func compareMap(actual map[string]string, expected map[string]string) bool { - if len(actual) != len(expected) { - return false - } - for key, value := range actual { - if expectedValue, ok := expected[key]; ok { - if expectedValue != value { - return false - } - } else { - return false - } - } - return true -} diff --git a/cli/command/service/create.go b/cli/command/service/create.go index a9400ad38d..cfc2830f55 100644 --- a/cli/command/service/create.go +++ b/cli/command/service/create.go @@ -43,6 +43,8 @@ func newCreateCommand(dockerCli *command.DockerCli) *cobra.Command { flags.Var(&opts.networks, flagNetwork, "Network attachments") flags.Var(&opts.secrets, flagSecret, "Specify secrets to expose to the service") flags.SetAnnotation(flagSecret, "version", []string{"1.25"}) + flags.Var(&opts.configs, flagConfig, "Specify configurations to expose to the service") + flags.SetAnnotation(flagConfig, "version", []string{"1.30"}) flags.VarP(&opts.endpoint.publishPorts, flagPublish, "p", "Publish a port as a node port") flags.Var(&opts.groups, flagGroup, "Set one or more supplementary user groups for the container") flags.SetAnnotation(flagGroup, "version", []string{"1.25"}) @@ -78,7 +80,16 @@ func runCreate(dockerCli *command.DockerCli, flags *pflag.FlagSet, opts *service return err } service.TaskTemplate.ContainerSpec.Secrets = secrets + } + specifiedConfigs := opts.configs.Value() + if len(specifiedConfigs) > 0 { + // parse and validate configs + configs, err := ParseConfigs(apiClient, specifiedConfigs) + if err != nil { + return err + } + service.TaskTemplate.ContainerSpec.Configs = configs } if err := resolveServiceImageDigest(dockerCli, &service); err != nil { diff --git a/cli/command/service/opts.go b/cli/command/service/opts.go index 027c2081f6..d1742fea41 100644 --- a/cli/command/service/opts.go +++ b/cli/command/service/opts.go @@ -548,6 +548,7 @@ type serviceOptions struct { healthcheck healthCheckOptions secrets opts.SecretOpt + configs opts.ConfigOpt } func newServiceOptions() *serviceOptions { @@ -657,7 +658,6 @@ func (opts *serviceOptions) ToService(ctx context.Context, apiClient client.Netw }, Hosts: convertExtraHostsToSwarmHosts(opts.hosts.GetAll()), StopGracePeriod: opts.ToStopGracePeriod(flags), - Secrets: nil, Healthcheck: healthConfig, }, Networks: networks, @@ -910,4 +910,7 @@ const ( flagSecret = "secret" flagSecretAdd = "secret-add" flagSecretRemove = "secret-rm" + flagConfig = "config" + flagConfigAdd = "config-add" + flagConfigRemove = "config-rm" ) diff --git a/cli/command/service/parse.go b/cli/command/service/parse.go index acee08761f..f3c9306881 100644 --- a/cli/command/service/parse.go +++ b/cli/command/service/parse.go @@ -57,3 +57,53 @@ func ParseSecrets(client client.SecretAPIClient, requestedSecrets []*swarmtypes. return addedSecrets, nil } + +// ParseConfigs retrieves the configs from the requested names and converts +// them to config references to use with the spec +func ParseConfigs(client client.ConfigAPIClient, requestedConfigs []*swarmtypes.ConfigReference) ([]*swarmtypes.ConfigReference, error) { + configRefs := make(map[string]*swarmtypes.ConfigReference) + ctx := context.Background() + + for _, config := range requestedConfigs { + if _, exists := configRefs[config.File.Name]; exists { + return nil, errors.Errorf("duplicate config target for %s not allowed", config.ConfigName) + } + + configRef := new(swarmtypes.ConfigReference) + *configRef = *config + configRefs[config.File.Name] = configRef + } + + args := filters.NewArgs() + for _, s := range configRefs { + args.Add("name", s.ConfigName) + } + + configs, err := client.ConfigList(ctx, types.ConfigListOptions{ + Filters: args, + }) + if err != nil { + return nil, err + } + + foundConfigs := make(map[string]string) + for _, config := range configs { + foundConfigs[config.Spec.Annotations.Name] = config.ID + } + + addedConfigs := []*swarmtypes.ConfigReference{} + + for _, ref := range configRefs { + id, ok := foundConfigs[ref.ConfigName] + if !ok { + return nil, errors.Errorf("config not found: %s", ref.ConfigName) + } + + // set the id for the ref to properly assign in swarm + // since swarm needs the ID instead of the name + ref.ConfigID = id + addedConfigs = append(addedConfigs, ref) + } + + return addedConfigs, nil +} diff --git a/cli/command/service/update.go b/cli/command/service/update.go index cb12d04262..6dba8c8bae 100644 --- a/cli/command/service/update.go +++ b/cli/command/service/update.go @@ -68,6 +68,12 @@ func newUpdateCommand(dockerCli *command.DockerCli) *cobra.Command { flags.SetAnnotation(flagSecretRemove, "version", []string{"1.25"}) flags.Var(&serviceOpts.secrets, flagSecretAdd, "Add or update a secret on a service") flags.SetAnnotation(flagSecretAdd, "version", []string{"1.25"}) + + flags.Var(newListOptsVar(), flagConfigRemove, "Remove a configuration file") + flags.SetAnnotation(flagConfigRemove, "version", []string{"1.30"}) + flags.Var(&serviceOpts.configs, flagConfigAdd, "Add or update a config file on a service") + flags.SetAnnotation(flagConfigAdd, "version", []string{"1.30"}) + flags.Var(&serviceOpts.mounts, flagMountAdd, "Add or update a mount on a service") flags.Var(&serviceOpts.constraints, flagConstraintAdd, "Add or update a placement constraint") flags.Var(&serviceOpts.placementPrefs, flagPlacementPrefAdd, "Add a placement preference") @@ -170,6 +176,13 @@ func runUpdate(dockerCli *command.DockerCli, flags *pflag.FlagSet, opts *service spec.TaskTemplate.ContainerSpec.Secrets = updatedSecrets + updatedConfigs, err := getUpdatedConfigs(apiClient, flags, spec.TaskTemplate.ContainerSpec.Configs) + if err != nil { + return err + } + + spec.TaskTemplate.ContainerSpec.Configs = updatedConfigs + // only send auth if flag was set sendAuth, err := flags.GetBool(flagRegistryAuth) if err != nil { @@ -581,6 +594,29 @@ func getUpdatedSecrets(apiClient client.SecretAPIClient, flags *pflag.FlagSet, s return newSecrets, nil } +func getUpdatedConfigs(apiClient client.ConfigAPIClient, flags *pflag.FlagSet, configs []*swarm.ConfigReference) ([]*swarm.ConfigReference, error) { + newConfigs := []*swarm.ConfigReference{} + + toRemove := buildToRemoveSet(flags, flagConfigRemove) + for _, config := range configs { + if _, exists := toRemove[config.ConfigName]; !exists { + newConfigs = append(newConfigs, config) + } + } + + if flags.Changed(flagConfigAdd) { + values := flags.Lookup(flagConfigAdd).Value.(*opts.ConfigOpt).Value() + + addConfigs, err := ParseConfigs(apiClient, values) + if err != nil { + return nil, err + } + newConfigs = append(newConfigs, addConfigs...) + } + + return newConfigs, nil +} + func envKey(value string) string { kv := strings.SplitN(value, "=", 2) return kv[0] diff --git a/cli/command/volume/create_test.go b/cli/command/volume/create_test.go index a0ef71ffb0..74c81aba1f 100644 --- a/cli/command/volume/create_test.go +++ b/cli/command/volume/create_test.go @@ -3,6 +3,7 @@ package volume import ( "bytes" "io/ioutil" + "reflect" "strings" "testing" @@ -104,10 +105,10 @@ func TestVolumeCreateWithFlags(t *testing.T) { if body.Driver != expectedDriver { return types.Volume{}, errors.Errorf("expected driver %q, got %q", expectedDriver, body.Driver) } - if !compareMap(body.DriverOpts, expectedOpts) { + if !reflect.DeepEqual(body.DriverOpts, expectedOpts) { return types.Volume{}, errors.Errorf("expected drivers opts %v, got %v", expectedOpts, body.DriverOpts) } - if !compareMap(body.Labels, expectedLabels) { + if !reflect.DeepEqual(body.Labels, expectedLabels) { return types.Volume{}, errors.Errorf("expected labels %v, got %v", expectedLabels, body.Labels) } return types.Volume{ @@ -125,19 +126,3 @@ func TestVolumeCreateWithFlags(t *testing.T) { assert.NoError(t, cmd.Execute()) assert.Equal(t, name, strings.TrimSpace(buf.String())) } - -func compareMap(actual map[string]string, expected map[string]string) bool { - if len(actual) != len(expected) { - return false - } - for key, value := range actual { - if expectedValue, ok := expected[key]; ok { - if expectedValue != value { - return false - } - } else { - return false - } - } - return true -} diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 44bf71f388..7214325d87 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -38,6 +38,7 @@ type ConfigFile struct { ServicesFormat string `json:"servicesFormat,omitempty"` TasksFormat string `json:"tasksFormat,omitempty"` SecretFormat string `json:"secretFormat,omitempty"` + ConfigFormat string `json:"configFormat,omitempty"` NodesFormat string `json:"nodesFormat,omitempty"` PruneFilters []string `json:"pruneFilters,omitempty"` } diff --git a/cli/internal/test/builders/config.go b/cli/internal/test/builders/config.go new file mode 100644 index 0000000000..dee6e90a82 --- /dev/null +++ b/cli/internal/test/builders/config.go @@ -0,0 +1,61 @@ +package builders + +import ( + "time" + + "github.com/docker/docker/api/types/swarm" +) + +// Config creates a config with default values. +// Any number of config builder functions can be passed to augment it. +func Config(builders ...func(config *swarm.Config)) *swarm.Config { + config := &swarm.Config{} + + for _, builder := range builders { + builder(config) + } + + return config +} + +// ConfigLabels sets the config's labels +func ConfigLabels(labels map[string]string) func(config *swarm.Config) { + return func(config *swarm.Config) { + config.Spec.Labels = labels + } +} + +// ConfigName sets the config's name +func ConfigName(name string) func(config *swarm.Config) { + return func(config *swarm.Config) { + config.Spec.Name = name + } +} + +// ConfigID sets the config's ID +func ConfigID(ID string) func(config *swarm.Config) { + return func(config *swarm.Config) { + config.ID = ID + } +} + +// ConfigVersion sets the version for the config +func ConfigVersion(v swarm.Version) func(*swarm.Config) { + return func(config *swarm.Config) { + config.Version = v + } +} + +// ConfigCreatedAt sets the creation time for the config +func ConfigCreatedAt(t time.Time) func(*swarm.Config) { + return func(config *swarm.Config) { + config.CreatedAt = t + } +} + +// ConfigUpdatedAt sets the update time for the config +func ConfigUpdatedAt(t time.Time) func(*swarm.Config) { + return func(config *swarm.Config) { + config.UpdatedAt = t + } +}