m/c/metroctl: implement add, remove role commands

This adds commands accomodating node role modification.

Change-Id: I3532081fa5e7ce521c3bd90cc4ebf46fec4bf168
Reviewed-on: https://review.monogon.dev/c/monogon/+/851
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 9b4d6ec..d35ba41 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -13,6 +13,7 @@
         "main.go",
         "node.go",
         "rpc.go",
+        "set.go",
         "takeownership.go",
     ],
     data = [
diff --git a/metropolis/cli/metroctl/set.go b/metropolis/cli/metroctl/set.go
new file mode 100644
index 0000000..2d6eaf9
--- /dev/null
+++ b/metropolis/cli/metroctl/set.go
@@ -0,0 +1,118 @@
+package main
+
+import (
+	"context"
+	"log"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
+	"source.monogon.dev/metropolis/proto/api"
+)
+
+var addCmd = &cobra.Command{
+	Short: "Updates node configuration.",
+	Use:   "add",
+}
+
+var removeCmd = &cobra.Command{
+	Short: "Updates node configuration.",
+	Use:   "remove",
+}
+
+var addRoleCmd = &cobra.Command{
+	Short:   "Updates node roles.",
+	Use:     "role <KubernetesWorker|ConsensusMember> [NodeID, ...]",
+	Example: "metroctl node add role KubernetesWorker metropolis-25fa5f5e9349381d4a5e9e59de0215e3",
+	Args:    cobra.ArbitraryArgs,
+	Run:     doAdd,
+}
+
+var removeRoleCmd = &cobra.Command{
+	Short:   "Updates node roles.",
+	Use:     "role <KubernetesWorker|ConsensusMember> [NodeID, ...]",
+	Example: "metroctl node remove role KubernetesWorker metropolis-25fa5f5e9349381d4a5e9e59de0215e3",
+	Args:    cobra.ArbitraryArgs,
+	Run:     doRemove,
+}
+
+func init() {
+	addCmd.AddCommand(addRoleCmd)
+	nodeCmd.AddCommand(addCmd)
+
+	removeCmd.AddCommand(removeRoleCmd)
+	nodeCmd.AddCommand(removeCmd)
+}
+
+func doAdd(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	cc := dialAuthenticated(ctx)
+	mgmt := api.NewManagementClient(cc)
+
+	if len(args) < 2 {
+		log.Fatal("Provide the role parameter together with at least one node ID.")
+	}
+
+	role := strings.ToLower(args[0])
+	nodes := args[1:]
+
+	opt := func(v bool) *bool { return &v }
+	for _, node := range nodes {
+		req := &api.UpdateNodeRolesRequest{
+			Node: &api.UpdateNodeRolesRequest_Id{
+				Id: node,
+			},
+		}
+		switch role {
+		case "kubernetesworker", "kw":
+			req.KubernetesWorker = opt(true)
+		case "consensusmember", "cm":
+			req.ConsensusMember = opt(true)
+		default:
+			log.Fatalf("Unknown role: %s", role)
+		}
+
+		_, err := mgmt.UpdateNodeRoles(ctx, req)
+		if err != nil {
+			log.Printf("Couldn't update node \"%s\": %v", node, err)
+		}
+		log.Printf("Updated node %s. Must be one of: KubernetesWorker, ConsensusMember.", node)
+	}
+}
+
+func doRemove(cmd *cobra.Command, args []string) {
+	ctx := clicontext.WithInterrupt(context.Background())
+	cc := dialAuthenticated(ctx)
+	mgmt := api.NewManagementClient(cc)
+
+	if len(args) < 2 {
+		log.Fatal("Provide the role parameter together with at least one node ID.")
+	}
+
+	role := strings.ToLower(args[0])
+	nodes := args[1:]
+
+	opt := func(v bool) *bool { return &v }
+	for _, node := range nodes {
+		req := &api.UpdateNodeRolesRequest{
+			Node: &api.UpdateNodeRolesRequest_Id{
+				Id: node,
+			},
+		}
+		switch role {
+		case "kubernetesworker", "kw":
+			req.KubernetesWorker = opt(false)
+		case "consensusmember", "cm":
+			req.ConsensusMember = opt(false)
+		default:
+			log.Fatalf("Unknown role: %s. Must be one of: KubernetesWorker, ConsensusMember.", role)
+		}
+
+		_, err := mgmt.UpdateNodeRoles(ctx, req)
+		if err != nil {
+			log.Printf("Couldn't update node \"%s\": %v", node, err)
+		}
+		log.Printf("Updated node %s.", node)
+	}
+}
diff --git a/metropolis/cli/metroctl/test/test.go b/metropolis/cli/metroctl/test/test.go
index 0d9b2e1..e8310d7 100644
--- a/metropolis/cli/metroctl/test/test.go
+++ b/metropolis/cli/metroctl/test/test.go
@@ -253,4 +253,45 @@
 			return nil
 		})
 	})
+	t.Run("set/unset role", func(t *testing.T) {
+		util.TestEventual(t, "metroctl set/unset role KubernetesWorker", ctx, 10*time.Second, func(ctx context.Context) error {
+			nid := cl.NodeIDs[1]
+			naddr := cl.Nodes[nid].ManagementAddress
+
+			// In this test we'll unset a node role, make sure that it's been in fact
+			// unset, then set it again, and check again. This exercises commands of
+			// the form "metroctl set/unset role KubernetesWorker [NodeID, ...]".
+
+			// Check that KubernetesWorker role is set initially.
+			var describeArgs []string
+			describeArgs = append(describeArgs, commonOpts...)
+			describeArgs = append(describeArgs, endpointOpts...)
+			describeArgs = append(describeArgs, "node", "describe", "--filter", fmt.Sprintf("node.status.external_address==\"%s\"", naddr))
+			if err := mctlFailIfMissing(t, ctx, describeArgs, "KubernetesWorker"); err != nil {
+				return err
+			}
+			// Remove the role.
+			var unsetArgs []string
+			unsetArgs = append(unsetArgs, commonOpts...)
+			unsetArgs = append(unsetArgs, endpointOpts...)
+			unsetArgs = append(unsetArgs, "node", "remove", "role", "KubernetesWorker", nid)
+			if err := mctlRun(t, ctx, unsetArgs); err != nil {
+				return err
+			}
+			// Check that the role is unset.
+			if err := mctlFailIfFound(t, ctx, describeArgs, "KubernetesWorker"); err != nil {
+				return err
+			}
+			// Set the role back to the initial value.
+			var setArgs []string
+			setArgs = append(setArgs, commonOpts...)
+			setArgs = append(setArgs, endpointOpts...)
+			setArgs = append(setArgs, "node", "add", "role", "KubernetesWorker", nid)
+			if err := mctlRun(t, ctx, setArgs); err != nil {
+				return err
+			}
+			// Chack that the role is set.
+			return mctlFailIfMissing(t, ctx, describeArgs, "KubernetesWorker")
+		})
+	})
 }