m/c/metroctl: implement the "describe" command

This implements metroctl's "describe".

cmd.TerminateIfFound was adjusted to meet new test needs.

Change-Id: If86f35bc648d99396e7d5be48ab459d6b13334ce
Reviewed-on: https://review.monogon.dev/c/monogon/+/850
Reviewed-by: Sergiusz Bazanski <serge@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index 38ed5ae..9b4d6ec 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -5,6 +5,7 @@
     srcs = [
         "approve.go",
         "credentials.go",
+        "describe.go",
         "format.go",
         "install.go",
         "k8scredplugin.go",
@@ -30,6 +31,7 @@
         "//metropolis/node/core/identity",
         "//metropolis/node/core/rpc",
         "//metropolis/proto/api",
+        "//metropolis/proto/common",
         "@com_github_adrg_xdg//:xdg",
         "@com_github_spf13_cobra//:cobra",
         "@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
diff --git a/metropolis/cli/metroctl/describe.go b/metropolis/cli/metroctl/describe.go
new file mode 100644
index 0000000..3426b5d
--- /dev/null
+++ b/metropolis/cli/metroctl/describe.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/spf13/cobra"
+
+	"source.monogon.dev/metropolis/cli/metroctl/core"
+	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
+	"source.monogon.dev/metropolis/node/core/identity"
+	apb "source.monogon.dev/metropolis/proto/api"
+)
+
+var describeCmd = &cobra.Command{
+	Short:   "Describes cluster nodes.",
+	Use:     "describe [node-id] [--filter] [--output] [--format]",
+	Example: "metroctl node describe metropolis-c556e31c3fa2bf0a36e9ccb9fd5d6056",
+	Run:     doDescribe,
+	Args:    cobra.ArbitraryArgs,
+}
+
+func init() {
+	nodeCmd.AddCommand(describeCmd)
+}
+
+func printNodes(of func(*encoder, *apb.Node) error, nodes []*apb.Node, args []string) {
+	// Narrow down the output set to supplied node IDs, if any.
+	qids := make(map[string]bool)
+	if len(args) != 0 && args[0] != "all" {
+		for _, a := range args {
+			qids[a] = true
+		}
+	}
+
+	enc := newOutputEncoder()
+	defer enc.close()
+
+	for _, n := range nodes {
+		// Filter the information we want client-side.
+		if len(qids) != 0 {
+			nid := identity.NodeID(n.Pubkey)
+			if _, e := qids[nid]; !e {
+				continue
+			}
+		}
+
+		if err := of(enc, n); err != nil {
+			log.Fatalf("While listing nodes: %v", err)
+		}
+	}
+}
+
+func doDescribe(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	cc := dialAuthenticated(ctx)
+	mgmt := apb.NewManagementClient(cc)
+
+	nodes, err := core.GetNodes(ctx, mgmt, flags.filter)
+	if err != nil {
+		log.Fatalf("While calling Management.GetNodes: %v", err)
+	}
+
+	of := func(enc *encoder, n *apb.Node) error {
+		return enc.writeNode(n)
+	}
+	printNodes(of, nodes, args)
+}
diff --git a/metropolis/cli/metroctl/format.go b/metropolis/cli/metroctl/format.go
index aaf7984..d2c5ddf 100644
--- a/metropolis/cli/metroctl/format.go
+++ b/metropolis/cli/metroctl/format.go
@@ -8,6 +8,7 @@
 
 	"source.monogon.dev/metropolis/node/core/identity"
 	apb "source.monogon.dev/metropolis/proto/api"
+	cpb "source.monogon.dev/metropolis/proto/common"
 )
 
 type encoder struct {
@@ -20,6 +21,45 @@
 	return err
 }
 
+func (e *encoder) writeNode(n *apb.Node) error {
+	id := identity.NodeID(n.Pubkey)
+	if _, err := fmt.Fprintf(e.out, "%s", id); err != nil {
+		return err
+	}
+
+	state := cpb.NodeState_name[int32(n.State)]
+	if _, err := fmt.Fprintf(e.out, "\t%s", state); err != nil {
+		return err
+	}
+
+	addr := n.Status.ExternalAddress
+	if _, err := fmt.Fprintf(e.out, "\t%s", addr); err != nil {
+		return err
+	}
+
+	health := apb.Node_Health_name[int32(n.Health)]
+	if _, err := fmt.Fprintf(e.out, "\t%s", health); err != nil {
+		return err
+	}
+
+	var roles string
+	if n.Roles.KubernetesWorker != nil {
+		roles += "KubernetesWorker"
+	}
+	if n.Roles.ConsensusMember != nil {
+		roles += ",ConsensusMember"
+	}
+	if _, err := fmt.Fprintf(e.out, "\t%s", roles); err != nil {
+		return err
+	}
+
+	tshs := n.TimeSinceHeartbeat.GetSeconds()
+	if _, err := fmt.Fprintf(e.out, "\t%ds\n", tshs); err != nil {
+		return err
+	}
+	return nil
+}
+
 func (e *encoder) close() error {
 	if e.out != os.Stdout {
 		return e.out.Close()
diff --git a/metropolis/cli/metroctl/list.go b/metropolis/cli/metroctl/list.go
index afdf6d9..13e44cb 100644
--- a/metropolis/cli/metroctl/list.go
+++ b/metropolis/cli/metroctl/list.go
@@ -8,8 +8,7 @@
 
 	"source.monogon.dev/metropolis/cli/metroctl/core"
 	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
-	"source.monogon.dev/metropolis/node/core/identity"
-	"source.monogon.dev/metropolis/proto/api"
+	apb "source.monogon.dev/metropolis/proto/api"
 )
 
 var listCmd = &cobra.Command{
@@ -27,7 +26,7 @@
 func doList(cmd *cobra.Command, args []string) {
 	ctx := clicontext.WithInterrupt(context.Background())
 	cc := dialAuthenticated(ctx)
-	mgmt := api.NewManagementClient(cc)
+	mgmt := apb.NewManagementClient(cc)
 
 	// Narrow down the output set to supplied node IDs, if any.
 	qids := make(map[string]bool)
@@ -42,20 +41,8 @@
 		log.Fatalf("While calling Management.GetNodes: %v", err)
 	}
 
-	enc := newOutputEncoder()
-	defer enc.close()
-
-	for _, n := range nodes {
-		// Filter the information we want client-side.
-		if len(qids) != 0 {
-			nid := identity.NodeID(n.Pubkey)
-			if _, e := qids[nid]; !e {
-				continue
-			}
-		}
-
-		if err := enc.writeNodeID(n); err != nil {
-			log.Fatalf("While listing nodes: %v", err)
-		}
+	of := func(enc *encoder, n *apb.Node) error {
+		return enc.writeNodeID(n)
 	}
+	printNodes(of, nodes, args)
 }
diff --git a/metropolis/cli/metroctl/test/test.go b/metropolis/cli/metroctl/test/test.go
index 2863021..0d9b2e1 100644
--- a/metropolis/cli/metroctl/test/test.go
+++ b/metropolis/cli/metroctl/test/test.go
@@ -16,19 +16,42 @@
 	"source.monogon.dev/metropolis/test/util"
 )
 
+// resolveMetroctl resolves metroctl filesystem path. It will return a correct
+// path, or terminate test execution.
+func resolveMetroctl() string {
+	path, err := datafile.ResolveRunfile("metropolis/cli/metroctl/metroctl_/metroctl")
+	if err != nil {
+		log.Fatalf("Couldn't resolve metroctl binary: %v", err)
+	}
+	return path
+}
+
+// mctlRun starts metroctl, and waits till it exits. It returns nil, if the run
+// was successful.
+func mctlRun(t *testing.T, ctx context.Context, args []string) error {
+	t.Helper()
+
+	path := resolveMetroctl()
+	log.Printf("$ metroctl %s", strings.Join(args, " "))
+	logf := func(line string) {
+		log.Printf("metroctl: %s", line)
+	}
+	_, err := cmd.RunCommand(ctx, path, args, cmd.WaitUntilCompletion(logf))
+	return err
+}
+
 // mctlExpectOutput returns true in the event the expected string is found in
 // metroctl output, and false otherwise.
 func mctlExpectOutput(t *testing.T, ctx context.Context, args []string, expect string) (bool, error) {
 	t.Helper()
 
-	path, err := datafile.ResolveRunfile("metropolis/cli/metroctl/metroctl_/metroctl")
-	if err != nil {
-		return false, fmt.Errorf("couldn't resolve metroctl binary: %v", err)
-	}
-
+	path := resolveMetroctl()
 	log.Printf("$ metroctl %s", strings.Join(args, " "))
 	// Terminate metroctl as soon as the expected output is found.
-	found, err := cmd.RunCommand(ctx, path, args, cmd.TerminateIfFound(expect))
+	logf := func(line string) {
+		log.Printf("metroctl: %s", line)
+	}
+	found, err := cmd.RunCommand(ctx, path, args, cmd.TerminateIfFound(expect, logf))
 	if err != nil {
 		return false, fmt.Errorf("while running metroctl: %v", err)
 	}
@@ -183,4 +206,51 @@
 			return mctlFailIfFound(t, ctx, args, cl.NodeIDs[0])
 		})
 	})
+	t.Run("describe --filter", func(t *testing.T) {
+		util.TestEventual(t, "metroctl list --filter", ctx, 10*time.Second, func(ctx context.Context) error {
+			nid := cl.NodeIDs[0]
+			naddr := cl.Nodes[nid].ManagementAddress
+
+			var args []string
+			args = append(args, commonOpts...)
+			args = append(args, endpointOpts...)
+
+			// Filter out the first node. Afterwards, only one node should be left.
+			args = append(args, "node", "describe", "--output", "describe.txt", "--filter", fmt.Sprintf("node.status.external_address==\"%s\"", naddr))
+			if err := mctlRun(t, ctx, args); err != nil {
+				return err
+			}
+
+			// Try matching metroctl output against the advertised format.
+			ob, err := os.ReadFile("describe.txt")
+			if err != nil {
+				return fmt.Errorf("while reading metroctl output: %v", err)
+			}
+			var onid, ostate, onaddr, onstatus, onroles string
+			var ontimeout int
+			_, err = fmt.Sscanf(string(ob[:]), "%s\t%s\t%s\t%s\t%s\t%ds\n", &onid, &ostate, &onaddr, &onstatus, &onroles, &ontimeout)
+			if err != nil {
+				return fmt.Errorf("while parsing metroctl output: %v", err)
+			}
+			if onid != nid {
+				return fmt.Errorf("node id mismatch")
+			}
+			if ostate != "NODE_STATE_UP" {
+				return fmt.Errorf("node state mismatch")
+			}
+			if onaddr != naddr {
+				return fmt.Errorf("node address mismatch")
+			}
+			if onstatus != "HEALTHY" {
+				return fmt.Errorf("node status mismatch")
+			}
+			if onroles != "KubernetesWorker,ConsensusMember" {
+				return fmt.Errorf("node role mismatch")
+			}
+			if ontimeout < 0 || ontimeout > 30 {
+				return fmt.Errorf("node timeout mismatch")
+			}
+			return nil
+		})
+	})
 }
diff --git a/metropolis/installer/test/main.go b/metropolis/installer/test/main.go
index 6b5bbfd..5e37b7d 100644
--- a/metropolis/installer/test/main.go
+++ b/metropolis/installer/test/main.go
@@ -69,7 +69,7 @@
 		"-no-reboot",
 	}
 	qemuArgs := append(defaultArgs, args...)
-	pf := cmd.TerminateIfFound(expectedOutput)
+	pf := cmd.TerminateIfFound(expectedOutput, nil)
 	return cmd.RunCommand(ctx, "external/qemu/qemu-x86_64-softmmu", qemuArgs, pf)
 }
 
diff --git a/metropolis/pkg/cmd/run.go b/metropolis/pkg/cmd/run.go
index f92cb84..9f554b5 100644
--- a/metropolis/pkg/cmd/run.go
+++ b/metropolis/pkg/cmd/run.go
@@ -79,9 +79,25 @@
 // TerminateIfFound creates RunCommand predicates that instantly terminate
 // program execution in the event the given string is found in any line
 // produced. RunCommand will return true, if the string searched for was found,
-// and false otherwise.
-func TerminateIfFound(needle string) func(string) bool {
+// and false otherwise. If logf isn't nil, it will be called whenever a new
+// line is received.
+func TerminateIfFound(needle string, logf func(string)) func(string) bool {
 	return func(haystack string) bool {
+		if logf != nil {
+			logf(haystack)
+		}
 		return strings.Contains(haystack, needle)
 	}
 }
+
+// WaitUntilCompletion creates a RunCommand predicate that will make it wait
+// for the process to exit on its own. If logf isn't nil, it will be called
+// whenever a new line is received.
+func WaitUntilCompletion(logf func(string)) func(string) bool {
+	return func(line string) bool {
+		if logf != nil {
+			logf(line)
+		}
+		return false
+	}
+}