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

list prints node IDs of existing nodes.

If no positional arguments are passed, or the only positional argument
equals "all", all existing nodes will be processed.

Otherwise, if any positional arguments are used, the output set will
be limited to node IDs matching the positional arguments. Nonexistent
nodes will not be listed.

The node set can be further narrowed with an optional CEL node filter
expression, set with the new --filter flag.

The output format can be adjusted using the --format (-f) flag.
Currently, only plaintext output is available.

The output will be saved to a file, if a path is specified with the
--output (-o) flag.

Change-Id: I44d57ad52805924673354c70e54cd299a88ad75f
Reviewed-on: https://review.monogon.dev/c/monogon/+/848
Tested-by: Jenkins CI
Reviewed-by: Sergiusz Bazanski <serge@monogon.tech>
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index d6a0e47..38ed5ae 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -5,9 +5,12 @@
     srcs = [
         "approve.go",
         "credentials.go",
+        "format.go",
         "install.go",
         "k8scredplugin.go",
+        "list.go",
         "main.go",
+        "node.go",
         "rpc.go",
         "takeownership.go",
     ],
diff --git a/metropolis/cli/metroctl/approve.go b/metropolis/cli/metroctl/approve.go
index 892a756..f7aa049 100644
--- a/metropolis/cli/metroctl/approve.go
+++ b/metropolis/cli/metroctl/approve.go
@@ -35,13 +35,13 @@
 }
 
 func doApprove(cmd *cobra.Command, args []string) {
-	cc := dialAuthenticated()
+	ctx := clicontext.WithInterrupt(context.Background())
+	cc := dialAuthenticated(ctx)
 	mgmt := api.NewManagementClient(cc)
 
 	// Get a list of all nodes pending approval by calling Management.GetNodes.
 	// We need this list regardless of whether we're actually approving nodes, or
 	// just listing them.
-	ctx := clicontext.WithInterrupt(context.Background())
 	nodes, err := core.GetNodes(ctx, mgmt, "node.state == NODE_STATE_NEW")
 	if err != nil {
 		log.Fatalf("While fetching a list of nodes pending approval: %v", err)
diff --git a/metropolis/cli/metroctl/format.go b/metropolis/cli/metroctl/format.go
new file mode 100644
index 0000000..aaf7984
--- /dev/null
+++ b/metropolis/cli/metroctl/format.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+
+	"source.monogon.dev/metropolis/node/core/identity"
+	apb "source.monogon.dev/metropolis/proto/api"
+)
+
+type encoder struct {
+	out io.WriteCloser
+}
+
+func (e *encoder) writeNodeID(n *apb.Node) error {
+	id := identity.NodeID(n.Pubkey)
+	_, err := fmt.Fprintf(e.out, "%s\n", id)
+	return err
+}
+
+func (e *encoder) close() error {
+	if e.out != os.Stdout {
+		return e.out.Close()
+	}
+	return nil
+}
+
+func newOutputEncoder() *encoder {
+	var o io.WriteCloser
+	o = os.Stdout
+
+	// Redirect output to the file at flags.output, if the flag was provided.
+	if flags.output != "" {
+		of, err := os.Create(flags.output)
+		if err != nil {
+			log.Fatalf("Couldn't create the output file at %s: %v", flags.output, err)
+		}
+		o = of
+	}
+
+	if flags.format != "plaintext" {
+		log.Fatalf("Currently only the plaintext output format is supported.")
+	}
+	return &encoder{
+		out: o,
+	}
+}
diff --git a/metropolis/cli/metroctl/install.go b/metropolis/cli/metroctl/install.go
index 07f5d3e..07faad4 100644
--- a/metropolis/cli/metroctl/install.go
+++ b/metropolis/cli/metroctl/install.go
@@ -118,7 +118,7 @@
 			},
 		}
 	} else {
-		cc := dialAuthenticated()
+		cc := dialAuthenticated(ctx)
 		mgmt := api.NewManagementClient(cc)
 		resT, err := mgmt.GetRegisterTicket(ctx, &api.GetRegisterTicketRequest{})
 		if err != nil {
diff --git a/metropolis/cli/metroctl/list.go b/metropolis/cli/metroctl/list.go
new file mode 100644
index 0000000..afdf6d9
--- /dev/null
+++ b/metropolis/cli/metroctl/list.go
@@ -0,0 +1,61 @@
+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"
+	"source.monogon.dev/metropolis/proto/api"
+)
+
+var listCmd = &cobra.Command{
+	Short:   "Lists cluster nodes.",
+	Use:     "list [node-id] [--filter] [--output] [--format]",
+	Example: "metroctl node list --filter node.status.external_address==\"10.8.0.2\"",
+	Run:     doList,
+	Args:    cobra.ArbitraryArgs,
+}
+
+func init() {
+	nodeCmd.AddCommand(listCmd)
+}
+
+func doList(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	cc := dialAuthenticated(ctx)
+	mgmt := api.NewManagementClient(cc)
+
+	// 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
+		}
+	}
+
+	nodes, err := core.GetNodes(ctx, mgmt, flags.filter)
+	if err != nil {
+		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)
+		}
+	}
+}
diff --git a/metropolis/cli/metroctl/main.go b/metropolis/cli/metroctl/main.go
index 949eb63..780b8dd 100644
--- a/metropolis/cli/metroctl/main.go
+++ b/metropolis/cli/metroctl/main.go
@@ -25,6 +25,14 @@
 	// verbose, if set, will make this utility log additional runtime
 	// information.
 	verbose bool
+	// format refers to how the output data, except logs, is formatted.
+	format string
+	// filter specifies a CEL filter used to narrow down the set of output
+	// objects.
+	filter string
+	// output is an optional output file path the resulting data will be saved
+	// at. If unspecified, the data will be written to stdout.
+	output string
 }
 
 var flags metroctlFlags
@@ -34,6 +42,9 @@
 	rootCmd.PersistentFlags().StringVar(&flags.proxyAddr, "proxy", "", "SOCKS5 proxy address")
 	rootCmd.PersistentFlags().StringVar(&flags.configPath, "config", filepath.Join(xdg.ConfigHome, "metroctl"), "An alternative cluster config path")
 	rootCmd.PersistentFlags().BoolVar(&flags.verbose, "verbose", false, "Log additional runtime information")
+	rootCmd.PersistentFlags().StringVarP(&flags.format, "format", "f", "plaintext", "Data output format")
+	rootCmd.PersistentFlags().StringVar(&flags.filter, "filter", "", "The object filter applied to the output data")
+	rootCmd.PersistentFlags().StringVarP(&flags.output, "output", "o", "", "Redirects output to the specified file")
 }
 
 // rpcLogger passes through the cluster resolver logs, if "--verbose" flag was
diff --git a/metropolis/cli/metroctl/node.go b/metropolis/cli/metroctl/node.go
new file mode 100644
index 0000000..c84013f
--- /dev/null
+++ b/metropolis/cli/metroctl/node.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var nodeCmd = &cobra.Command{
+	Short:   "Updates and queries node information.",
+	Use:     "node",
+}
+
+func init() {
+	rootCmd.AddCommand(nodeCmd)
+}
diff --git a/metropolis/cli/metroctl/rpc.go b/metropolis/cli/metroctl/rpc.go
index a050e9b..d1719ee 100644
--- a/metropolis/cli/metroctl/rpc.go
+++ b/metropolis/cli/metroctl/rpc.go
@@ -7,22 +7,19 @@
 	"google.golang.org/grpc"
 
 	"source.monogon.dev/metropolis/cli/metroctl/core"
-	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
 )
 
-func dialAuthenticated() *grpc.ClientConn {
-	if len(flags.clusterEndpoints) == 0 {
-		log.Fatal("Please provide at least one cluster endpoint using the --endpoint parameter.")
-	}
 
+func dialAuthenticated(ctx context.Context) *grpc.ClientConn {
 	// Collect credentials, validate command parameters, and try dialing the
 	// cluster.
 	ocert, opkey, err := getCredentials()
 	if err == noCredentialsError {
 		log.Fatalf("You have to take ownership of the cluster first: %v", err)
 	}
-
-	ctx := clicontext.WithInterrupt(context.Background())
+	if len(flags.clusterEndpoints) == 0 {
+		log.Fatal("Please provide at least one cluster endpoint using the --endpoint parameter.")
+	}
 	cc, err := core.DialCluster(ctx, opkey, ocert, flags.proxyAddr, flags.clusterEndpoints, rpcLogger)
 	if err != nil {
 		log.Fatalf("While dialing the cluster: %v", err)
diff --git a/metropolis/cli/metroctl/test/test.go b/metropolis/cli/metroctl/test/test.go
index 4f572a1..2863021 100644
--- a/metropolis/cli/metroctl/test/test.go
+++ b/metropolis/cli/metroctl/test/test.go
@@ -16,22 +16,49 @@
 	"source.monogon.dev/metropolis/test/util"
 )
 
-func expectMetroctl(t *testing.T, ctx context.Context, args []string, expect string) error {
+// 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 fmt.Errorf("couldn't resolve metroctl binary: %v", err)
+		return false, fmt.Errorf("couldn't resolve metroctl binary: %v", err)
 	}
 
 	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))
 	if err != nil {
-		return fmt.Errorf("while running metroctl: %v", err)
+		return false, fmt.Errorf("while running metroctl: %v", err)
+	}
+	return found, nil
+}
+
+// mctlFailIfMissing will return a non-nil error value either if the expected
+// output string s is missing in metroctl output, or in case metroctl can't be
+// launched.
+func mctlFailIfMissing(t *testing.T, ctx context.Context, args []string, s string) error {
+	found, err := mctlExpectOutput(t, ctx, args, s)
+	if err != nil {
+		return err
 	}
 	if !found {
-		return fmt.Errorf("expected string wasn't found while running metroctl.")
+		return fmt.Errorf("expected output is missing: \"%s\"", s)
+	}
+	return nil
+}
+
+// mctlFailIfFound will return a non-nil error value either if the expected
+// output string s is found in metroctl output, or in case metroctl can't be
+// launched.
+func mctlFailIfFound(t *testing.T, ctx context.Context, args []string, s string) error {
+	found, err := mctlExpectOutput(t, ctx, args, s)
+	if err != nil {
+		return err
+	}
+	if found {
+		return fmt.Errorf("unexpected output was found: \"%s\"", s)
 	}
 	return nil
 }
@@ -86,10 +113,74 @@
 			args = append(args, commonOpts...)
 			args = append(args, endpointOpts[0])
 			args = append(args, "takeownership")
-			return expectMetroctl(t, ctx, args, "Successfully retrieved owner credentials")
+			return mctlFailIfMissing(t, ctx, args, "Successfully retrieved owner credentials")
 		})
 	})
 	if !st {
 		t.Fatalf("metroctl: Couldn't get cluster ownership.")
 	}
+	t.Run("list", func(t *testing.T) {
+		util.TestEventual(t, "metroctl list", ctx, 10*time.Second, func(ctx context.Context) error {
+			var args []string
+			args = append(args, commonOpts...)
+			args = append(args, endpointOpts...)
+			args = append(args, "node", "list")
+			// Expect both node IDs to show up in the results.
+			if err := mctlFailIfMissing(t, ctx, args, cl.NodeIDs[0]); err != nil {
+				return err
+			}
+			return mctlFailIfMissing(t, ctx, args, cl.NodeIDs[1])
+		})
+	})
+	t.Run("list [nodeID]", func(t *testing.T) {
+		util.TestEventual(t, "metroctl list [nodeID]", ctx, 10*time.Second, func(ctx context.Context) error {
+			var args []string
+			args = append(args, commonOpts...)
+			args = append(args, endpointOpts...)
+			args = append(args, "node", "list", cl.NodeIDs[1])
+			// Expect just the supplied node IDs to show up in the results.
+			if err := mctlFailIfFound(t, ctx, args, cl.NodeIDs[0]); err != nil {
+				return err
+			}
+			return mctlFailIfMissing(t, ctx, args, cl.NodeIDs[1])
+		})
+	})
+	t.Run("list --output", func(t *testing.T) {
+		util.TestEventual(t, "metroctl list --output", ctx, 10*time.Second, func(ctx context.Context) error {
+			var args []string
+			args = append(args, commonOpts...)
+			args = append(args, endpointOpts...)
+			args = append(args, "node", "list", "--output", "list.txt")
+			// In this case metroctl should write its output to a file rather than stdout.
+			if err := mctlFailIfFound(t, ctx, args, cl.NodeIDs[0]); err != nil {
+				return err
+			}
+			od, err := os.ReadFile("list.txt")
+			if err != nil {
+				return fmt.Errorf("while reading metroctl output file: %v", err)
+			}
+			if !strings.Contains(string(od), cl.NodeIDs[0]) {
+				return fmt.Errorf("expected node ID hasn't been found in metroctl output")
+			}
+			return nil
+		})
+	})
+	t.Run("list --filter", func(t *testing.T) {
+		util.TestEventual(t, "metroctl list --filter", ctx, 10*time.Second, func(ctx context.Context) error {
+			nid := cl.NodeIDs[1]
+			naddr := cl.Nodes[nid].ManagementAddress
+
+			var args []string
+			args = append(args, commonOpts...)
+			args = append(args, endpointOpts...)
+			// Filter list results based on nodes' external addresses.
+			args = append(args, "node", "list", "--filter", fmt.Sprintf("node.status.external_address==\"%s\"", naddr))
+			// Expect the second node's ID to show up in the results.
+			if err := mctlFailIfMissing(t, ctx, args, cl.NodeIDs[1]); err != nil {
+				return err
+			}
+			// The first node should've been filtered away.
+			return mctlFailIfFound(t, ctx, args, cl.NodeIDs[0])
+		})
+	})
 }