From 61361c2e5d2aefb13e8f7337d9c64b490f6bebfd Mon Sep 17 00:00:00 2001 From: "R.B. Boyer" <4903+rboyer@users.noreply.github.com> Date: Thu, 28 Oct 2021 17:27:31 -0500 Subject: [PATCH] cli: update consul members output to display partitions and sort the results usefully (#11446) --- .changelog/11446.txt | 3 + api/agent.go | 12 ++++ command/members/members.go | 113 +++++++++++++++++++++++++------- command/members/members_test.go | 65 ++++++++++++++++++ 4 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 .changelog/11446.txt diff --git a/.changelog/11446.txt b/.changelog/11446.txt new file mode 100644 index 0000000000..49f7cee40a --- /dev/null +++ b/.changelog/11446.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: update consul members output to display partitions and sort the results usefully +``` diff --git a/api/agent.go b/api/agent.go index e4be1e6dc2..51a8b88ae6 100644 --- a/api/agent.go +++ b/api/agent.go @@ -144,11 +144,23 @@ const ( // that the member represents a Consul server. MemberTagValueRoleServer = "consul" + // MemberTagValueRoleClient is the value of the MemberTagKeyRole used to indicate + // that the member represents a Consul client. + MemberTagValueRoleClient = "node" + + // MemberTagKeyDatacenter is the key used to indicate which datacenter this member is in. + MemberTagKeyDatacenter = "dc" + // MemberTagKeySegment is the key name of the tag used to indicate which network // segment this member is in. // Network Segments are a Consul Enterprise feature. MemberTagKeySegment = "segment" + // MemberTagKeyPartition is the key name of the tag used to indicate which partition + // this member is in. + // Partitions are a Consul Enterprise feature. + MemberTagKeyPartition = "ap" + // MemberTagKeyBootstrap is the key name of the tag used to indicate whether this // agent was started with the "bootstrap" configuration enabled MemberTagKeyBootstrap = "bootstrap" diff --git a/command/members/members.go b/command/members/members.go index 5ee0545308..b7ce700aba 100644 --- a/command/members/members.go +++ b/command/members/members.go @@ -12,6 +12,7 @@ import ( "github.com/mitchellh/cli" "github.com/ryanuber/columnize" + "github.com/hashicorp/consul/agent/structs" consulapi "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/flags" ) @@ -90,11 +91,17 @@ func (c *cmd) Run(args []string) int { n := len(members) for i := 0; i < n; i++ { member := members[i] - if member.Tags["segment"] == "" { - member.Tags["segment"] = "" + if member.Tags[consulapi.MemberTagKeyPartition] == "" { + member.Tags[consulapi.MemberTagKeyPartition] = "default" } - if c.segment == consulapi.AllSegments && member.Tags["role"] == "consul" { - member.Tags["segment"] = "" + if structs.IsDefaultPartition(member.Tags[consulapi.MemberTagKeyPartition]) { + if c.segment == consulapi.AllSegments && member.Tags[consulapi.MemberTagKeyRole] == consulapi.MemberTagValueRoleServer { + member.Tags[consulapi.MemberTagKeySegment] = "" + } else if member.Tags[consulapi.MemberTagKeySegment] == "" { + member.Tags[consulapi.MemberTagKeySegment] = "" + } + } else { + member.Tags[consulapi.MemberTagKeySegment] = "" } statusString := serf.MemberStatus(member.Status).String() if !statusRe.MatchString(statusString) { @@ -111,7 +118,7 @@ func (c *cmd) Run(args []string) int { return 2 } - sort.Sort(ByMemberNameAndSegment(members)) + sort.Sort(ByMemberNamePartitionAndSegment(members)) // Generate the output var result []string @@ -128,29 +135,69 @@ func (c *cmd) Run(args []string) int { return 0 } -// so we can sort members by name -type ByMemberNameAndSegment []*consulapi.AgentMember +// ByMemberNamePartitionAndSegment sorts members by name with a stable sort. +// +// 1. servers go at the top +// 2. members of the default partition go next (including segments) +// 3. members of partitions follow +type ByMemberNamePartitionAndSegment []*consulapi.AgentMember -func (m ByMemberNameAndSegment) Len() int { return len(m) } -func (m ByMemberNameAndSegment) Swap(i, j int) { m[i], m[j] = m[j], m[i] } -func (m ByMemberNameAndSegment) Less(i, j int) bool { +func (m ByMemberNamePartitionAndSegment) Len() int { return len(m) } +func (m ByMemberNamePartitionAndSegment) Swap(i, j int) { m[i], m[j] = m[j], m[i] } +func (m ByMemberNamePartitionAndSegment) Less(i, j int) bool { + tags_i := parseTags(m[i].Tags) + tags_j := parseTags(m[j].Tags) + + // put role=consul first switch { - case m[i].Tags["segment"] < m[j].Tags["segment"]: + case tags_i.role == consulapi.MemberTagValueRoleServer && tags_j.role != consulapi.MemberTagValueRoleServer: return true - case m[i].Tags["segment"] > m[j].Tags["segment"]: + case tags_i.role != consulapi.MemberTagValueRoleServer && tags_j.role == consulapi.MemberTagValueRoleServer: return false - default: - return m[i].Name < m[j].Name } + + // then the default partitions + switch { + case isDefault(tags_i.partition) && !isDefault(tags_j.partition): + return true + case !isDefault(tags_i.partition) && isDefault(tags_j.partition): + return false + } + + // then by segments within the default + switch { + case tags_i.segment < tags_j.segment: + return true + case tags_i.segment > tags_j.segment: + return false + } + + // then by partitions + switch { + case tags_i.partition < tags_j.partition: + return true + case tags_i.partition > tags_j.partition: + return false + } + + // finally by name + return m[i].Name < m[j].Name +} + +func isDefault(s string) bool { + // NOTE: we can't use structs.IsDefaultPartition since that discards the input + return s == "" || s == "default" } // standardOutput is used to dump the most useful information about nodes // in a more human-friendly format func (c *cmd) standardOutput(members []*consulapi.AgentMember) []string { result := make([]string, 0, len(members)) - header := "Node\x1fAddress\x1fStatus\x1fType\x1fBuild\x1fProtocol\x1fDC\x1fSegment" + header := "Node\x1fAddress\x1fStatus\x1fType\x1fBuild\x1fProtocol\x1fDC\x1fPartition\x1fSegment" result = append(result, header) for _, member := range members { + tags := parseTags(member.Tags) + addr := net.TCPAddr{IP: net.ParseIP(member.Addr), Port: int(member.Port)} protocol := member.Tags["vsn"] build := member.Tags["build"] @@ -159,21 +206,21 @@ func (c *cmd) standardOutput(members []*consulapi.AgentMember) []string { } else if idx := strings.Index(build, ":"); idx != -1 { build = build[:idx] } - dc := member.Tags["dc"] - segment := member.Tags["segment"] statusString := serf.MemberStatus(member.Status).String() - switch member.Tags["role"] { - case "node": - line := fmt.Sprintf("%s\x1f%s\x1f%s\x1fclient\x1f%s\x1f%s\x1f%s\x1f%s", - member.Name, addr.String(), statusString, build, protocol, dc, segment) + switch tags.role { + case consulapi.MemberTagValueRoleClient: + line := fmt.Sprintf("%s\x1f%s\x1f%s\x1fclient\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s", + member.Name, addr.String(), statusString, build, protocol, tags.datacenter, tags.partition, tags.segment) result = append(result, line) - case "consul": - line := fmt.Sprintf("%s\x1f%s\x1f%s\x1fserver\x1f%s\x1f%s\x1f%s\x1f%s", - member.Name, addr.String(), statusString, build, protocol, dc, segment) + + case consulapi.MemberTagValueRoleServer: + line := fmt.Sprintf("%s\x1f%s\x1f%s\x1fserver\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s", + member.Name, addr.String(), statusString, build, protocol, tags.datacenter, tags.partition, tags.segment) result = append(result, line) + default: - line := fmt.Sprintf("%s\x1f%s\x1f%s\x1funknown\x1f\x1f\x1f\x1f", + line := fmt.Sprintf("%s\x1f%s\x1f%s\x1funknown\x1f\x1f\x1f\x1f\x1f", member.Name, addr.String(), statusString) result = append(result, line) } @@ -181,6 +228,22 @@ func (c *cmd) standardOutput(members []*consulapi.AgentMember) []string { return result } +type decodedTags struct { + role string + segment string + partition string + datacenter string +} + +func parseTags(tags map[string]string) decodedTags { + return decodedTags{ + role: tags[consulapi.MemberTagKeyRole], + segment: tags[consulapi.MemberTagKeySegment], + partition: tags[consulapi.MemberTagKeyPartition], + datacenter: tags[consulapi.MemberTagKeyDatacenter], + } +} + // detailedOutput is used to dump all known information about nodes in // their raw format func (c *cmd) detailedOutput(members []*consulapi.AgentMember) []string { diff --git a/command/members/members_test.go b/command/members/members_test.go index 694eb40a3b..7f8a6479db 100644 --- a/command/members/members_test.go +++ b/command/members/members_test.go @@ -2,12 +2,17 @@ package members import ( "fmt" + "math/rand" + "sort" "strings" "testing" "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" "github.com/hashicorp/consul/agent" + consulapi "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/lib" ) // TODO(partitions): split these tests @@ -169,3 +174,63 @@ func TestMembersCommand_verticalBar(t *testing.T) { t.Fatalf("bad: %#v", ui.OutputWriter.String()) } } + +func TestSortByMemberNamePartitionAndSegment(t *testing.T) { + lib.SeedMathRand() + + // For the test data we'll give them names that would sort them backwards + // if we only sorted by name. + newData := func() []*consulapi.AgentMember { + // NOTE: This should be sorted for assertions. + return []*consulapi.AgentMember{ + // servers + {Name: "p-betty", Tags: map[string]string{"role": "consul"}}, + {Name: "q-bob", Tags: map[string]string{"role": "consul"}}, + {Name: "r-bonnie", Tags: map[string]string{"role": "consul"}}, + // default clients + {Name: "m-betty", Tags: map[string]string{}}, + {Name: "n-bob", Tags: map[string]string{}}, + {Name: "o-bonnie", Tags: map[string]string{}}, + // segment 1 clients + {Name: "j-betty", Tags: map[string]string{"segment": "alpha"}}, + {Name: "k-bob", Tags: map[string]string{"segment": "alpha"}}, + {Name: "l-bonnie", Tags: map[string]string{"segment": "alpha"}}, + // segment 2 clients + {Name: "g-betty", Tags: map[string]string{"segment": "beta"}}, + {Name: "h-bob", Tags: map[string]string{"segment": "beta"}}, + {Name: "i-bonnie", Tags: map[string]string{"segment": "beta"}}, + // partition 1 clients + {Name: "d-betty", Tags: map[string]string{"ap": "part1"}}, + {Name: "e-bob", Tags: map[string]string{"ap": "part1"}}, + {Name: "f-bonnie", Tags: map[string]string{"ap": "part1"}}, + // partition 2 clients + {Name: "a-betty", Tags: map[string]string{"ap": "part2"}}, + {Name: "b-bob", Tags: map[string]string{"ap": "part2"}}, + {Name: "c-bonnie", Tags: map[string]string{"ap": "part2"}}, + } + } + + stringify := func(data []*consulapi.AgentMember) []string { + var out []string + for _, m := range data { + out = append(out, fmt.Sprintf("<%s, %s, %s, %s>", + m.Tags["role"], + m.Tags["ap"], + m.Tags["segment"], + m.Name)) + } + return out + } + + expect := newData() + for i := 0; i < 10; i++ { + data := newData() + rand.Shuffle(len(data), func(i, j int) { + data[i], data[j] = data[j], data[i] + }) + + sort.Sort(ByMemberNamePartitionAndSegment(data)) + + require.Equal(t, stringify(expect), stringify(data), "iteration #%d", i) + } +}