m/c/metroctl: implement multi-node installation
This enables metroctl to include the ClusterRegister part of
NodeParameters in generated installer images, making it possible for
newly installed nodes to join an existing cluster.
Change-Id: I648207d70a4bec2ed7acf42e02f2b2c93319f559
Reviewed-on: https://review.monogon.dev/c/monogon/+/822
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/cli/metroctl/install.go b/metropolis/cli/metroctl/install.go
index 7ea36e3..a3657ae 100644
--- a/metropolis/cli/metroctl/install.go
+++ b/metropolis/cli/metroctl/install.go
@@ -2,6 +2,7 @@
import (
"bytes"
+ "context"
"crypto/ed25519"
"crypto/rand"
_ "embed"
@@ -15,6 +16,7 @@
"github.com/spf13/cobra"
"source.monogon.dev/metropolis/cli/metroctl/core"
+ clicontext "source.monogon.dev/metropolis/cli/pkg/context"
"source.monogon.dev/metropolis/cli/pkg/datafile"
"source.monogon.dev/metropolis/proto/api"
)
@@ -34,6 +36,12 @@
Run: doGenUSB,
}
+// bootstrap is a flag controlling node parameters included in the installer
+// image. If set, the installed node will bootstrap a new cluster. Otherwise,
+// it will try to connect to the cluster which endpoints were provided with
+// the --endpoints flag.
+var bootstrap bool
+
// A PEM block type for a Metropolis initial owner private key
const ownerKeyType = "METROPOLIS INITIAL OWNER PRIVATE KEY"
@@ -65,48 +73,85 @@
bundleSize = uint64(bundleStat.Size())
}
+ ctx := clicontext.WithInterrupt(context.Background())
+
// 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 := os.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 := os.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,
+ var params *api.NodeParameters
+ if bootstrap {
+ var ownerPublicKey ed25519.PublicKey
+ ownerPrivateKeyPEM, err := os.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 := os.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)
+ }
+
+ params = &api.NodeParameters{
+ Cluster: &api.NodeParameters_ClusterBootstrap_{
+ ClusterBootstrap: &api.NodeParameters_ClusterBootstrap{
+ OwnerPublicKey: ownerPublicKey,
+ },
},
- },
+ }
+ } else {
+ ocert, opkey, err := getCredentials()
+ if err == noCredentialsError {
+ log.Fatalf("In order to create a non-bootstrap node installer, you have to take ownership of the cluster first: %v", err)
+ }
+ if err != nil {
+ log.Fatalf("While retrieving owner credentials: %v", err)
+ }
+ if len(flags.clusterEndpoints) == 0 {
+ log.Fatal("At least one cluster endpoint is required while generating non-bootstrap installer images.")
+ }
+ cc, err := dialCluster(ctx, opkey, ocert, "", flags.clusterEndpoints)
+ if err != nil {
+ log.Fatalf("While dialing the cluster: %v", err)
+ }
+ mgmt := api.NewManagementClient(cc)
+ resT, err := mgmt.GetRegisterTicket(ctx, &api.GetRegisterTicketRequest{})
+ if err != nil {
+ log.Fatalf("While receiving register ticket: %v", err)
+ }
+ resI, err := mgmt.GetClusterInfo(ctx, &api.GetClusterInfoRequest{})
+ if err != nil {
+ log.Fatalf("While receiving cluster directory: %v", err)
+ }
+
+ params = &api.NodeParameters{
+ Cluster: &api.NodeParameters_ClusterRegister_{
+ ClusterRegister: &api.NodeParameters_ClusterRegister{
+ RegisterTicket: resT.Ticket,
+ ClusterDirectory: resI.ClusterDirectory,
+ CaCertificate: resI.CaCertificate,
+ },
+ },
+ }
}
installerImageArgs := core.MakeInstallerImageArgs{
@@ -126,5 +171,7 @@
func init() {
rootCmd.AddCommand(installCmd)
+
+ genusbCmd.Flags().BoolVar(&bootstrap, "bootstrap", false, "Create a bootstrap installer image.")
installCmd.AddCommand(genusbCmd)
}