go/clitable: factor out from metroctl
We need the same functionality in bmcli, so factor it out from metroctl
into a generic library.
Change-Id: I3fb3dfaae44a64d204e9220f117f379c382c5c4f
Reviewed-on: https://review.monogon.dev/c/monogon/+/2172
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/go/clitable/BUILD.bazel b/go/clitable/BUILD.bazel
new file mode 100644
index 0000000..539328d
--- /dev/null
+++ b/go/clitable/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "clitable",
+ srcs = ["table.go"],
+ importpath = "source.monogon.dev/go/clitable",
+ visibility = ["//visibility:public"],
+)
+
+go_test(
+ name = "clitable_test",
+ srcs = ["table_test.go"],
+ embed = [":clitable"],
+)
diff --git a/go/clitable/table.go b/go/clitable/table.go
new file mode 100644
index 0000000..ce1f7fa
--- /dev/null
+++ b/go/clitable/table.go
@@ -0,0 +1,140 @@
+// Package clitable implements tabular display for command line tools.
+//
+// The generated tables are vaguely reminiscent of the output of 'kubectl'. For
+// example:
+//
+// NAME ADDRESS STATUS
+// foo 1.2.3.4 Healthy
+// bar 1.2.3.12 Timeout
+//
+// The tables are sparse by design, with each Entry having a number of Key/Value
+// entries. Then, all keys for all entries get unified into columns, and entries
+// without a given key are rendered spare (i.e. the rendered cell is empty).
+package clitable
+
+import (
+ "fmt"
+ "io"
+ "strings"
+)
+
+// Table is a list of entries that form a table with sparse columns. Each entry
+// defines its own columns.
+type Table struct {
+ entries []Entry
+}
+
+// Add an entry to the table.
+func (t *Table) Add(e Entry) {
+ t.entries = append(t.entries, e)
+}
+
+// An Entry is made up of column key -> value pairs.
+type Entry struct {
+ columns []entryColumn
+}
+
+// Add a key/value pair to the entry.
+func (e *Entry) Add(key, value string) {
+ e.columns = append(e.columns, entryColumn{
+ key: key,
+ value: value,
+ })
+}
+
+// Get a value from a given key, returning zero string if not set.
+func (e *Entry) Get(key string) string {
+ for _, col := range e.columns {
+ if col.key == key {
+ return col.value
+ }
+ }
+ return ""
+}
+
+// An entryColumn is a pair for table column key and entry column value.
+type entryColumn struct {
+ key string
+ value string
+}
+
+// Columns returns the keys and widths of columns that are present in the table's
+// entries.
+func (t *Table) Columns() Columns {
+ var res Columns
+ for _, e := range t.entries {
+ for _, c := range e.columns {
+ tc := res.upsert(c.key)
+ if len(c.value) > tc.width {
+ tc.width = len(c.value)
+ }
+ }
+ }
+ return res
+}
+
+type Columns []*Column
+
+// A Column in a table, not containing all entries that make up this table, but
+// containing their maximum width (for layout purposes).
+type Column struct {
+ // key is the column key.
+ key string
+ // width is the maximum width (in runes) of all the entries' data in this column.
+ width int
+}
+
+// upsert a key into a list of columns, returning the upserted column.
+func (c *Columns) upsert(key string) *Column {
+ for _, col := range *c {
+ if col.key == key {
+ return col
+ }
+ }
+ col := &Column{
+ key: key,
+ width: len(key),
+ }
+ *c = append(*c, col)
+ return col
+}
+
+// filter returns a copy of columns where the only columns present are the ones
+// whose onlyColumns values are true. If only columns is nil, no filtering takes
+// place (all columns are returned).
+func (c Columns) filter(onlyColumns map[string]bool) Columns {
+ var res []*Column
+ for _, cc := range c {
+ if onlyColumns != nil && !onlyColumns[cc.key] {
+ continue
+ }
+ res = append(res, cc)
+ }
+ return res
+}
+
+// PrintHeader writes a table-like header to the given file, keeping margin
+// spaces between columns.
+func (c Columns) printHeader(f io.Writer, margin int) {
+ for _, cc := range c {
+ fmt.Fprintf(f, "%-*s", cc.width+margin, strings.ToUpper(cc.key))
+ }
+ fmt.Fprintf(f, "\n")
+}
+
+// Print writes a table-like representation of this table to the given file,
+// first filtering the columns by onlyColumns (if not set, no filtering takes
+// place).
+func (t *Table) Print(f io.Writer, onlyColumns map[string]bool) {
+ margin := 3
+ cols := t.Columns().filter(onlyColumns)
+ cols.printHeader(f, margin)
+
+ for _, e := range t.entries {
+ for _, c := range cols {
+ v := e.Get(c.key)
+ fmt.Fprintf(f, "%-*s", c.width+margin, v)
+ }
+ fmt.Fprintf(f, "\n")
+ }
+}
diff --git a/metropolis/cli/metroctl/table_test.go b/go/clitable/table_test.go
similarity index 64%
rename from metropolis/cli/metroctl/table_test.go
rename to go/clitable/table_test.go
index 0ea1448..5fca84c 100644
--- a/metropolis/cli/metroctl/table_test.go
+++ b/go/clitable/table_test.go
@@ -1,4 +1,4 @@
-package main
+package clitable
import (
"bytes"
@@ -8,26 +8,26 @@
// TestTableLayout performs a smoke test of the table layout functionality.
func TestTableLayout(t *testing.T) {
- tab := table{}
+ tab := Table{}
- e := entry{}
- e.add("id", "short")
- e.add("labels", "")
- tab.add(e)
+ e := Entry{}
+ e.Add("id", "short")
+ e.Add("labels", "")
+ tab.Add(e)
- e = entry{}
- e.add("whoops", "only in second")
- e.add("labels", "bar")
- e.add("id", "this one is a very long one")
- tab.add(e)
+ e = Entry{}
+ e.Add("whoops", "only in second")
+ e.Add("labels", "bar")
+ e.Add("id", "this one is a very long one")
+ tab.Add(e)
- e = entry{}
- e.add("id", "normal length")
- e.add("labels", "foo")
- tab.add(e)
+ e = Entry{}
+ e.Add("id", "normal length")
+ e.Add("labels", "foo")
+ tab.Add(e)
buf := bytes.NewBuffer(nil)
- tab.print(buf, nil)
+ tab.Print(buf, nil)
golden := `
ID LABELS WHOOPS
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index 111c6e6..e3044d5 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -1,4 +1,4 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
load(":defs.bzl", "buildkind")
buildkind(
@@ -28,7 +28,6 @@
"cmd_takeownership.go",
"main.go",
"rpc.go",
- "table.go",
"table_node.go",
],
data = select({
@@ -41,6 +40,7 @@
importpath = "source.monogon.dev/metropolis/cli/metroctl",
visibility = ["//visibility:private"],
deps = [
+ "//go/clitable",
"//metropolis/cli/metroctl/core",
"//metropolis/cli/pkg/context",
"//metropolis/cli/pkg/datafile",
@@ -64,9 +64,3 @@
embed = [":metroctl_lib"],
visibility = ["//visibility:public"],
)
-
-go_test(
- name = "metroctl_test",
- srcs = ["table_test.go"],
- embed = [":metroctl_lib"],
-)
diff --git a/metropolis/cli/metroctl/cmd_node.go b/metropolis/cli/metroctl/cmd_node.go
index b9692b6..1e4afa5 100644
--- a/metropolis/cli/metroctl/cmd_node.go
+++ b/metropolis/cli/metroctl/cmd_node.go
@@ -11,6 +11,7 @@
"github.com/spf13/cobra"
+ "source.monogon.dev/go/clitable"
"source.monogon.dev/metropolis/cli/metroctl/core"
clicontext "source.monogon.dev/metropolis/cli/pkg/context"
"source.monogon.dev/metropolis/node/core/identity"
@@ -173,7 +174,7 @@
}
}
- var t table
+ var t clitable.Table
for _, n := range nodes {
// Filter the information we want client-side.
if len(qids) != 0 {
@@ -182,8 +183,8 @@
continue
}
}
- t.add(nodeEntry(n))
+ t.Add(nodeEntry(n))
}
- t.print(o, onlyColumns)
+ t.Print(o, onlyColumns)
}
diff --git a/metropolis/cli/metroctl/table.go b/metropolis/cli/metroctl/table.go
deleted file mode 100644
index 1d947d9..0000000
--- a/metropolis/cli/metroctl/table.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package main
-
-import (
- "fmt"
- "io"
- "strings"
-)
-
-// table is a list of entries that form a table with sparse columns. Each entry
-// defines its own columns.
-type table struct {
- entries []entry
-}
-
-// add an entry to the table.
-func (t *table) add(e entry) {
- t.entries = append(t.entries, e)
-}
-
-// An entry is made up of column key -> value pairs.
-type entry struct {
- columns []entryColumn
-}
-
-// add a key/value pair to the entry.
-func (e *entry) add(key, value string) {
- e.columns = append(e.columns, entryColumn{
- key: key,
- value: value,
- })
-}
-
-// get a value from a given key, returning zero string if not set.
-func (e *entry) get(key string) string {
- for _, col := range e.columns {
- if col.key == key {
- return col.value
- }
- }
- return ""
-}
-
-// An entryColumn is a pair for table column key and entry column value.
-type entryColumn struct {
- key string
- value string
-}
-
-// columns returns the keys and widths of columns that are present in the table's
-// entries.
-func (t *table) columns() columns {
- var res columns
- for _, e := range t.entries {
- for _, c := range e.columns {
- tc := res.upsert(c.key)
- if len(c.value) > tc.width {
- tc.width = len(c.value)
- }
- }
- }
- return res
-}
-
-type columns []*column
-
-// A column in a table, not containing all entries that make up this table, but
-// containing their maximum width (for layout purposes).
-type column struct {
- // key is the column key.
- key string
- // width is the maximum width (in runes) of all the entries' data in this column.
- width int
-}
-
-// upsert a key into a list of columns, returning the upserted column.
-func (c *columns) upsert(key string) *column {
- for _, col := range *c {
- if col.key == key {
- return col
- }
- }
- col := &column{
- key: key,
- width: len(key),
- }
- *c = append(*c, col)
- return col
-}
-
-// filter returns a copy of columns where the only columns present are the ones
-// whose onlyColumns values are true. If only columns is nil, no filtering takes
-// place (all columns are returned).
-func (c columns) filter(onlyColumns map[string]bool) columns {
- var res []*column
- for _, cc := range c {
- if onlyColumns != nil && !onlyColumns[cc.key] {
- continue
- }
- res = append(res, cc)
- }
- return res
-}
-
-// printHeader writes a table-like header to the given file, keeping margin
-// spaces between columns.
-func (c columns) printHeader(f io.Writer, margin int) {
- for _, cc := range c {
- fmt.Fprintf(f, "%-*s", cc.width+margin, strings.ToUpper(cc.key))
- }
- fmt.Fprintf(f, "\n")
-}
-
-// print writes a table-like representation of this table to the given file,
-// first filtering the columns by onlyColumns (if not set, no filtering takes
-// place).
-func (t *table) print(f io.Writer, onlyColumns map[string]bool) {
- margin := 3
- cols := t.columns().filter(onlyColumns)
- cols.printHeader(f, margin)
-
- for _, e := range t.entries {
- for _, c := range cols {
- v := e.get(c.key)
- fmt.Fprintf(f, "%-*s", c.width+margin, v)
- }
- fmt.Fprintf(f, "\n")
- }
-}
diff --git a/metropolis/cli/metroctl/table_node.go b/metropolis/cli/metroctl/table_node.go
index c708443..07f994c 100644
--- a/metropolis/cli/metroctl/table_node.go
+++ b/metropolis/cli/metroctl/table_node.go
@@ -5,24 +5,25 @@
"sort"
"strings"
+ "source.monogon.dev/go/clitable"
"source.monogon.dev/metropolis/node/core/identity"
apb "source.monogon.dev/metropolis/proto/api"
cpb "source.monogon.dev/metropolis/proto/common"
)
-func nodeEntry(n *apb.Node) entry {
- res := entry{}
+func nodeEntry(n *apb.Node) clitable.Entry {
+ res := clitable.Entry{}
- res.add("node id", identity.NodeID(n.Pubkey))
+ res.Add("node id", identity.NodeID(n.Pubkey))
state := n.State.String()
state = strings.ReplaceAll(state, "NODE_STATE_", "")
- res.add("state", state)
+ res.Add("state", state)
address := "unknown"
if n.Status != nil && n.Status.ExternalAddress != "" {
address = n.Status.ExternalAddress
}
- res.add("address", address)
- res.add("health", n.Health.String())
+ res.Add("address", address)
+ res.Add("health", n.Health.String())
var roles []string
if n.Roles.ConsensusMember != nil {
@@ -35,7 +36,7 @@
roles = append(roles, "KubernetesWorker")
}
sort.Strings(roles)
- res.add("roles", strings.Join(roles, ","))
+ res.Add("roles", strings.Join(roles, ","))
tpm := "unk"
switch n.TpmUsage {
@@ -46,10 +47,10 @@
case cpb.NodeTPMUsage_NODE_TPM_NOT_PRESENT:
tpm = "no"
}
- res.add("tpm", tpm)
+ res.Add("tpm", tpm)
tshs := n.TimeSinceHeartbeat.GetSeconds()
- res.add("heartbeat", fmt.Sprintf("%ds", tshs))
+ res.Add("heartbeat", fmt.Sprintf("%ds", tshs))
return res
}