metropolis/cli/metroctl: implement install ssh

This implements another way of installing metropolis via ssh. It does
this by uploading the files to the target machine and then doing a kexec
into the install environment. If it fails at any point it will print the
error and reboot.

Change-Id: I1ac6538896709c386b053a84903fa04940c1f012
Reviewed-on: https://review.monogon.dev/c/monogon/+/2079
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/cli/takeover/takeover.go b/metropolis/cli/takeover/takeover.go
new file mode 100644
index 0000000..327d3c1
--- /dev/null
+++ b/metropolis/cli/takeover/takeover.go
@@ -0,0 +1,221 @@
+package main
+
+import (
+	"archive/zip"
+	"bytes"
+	_ "embed"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+
+	"github.com/cavaliergopher/cpio"
+	"github.com/klauspost/compress/zstd"
+	"golang.org/x/sys/unix"
+	"google.golang.org/protobuf/proto"
+
+	apb "source.monogon.dev/metropolis/proto/api"
+	netapi "source.monogon.dev/osbase/net/proto"
+
+	"source.monogon.dev/osbase/bootparam"
+	"source.monogon.dev/osbase/build/mkimage/osimage"
+	"source.monogon.dev/osbase/kexec"
+	netdump "source.monogon.dev/osbase/net/dump"
+)
+
+//go:embed third_party/linux/bzImage
+var kernel []byte
+
+//go:embed third_party/ucode.cpio
+var ucode []byte
+
+//go:embed initramfs.cpio.zst
+var initramfs []byte
+
+// newMemfile creates a new file which is not located on a specific filesystem,
+// but is instead backed by anonymous memory.
+func newMemfile(name string, flags int) (*os.File, error) {
+	fd, err := unix.MemfdCreate(name, flags)
+	if err != nil {
+		return nil, fmt.Errorf("memfd_create failed: %w", err)
+	}
+	return os.NewFile(uintptr(fd), name), nil
+}
+
+func setupTakeover(nodeParamsRaw []byte, target string) ([]string, error) {
+	// Validate we are running via EFI.
+	if _, err := os.Stat("/sys/firmware/efi"); os.IsNotExist(err) {
+		//nolint:ST1005
+		return nil, fmt.Errorf("Monogon OS can only be installed on EFI-booted machines, this one is not")
+	}
+
+	currPath, err := os.Executable()
+	if err != nil {
+		return nil, err
+	}
+
+	bundleRaw, err := os.Open(filepath.Join(filepath.Dir(currPath), "bundle.zip"))
+	if err != nil {
+		return nil, err
+	}
+
+	bundleStat, err := bundleRaw.Stat()
+	if err != nil {
+		return nil, err
+	}
+
+	bundle, err := zip.NewReader(bundleRaw, bundleStat.Size())
+	if err != nil {
+		return nil, fmt.Errorf("failed to open node bundle: %w", err)
+	}
+
+	// Dump the current network configuration
+	netconf, warnings, err := netdump.Dump()
+	if err != nil {
+		return nil, fmt.Errorf("failed to dump network configuration: %w", err)
+	}
+
+	if len(netconf.Nameserver) == 0 {
+		netconf.Nameserver = []*netapi.Nameserver{{
+			Ip: "8.8.8.8",
+		}, {
+			Ip: "1.1.1.1",
+		}}
+	}
+
+	var params apb.NodeParameters
+	if err := proto.Unmarshal(nodeParamsRaw, &params); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal node parameters: %w", err)
+	}
+
+	// Override the NodeParameters.NetworkConfig with the current NetworkConfig
+	// if it's missing.
+	if params.NetworkConfig == nil {
+		params.NetworkConfig = netconf
+	}
+
+	// Marshal NodeParameters again.
+	nodeParamsRaw, err = proto.Marshal(&params)
+	if err != nil {
+		return nil, fmt.Errorf("failed marshaling: %w", err)
+	}
+
+	oParams, err := setupOSImageParams(bundle, nodeParamsRaw, target)
+	if err != nil {
+		return nil, err
+	}
+
+	// Validate that this installation will not fail because of disk issues
+	if _, err := osimage.Plan(oParams); err != nil {
+		return nil, fmt.Errorf("failed to plan installation: %w", err)
+	}
+
+	// Load data from embedded files into memfiles as the kexec load syscall
+	// requires file descriptors.
+	kernelFile, err := newMemfile("kernel", 0)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create kernel memfile: %w", err)
+	}
+	initramfsFile, err := newMemfile("initramfs", 0)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create initramfs memfile: %w", err)
+	}
+	if _, err := kernelFile.ReadFrom(bytes.NewReader(kernel)); err != nil {
+		return nil, fmt.Errorf("failed to read kernel into memory-backed file: %w", err)
+	}
+	if _, err := initramfsFile.ReadFrom(bytes.NewReader(ucode)); err != nil {
+		return nil, fmt.Errorf("failed to read ucode into memory-backed file: %w", err)
+	}
+	if _, err := initramfsFile.ReadFrom(bytes.NewReader(initramfs)); err != nil {
+		return nil, fmt.Errorf("failed to read initramfs into memory-backed file: %w", err)
+	}
+
+	// Append this executable, the bundle and node params to initramfs
+	compressedW, err := zstd.NewWriter(initramfsFile, zstd.WithEncoderLevel(1))
+	if err != nil {
+		return nil, fmt.Errorf("while creating zstd writer: %w", err)
+	}
+	{
+		self, err := os.Open("/proc/self/exe")
+		if err != nil {
+			return nil, err
+		}
+		selfStat, err := self.Stat()
+		if err != nil {
+			return nil, err
+		}
+
+		cpioW := cpio.NewWriter(compressedW)
+		cpioW.WriteHeader(&cpio.Header{
+			Name: "/init",
+			Size: selfStat.Size(),
+			Mode: cpio.TypeReg | 0o755,
+		})
+		io.Copy(cpioW, self)
+		cpioW.Close()
+	}
+	{
+		cpioW := cpio.NewWriter(compressedW)
+		cpioW.WriteHeader(&cpio.Header{
+			Name: "/bundle.zip",
+			Size: bundleStat.Size(),
+			Mode: cpio.TypeReg | 0o644,
+		})
+		bundleRaw.Seek(0, io.SeekStart)
+		io.Copy(cpioW, bundleRaw)
+		cpioW.Close()
+	}
+	{
+		cpioW := cpio.NewWriter(compressedW)
+		cpioW.WriteHeader(&cpio.Header{
+			Name: "/params.pb",
+			Size: int64(len(nodeParamsRaw)),
+			Mode: cpio.TypeReg | 0o644,
+		})
+		cpioW.Write(nodeParamsRaw)
+		cpioW.Close()
+	}
+	compressedW.Close()
+
+	initParams := bootparam.Params{
+		bootparam.Param{Param: "quiet"},
+		bootparam.Param{Param: launchModeEnv, Value: launchModeInit},
+		bootparam.Param{Param: EnvInstallTarget, Value: target},
+		bootparam.Param{Param: "init", Value: "/init"},
+	}
+
+	var customConsoles bool
+	cmdline, err := os.ReadFile("/proc/cmdline")
+	if err != nil {
+		warnings = append(warnings, fmt.Errorf("unable to read current kernel command line: %w", err))
+	} else {
+		params, _, err := bootparam.Unmarshal(string(cmdline))
+		// If the existing command line is well-formed, add all existing console
+		// parameters to the console for the agent
+		if err == nil {
+			for _, p := range params {
+				if p.Param == "console" {
+					initParams = append(initParams, p)
+					customConsoles = true
+				}
+			}
+		}
+	}
+	if !customConsoles {
+		// Add the "default" console on x86
+		initParams = append(initParams, bootparam.Param{Param: "console", Value: "ttyS0,115200"})
+	}
+	agentCmdline, err := bootparam.Marshal(initParams, "")
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal bootparams: %w", err)
+	}
+	// Stage agent payload into kernel memory
+	if err := kexec.FileLoad(kernelFile, initramfsFile, agentCmdline); err != nil {
+		return nil, fmt.Errorf("failed to load kexec payload: %w", err)
+	}
+	var warningsStrs []string
+	for _, w := range warnings {
+		warningsStrs = append(warningsStrs, w.Error())
+	}
+	return warningsStrs, nil
+}