metropolis/metroctl: implement cluster configure

This is a framework for simple ClusterConfiguration changes via
metroctl. We only have one mutable field for now
(kubernetes.node_labels_to_synchronize), but more fields can be
supported later.

This could also be extended to support operations like 'add' and
'remove' for repeated fields.

Finally, there could be another CLI command that would drop you into a
prototext editor, similar to `kubectl edit xxx`. But this solves the
simplest usecase for now.

Change-Id: I2fc588a2a2249a5c4f0cf52acb162cac9ed3d9a4
Reviewed-on: https://review.monogon.dev/c/monogon/+/3595
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/cli/metroctl/cmd_cluster_configure.go b/metropolis/cli/metroctl/cmd_cluster_configure.go
new file mode 100644
index 0000000..b59246b
--- /dev/null
+++ b/metropolis/cli/metroctl/cmd_cluster_configure.go
@@ -0,0 +1,146 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"regexp"
+	"strings"
+
+	"github.com/spf13/cobra"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+
+	apb "source.monogon.dev/metropolis/proto/api"
+	cpb "source.monogon.dev/metropolis/proto/common"
+)
+
+type configurableClusterKey struct {
+	key         string
+	description string
+	set         func(value []string) (*apb.ConfigureClusterRequest, error)
+	get         func(c *cpb.ClusterConfiguration) (string, error)
+}
+
+var configurableClusterKeys = []configurableClusterKey{
+	{
+		key:         "kubernetes.node_labels_to_synchronize",
+		description: "list of label regexes to sync from Metropolis to Kubernetes nodes",
+		set: func(value []string) (*apb.ConfigureClusterRequest, error) {
+			res := &apb.ConfigureClusterRequest{
+				NewConfig: &cpb.ClusterConfiguration{
+					Kubernetes: &cpb.ClusterConfiguration_Kubernetes{},
+				},
+				UpdateMask: &fieldmaskpb.FieldMask{
+					Paths: []string{"kubernetes.node_labels_to_synchronize"},
+				},
+			}
+			for _, v := range value {
+				_, err := regexp.Compile(v)
+				if err != nil {
+					return nil, fmt.Errorf("%q is not a valid regexp: %w", v, err)
+				}
+				res.NewConfig.Kubernetes.NodeLabelsToSynchronize = append(res.NewConfig.Kubernetes.NodeLabelsToSynchronize, &cpb.ClusterConfiguration_Kubernetes_NodeLabelsToSynchronize{
+					Regexp: v,
+				})
+			}
+			return res, nil
+		},
+		get: func(c *cpb.ClusterConfiguration) (string, error) {
+			var res []string
+			if kc := c.Kubernetes; kc != nil {
+				for _, r := range kc.NodeLabelsToSynchronize {
+					res = append(res, fmt.Sprintf("%q", r.Regexp))
+				}
+			}
+			return strings.Join(res, ", "), nil
+		},
+	},
+}
+
+var clusterConfigureCommand = &cobra.Command{
+	Use:   "configure <set/get> <field>",
+	Short: "Gets/sets values in the cluster configuration structure",
+	Long: `Gets/sets values in the cluster configuration structure.
+
+The cluster is configured through a ClusterConfiguration structure, of which
+a subset of fields can be modified. To set a field's value, use:
+
+    cluster configure set <field> <value>
+
+To get a field's current value, use the:
+
+    cluster configure get <field>
+
+Available configuration fields:
+
+`,
+	Args: PrintUsageOnWrongArgs(cobra.MinimumNArgs(2)),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		mode := strings.ToLower(args[0])
+		isSet := false
+		switch mode {
+		case "set":
+			isSet = true
+		case "get":
+		default:
+			return fmt.Errorf("invalid mode %q: must be set or get", mode)
+		}
+
+		var key *configurableClusterKey
+		for _, k := range configurableClusterKeys {
+			if k.key == args[1] {
+				key = &k
+				break
+			}
+		}
+		if key == nil {
+			return fmt.Errorf("unknown field %q, see help for list of supported fields", args[1])
+		}
+
+		ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
+		cc, err := dialAuthenticated(ctx)
+		if err != nil {
+			return fmt.Errorf("while dialing node: %w", err)
+		}
+		mgmt := apb.NewManagementClient(cc)
+
+		if isSet {
+			req, err := key.set(args[2:])
+			if err != nil {
+				return err
+			}
+			res, err := mgmt.ConfigureCluster(ctx, req)
+			if err != nil {
+				return fmt.Errorf("could not mutate config: %w", err)
+			}
+			newValue, err := key.get(res.ResultingConfig)
+			if err != nil {
+				return fmt.Errorf("could not extract value from new config: %w", err)
+			}
+			log.Printf("New value: %s", newValue)
+		} else {
+			if len(args[2:]) > 0 {
+				return fmt.Errorf("get <field> takes no extra arguments")
+			}
+			ci, err := mgmt.GetClusterInfo(ctx, &apb.GetClusterInfoRequest{})
+			if err != nil {
+				return fmt.Errorf("could not get cluster information: %w", err)
+			}
+			newValue, err := key.get(ci.ClusterConfiguration)
+			if err != nil {
+				return fmt.Errorf("could not extract value from new config: %w", err)
+			}
+			log.Printf("Value: %s", newValue)
+		}
+		return nil
+	},
+}
+
+func init() {
+	for _, key := range configurableClusterKeys {
+		clusterConfigureCommand.Long += fmt.Sprintf("  - %s: %s", key.key, key.description)
+	}
+	clusterCmd.AddCommand(clusterConfigureCommand)
+}