m/c/metroctl: add MVP disk/image-only installer

This adds a very minimalist metroctl install command.
Only supports boostrapping and a single owner key.
But good enough to set up a node.

Change-Id: I9306a054b9540a3a0c70621f3f5d9cb34fc18d14
Reviewed-on: https://review.monogon.dev/c/monogon/+/417
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/build/fietsje/deps_monogon.go b/build/fietsje/deps_monogon.go
index 6d1ddda..54e7d94 100644
--- a/build/fietsje/deps_monogon.go
+++ b/build/fietsje/deps_monogon.go
@@ -121,6 +121,9 @@
 		"github.com/muesli/reflow",
 	)
 
+	// Used by metroctl to resolve XDG directories
+	p.collect("github.com/adrg/xdg", "v0.4.0")
+
 	// First generate the repositories starlark rule into memory. This is because
 	// rendering will lock all unlocked dependencies, which might take a while. If a
 	// use were to interrupt it now, they would end up with an incomplete
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index 154fba1..fbe2fea 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -2,10 +2,18 @@
 
 go_library(
     name = "go_default_library",
-    srcs = ["main.go"],
+    srcs = [
+        "install.go",
+        "main.go",
+    ],
     importpath = "source.monogon.dev/metropolis/cli/metroctl",
     visibility = ["//visibility:private"],
-    deps = ["@com_github_spf13_cobra//:go_default_library"],
+    deps = [
+        "//metropolis/cli/metroctl/core:go_default_library",
+        "//metropolis/proto/api:go_default_library",
+        "@com_github_adrg_xdg//:go_default_library",
+        "@com_github_spf13_cobra//:go_default_library",
+    ],
 )
 
 go_binary(
diff --git a/metropolis/cli/metroctl/install.go b/metropolis/cli/metroctl/install.go
new file mode 100644
index 0000000..ab31610
--- /dev/null
+++ b/metropolis/cli/metroctl/install.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/pem"
+	"io/ioutil"
+	"log"
+	"os"
+	"path/filepath"
+
+	"github.com/adrg/xdg"
+	"github.com/spf13/cobra"
+
+	"source.monogon.dev/metropolis/cli/metroctl/core"
+	"source.monogon.dev/metropolis/proto/api"
+)
+
+var installCmd = &cobra.Command{
+	Short: "Contains subcommands to install Metropolis over different mediums.",
+	Use:   "install",
+}
+
+// install flags
+var installer *string
+var bundle *string
+
+var genusbCmd = &cobra.Command{
+	Use:     "genusb target --installer=inst.efi --bundle=bundle.bin",
+	Short:   "Generates a Metropolis installer disk or image.",
+	Example: "metroctl install genusb /dev/sdx --installer=installer_x86_64.efi --bundle=metropolis_dev_x86_64.tar.xz",
+	Args:    cobra.ExactArgs(1), // One positional argument: the target
+	Run:     doGenUSB,
+}
+
+// A PEM block type for a Metropolis initial owner private key
+const ownerKeyType = "METROPOLIS INITIAL OWNER PRIVATE KEY"
+
+func doGenUSB(cmd *cobra.Command, args []string) {
+	installerFile, err := os.Open(*installer)
+	if err != nil {
+		log.Fatalf("Failed to open installer: %v", err)
+	}
+	installerFileStat, err := installerFile.Stat()
+	if err != nil {
+		log.Fatalf("Failed to stat installer: %v", err)
+	}
+	var bundleFile *os.File
+	var bundleFileStat os.FileInfo
+	if bundle != nil && *bundle != "" {
+		bundleFile, err = os.Open(*bundle)
+		if err != nil {
+			log.Fatalf("Failed to open bundle: %v", err)
+		}
+		bundleFileStat, err = bundleFile.Stat()
+		if err != nil {
+			log.Fatalf("Failed to stat bundle: %v", err)
+		}
+	}
+
+	// TODO(lorenz): Have a key management story for this
+	if err := os.MkdirAll(filepath.Join(xdg.ConfigHome, "metroctl"), 0700); err != nil {
+		log.Fatalf("Failed to create config directory: %v", err)
+	}
+	var ownerPublicKey ed25519.PublicKey
+	ownerPrivateKeyPEM, err := ioutil.ReadFile(filepath.Join(xdg.ConfigHome, "metroctl/owner-key.pem"))
+	if os.IsNotExist(err) {
+		pub, priv, err := ed25519.GenerateKey(rand.Reader)
+		if err != nil {
+			log.Fatalf("Failed to generate owner private key: %v", err)
+		}
+		pemPriv := pem.EncodeToMemory(&pem.Block{Type: ownerKeyType, Bytes: priv})
+		if err := ioutil.WriteFile(filepath.Join(xdg.ConfigHome, "metroctl/owner-key.pem"), pemPriv, 0600); err != nil {
+			log.Fatalf("Failed to store owner private key: %v", err)
+		}
+		ownerPublicKey = pub
+	} else if err != nil {
+		log.Fatalf("Failed to load owner private key: %v", err)
+	} else {
+		block, _ := pem.Decode(ownerPrivateKeyPEM)
+		if block == nil {
+			log.Fatalf("owner-key.pem contains invalid PEM")
+		}
+		if block.Type != ownerKeyType {
+			log.Fatalf("owner-key.pem contains a PEM block that's not a %v", ownerKeyType)
+		}
+		if len(block.Bytes) != ed25519.PrivateKeySize {
+			log.Fatal("owner-key.pem contains non-Ed25519 key")
+		}
+		ownerPrivateKey := ed25519.PrivateKey(block.Bytes)
+		ownerPublicKey = ownerPrivateKey.Public().(ed25519.PublicKey)
+	}
+
+	// TODO(lorenz): This can only bootstrap right now. As soon as @serge's role
+	// management has stabilized we can replace this with a proper
+	// implementation.
+	params := &api.NodeParameters{
+		Cluster: &api.NodeParameters_ClusterBootstrap_{
+			ClusterBootstrap: &api.NodeParameters_ClusterBootstrap{
+				OwnerPublicKey: ownerPublicKey,
+			},
+		},
+	}
+
+	installerImageArgs := core.MakeInstallerImageArgs{
+		TargetPath:    args[0],
+		Installer:     installerFile,
+		InstallerSize: uint64(installerFileStat.Size()),
+		NodeParams:    params,
+	}
+
+	if bundleFile != nil {
+		installerImageArgs.Bundle = bundleFile
+		installerImageArgs.BundleSize = uint64(bundleFileStat.Size())
+	}
+
+	if err := core.MakeInstallerImage(installerImageArgs); err != nil {
+		log.Fatalf("Failed to create installer: %v", err)
+	}
+}
+
+func init() {
+	rootCmd.AddCommand(installCmd)
+	installCmd.AddCommand(genusbCmd)
+
+	bundle = installCmd.PersistentFlags().StringP("bundle", "b", "", "Metropolis bundle file to use")
+	installer = installCmd.PersistentFlags().StringP("installer", "i", "", "Metropolis installer file to use")
+}
diff --git a/third_party/go/repositories.bzl b/third_party/go/repositories.bzl
index 2bee624..ef9f9c8 100644
--- a/third_party/go/repositories.bzl
+++ b/third_party/go/repositories.bzl
@@ -93,6 +93,16 @@
         ],
     )
     go_repository(
+        name = "com_github_adrg_xdg",
+        importpath = "github.com/adrg/xdg",
+        version = "v0.4.0",
+        sum = "h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=",
+        build_extra_args = [
+            "-go_naming_convention=go_default_library",
+            "-go_naming_convention_external=go_default_library",
+        ],
+    )
+    go_repository(
         name = "com_github_alexflint_go_filemutex",
         importpath = "github.com/alexflint/go-filemutex",
         version = "v0.0.0-20171022225611-72bdc8eae2ae",
diff --git a/third_party/go/shelf.pb.text b/third_party/go/shelf.pb.text
index de2f33e..a2f816b 100644
--- a/third_party/go/shelf.pb.text
+++ b/third_party/go/shelf.pb.text
@@ -111,6 +111,13 @@
   semver: "v0.0.0-20170810143723-de5bf2ad4578"
 >
 entry: <
+  import_path: "github.com/adrg/xdg"
+  version: "v0.4.0"
+  bazel_name: "com_github_adrg_xdg"
+  sum: "h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls="
+  semver: "v0.4.0"
+>
+entry: <
   import_path: "github.com/alexflint/go-filemutex"
   version: "v0.0.0-20171022225611-72bdc8eae2ae"
   bazel_name: "com_github_alexflint_go_filemutex"