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 }