From 3455580ebce0efdb449d9b23a63318b7f0dceea6 Mon Sep 17 00:00:00 2001 From: Drew Erny Date: Tue, 23 Feb 2021 08:23:53 -0600 Subject: [PATCH 1/2] Add cluster volume support - Write test for cluster volumes - Add inspect test, add update command - Add cluster volume opts to create - Add requisite and preferred topology flags - volume: move cluster bool in opts Signed-off-by: Drew Erny Signed-off-by: Sebastiaan van Stijn --- cli/command/formatter/volume.go | 66 ++++++++-- cli/command/formatter/volume_test.go | 4 +- cli/command/volume/cmd.go | 1 + cli/command/volume/create.go | 121 +++++++++++++++++- cli/command/volume/create_test.go | 97 ++++++++++++++ cli/command/volume/inspect_test.go | 106 +++++++++++++++ cli/command/volume/list.go | 32 ++++- cli/command/volume/list_test.go | 105 +++++++++++++++ .../volume-cluster-volume-list.golden | 5 + .../testdata/volume-inspect-cluster.golden | 99 ++++++++++++++ cli/command/volume/update.go | 61 +++++++++ 11 files changed, 676 insertions(+), 21 deletions(-) create mode 100644 cli/command/volume/testdata/volume-cluster-volume-list.golden create mode 100644 cli/command/volume/testdata/volume-inspect-cluster.golden create mode 100644 cli/command/volume/update.go diff --git a/cli/command/formatter/volume.go b/cli/command/formatter/volume.go index 1309061abae0..fa8ef7f23c1b 100644 --- a/cli/command/formatter/volume.go +++ b/cli/command/formatter/volume.go @@ -1,6 +1,7 @@ package formatter import ( + "fmt" "strconv" "strings" @@ -12,10 +13,13 @@ const ( defaultVolumeQuietFormat = "{{.Name}}" defaultVolumeTableFormat = "table {{.Driver}}\t{{.Name}}" - volumeNameHeader = "VOLUME NAME" - mountpointHeader = "MOUNTPOINT" - linksHeader = "LINKS" - // Status header ? + idHeader = "ID" + volumeNameHeader = "VOLUME NAME" + mountpointHeader = "MOUNTPOINT" + linksHeader = "LINKS" + groupHeader = "GROUP" + availabilityHeader = "AVAILABILITY" + statusHeader = "STATUS" ) // NewVolumeFormat returns a format for use with a volume Context @@ -56,13 +60,17 @@ type volumeContext struct { func newVolumeContext() *volumeContext { volumeCtx := volumeContext{} volumeCtx.Header = SubHeaderContext{ - "Name": volumeNameHeader, - "Driver": DriverHeader, - "Scope": ScopeHeader, - "Mountpoint": mountpointHeader, - "Labels": LabelsHeader, - "Links": linksHeader, - "Size": SizeHeader, + "ID": idHeader, + "Name": volumeNameHeader, + "Group": groupHeader, + "Driver": DriverHeader, + "Scope": ScopeHeader, + "Availability": availabilityHeader, + "Mountpoint": mountpointHeader, + "Labels": LabelsHeader, + "Links": linksHeader, + "Size": SizeHeader, + "Status": statusHeader, } return &volumeCtx } @@ -119,3 +127,39 @@ func (c *volumeContext) Size() string { } return units.HumanSize(float64(c.v.UsageData.Size)) } + +func (c *volumeContext) Group() string { + if c.v.ClusterVolume == nil { + return "N/A" + } + + return c.v.ClusterVolume.Spec.Group +} + +func (c *volumeContext) Availability() string { + if c.v.ClusterVolume == nil { + return "N/A" + } + + return string(c.v.ClusterVolume.Spec.Availability) +} + +func (c *volumeContext) Status() string { + if c.v.ClusterVolume == nil { + return "N/A" + } + + if c.v.ClusterVolume.Info == nil || c.v.ClusterVolume.Info.VolumeID == "" { + return "pending creation" + } + + l := len(c.v.ClusterVolume.PublishStatus) + switch l { + case 0: + return "created" + case 1: + return "in use (1 node)" + default: + return fmt.Sprintf("in use (%d nodes)", l) + } +} diff --git a/cli/command/formatter/volume_test.go b/cli/command/formatter/volume_test.go index 01e17500cad7..64997a2d8191 100644 --- a/cli/command/formatter/volume_test.go +++ b/cli/command/formatter/volume_test.go @@ -148,8 +148,8 @@ func TestVolumeContextWriteJSON(t *testing.T) { {Driver: "bar", Name: "foobar_bar"}, } expectedJSONs := []map[string]interface{}{ - {"Driver": "foo", "Labels": "", "Links": "N/A", "Mountpoint": "", "Name": "foobar_baz", "Scope": "", "Size": "N/A"}, - {"Driver": "bar", "Labels": "", "Links": "N/A", "Mountpoint": "", "Name": "foobar_bar", "Scope": "", "Size": "N/A"}, + {"Availability": "N/A", "Driver": "foo", "Group": "N/A", "Labels": "", "Links": "N/A", "Mountpoint": "", "Name": "foobar_baz", "Scope": "", "Size": "N/A", "Status": "N/A"}, + {"Availability": "N/A", "Driver": "bar", "Group": "N/A", "Labels": "", "Links": "N/A", "Mountpoint": "", "Name": "foobar_bar", "Scope": "", "Size": "N/A", "Status": "N/A"}, } out := bytes.NewBufferString("") err := VolumeWrite(Context{Format: "{{json .}}", Output: out}, volumes) diff --git a/cli/command/volume/cmd.go b/cli/command/volume/cmd.go index b2a552ae35e7..386352791e2f 100644 --- a/cli/command/volume/cmd.go +++ b/cli/command/volume/cmd.go @@ -21,6 +21,7 @@ func NewVolumeCommand(dockerCli command.Cli) *cobra.Command { newListCommand(dockerCli), newRemoveCommand(dockerCli), NewPruneCommand(dockerCli), + newUpdateCommand(dockerCli), ) return cmd } diff --git a/cli/command/volume/create.go b/cli/command/volume/create.go index de9f63aa7bd5..f2ab0b452066 100644 --- a/cli/command/volume/create.go +++ b/cli/command/volume/create.go @@ -3,6 +3,7 @@ package volume import ( "context" "fmt" + "strings" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -11,6 +12,7 @@ import ( "github.com/docker/docker/api/types/volume" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) type createOptions struct { @@ -18,12 +20,28 @@ type createOptions struct { driver string driverOpts opts.MapOpts labels opts.ListOpts + + // options for cluster volumes only + cluster bool + group string + scope string + sharing string + availability string + secrets opts.MapOpts + requiredBytes opts.MemBytes + limitBytes opts.MemBytes + accessType string + requisiteTopology opts.ListOpts + preferredTopology opts.ListOpts } func newCreateCommand(dockerCli command.Cli) *cobra.Command { options := createOptions{ - driverOpts: *opts.NewMapOpts(nil, nil), - labels: opts.NewListOpts(opts.ValidateLabel), + driverOpts: *opts.NewMapOpts(nil, nil), + labels: opts.NewListOpts(opts.ValidateLabel), + secrets: *opts.NewMapOpts(nil, nil), + requisiteTopology: opts.NewListOpts(nil), + preferredTopology: opts.NewListOpts(nil), } cmd := &cobra.Command{ @@ -37,6 +55,7 @@ func newCreateCommand(dockerCli command.Cli) *cobra.Command { } options.name = args[0] } + options.cluster = hasClusterVolumeOptionSet(cmd.Flags()) return runCreate(dockerCli, options) }, ValidArgsFunction: completion.NoComplete, @@ -48,16 +67,110 @@ func newCreateCommand(dockerCli command.Cli) *cobra.Command { flags.VarP(&options.driverOpts, "opt", "o", "Set driver specific options") flags.Var(&options.labels, "label", "Set metadata for a volume") + // flags for cluster volumes only + flags.StringVar(&options.group, "group", "", "Cluster Volume group (cluster volumes)") + flags.StringVar(&options.scope, "scope", "single", `Cluster Volume access scope ("single"|"multi")`) + flags.StringVar(&options.sharing, "sharing", "none", `Cluster Volume access sharing ("none"|"readonly"|"onewriter"|"all")`) + flags.StringVar(&options.availability, "availability", "active", `Cluster Volume availability ("active"|"pause"|"drain")`) + flags.StringVar(&options.accessType, "type", "block", `Cluster Volume access type ("mount"|"block")`) + flags.Var(&options.secrets, "secret", "Cluster Volume secrets") + flags.Var(&options.limitBytes, "limit-bytes", "Minimum size of the Cluster Volume in bytes") + flags.Var(&options.requiredBytes, "required-bytes", "Maximum size of the Cluster Volume in bytes") + flags.Var(&options.requisiteTopology, "topology-required", "A topology that the Cluster Volume must be accessible from") + flags.Var(&options.preferredTopology, "topology-preferred", "A topology that the Cluster Volume would be preferred in") + return cmd } +// hasClusterVolumeOptionSet returns true if any of the cluster-specific +// options are set. +func hasClusterVolumeOptionSet(flags *pflag.FlagSet) bool { + return flags.Changed("group") || flags.Changed("scope") || + flags.Changed("sharing") || flags.Changed("availability") || + flags.Changed("type") || flags.Changed("secrets") || + flags.Changed("limit-bytes") || flags.Changed("required-bytes") +} + func runCreate(dockerCli command.Cli, options createOptions) error { - vol, err := dockerCli.Client().VolumeCreate(context.Background(), volume.CreateOptions{ + volOpts := volume.CreateOptions{ Driver: options.driver, DriverOpts: options.driverOpts.GetAll(), Name: options.name, Labels: opts.ConvertKVStringsToMap(options.labels.GetAll()), - }) + } + if options.cluster { + volOpts.ClusterVolumeSpec = &volume.ClusterVolumeSpec{ + Group: options.group, + AccessMode: &volume.AccessMode{ + Scope: volume.Scope(options.scope), + Sharing: volume.SharingMode(options.sharing), + }, + Availability: volume.Availability(options.availability), + } + + if options.accessType == "mount" { + volOpts.ClusterVolumeSpec.AccessMode.MountVolume = &volume.TypeMount{} + } else if options.accessType == "block" { + volOpts.ClusterVolumeSpec.AccessMode.BlockVolume = &volume.TypeBlock{} + } + + vcr := &volume.CapacityRange{} + if r := options.requiredBytes.Value(); r >= 0 { + vcr.RequiredBytes = r + } + + if l := options.limitBytes.Value(); l >= 0 { + vcr.LimitBytes = l + } + volOpts.ClusterVolumeSpec.CapacityRange = vcr + + for key, secret := range options.secrets.GetAll() { + volOpts.ClusterVolumeSpec.Secrets = append( + volOpts.ClusterVolumeSpec.Secrets, + volume.Secret{ + Key: key, + Secret: secret, + }, + ) + } + + // TODO(dperny): ignore if no topology specified + topology := &volume.TopologyRequirement{} + for _, top := range options.requisiteTopology.GetAll() { + // each topology takes the form segment=value,segment=value + // comma-separated list of equal separated maps + segments := map[string]string{} + for _, segment := range strings.Split(top, ",") { + parts := strings.SplitN(segment, "=", 2) + // TODO(dperny): validate topology syntax + segments[parts[0]] = parts[1] + } + topology.Requisite = append( + topology.Requisite, + volume.Topology{Segments: segments}, + ) + } + + for _, top := range options.preferredTopology.GetAll() { + // each topology takes the form segment=value,segment=value + // comma-separated list of equal separated maps + segments := map[string]string{} + for _, segment := range strings.Split(top, ",") { + parts := strings.SplitN(segment, "=", 2) + // TODO(dperny): validate topology syntax + segments[parts[0]] = parts[1] + } + + topology.Preferred = append( + topology.Preferred, + volume.Topology{Segments: segments}, + ) + } + + volOpts.ClusterVolumeSpec.AccessibilityRequirements = topology + } + + vol, err := dockerCli.Client().VolumeCreate(context.Background(), volOpts) if err != nil { return err } diff --git a/cli/command/volume/create_test.go b/cli/command/volume/create_test.go index 43168d9570b6..2dfb9e8467ee 100644 --- a/cli/command/volume/create_test.go +++ b/cli/command/volume/create_test.go @@ -123,3 +123,100 @@ func TestVolumeCreateWithFlags(t *testing.T) { assert.NilError(t, cmd.Execute()) assert.Check(t, is.Equal(name, strings.TrimSpace(cli.OutBuffer().String()))) } + +func TestVolumeCreateCluster(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{ + volumeCreateFunc: func(body volume.CreateOptions) (volume.Volume, error) { + if body.Driver == "csi" && body.ClusterVolumeSpec == nil { + return volume.Volume{}, errors.New("expected ClusterVolumeSpec, but none present") + } + if body.Driver == "notcsi" && body.ClusterVolumeSpec != nil { + return volume.Volume{}, errors.New("expected no ClusterVolumeSpec, but present") + } + return volume.Volume{}, nil + }, + }) + + cmd := newCreateCommand(cli) + cmd.Flags().Set("type", "block") + cmd.Flags().Set("group", "gronp") + cmd.Flags().Set("driver", "csi") + cmd.SetArgs([]string{"name"}) + + assert.NilError(t, cmd.Execute()) + + cmd = newCreateCommand(cli) + cmd.Flags().Set("driver", "notcsi") + cmd.SetArgs([]string{"name"}) + + assert.NilError(t, cmd.Execute()) +} + +func TestVolumeCreateClusterOpts(t *testing.T) { + expectedBody := volume.CreateOptions{ + Name: "name", + Driver: "csi", + DriverOpts: map[string]string{}, + Labels: map[string]string{}, + ClusterVolumeSpec: &volume.ClusterVolumeSpec{ + Group: "gronp", + AccessMode: &volume.AccessMode{ + Scope: volume.ScopeMultiNode, + Sharing: volume.SharingOneWriter, + // TODO(dperny): support mount options + MountVolume: &volume.TypeMount{}, + }, + // TODO(dperny): topology requirements + CapacityRange: &volume.CapacityRange{ + RequiredBytes: 1234, + LimitBytes: 567890, + }, + Secrets: []volume.Secret{ + {Key: "key1", Secret: "secret1"}, + {Key: "key2", Secret: "secret2"}, + }, + Availability: volume.AvailabilityActive, + AccessibilityRequirements: &volume.TopologyRequirement{ + Requisite: []volume.Topology{ + {Segments: map[string]string{"region": "R1", "zone": "Z1"}}, + {Segments: map[string]string{"region": "R1", "zone": "Z2"}}, + {Segments: map[string]string{"region": "R1", "zone": "Z3"}}, + }, + Preferred: []volume.Topology{ + {Segments: map[string]string{"region": "R1", "zone": "Z2"}}, + {Segments: map[string]string{"region": "R1", "zone": "Z3"}}, + }, + }, + }, + } + + cli := test.NewFakeCli(&fakeClient{ + volumeCreateFunc: func(body volume.CreateOptions) (volume.Volume, error) { + assert.DeepEqual(t, body, expectedBody) + return volume.Volume{}, nil + }, + }) + + cmd := newCreateCommand(cli) + cmd.SetArgs([]string{"name"}) + cmd.Flags().Set("driver", "csi") + cmd.Flags().Set("group", "gronp") + cmd.Flags().Set("scope", "multi") + cmd.Flags().Set("sharing", "onewriter") + cmd.Flags().Set("type", "mount") + cmd.Flags().Set("sharing", "onewriter") + cmd.Flags().Set("required-bytes", "1234") + cmd.Flags().Set("limit-bytes", "567890") + + cmd.Flags().Set("secret", "key1=secret1") + cmd.Flags().Set("secret", "key2=secret2") + + cmd.Flags().Set("topology-required", "region=R1,zone=Z1") + cmd.Flags().Set("topology-required", "region=R1,zone=Z2") + cmd.Flags().Set("topology-required", "region=R1,zone=Z3") + + cmd.Flags().Set("topology-preferred", "region=R1,zone=Z2") + cmd.Flags().Set("topology-preferred", "region=R1,zone=Z3") + + cmd.Execute() +} diff --git a/cli/command/volume/inspect_test.go b/cli/command/volume/inspect_test.go index 0389a0203a4c..bf1643752417 100644 --- a/cli/command/volume/inspect_test.go +++ b/cli/command/volume/inspect_test.go @@ -7,6 +7,7 @@ import ( "github.com/docker/cli/internal/test" . "github.com/docker/cli/internal/test/builders" // Import builders to get the builder function as package function + "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/volume" "github.com/pkg/errors" "gotest.tools/v3/assert" @@ -138,3 +139,108 @@ func TestVolumeInspectWithFormat(t *testing.T) { golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("volume-inspect-with-format.%s.golden", tc.name)) } } + +func TestVolumeInspectCluster(t *testing.T) { + volumeInspectFunc := func(volumeID string) (volume.Volume, error) { + return volume.Volume{ + Name: "clustervolume", + Driver: "clusterdriver1", + Scope: "global", + ClusterVolume: &volume.ClusterVolume{ + ID: "fooid", + Meta: swarm.Meta{ + Version: swarm.Version{ + Index: uint64(123), + }, + }, + Spec: volume.ClusterVolumeSpec{ + Group: "group0", + AccessMode: &volume.AccessMode{ + Scope: volume.ScopeMultiNode, + Sharing: volume.SharingAll, + BlockVolume: &volume.TypeBlock{}, + }, + AccessibilityRequirements: &volume.TopologyRequirement{ + Requisite: []volume.Topology{ + { + Segments: map[string]string{ + "region": "R1", + "zone": "Z1", + }, + }, { + Segments: map[string]string{ + "region": "R1", + "zone": "Z2", + }, + }, + }, + Preferred: []volume.Topology{ + { + Segments: map[string]string{ + "region": "R1", + "zone": "Z1", + }, + }, + }, + }, + CapacityRange: &volume.CapacityRange{ + RequiredBytes: 1000, + LimitBytes: 1000000, + }, + Secrets: []volume.Secret{ + { + Key: "secretkey1", + Secret: "mysecret1", + }, { + Key: "secretkey2", + Secret: "mysecret2", + }, + }, + Availability: volume.AvailabilityActive, + }, + Info: &volume.Info{ + CapacityBytes: 10000, + VolumeContext: map[string]string{ + "the": "context", + "has": "entries", + }, + VolumeID: "clusterdriver1volume1id", + AccessibleTopology: []volume.Topology{ + { + Segments: map[string]string{ + "region": "R1", + "zone": "Z1", + }, + }, + }, + }, + PublishStatus: []*volume.PublishStatus{ + { + NodeID: "node1", + State: volume.StatePublished, + PublishContext: map[string]string{ + "some": "data", + "yup": "data", + }, + }, { + NodeID: "node2", + State: volume.StatePendingNodeUnpublish, + PublishContext: map[string]string{ + "some": "more", + "publish": "context", + }, + }, + }, + }, + }, nil + } + + cli := test.NewFakeCli(&fakeClient{ + volumeInspectFunc: volumeInspectFunc, + }) + + cmd := newInspectCommand(cli) + cmd.SetArgs([]string{"clustervolume"}) + assert.NilError(t, cmd.Execute()) + golden.Assert(t, cli.OutBuffer().String(), "volume-inspect-cluster.golden") +} diff --git a/cli/command/volume/list.go b/cli/command/volume/list.go index 2c04675f2140..4d9a4f8b4583 100644 --- a/cli/command/volume/list.go +++ b/cli/command/volume/list.go @@ -14,10 +14,15 @@ import ( "github.com/spf13/cobra" ) +const ( + clusterTableFormat = "table {{.Name}}\t{{.Group}}\t{{.Driver}}\t{{.Availability}}\t{{.Status}}" +) + type listOptions struct { - quiet bool - format string - filter opts.FilterOpt + quiet bool + format string + cluster bool + filter opts.FilterOpt } func newListCommand(dockerCli command.Cli) *cobra.Command { @@ -38,6 +43,7 @@ func newListCommand(dockerCli command.Cli) *cobra.Command { flags.BoolVarP(&options.quiet, "quiet", "q", false, "Only display volume names") flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp) flags.VarP(&options.filter, "filter", "f", "Provide filter values (e.g. 'dangling=true')") + flags.BoolVar(&options.cluster, "cluster", false, "Display only cluster volumes, and use cluster volume list formatting") return cmd } @@ -50,12 +56,30 @@ func runList(dockerCli command.Cli, options listOptions) error { } format := options.format - if len(format) == 0 { + if len(format) == 0 && !options.cluster { if len(dockerCli.ConfigFile().VolumesFormat) > 0 && !options.quiet { format = dockerCli.ConfigFile().VolumesFormat } else { format = formatter.TableFormatKey } + } else if options.cluster { + // TODO(dperny): write server-side filter for cluster volumes. For this + // proof of concept, we'll just filter out non-cluster volumes here + + // trick for filtering in place + n := 0 + for _, volume := range volumes.Volumes { + if volume.ClusterVolume != nil { + volumes.Volumes[n] = volume + n++ + } + } + volumes.Volumes = volumes.Volumes[:n] + if !options.quiet { + format = clusterTableFormat + } else { + format = formatter.TableFormatKey + } } sort.Slice(volumes.Volumes, func(i, j int) bool { diff --git a/cli/command/volume/list_test.go b/cli/command/volume/list_test.go index b225c49093cd..7a8006449f0c 100644 --- a/cli/command/volume/list_test.go +++ b/cli/command/volume/list_test.go @@ -125,3 +125,108 @@ func TestVolumeListSortOrder(t *testing.T) { assert.NilError(t, cmd.Execute()) golden.Assert(t, cli.OutBuffer().String(), "volume-list-sort.golden") } + +func TestClusterVolumeList(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{ + volumeListFunc: func(filter filters.Args) (volume.ListResponse, error) { + return volume.ListResponse{ + Volumes: []*volume.Volume{ + { + Name: "volume1", + Scope: "global", + Driver: "driver1", + ClusterVolume: &volume.ClusterVolume{ + Spec: volume.ClusterVolumeSpec{ + Group: "group1", + AccessMode: &volume.AccessMode{ + Scope: volume.ScopeSingleNode, + Sharing: volume.SharingOneWriter, + MountVolume: &volume.TypeMount{}, + }, + Availability: volume.AvailabilityActive, + }, + }, + }, { + Name: "volume2", + Scope: "global", + Driver: "driver1", + ClusterVolume: &volume.ClusterVolume{ + Spec: volume.ClusterVolumeSpec{ + Group: "group1", + AccessMode: &volume.AccessMode{ + Scope: volume.ScopeSingleNode, + Sharing: volume.SharingOneWriter, + MountVolume: &volume.TypeMount{}, + }, + Availability: volume.AvailabilityPause, + }, + Info: &volume.Info{ + CapacityBytes: 100000000, + VolumeID: "driver1vol2", + }, + }, + }, { + Name: "volume3", + Scope: "global", + Driver: "driver2", + ClusterVolume: &volume.ClusterVolume{ + Spec: volume.ClusterVolumeSpec{ + Group: "group2", + AccessMode: &volume.AccessMode{ + Scope: volume.ScopeMultiNode, + Sharing: volume.SharingAll, + MountVolume: &volume.TypeMount{}, + }, + Availability: volume.AvailabilityActive, + }, + PublishStatus: []*volume.PublishStatus{ + { + NodeID: "nodeid1", + State: volume.StatePublished, + }, + }, + Info: &volume.Info{ + CapacityBytes: 100000000, + VolumeID: "driver1vol3", + }, + }, + }, { + Name: "volume4", + Scope: "global", + Driver: "driver2", + ClusterVolume: &volume.ClusterVolume{ + Spec: volume.ClusterVolumeSpec{ + Group: "group2", + AccessMode: &volume.AccessMode{ + Scope: volume.ScopeMultiNode, + Sharing: volume.SharingAll, + MountVolume: &volume.TypeMount{}, + }, + Availability: volume.AvailabilityActive, + }, + PublishStatus: []*volume.PublishStatus{ + { + NodeID: "nodeid1", + State: volume.StatePublished, + }, { + NodeID: "nodeid2", + State: volume.StatePublished, + }, + }, + Info: &volume.Info{ + CapacityBytes: 100000000, + VolumeID: "driver1vol4", + }, + }, + }, + Volume(VolumeName("volume-local-1")), + }, + }, nil + }, + }) + + cmd := newListCommand(cli) + cmd.Flags().Set("cluster", "true") + assert.NilError(t, cmd.Execute()) + golden.Assert(t, cli.OutBuffer().String(), "volume-cluster-volume-list.golden") +} diff --git a/cli/command/volume/testdata/volume-cluster-volume-list.golden b/cli/command/volume/testdata/volume-cluster-volume-list.golden new file mode 100644 index 000000000000..fa3bcf05460b --- /dev/null +++ b/cli/command/volume/testdata/volume-cluster-volume-list.golden @@ -0,0 +1,5 @@ +VOLUME NAME GROUP DRIVER AVAILABILITY STATUS +volume1 group1 driver1 active pending creation +volume2 group1 driver1 pause created +volume3 group2 driver2 active in use (1 node) +volume4 group2 driver2 active in use (2 nodes) diff --git a/cli/command/volume/testdata/volume-inspect-cluster.golden b/cli/command/volume/testdata/volume-inspect-cluster.golden new file mode 100644 index 000000000000..22e1382e9e72 --- /dev/null +++ b/cli/command/volume/testdata/volume-inspect-cluster.golden @@ -0,0 +1,99 @@ +[ + { + "ClusterVolume": { + "ID": "fooid", + "Version": { + "Index": 123 + }, + "CreatedAt": "0001-01-01T00:00:00Z", + "UpdatedAt": "0001-01-01T00:00:00Z", + "Spec": { + "Group": "group0", + "AccessMode": { + "Scope": "multi", + "Sharing": "all", + "BlockVolume": {} + }, + "AccessibilityRequirements": { + "Requisite": [ + { + "Segments": { + "region": "R1", + "zone": "Z1" + } + }, + { + "Segments": { + "region": "R1", + "zone": "Z2" + } + } + ], + "Preferred": [ + { + "Segments": { + "region": "R1", + "zone": "Z1" + } + } + ] + }, + "CapacityRange": { + "RequiredBytes": 1000, + "LimitBytes": 1000000 + }, + "Secrets": [ + { + "Key": "secretkey1", + "Secret": "mysecret1" + }, + { + "Key": "secretkey2", + "Secret": "mysecret2" + } + ], + "Availability": "active" + }, + "PublishStatus": [ + { + "NodeID": "node1", + "State": "published", + "PublishContext": { + "some": "data", + "yup": "data" + } + }, + { + "NodeID": "node2", + "State": "pending-node-unpublish", + "PublishContext": { + "publish": "context", + "some": "more" + } + } + ], + "Info": { + "CapacityBytes": 10000, + "VolumeContext": { + "has": "entries", + "the": "context" + }, + "VolumeID": "clusterdriver1volume1id", + "AccessibleTopology": [ + { + "Segments": { + "region": "R1", + "zone": "Z1" + } + } + ] + } + }, + "Driver": "clusterdriver1", + "Labels": null, + "Mountpoint": "", + "Name": "clustervolume", + "Options": null, + "Scope": "global" + } +] diff --git a/cli/command/volume/update.go b/cli/command/volume/update.go new file mode 100644 index 000000000000..240b2e51b9c2 --- /dev/null +++ b/cli/command/volume/update.go @@ -0,0 +1,61 @@ +package volume + +import ( + "context" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/completion" + "github.com/docker/docker/api/types/volume" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func newUpdateCommand(dockerCli command.Cli) *cobra.Command { + var availability string + + cmd := &cobra.Command{ + Use: "update [OPTIONS] [VOLUME]", + Short: "Update a volume (cluster volumes only)", + Args: cli.RequiresMaxArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(dockerCli, args[0], availability, cmd.Flags()) + }, + ValidArgsFunction: completion.VolumeNames(dockerCli), + } + + flags := cmd.Flags() + flags.StringVar(&availability, "availability", "active", `Cluster Volume availability ("active"|"pause"|"drain")`) + + return cmd +} + +func runUpdate(dockerCli command.Cli, volumeID, availability string, flags *pflag.FlagSet) error { + // TODO(dperny): For this earliest version, the only thing that can be + // updated is Availability, which is necessary because to delete a cluster + // volume, the availability must first be set to "drain" + + apiClient := dockerCli.Client() + ctx := context.Background() + + vol, _, err := apiClient.VolumeInspectWithRaw(ctx, volumeID) + if err != nil { + return err + } + + if vol.ClusterVolume == nil { + return errors.New("Can only update cluster volumes") + } + + if flags.Changed("availability") { + vol.ClusterVolume.Spec.Availability = volume.Availability(availability) + } + + return apiClient.VolumeUpdate( + ctx, vol.ClusterVolume.ID, vol.ClusterVolume.Version, + volume.UpdateOptions{ + Spec: &vol.ClusterVolume.Spec, + }, + ) +} From 0fab8ecf3655ea189b3e1f9ed57863a40ed9ea5a Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 13 May 2022 19:48:24 +0200 Subject: [PATCH 2/2] volume: annotate cluster-volumes flags for API version and swarm This hides the flags when connecting to an older engine, or if swarm is not enabled, and is also used to add badges in the documentation. Signed-off-by: Sebastiaan van Stijn --- cli/command/volume/create.go | 20 ++++++++++++++++++++ cli/command/volume/list.go | 2 ++ cli/command/volume/update.go | 6 ++++++ 3 files changed, 28 insertions(+) diff --git a/cli/command/volume/create.go b/cli/command/volume/create.go index f2ab0b452066..6fbdb767eacd 100644 --- a/cli/command/volume/create.go +++ b/cli/command/volume/create.go @@ -69,15 +69,35 @@ func newCreateCommand(dockerCli command.Cli) *cobra.Command { // flags for cluster volumes only flags.StringVar(&options.group, "group", "", "Cluster Volume group (cluster volumes)") + flags.SetAnnotation("group", "version", []string{"1.42"}) + flags.SetAnnotation("group", "swarm", []string{"manager"}) flags.StringVar(&options.scope, "scope", "single", `Cluster Volume access scope ("single"|"multi")`) + flags.SetAnnotation("scope", "version", []string{"1.42"}) + flags.SetAnnotation("scope", "swarm", []string{"manager"}) flags.StringVar(&options.sharing, "sharing", "none", `Cluster Volume access sharing ("none"|"readonly"|"onewriter"|"all")`) + flags.SetAnnotation("sharing", "version", []string{"1.42"}) + flags.SetAnnotation("sharing", "swarm", []string{"manager"}) flags.StringVar(&options.availability, "availability", "active", `Cluster Volume availability ("active"|"pause"|"drain")`) + flags.SetAnnotation("availability", "version", []string{"1.42"}) + flags.SetAnnotation("availability", "swarm", []string{"manager"}) flags.StringVar(&options.accessType, "type", "block", `Cluster Volume access type ("mount"|"block")`) + flags.SetAnnotation("type", "version", []string{"1.42"}) + flags.SetAnnotation("type", "swarm", []string{"manager"}) flags.Var(&options.secrets, "secret", "Cluster Volume secrets") + flags.SetAnnotation("secret", "version", []string{"1.42"}) + flags.SetAnnotation("secret", "swarm", []string{"manager"}) flags.Var(&options.limitBytes, "limit-bytes", "Minimum size of the Cluster Volume in bytes") + flags.SetAnnotation("limit-bytes", "version", []string{"1.42"}) + flags.SetAnnotation("limit-bytes", "swarm", []string{"manager"}) flags.Var(&options.requiredBytes, "required-bytes", "Maximum size of the Cluster Volume in bytes") + flags.SetAnnotation("required-bytes", "version", []string{"1.42"}) + flags.SetAnnotation("required-bytes", "swarm", []string{"manager"}) flags.Var(&options.requisiteTopology, "topology-required", "A topology that the Cluster Volume must be accessible from") + flags.SetAnnotation("topology-required", "version", []string{"1.42"}) + flags.SetAnnotation("topology-required", "swarm", []string{"manager"}) flags.Var(&options.preferredTopology, "topology-preferred", "A topology that the Cluster Volume would be preferred in") + flags.SetAnnotation("topology-preferred", "version", []string{"1.42"}) + flags.SetAnnotation("topology-preferred", "swarm", []string{"manager"}) return cmd } diff --git a/cli/command/volume/list.go b/cli/command/volume/list.go index 4d9a4f8b4583..6b376d6c5d7e 100644 --- a/cli/command/volume/list.go +++ b/cli/command/volume/list.go @@ -44,6 +44,8 @@ func newListCommand(dockerCli command.Cli) *cobra.Command { flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp) flags.VarP(&options.filter, "filter", "f", "Provide filter values (e.g. 'dangling=true')") flags.BoolVar(&options.cluster, "cluster", false, "Display only cluster volumes, and use cluster volume list formatting") + flags.SetAnnotation("cluster", "version", []string{"1.42"}) + flags.SetAnnotation("cluster", "swarm", []string{"manager"}) return cmd } diff --git a/cli/command/volume/update.go b/cli/command/volume/update.go index 240b2e51b9c2..0fe67cff4cd1 100644 --- a/cli/command/volume/update.go +++ b/cli/command/volume/update.go @@ -22,11 +22,17 @@ func newUpdateCommand(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return runUpdate(dockerCli, args[0], availability, cmd.Flags()) }, + Annotations: map[string]string{ + "version": "1.42", + "swarm": "manager", + }, ValidArgsFunction: completion.VolumeNames(dockerCli), } flags := cmd.Flags() flags.StringVar(&availability, "availability", "active", `Cluster Volume availability ("active"|"pause"|"drain")`) + flags.SetAnnotation("availability", "version", []string{"1.42"}) + flags.SetAnnotation("availability", "swarm", []string{"manager"}) return cmd }