metropolis/cli/metroctl: add update command

Change-Id: Iab7f930923f8009e0e14f96fc64d336614b1251e
Reviewed-on: https://review.monogon.dev/c/monogon/+/1937
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/cli/metroctl/cmd_node.go b/metropolis/cli/metroctl/cmd_node.go
index 97198d8..b9692b6 100644
--- a/metropolis/cli/metroctl/cmd_node.go
+++ b/metropolis/cli/metroctl/cmd_node.go
@@ -2,15 +2,19 @@
 
 import (
 	"context"
+	"crypto/x509"
+	"fmt"
 	"io"
 	"log"
 	"os"
+	"strings"
 
 	"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"
 )
 
@@ -57,9 +61,97 @@
 	Args: cobra.ArbitraryArgs,
 }
 
+var nodeUpdateCmd = &cobra.Command{
+	Short:   "Updates the operating system of a cluster node.",
+	Use:     "update [NodeID] --bundle-url bundleURL [--activation-mode <none|reboot|kexec>]",
+	Example: "metroctl node update --bundle-url https://example.com/bundle.zip --activation-mode reboot metropolis-25fa5f5e9349381d4a5e9e59de0215e3",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		bundleUrl, err := cmd.Flags().GetString("bundle-url")
+		if err != nil {
+			return err
+		}
+
+		if len(bundleUrl) == 0 {
+			return fmt.Errorf("flag bundle-url is required")
+		}
+
+		activationMode, err := cmd.Flags().GetString("activation-mode")
+		if err != nil {
+			return err
+		}
+
+		var am apb.ActivationMode
+		switch strings.ToLower(activationMode) {
+		case "none":
+			am = apb.ActivationMode_ACTIVATION_NONE
+		case "reboot":
+			am = apb.ActivationMode_ACTIVATION_REBOOT
+		case "kexec":
+			am = apb.ActivationMode_ACTIVATION_KEXEC
+		default:
+			return fmt.Errorf("invalid value for flag activation-mode")
+		}
+
+		ctx := clicontext.WithInterrupt(context.Background())
+		mgmt := apb.NewManagementClient(dialAuthenticated(ctx))
+
+		// TODO(q3k): save CA certificate on takeover
+		info, err := mgmt.GetClusterInfo(ctx, &apb.GetClusterInfoRequest{})
+		if err != nil {
+			return fmt.Errorf("couldn't get cluster info: %w", err)
+		}
+		cacert, err := x509.ParseCertificate(info.CaCertificate)
+		if err != nil {
+			return fmt.Errorf("remote CA certificate invalid: %w", err)
+		}
+
+		nodes, err := core.GetNodes(ctx, mgmt, "")
+		if err != nil {
+			return fmt.Errorf("while calling Management.GetNodes: %v", err)
+		}
+		// 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
+			}
+		}
+
+		updateReq := &apb.UpdateNodeRequest{
+			BundleUrl:      bundleUrl,
+			ActivationMode: am,
+		}
+
+		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
+				}
+			}
+
+			cc := dialAuthenticatedNode(ctx, n.Id, n.Status.ExternalAddress, cacert)
+			nodeMgmt := apb.NewNodeManagementClient(cc)
+			log.Printf("sending update request to: %s (%s)", n.Id, n.Status.ExternalAddress)
+			_, err := nodeMgmt.UpdateNode(ctx, updateReq)
+			if err != nil {
+				return err
+			}
+		}
+
+		return nil
+	},
+	Args: cobra.ExactArgs(1),
+}
+
 func init() {
+	nodeUpdateCmd.Flags().String("bundle-url", "", "The URL to the new version")
+	nodeUpdateCmd.Flags().String("activation-mode", "reboot", "How the update should be activated (kexec, reboot, none)")
+
 	nodeCmd.AddCommand(nodeDescribeCmd)
 	nodeCmd.AddCommand(nodeListCmd)
+	nodeCmd.AddCommand(nodeUpdateCmd)
 	rootCmd.AddCommand(nodeCmd)
 }