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
+ }
+}