cloud/shepherd/equinix/manager: init
This adds implementation managing Equinix Metal server lifecycle as
part of the BMaaS project.
Co-authored-by: Mateusz Zalega <mateusz@monogon.tech>
Supersedes: https://review.monogon.dev/c/monogon/+/990
Change-Id: I5537b2d07763985ad27aecac544ed19f933d6727
Reviewed-on: https://review.monogon.dev/c/monogon/+/1129
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/shepherd/equinix/manager/shared_config.go b/cloud/shepherd/equinix/manager/shared_config.go
new file mode 100644
index 0000000..6ece4ce
--- /dev/null
+++ b/cloud/shepherd/equinix/manager/shared_config.go
@@ -0,0 +1,256 @@
+package manager
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/packethost/packngo"
+ "golang.org/x/crypto/ssh"
+ "k8s.io/klog/v2"
+
+ ecl "source.monogon.dev/cloud/shepherd/equinix/wrapngo"
+)
+
+var (
+ NoSuchKey = errors.New("no such key")
+)
+
+// SharedConfig contains configuration options used by both the Initializer and
+// Provisioner components of the Shepherd. In CLI scenarios, RegisterFlags should
+// be called to configure this struct from CLI flags. Otherwise, this structure
+// should be explicitly configured, as the default values are not valid.
+type SharedConfig struct {
+ // ProjectId is the Equinix project UUID used by the manager. See Equinix API
+ // documentation for details. Must be set.
+ ProjectId string
+
+ // Label specifies the ID to use when handling the Equinix-registered SSH key
+ // used to authenticate to newly created servers. Must be set.
+ KeyLabel string
+
+ // myKey guards Key.
+ muKey sync.Mutex
+
+ // SSH key to use when creating machines and then connecting to them. If not
+ // provided, it will be automatically loaded from KeyPersistPath, and if that
+ // doesn't exist either, it will be first generated and persisted there.
+ Key ed25519.PrivateKey
+
+ // Path at which the SSH key will be loaded from and persisted to, if Key is not
+ // explicitly set. Either KeyPersistPath or Key must be set.
+ KeyPersistPath string
+
+ // Prefix applied to all devices (machines) created by the Provisioner, and used
+ // by the Provisioner to identify machines which it managed. Must be set.
+ DevicePrefix string
+
+ // configPrefix will be set to the prefix of the latest RegisterFlags call and
+ // will be then used by various methods to display the full name of a
+ // misconfigured flag.
+ configPrefix string
+}
+
+func (c *SharedConfig) check() error {
+ if c.ProjectId == "" {
+ return fmt.Errorf("-%sequinix_project_id must be set", c.configPrefix)
+ }
+ if c.KeyLabel == "" {
+ return fmt.Errorf("-%sequinix_ssh_key_label must be set", c.configPrefix)
+ }
+ if c.DevicePrefix == "" {
+ return fmt.Errorf("-%sequinix_device_prefix must be set", c.configPrefix)
+ }
+ return nil
+}
+
+func (k *SharedConfig) RegisterFlags(prefix string) {
+ k.configPrefix = prefix
+
+ flag.StringVar(&k.ProjectId, prefix+"equinix_project_id", "", "Equinix project ID where resources will be managed")
+ flag.StringVar(&k.KeyLabel, prefix+"equinix_ssh_key_label", "shepherd-FIXME", "Label used to identify managed SSH key in Equinix project")
+ flag.StringVar(&k.KeyPersistPath, prefix+"ssh_key_path", "shepherd-key.priv", "Local filesystem path to read SSH key from, and save generated key to")
+ flag.StringVar(&k.DevicePrefix, prefix+"equinix_device_prefix", "shepherd-FIXME-", "Prefix applied to all devices (machines) in Equinix project, used to identify managed machines")
+}
+
+// sshKey returns the SSH key as defined by the Key and KeyPersistPath options,
+// loading/generating/persisting it as necessary.
+func (c *SharedConfig) sshKey() (ed25519.PrivateKey, error) {
+ c.muKey.Lock()
+ defer c.muKey.Unlock()
+
+ if c.Key != nil {
+ return c.Key, nil
+ }
+ if c.KeyPersistPath == "" {
+ return nil, fmt.Errorf("-%sequinix_ssh_key_path must be set", c.configPrefix)
+ }
+
+ data, err := os.ReadFile(c.KeyPersistPath)
+ switch {
+ case err == nil:
+ if len(data) != ed25519.PrivateKeySize {
+ return nil, fmt.Errorf("%s is not a valid ed25519 private key", c.KeyPersistPath)
+ }
+ c.Key = data
+ klog.Infof("Loaded SSH key from %s", c.KeyPersistPath)
+ return c.Key, nil
+ case os.IsNotExist(err):
+ if err := c.sshGenerateUnlocked(); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(c.KeyPersistPath, c.Key, 0400); err != nil {
+ return nil, fmt.Errorf("could not persist key: %w", err)
+ }
+ return c.Key, nil
+ default:
+ return nil, fmt.Errorf("could not load peristed key: %w", err)
+ }
+}
+
+// sshPub returns the SSH public key marshaled for use, based on sshKey.
+func (c *SharedConfig) sshPub() (string, error) {
+ private, err := c.sshKey()
+ if err != nil {
+ return "", err
+ }
+ // Marshal the public key part in OpenSSH authorized_keys format that will be
+ // registered with Equinix Metal.
+ sshpub, err := ssh.NewPublicKey(private.Public())
+ if err != nil {
+ return "", fmt.Errorf("while building SSH public key: %w", err)
+ }
+ return string(ssh.MarshalAuthorizedKey(sshpub)), nil
+}
+
+// sshSigner builds an ssh.Signer (for use in SSH connections) based on sshKey.
+func (c *SharedConfig) sshSigner() (ssh.Signer, error) {
+ private, err := c.sshKey()
+ if err != nil {
+ return nil, err
+ }
+ // Set up the internal ssh.Signer to be later used to initiate SSH
+ // connections with newly provided hosts.
+ signer, err := ssh.NewSignerFromKey(private)
+ if err != nil {
+ return nil, fmt.Errorf("while building SSH signer: %w", err)
+ }
+ return signer, nil
+}
+
+// sshGenerateUnlocked saves a new private key into SharedConfig.Key.
+func (c *SharedConfig) sshGenerateUnlocked() error {
+ if c.Key != nil {
+ return nil
+ }
+ _, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return fmt.Errorf("while generating SSH key: %w", err)
+ }
+ c.Key = priv
+ return nil
+}
+
+// sshEquinixGet looks up the Equinix key matching SharedConfig.KeyLabel,
+// returning its packngo.SSHKey instance.
+func (c *SharedConfig) sshEquinix(ctx context.Context, cl ecl.Client) (*packngo.SSHKey, error) {
+ ks, err := cl.ListSSHKeys(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("while listing SSH keys: %w", err)
+ }
+
+ for _, k := range ks {
+ if k.Label == c.KeyLabel {
+ return &k, nil
+ }
+ }
+ return nil, NoSuchKey
+}
+
+// sshEquinixId looks up the Equinix key identified by SharedConfig.KeyLabel,
+// returning its Equinix-assigned UUID.
+func (c *SharedConfig) sshEquinixId(ctx context.Context, cl ecl.Client) (string, error) {
+ k, err := c.sshEquinix(ctx, cl)
+ if err != nil {
+ return "", err
+ }
+ return k.ID, nil
+}
+
+// sshEquinixUpdate makes sure the existing SSH key registered with Equinix
+// matches the one from sshPub.
+func (c *SharedConfig) sshEquinixUpdate(ctx context.Context, cl ecl.Client, kid string) error {
+ pub, err := c.sshPub()
+ if err != nil {
+ return err
+ }
+ _, err = cl.UpdateSSHKey(ctx, kid, &packngo.SSHKeyUpdateRequest{
+ Key: &pub,
+ })
+ if err != nil {
+ return fmt.Errorf("while updating the SSH key: %w", err)
+ }
+ return nil
+}
+
+// sshEquinixUpload registers a new SSH key from sshPub.
+func (c *SharedConfig) sshEquinixUpload(ctx context.Context, cl ecl.Client) error {
+ pub, err := c.sshPub()
+ if err != nil {
+ return fmt.Errorf("while generating public key: %w", err)
+ }
+ _, err = cl.CreateSSHKey(ctx, &packngo.SSHKeyCreateRequest{
+ Label: c.KeyLabel,
+ Key: pub,
+ ProjectID: c.ProjectId,
+ })
+ if err != nil {
+ return fmt.Errorf("while creating an SSH key: %w", err)
+ }
+ return nil
+}
+
+// SSHEquinixEnsure initializes the locally managed SSH key (from a persistence
+// path or explicitly set key) and updates or uploads it to Equinix. The key is
+// generated as needed The key is generated as needed
+func (c *SharedConfig) SSHEquinixEnsure(ctx context.Context, cl ecl.Client) error {
+ k, err := c.sshEquinix(ctx, cl)
+ switch err {
+ case NoSuchKey:
+ if err := c.sshEquinixUpload(ctx, cl); err != nil {
+ return fmt.Errorf("while uploading key: %w", err)
+ }
+ return nil
+ case nil:
+ if err := c.sshEquinixUpdate(ctx, cl, k.ID); err != nil {
+ return fmt.Errorf("while updating key: %w", err)
+ }
+ return nil
+ default:
+ return err
+ }
+}
+
+// managedDevices provides a map of device provider IDs to matching
+// packngo.Device instances. It calls Equinix API's ListDevices. The returned
+// devices are filtered according to DevicePrefix provided through Opts. The
+// returned error value, if not nil, will originate in wrapngo.
+func (c *SharedConfig) managedDevices(ctx context.Context, cl ecl.Client) (map[string]packngo.Device, error) {
+ ds, err := cl.ListDevices(ctx, c.ProjectId)
+ if err != nil {
+ return nil, err
+ }
+ dm := map[string]packngo.Device{}
+ for _, d := range ds {
+ if strings.HasPrefix(d.Hostname, c.DevicePrefix) {
+ dm[d.ID] = d
+ }
+ }
+ return dm, nil
+}