Sort swarm stacks and nodes using natural sorting

This commit changes the order stacks and nodes are displayed.
For example, running "docker stack ls" is expected to
display the following list:

NAME          SERVICES
service-1     1
service-2     1
service-10    1

However, currently this is what is printed:

NAME          SERVICES
service-1     1
service-10    1
service-2     1

To fix this, "docker stack ls" and "docker node ls" are using
natural sorting to make it more human readable.

Signed-off-by: Boaz Shuster <ripcurld.github@gmail.com>
This commit is contained in:
Boaz Shuster 2017-07-10 12:43:57 +03:00
parent e3746d388e
commit 1333b49194
12 changed files with 194 additions and 11 deletions

View File

@ -1,15 +1,27 @@
package node package node
import ( import (
"sort"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/net/context" "golang.org/x/net/context"
"vbom.ml/util/sortorder"
) )
type byHostname []swarm.Node
func (n byHostname) Len() int { return len(n) }
func (n byHostname) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (n byHostname) Less(i, j int) bool {
return sortorder.NaturalLess(n[i].Description.Hostname, n[j].Description.Hostname)
}
type listOptions struct { type listOptions struct {
quiet bool quiet bool
format string format string
@ -68,5 +80,6 @@ func runList(dockerCli command.Cli, options listOptions) error {
Output: dockerCli.Out(), Output: dockerCli.Out(),
Format: formatter.NewNodeFormat(format, options.quiet), Format: formatter.NewNodeFormat(format, options.quiet),
} }
sort.Sort(byHostname(nodes))
return formatter.NodeWrite(nodesCtx, nodes, info) return formatter.NodeWrite(nodesCtx, nodes, info)
} }

View File

@ -9,6 +9,8 @@ import (
"github.com/docker/cli/cli/internal/test" "github.com/docker/cli/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm" "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/pkg/errors"
// Import builders to get the builder function as package function // Import builders to get the builder function as package function
. "github.com/docker/cli/cli/internal/test/builders" . "github.com/docker/cli/cli/internal/test/builders"
@ -156,3 +158,22 @@ func TestNodeListFormat(t *testing.T) {
assert.Contains(t, buf.String(), `nodeHostname1: Leader`) assert.Contains(t, buf.String(), `nodeHostname1: Leader`)
assert.Contains(t, buf.String(), `nodeHostname2: Reachable`) assert.Contains(t, buf.String(), `nodeHostname2: Reachable`)
} }
func TestNodeListOrder(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{
nodeListFunc: func() ([]swarm.Node, error) {
return []swarm.Node{
*Node(Hostname("node-2-foo"), Manager(Leader())),
*Node(Hostname("node-10-foo"), Manager()),
*Node(Hostname("node-1-foo")),
}, nil
},
})
cmd := newListCommand(cli)
cmd.Flags().Set("format", "{{.Hostname}}: {{.ManagerStatus}}")
assert.NoError(t, cmd.Execute())
actual := cli.OutBuffer().String()
expected := golden.Get(t, []byte(actual), "node-list-sort.golden")
testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected))
}

View File

@ -0,0 +1,3 @@
node-1-foo:
node-2-foo: Leader
node-10-foo: Reachable

View File

@ -12,6 +12,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/net/context" "golang.org/x/net/context"
"vbom.ml/util/sortorder"
) )
type listOptions struct { type listOptions struct {
@ -60,7 +61,7 @@ type byName []*formatter.Stack
func (n byName) Len() int { return len(n) } func (n byName) Len() int { return len(n) }
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } func (n byName) Less(i, j int) bool { return sortorder.NaturalLess(n[i].Name, n[j].Name) }
func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) { func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) {
services, err := apiclient.ServiceList( services, err := apiclient.ServiceList(

View File

@ -98,10 +98,13 @@ func TestListWithoutFormat(t *testing.T) {
} }
func TestListOrder(t *testing.T) { func TestListOrder(t *testing.T) {
buf := new(bytes.Buffer) usecases := []struct {
cmd := newListCommand(test.NewFakeCliWithOutput(&fakeClient{ golden string
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) { swarmServices []swarm.Service
return []swarm.Service{ }{
{
golden: "stack-list-sort.golden",
swarmServices: []swarm.Service{
*Service( *Service(
ServiceLabels(map[string]string{ ServiceLabels(map[string]string{
"com.docker.stack.namespace": "service-name-foo", "com.docker.stack.namespace": "service-name-foo",
@ -112,11 +115,40 @@ func TestListOrder(t *testing.T) {
"com.docker.stack.namespace": "service-name-bar", "com.docker.stack.namespace": "service-name-bar",
}), }),
), ),
}, nil },
}, },
}, buf)) {
assert.NoError(t, cmd.Execute()) golden: "stack-list-sort-natural.golden",
actual := buf.String() swarmServices: []swarm.Service{
expected := golden.Get(t, []byte(actual), "stack-list-sort.golden") *Service(
testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected)) ServiceLabels(map[string]string{
"com.docker.stack.namespace": "service-name-1-foo",
}),
),
*Service(
ServiceLabels(map[string]string{
"com.docker.stack.namespace": "service-name-10-foo",
}),
),
*Service(
ServiceLabels(map[string]string{
"com.docker.stack.namespace": "service-name-2-foo",
}),
),
},
},
}
for _, uc := range usecases {
buf := new(bytes.Buffer)
cmd := newListCommand(test.NewFakeCliWithOutput(&fakeClient{
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
return uc.swarmServices, nil
},
}, buf))
assert.NoError(t, cmd.Execute())
actual := buf.String()
expected := golden.Get(t, []byte(actual), uc.golden)
testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected))
}
} }

View File

@ -0,0 +1,4 @@
NAME SERVICES
service-name-1-foo 1
service-name-2-foo 1
service-name-10-foo 1

View File

@ -52,3 +52,4 @@ gopkg.in/yaml.v2 4c78c975fe7c825c6d1466c42be594d1d6f3aba6
github.com/tonistiigi/fsutil 0ac4c11b053b9c5c7c47558f81f96c7100ce50fb github.com/tonistiigi/fsutil 0ac4c11b053b9c5c7c47558f81f96c7100ce50fb
github.com/stevvooe/continuity cd7a8e21e2b6f84799f5dd4b65faf49c8d3ee02d github.com/stevvooe/continuity cd7a8e21e2b6f84799f5dd4b65faf49c8d3ee02d
golang.org/x/sync de49d9dcd27d4f764488181bea099dfe6179bcf0 golang.org/x/sync de49d9dcd27d4f764488181bea099dfe6179bcf0
vbom.ml/util 928aaa586d7718c70f4090ddf83f2b34c16fdc8d

17
vendor/vbom.ml/util/LICENSE vendored Normal file
View File

@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2015 Frits van Bommel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5
vendor/vbom.ml/util/README.md vendored Normal file
View File

@ -0,0 +1,5 @@
## util [![GoDoc](https://godoc.org/vbom.ml/util?status.svg)](https://godoc.org/vbom.ml/util)
import "vbom.ml/util"
Go utility packages.

View File

@ -0,0 +1,5 @@
## sortorder [![GoDoc](https://godoc.org/vbom.ml/util/sortorder?status.svg)](https://godoc.org/vbom.ml/util/sortorder)
import "vbom.ml/util/sortorder"
Sort orders and comparison functions.

5
vendor/vbom.ml/util/sortorder/doc.go vendored Normal file
View File

@ -0,0 +1,5 @@
// Package sortorder implements sort orders and comparison functions.
//
// Currently, it only implements so-called "natural order", where integers
// embedded in strings are compared by value.
package sortorder // import "vbom.ml/util/sortorder"

View File

@ -0,0 +1,76 @@
package sortorder
// Natural implements sort.Interface to sort strings in natural order. This
// means that e.g. "abc2" < "abc12".
//
// Non-digit sequences and numbers are compared separately. The former are
// compared bytewise, while the latter are compared numerically (except that
// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
//
// Limitation: only ASCII digits (0-9) are considered.
type Natural []string
func (n Natural) Len() int { return len(n) }
func (n Natural) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (n Natural) Less(i, j int) bool { return NaturalLess(n[i], n[j]) }
func isdigit(b byte) bool { return '0' <= b && b <= '9' }
// NaturalLess compares two strings using natural ordering. This means that e.g.
// "abc2" < "abc12".
//
// Non-digit sequences and numbers are compared separately. The former are
// compared bytewise, while the latter are compared numerically (except that
// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
//
// Limitation: only ASCII digits (0-9) are considered.
func NaturalLess(str1, str2 string) bool {
idx1, idx2 := 0, 0
for idx1 < len(str1) && idx2 < len(str2) {
c1, c2 := str1[idx1], str2[idx2]
dig1, dig2 := isdigit(c1), isdigit(c2)
switch {
case dig1 != dig2: // Digits before other characters.
return dig1 // True if LHS is a digit, false if the RHS is one.
case !dig1: // && !dig2, because dig1 == dig2
// UTF-8 compares bytewise-lexicographically, no need to decode
// codepoints.
if c1 != c2 {
return c1 < c2
}
idx1++
idx2++
default: // Digits
// Eat zeros.
for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ {
}
for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ {
}
// Eat all digits.
nonZero1, nonZero2 := idx1, idx2
for ; idx1 < len(str1) && isdigit(str1[idx1]); idx1++ {
}
for ; idx2 < len(str2) && isdigit(str2[idx2]); idx2++ {
}
// If lengths of numbers with non-zero prefix differ, the shorter
// one is less.
if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 {
return len1 < len2
}
// If they're not equal, string comparison is correct.
if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 {
return nr1 < nr2
}
// Otherwise, the one with less zeros is less.
// Because everything up to the number is equal, comparing the index
// after the zeros is sufficient.
if nonZero1 != nonZero2 {
return nonZero1 < nonZero2
}
}
// They're identical so far, so continue comparing.
}
// So far they are identical. At least one is ended. If the other continues,
// it sorts last.
return len(str1) < len(str2)
}