github.com/billybanfield/evergreen@v0.0.0-20170525200750-eeee692790f7/cloud/providers/digitalocean/digitalocean.go (about)

     1  package digitalocean
     2  
     3  import (
     4  	"fmt"
     5  	"math"
     6  	"math/rand"
     7  	"strconv"
     8  	"time"
     9  
    10  	digo "github.com/dynport/gocloud/digitalocean"
    11  	"github.com/evergreen-ci/evergreen"
    12  	"github.com/evergreen-ci/evergreen/cloud"
    13  	"github.com/evergreen-ci/evergreen/db/bsonutil"
    14  	"github.com/evergreen-ci/evergreen/hostutil"
    15  	"github.com/evergreen-ci/evergreen/model/distro"
    16  	"github.com/evergreen-ci/evergreen/model/host"
    17  	"github.com/mitchellh/mapstructure"
    18  	"github.com/mongodb/grip"
    19  	"github.com/pkg/errors"
    20  )
    21  
    22  const (
    23  	DigitalOceanStatusOff     = "off"
    24  	DigitalOceanStatusNew     = "new"
    25  	DigitalOceanStatusActive  = "active"
    26  	DigitalOceanStatusArchive = "archive"
    27  
    28  	ProviderName = "digitalocean"
    29  )
    30  
    31  type DigitalOceanManager struct {
    32  	account *digo.Account
    33  }
    34  
    35  type Settings struct {
    36  	ImageId  int `mapstructure:"image_id" json:"image_id" bson:"image_id"`
    37  	SizeId   int `mapstructure:"size_id" json:"size_id" bson:"size_id"`
    38  	RegionId int `mapstructure:"region_id" json:"region_id" bson:"region_id"`
    39  	SSHKeyId int `mapstructure:"ssh_key_id" json:"ssh_key_id" bson:"ssh_key_id"`
    40  }
    41  
    42  var (
    43  	// bson fields for the Settings struct
    44  	ImageIdKey  = bsonutil.MustHaveTag(Settings{}, "ImageId")
    45  	SizeIdKey   = bsonutil.MustHaveTag(Settings{}, "SizeId")
    46  	RegionIdKey = bsonutil.MustHaveTag(Settings{}, "RegionId")
    47  	SSHKeyIdKey = bsonutil.MustHaveTag(Settings{}, "SSHKeyId")
    48  )
    49  
    50  //Validate checks that the settings from the config file are sane.
    51  func (self *Settings) Validate() error {
    52  	if self.ImageId == 0 {
    53  		return errors.New("ImageId must not be blank")
    54  	}
    55  
    56  	if self.SizeId == 0 {
    57  		return errors.New("Size ID must not be blank")
    58  	}
    59  
    60  	if self.RegionId == 0 {
    61  		return errors.New("Region must not be blank")
    62  	}
    63  
    64  	if self.SSHKeyId == 0 {
    65  		return errors.New("SSH Key ID must not be blank")
    66  	}
    67  
    68  	return nil
    69  }
    70  
    71  func (_ *DigitalOceanManager) GetSettings() cloud.ProviderSettings {
    72  	return &Settings{}
    73  }
    74  
    75  //SpawnInstance creates a new droplet for the given distro.
    76  func (digoMgr *DigitalOceanManager) SpawnInstance(d *distro.Distro, hostOpts cloud.HostOptions) (*host.Host, error) {
    77  	if d.Provider != ProviderName {
    78  		return nil, errors.Errorf("Can't spawn instance of %v for distro %v: provider is %v",
    79  			ProviderName, d.Id, d.Provider)
    80  	}
    81  
    82  	digoSettings := &Settings{}
    83  	if err := mapstructure.Decode(d.ProviderSettings, digoSettings); err != nil {
    84  		return nil, errors.Wrapf(err, "Error decoding params for distro %v", d.Id)
    85  	}
    86  
    87  	if err := digoSettings.Validate(); err != nil {
    88  		return nil, errors.Wrapf(err, "Invalid DigitalOcean settings in distro %v", d.Id)
    89  	}
    90  
    91  	instanceName := "droplet-" +
    92  		fmt.Sprintf("%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int())
    93  
    94  	intentHost := cloud.NewIntent(*d, instanceName, ProviderName, hostOpts)
    95  
    96  	dropletReq := &digo.Droplet{
    97  		SizeId:   digoSettings.SizeId,
    98  		ImageId:  digoSettings.ImageId,
    99  		RegionId: digoSettings.RegionId,
   100  		Name:     instanceName,
   101  		SshKey:   digoSettings.SSHKeyId,
   102  	}
   103  
   104  	if err := intentHost.Insert(); err != nil {
   105  		// TODO use github/pkg/errors for things like this
   106  		err = errors.Wrapf(err, "Could not insert intent host '%v': %v", intentHost.Id)
   107  		grip.Error(err)
   108  		return nil, err
   109  	}
   110  
   111  	grip.Debugf("Successfully inserted intent host '%v' for distro '%v' to signal cloud "+
   112  		"instance spawn intent", instanceName, d.Id)
   113  
   114  	newDroplet, err := digoMgr.account.CreateDroplet(dropletReq)
   115  	if err != nil {
   116  		grip.Errorf("DigitalOcean create droplet API call failed for intent host '%v': %+v",
   117  			intentHost.Id, err)
   118  
   119  		// remove the intent host document
   120  		rmErr := intentHost.Remove()
   121  		if rmErr != nil {
   122  			err = errors.Errorf("Could not remove intent host '%v': %+v",
   123  				intentHost.Id, rmErr)
   124  			grip.Error(err)
   125  			return nil, err
   126  		}
   127  		return nil, err
   128  	}
   129  
   130  	// find old intent host
   131  	host, err := host.FindOne(host.ById(intentHost.Id))
   132  	if host == nil {
   133  		err = errors.Errorf("Can't locate record inserted for intended host '%v'",
   134  			intentHost.Id)
   135  		grip.Error(err)
   136  		return nil, err
   137  	}
   138  	if err != nil {
   139  		err = errors.Wrapf(err, "Failed to look up intent host %v", intentHost.Id)
   140  		grip.Error(err)
   141  		return nil, err
   142  	}
   143  
   144  	host.Id = fmt.Sprintf("%v", newDroplet.Id)
   145  	host.Host = newDroplet.IpAddress
   146  
   147  	if err = host.Insert(); err != nil {
   148  		err = errors.Wrapf(err, "Failed to insert new host %v for intent host %v",
   149  			host.Id, intentHost.Id)
   150  		grip.Error(err)
   151  		return nil, err
   152  	}
   153  
   154  	// remove the intent host document
   155  	if err = intentHost.Remove(); err != nil {
   156  		err = errors.Wrapf(err, "Could not remove insert host '%v' (replaced by '%v')",
   157  			intentHost.Id, host.Id)
   158  		grip.Error(err)
   159  		return nil, err
   160  	}
   161  	return host, nil
   162  
   163  }
   164  
   165  //getDropletInfo is a helper function to retrieve metadata about a droplet by
   166  //querying DigitalOcean's API directly.
   167  func (digoMgr *DigitalOceanManager) getDropletInfo(dropletId int) (*digo.Droplet, error) {
   168  	droplet := digo.Droplet{Id: dropletId}
   169  	droplet.Account = digoMgr.account
   170  	if err := droplet.Reload(); err != nil {
   171  		return nil, errors.WithStack(err)
   172  	}
   173  	return &droplet, nil
   174  }
   175  
   176  //GetInstanceStatus returns a universal status code representing the state
   177  //of a droplet.
   178  func (digoMgr *DigitalOceanManager) GetInstanceStatus(host *host.Host) (cloud.CloudStatus, error) {
   179  	hostIdAsInt, err := strconv.Atoi(host.Id)
   180  	if err != nil {
   181  		err = errors.Wrapf(err, "Can't get status of '%v': DigitalOcean host id's "+
   182  			"must be integers", host.Id)
   183  		grip.Error(err)
   184  		return cloud.StatusUnknown, err
   185  
   186  	}
   187  	droplet, err := digoMgr.getDropletInfo(hostIdAsInt)
   188  	if err != nil {
   189  		return cloud.StatusUnknown, errors.Wrap(err, "Failed to get droplet info")
   190  	}
   191  
   192  	switch droplet.Status {
   193  	case DigitalOceanStatusNew:
   194  		return cloud.StatusInitializing, nil
   195  	case DigitalOceanStatusActive:
   196  		return cloud.StatusRunning, nil
   197  	case DigitalOceanStatusArchive:
   198  		return cloud.StatusStopped, nil
   199  	case DigitalOceanStatusOff:
   200  		return cloud.StatusTerminated, nil
   201  	default:
   202  		return cloud.StatusUnknown, nil
   203  	}
   204  }
   205  
   206  //GetDNSName gets the DNS hostname of a droplet by reading it directly from
   207  //the DigitalOcean API
   208  func (digoMgr *DigitalOceanManager) GetDNSName(host *host.Host) (string, error) {
   209  	hostIdAsInt, err := strconv.Atoi(host.Id)
   210  	if err != nil {
   211  		err = errors.Wrapf(err, "Can't get DNS name of '%v': DigitalOcean host id's must be integers",
   212  			host.Id)
   213  		grip.Error(err)
   214  		return "", err
   215  	}
   216  
   217  	droplet, err := digoMgr.getDropletInfo(hostIdAsInt)
   218  	if err != nil {
   219  		return "", errors.WithStack(err)
   220  	}
   221  	return droplet.IpAddress, nil
   222  
   223  }
   224  
   225  //CanSpawn returns if a given cloud provider supports spawning a new host
   226  //dynamically. Always returns true for DigitalOcean.
   227  func (digoMgr *DigitalOceanManager) CanSpawn() (bool, error) {
   228  	return true, nil
   229  }
   230  
   231  //TerminateInstance destroys a droplet.
   232  func (digoMgr *DigitalOceanManager) TerminateInstance(host *host.Host) error {
   233  	hostIdAsInt, err := strconv.Atoi(host.Id)
   234  	if err != nil {
   235  		err = errors.Wrapf(err, "Can't terminate '%v': DigitalOcean host id's must be integers", host.Id)
   236  		grip.Error(err)
   237  		return err
   238  	}
   239  	response, err := digoMgr.account.DestroyDroplet(hostIdAsInt)
   240  	if err != nil {
   241  		err = errors.Wrapf(err, "Failed to destroy droplet '%v'", host.Id)
   242  		grip.Error(err)
   243  		return err
   244  	}
   245  
   246  	if response.Status != "OK" {
   247  		err = errors.Wrapf(err, "Failed to destroy droplet: '%+v'. message: %v",
   248  			response.ErrorMessage)
   249  		grip.Error(err)
   250  		return err
   251  	}
   252  
   253  	return errors.WithStack(host.Terminate())
   254  }
   255  
   256  //Configure populates a DigitalOceanManager by reading relevant settings from the
   257  //config object.
   258  func (digoMgr *DigitalOceanManager) Configure(settings *evergreen.Settings) error {
   259  	digoMgr.account = digo.NewAccount(settings.Providers.DigitalOcean.ClientId,
   260  		settings.Providers.DigitalOcean.Key)
   261  	return nil
   262  }
   263  
   264  //IsSSHReachable checks if a droplet appears to be reachable via SSH by
   265  //attempting to contact the host directly.
   266  func (digoMgr *DigitalOceanManager) IsSSHReachable(host *host.Host, keyPath string) (bool, error) {
   267  	sshOpts, err := digoMgr.GetSSHOptions(host, keyPath)
   268  	if err != nil {
   269  		return false, errors.WithStack(err)
   270  	}
   271  
   272  	ok, err := hostutil.CheckSSHResponse(host, sshOpts)
   273  	return ok, errors.WithStack(err)
   274  }
   275  
   276  //IsUp checks the droplet's state by querying the DigitalOcean API and
   277  //returns true if the host should be available to connect with SSH.
   278  func (digoMgr *DigitalOceanManager) IsUp(host *host.Host) (bool, error) {
   279  	cloudStatus, err := digoMgr.GetInstanceStatus(host)
   280  	if err != nil {
   281  		return false, errors.WithStack(err)
   282  	}
   283  	if cloudStatus == cloud.StatusRunning {
   284  		return true, nil
   285  	}
   286  	return false, nil
   287  }
   288  
   289  func (digoMgr *DigitalOceanManager) OnUp(host *host.Host) error {
   290  	//Currently a no-op as DigitalOcean doesn't support tags.
   291  	return nil
   292  }
   293  
   294  //GetSSHOptions returns an array of default SSH options for connecting to a
   295  //droplet.
   296  func (digoMgr *DigitalOceanManager) GetSSHOptions(host *host.Host, keyPath string) ([]string, error) {
   297  	if keyPath == "" {
   298  		return []string{}, errors.New("No key specified for DigitalOcean host")
   299  	}
   300  	opts := []string{"-i", keyPath}
   301  	for _, opt := range host.Distro.SSHOptions {
   302  		opts = append(opts, "-o", opt)
   303  	}
   304  	return opts, nil
   305  }
   306  
   307  // TimeTilNextPayment returns the amount of time until the next payment is due
   308  // for the host
   309  func (digoMgr *DigitalOceanManager) TimeTilNextPayment(host *host.Host) time.Duration {
   310  	now := time.Now()
   311  
   312  	// the time since the host was created
   313  	timeSinceCreation := now.Sub(host.CreationTime)
   314  
   315  	// the hours since the host was created, rounded up
   316  	hoursRoundedUp := time.Duration(math.Ceil(timeSinceCreation.Hours()))
   317  
   318  	// the next round number of hours the host will have been up - the time
   319  	// that the next payment will be due
   320  	nextPaymentTime := host.CreationTime.Add(hoursRoundedUp)
   321  
   322  	return nextPaymentTime.Sub(now)
   323  }