m/cli/metroctl: factor out some helper functions

We will need these to create metroctl-compatible configs from
//metropolis/cluster/launch.

Change-Id: I2705afefb62b7e1b35c87d9753c4ca9c7f534c26
Reviewed-on: https://review.monogon.dev/c/monogon/+/1324
Tested-by: Jenkins CI
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index d35ba41..7af98f3 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -4,7 +4,6 @@
     name = "metroctl_lib",
     srcs = [
         "approve.go",
-        "credentials.go",
         "describe.go",
         "format.go",
         "install.go",
@@ -37,8 +36,6 @@
         "@com_github_spf13_cobra//:cobra",
         "@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
         "@io_k8s_client_go//pkg/apis/clientauthentication/v1:clientauthentication",
-        "@io_k8s_client_go//tools/clientcmd",
-        "@io_k8s_client_go//tools/clientcmd/api",
         "@org_golang_google_grpc//:go_default_library",
     ],
 )
diff --git a/metropolis/cli/metroctl/core/BUILD.bazel b/metropolis/cli/metroctl/core/BUILD.bazel
index af948af..133ff2e 100644
--- a/metropolis/cli/metroctl/core/BUILD.bazel
+++ b/metropolis/cli/metroctl/core/BUILD.bazel
@@ -3,6 +3,7 @@
 go_library(
     name = "core",
     srcs = [
+        "config.go",
         "core.go",
         "install.go",
         "rpc.go",
@@ -18,6 +19,9 @@
         "@com_github_diskfs_go_diskfs//disk",
         "@com_github_diskfs_go_diskfs//filesystem",
         "@com_github_diskfs_go_diskfs//partition/gpt",
+        "@io_k8s_client_go//pkg/apis/clientauthentication/v1:clientauthentication",
+        "@io_k8s_client_go//tools/clientcmd",
+        "@io_k8s_client_go//tools/clientcmd/api",
         "@org_golang_google_grpc//:go_default_library",
         "@org_golang_google_protobuf//proto",
         "@org_golang_x_net//proxy",
diff --git a/metropolis/cli/metroctl/core/config.go b/metropolis/cli/metroctl/core/config.go
new file mode 100644
index 0000000..d62bf2e
--- /dev/null
+++ b/metropolis/cli/metroctl/core/config.go
@@ -0,0 +1,185 @@
+package core
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	clientauthentication "k8s.io/client-go/pkg/apis/clientauthentication/v1"
+	"k8s.io/client-go/tools/clientcmd"
+	clientapi "k8s.io/client-go/tools/clientcmd/api"
+)
+
+const (
+	// OwnerKeyFileName is the filename of the owner key in a metroctl config
+	// directory.
+	OwnerKeyFileName = "owner-key.pem"
+	// OwnerCertificateFileName is the filename of the owner certificate in a
+	// metroctl config directory.
+	OwnerCertificateFileName = "owner.pem"
+)
+
+// NoCredentialsError indicates that the requested datum (eg. owner key or owner
+// certificate) is not present in the requested directory.
+var NoCredentialsError = errors.New("owner certificate or key does not exist")
+
+// A PEM block type for a Metropolis initial owner private key
+const ownerKeyType = "METROPOLIS INITIAL OWNER PRIVATE KEY"
+
+// GetOrMakeOwnerKey returns the owner key for a given metroctl configuration
+// directory path, generating and saving it first if it doesn't exist.
+func GetOrMakeOwnerKey(path string) (ed25519.PrivateKey, error) {
+	existing, err := GetOwnerKey(path)
+	switch err {
+	case nil:
+		return existing, nil
+	case NoCredentialsError:
+	default:
+		return nil, err
+	}
+
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, fmt.Errorf("when generating key: %w", err)
+	}
+	if err := WriteOwnerKey(path, priv); err != nil {
+		return nil, err
+	}
+	return priv, nil
+}
+
+// WriteOwnerKey saves a given raw ED25519 private key as the owner key at a
+// given metroctl configuration directory path.
+func WriteOwnerKey(path string, priv ed25519.PrivateKey) error {
+	pemPriv := pem.EncodeToMemory(&pem.Block{Type: ownerKeyType, Bytes: priv})
+	if err := os.WriteFile(filepath.Join(path, OwnerKeyFileName), pemPriv, 0600); err != nil {
+		return fmt.Errorf("when saving key: %w", err)
+	}
+	return nil
+}
+
+// GetOwnerKey loads and returns a raw ED25519 private key from the saved owner
+// key in a given metroctl configuration directory path. If the owner key doesn't
+// exist, NoCredentialsError will be returned.
+func GetOwnerKey(path string) (ed25519.PrivateKey, error) {
+	ownerPrivateKeyPEM, err := os.ReadFile(filepath.Join(path, OwnerKeyFileName))
+	if os.IsNotExist(err) {
+		return nil, NoCredentialsError
+	} else if err != nil {
+		return nil, fmt.Errorf("failed to load owner private key: %w", err)
+	}
+	block, _ := pem.Decode(ownerPrivateKeyPEM)
+	if block == nil {
+		return nil, errors.New("owner-key.pem contains invalid PEM armoring")
+	}
+	if block.Type != ownerKeyType {
+		return nil, fmt.Errorf("owner-key.pem contains a PEM block that's not a %v", ownerKeyType)
+	}
+	if len(block.Bytes) != ed25519.PrivateKeySize {
+		return nil, errors.New("owner-key.pem contains a non-Ed25519 key")
+	}
+	return block.Bytes, nil
+}
+
+// WriteOwnerCertificate saves a given DER-encoded X509 certificate as the owner
+// key for a given metroctl configuration directory path.
+func WriteOwnerCertificate(path string, cert []byte) error {
+	ownerCertPEM := pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: cert,
+	}
+	if err := os.WriteFile(filepath.Join(path, OwnerCertificateFileName), pem.EncodeToMemory(&ownerCertPEM), 0644); err != nil {
+		return err
+	}
+	return nil
+}
+
+// GetOwnerCredentials loads and returns a raw ED25519 private key alongside a
+// DER-encoded X509 certificate from the saved owner key and certificate in a
+// given metroctl configuration directory path. If either the key or certificate
+// doesn't exist, NoCredentialsError will be returned.
+func GetOwnerCredentials(path string) (cert *x509.Certificate, key ed25519.PrivateKey, err error) {
+	key, err = GetOwnerKey(path)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	ownerCertPEM, err := os.ReadFile(filepath.Join(path, OwnerCertificateFileName))
+	if os.IsNotExist(err) {
+		return nil, nil, NoCredentialsError
+	} else if err != nil {
+		return nil, nil, fmt.Errorf("failed to load owner certificate: %w", err)
+	}
+	block, _ := pem.Decode(ownerCertPEM)
+	if block == nil {
+		return nil, nil, errors.New("owner.pem contains invalid PEM armoring")
+	}
+	if block.Type != "CERTIFICATE" {
+		return nil, nil, fmt.Errorf("owner.pem contains a PEM block that's not a CERTIFICATE")
+	}
+	cert, err = x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return nil, nil, fmt.Errorf("owner.pem contains an invalid X.509 certificate: %w", err)
+	}
+	return
+}
+
+// InstallK8SWrapper configures the current user's kubectl to connect to a
+// Kubernetes cluster as defined by server (Metropolis wrapped APIServer
+// endpoint), proxyURL (optional proxy URL) and metroctlPath (binary managing
+// credentials for this cluster, and used to implement the client-side part of
+// the Metropolis-wrapped APIServer protocol). The configuration will be saved to
+// the 'configName' context in kubectl.
+func InstallK8SWrapper(metroctlPath, configName, server, proxyURL string) error {
+	ca := clientcmd.NewDefaultPathOptions()
+	config, err := ca.GetStartingConfig()
+	if err != nil {
+		return fmt.Errorf("getting initial config failed: %w", err)
+	}
+
+	config.AuthInfos[configName] = &clientapi.AuthInfo{
+		Exec: &clientapi.ExecConfig{
+			APIVersion: clientauthentication.SchemeGroupVersion.String(),
+			Command:    metroctlPath,
+			Args:       []string{"k8scredplugin"},
+			InstallHint: `Authenticating to Metropolis clusters requires metroctl to be present.
+Running metroctl takeownership creates this entry and either points to metroctl as a command in
+PATH if metroctl is in PATH at that time or to the absolute path to metroctl at that time.
+If you moved metroctl afterwards or want to switch to PATH resolution, edit $HOME/.kube/config and
+change users.metropolis.exec.command to the required path (or just metroctl if using PATH resolution).`,
+			InteractiveMode: clientapi.NeverExecInteractiveMode,
+		},
+	}
+
+	config.Clusters[configName] = &clientapi.Cluster{
+		// MVP: This is insecure, but making this work would be wasted effort
+		// as all of it will be replaced by the identity system.
+		// TODO(issues/144): adjust cluster endpoints once have functioning roles
+		// implemented.
+		InsecureSkipTLSVerify: true,
+		Server:                server,
+		ProxyURL:              proxyURL,
+	}
+
+	config.Contexts[configName] = &clientapi.Context{
+		AuthInfo:  configName,
+		Cluster:   configName,
+		Namespace: "default",
+	}
+
+	// Only set us as the current context if no other exists. Changing that
+	// unprompted would be kind of rude.
+	if config.CurrentContext == "" {
+		config.CurrentContext = configName
+	}
+
+	if err := clientcmd.ModifyConfig(ca, *config, true); err != nil {
+		return fmt.Errorf("modifying config failed: %w", err)
+	}
+	return nil
+}
diff --git a/metropolis/cli/metroctl/credentials.go b/metropolis/cli/metroctl/credentials.go
deleted file mode 100644
index 2160bea..0000000
--- a/metropolis/cli/metroctl/credentials.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package main
-
-import (
-	"crypto/ed25519"
-	"crypto/x509"
-	"encoding/pem"
-	"errors"
-	"fmt"
-	"os"
-	"path/filepath"
-)
-
-var noCredentialsError = errors.New("owner certificate or key does not exist")
-
-// getOwnerKey returns the cluster owner's key, if one exists, from the current
-// metroctl config directory.
-func getOwnerKey() (ed25519.PrivateKey, error) {
-	ownerPrivateKeyPEM, err := os.ReadFile(filepath.Join(flags.configPath, "owner-key.pem"))
-	if os.IsNotExist(err) {
-		return nil, noCredentialsError
-	} else if err != nil {
-		return nil, fmt.Errorf("failed to load owner private key: %w", err)
-	}
-	block, _ := pem.Decode(ownerPrivateKeyPEM)
-	if block == nil {
-		return nil, errors.New("owner-key.pem contains invalid PEM armoring")
-	}
-	if block.Type != ownerKeyType {
-		return nil, fmt.Errorf("owner-key.pem contains a PEM block that's not a %v", ownerKeyType)
-	}
-	if len(block.Bytes) != ed25519.PrivateKeySize {
-		return nil, errors.New("owner-key.pem contains a non-Ed25519 key")
-	}
-	return block.Bytes, nil
-}
-
-// getCredentials returns Metropolis credentials (if any) from the current
-// metroctl config directory.
-func getCredentials() (cert *x509.Certificate, key ed25519.PrivateKey, err error) {
-	key, err = getOwnerKey()
-	if err != nil {
-		return nil, nil, err
-	}
-
-	ownerCertPEM, err := os.ReadFile(filepath.Join(flags.configPath, "owner.pem"))
-	if os.IsNotExist(err) {
-		return nil, nil, noCredentialsError
-	} else if err != nil {
-		return nil, nil, fmt.Errorf("failed to load owner certificate: %w", err)
-	}
-	block, _ := pem.Decode(ownerCertPEM)
-	if block == nil {
-		return nil, nil, errors.New("owner.pem contains invalid PEM armoring")
-	}
-	if block.Type != "CERTIFICATE" {
-		return nil, nil, fmt.Errorf("owner.pem contains a PEM block that's not a CERTIFICATE")
-	}
-	cert, err = x509.ParseCertificate(block.Bytes)
-	if err != nil {
-		return nil, nil, fmt.Errorf("owner.pem contains an invalid X.509 certificate: %w", err)
-	}
-	return
-}
diff --git a/metropolis/cli/metroctl/install.go b/metropolis/cli/metroctl/install.go
index 07faad4..e43e07c 100644
--- a/metropolis/cli/metroctl/install.go
+++ b/metropolis/cli/metroctl/install.go
@@ -4,13 +4,10 @@
 	"bytes"
 	"context"
 	"crypto/ed25519"
-	"crypto/rand"
 	_ "embed"
-	"encoding/pem"
 	"io"
 	"log"
 	"os"
-	"path/filepath"
 
 	"github.com/spf13/cobra"
 
@@ -41,9 +38,6 @@
 // the --endpoints flag.
 var bootstrap bool
 
-// A PEM block type for a Metropolis initial owner private key
-const ownerKeyType = "METROPOLIS INITIAL OWNER PRIVATE KEY"
-
 //go:embed metropolis/installer/kernel.efi
 var installer []byte
 
@@ -81,39 +75,15 @@
 
 	var params *api.NodeParameters
 	if bootstrap {
-		var ownerPublicKey ed25519.PublicKey
-		ownerPrivateKeyPEM, err := os.ReadFile(filepath.Join(flags.configPath, "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 := os.WriteFile(filepath.Join(flags.configPath, "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)
+		priv, err := core.GetOrMakeOwnerKey(flags.configPath)
+		if err != nil {
+			log.Fatalf("Failed to generate or get owner key: %v", err)
 		}
-
+		pub := priv.Public().(ed25519.PublicKey)
 		params = &api.NodeParameters{
 			Cluster: &api.NodeParameters_ClusterBootstrap_{
 				ClusterBootstrap: &api.NodeParameters_ClusterBootstrap{
-					OwnerPublicKey: ownerPublicKey,
+					OwnerPublicKey: pub,
 				},
 			},
 		}
diff --git a/metropolis/cli/metroctl/k8scredplugin.go b/metropolis/cli/metroctl/k8scredplugin.go
index e434a61..605bc87 100644
--- a/metropolis/cli/metroctl/k8scredplugin.go
+++ b/metropolis/cli/metroctl/k8scredplugin.go
@@ -10,6 +10,8 @@
 	"github.com/spf13/cobra"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	clientauthentication "k8s.io/client-go/pkg/apis/clientauthentication/v1"
+
+	"source.monogon.dev/metropolis/cli/metroctl/core"
 )
 
 var k8scredpluginCmd = &cobra.Command{
@@ -23,8 +25,8 @@
 }
 
 func doK8sCredPlugin(cmd *cobra.Command, args []string) {
-	cert, key, err := getCredentials()
-	if err == noCredentialsError {
+	cert, key, err := core.GetOwnerCredentials(flags.configPath)
+	if err == core.NoCredentialsError {
 		log.Fatal("No credentials found on your machine")
 	}
 	if err != nil {
diff --git a/metropolis/cli/metroctl/rpc.go b/metropolis/cli/metroctl/rpc.go
index 9a8903a..8e8ee48 100644
--- a/metropolis/cli/metroctl/rpc.go
+++ b/metropolis/cli/metroctl/rpc.go
@@ -12,8 +12,8 @@
 func dialAuthenticated(ctx context.Context) *grpc.ClientConn {
 	// Collect credentials, validate command parameters, and try dialing the
 	// cluster.
-	ocert, opkey, err := getCredentials()
-	if err == noCredentialsError {
+	ocert, opkey, err := core.GetOwnerCredentials(flags.configPath)
+	if err == core.NoCredentialsError {
 		log.Fatalf("You have to take ownership of the cluster first: %v", err)
 	}
 	if len(flags.clusterEndpoints) == 0 {
diff --git a/metropolis/cli/metroctl/takeownership.go b/metropolis/cli/metroctl/takeownership.go
index 6102cf1..e0b2f79 100644
--- a/metropolis/cli/metroctl/takeownership.go
+++ b/metropolis/cli/metroctl/takeownership.go
@@ -2,17 +2,12 @@
 
 import (
 	"context"
-	"encoding/pem"
 	"log"
 	"net"
 	"os"
 	"os/exec"
-	"path/filepath"
 
 	"github.com/spf13/cobra"
-	clientauthentication "k8s.io/client-go/pkg/apis/clientauthentication/v1"
-	"k8s.io/client-go/tools/clientcmd"
-	clientapi "k8s.io/client-go/tools/clientcmd/api"
 
 	"source.monogon.dev/metropolis/cli/metroctl/core"
 	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
@@ -39,8 +34,8 @@
 
 	// Retrieve the cluster owner's private key, and use it to construct
 	// ephemeral credentials. Then, dial the cluster.
-	opk, err := getOwnerKey()
-	if err == noCredentialsError {
+	opk, err := core.GetOwnerKey(flags.configPath)
+	if err == core.NoCredentialsError {
 		log.Fatalf("Owner key does not exist. takeownership needs to be executed on the same system that has previously installed the cluster using metroctl install.")
 	}
 	if err != nil {
@@ -57,21 +52,12 @@
 	if err != nil {
 		log.Fatalf("Failed to retrive owner certificate from cluster: %v", err)
 	}
-	ownerCertPEM := pem.Block{
-		Type:  "CERTIFICATE",
-		Bytes: ownerCert.Certificate[0],
-	}
-	if err := os.WriteFile(filepath.Join(flags.configPath, "owner.pem"), pem.EncodeToMemory(&ownerCertPEM), 0644); err != nil {
+	if err := core.WriteOwnerCertificate(flags.configPath, ownerCert.Certificate[0]); err != nil {
 		log.Printf("Failed to store retrieved owner certificate: %v", err)
 		log.Fatalln("Sorry, the cluster has been lost as taking ownership cannot be repeated. Fix the reason the file couldn't be written and reinstall the node.")
 	}
 	log.Print("Successfully retrieved owner credentials! You now own this cluster. Setting up kubeconfig now...")
 
-	ca := clientcmd.NewDefaultPathOptions()
-	config, err := ca.GetStartingConfig()
-	if err != nil {
-		log.Fatalf("Failed to get initial kubeconfig to add Metropolis cluster: %v", err)
-	}
 	// If the user has metroctl in their path, use the metroctl from path as
 	// a credential plugin. Otherwise use the path to the currently-running
 	// metroctl.
@@ -82,44 +68,11 @@
 			log.Fatalf("Failed to create kubectl entry as metroctl is neither in PATH nor can its absolute path be determined: %v", err)
 		}
 	}
-
-	config.AuthInfos["metropolis"] = &clientapi.AuthInfo{
-		Exec: &clientapi.ExecConfig{
-			APIVersion: clientauthentication.SchemeGroupVersion.String(),
-			Command:    metroctlPath,
-			Args:       []string{k8scredpluginCmd.Use},
-			InstallHint: `Authenticating to Metropolis clusters requires metroctl to be present.
-Running metroctl takeownership creates this entry and either points to metroctl as a command in
-PATH if metroctl is in PATH at that time or to the absolute path to metroctl at that time.
-If you moved metroctl afterwards or want to switch to PATH resolution, edit $HOME/.kube/config and
-change users.metropolis.exec.command to the required path (or just metroctl if using PATH resolution).`,
-			InteractiveMode: clientapi.NeverExecInteractiveMode,
-		},
-	}
-
-	config.Clusters["metropolis"] = &clientapi.Cluster{
-		// MVP: This is insecure, but making this work would be wasted effort
-		// as all of it will be replaced by the identity system.
-		// TODO(issues/144): adjust cluster endpoints once have functioning roles
-		// implemented.
-		InsecureSkipTLSVerify: true,
-		Server:                "https://" + net.JoinHostPort(flags.clusterEndpoints[0], node.KubernetesAPIWrappedPort.PortString()),
-	}
-
-	config.Contexts["metropolis"] = &clientapi.Context{
-		AuthInfo:  "metropolis",
-		Cluster:   "metropolis",
-		Namespace: "default",
-	}
-
-	// Only set us as the current context if no other exists. Changing that
-	// unprompted would be kind of rude.
-	if config.CurrentContext == "" {
-		config.CurrentContext = "metropolis"
-	}
-
-	if err := clientcmd.ModifyConfig(ca, *config, true); err != nil {
-		log.Fatalf("Failed to modify kubeconfig to add Metropolis cluster: %v", err)
+	// TODO(issues/144): adjust cluster endpoints once have functioning roles
+	// implemented.
+	server := "https://" + net.JoinHostPort(flags.clusterEndpoints[0], node.KubernetesAPIWrappedPort.PortString())
+	if err := core.InstallK8SWrapper(metroctlPath, "metroctl", server, ""); err != nil {
+		log.Fatalf("Failed to install metroctl/k8s integration: %v", err)
 	}
 	log.Println("Success! kubeconfig is set up. You can now run kubectl --context=metropolis ... to access the Kubernetes cluster.")
 }