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
 }