github.com/billybanfield/evergreen@v0.0.0-20170525200750-eeee692790f7/cloud/providers/ec2/ec2_util.go (about) 1 package ec2 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io/ioutil" 7 "math" 8 "math/rand" 9 "net" 10 "net/http" 11 "os" 12 "os/user" 13 "regexp" 14 "strconv" 15 "strings" 16 "sync" 17 "time" 18 19 gcec2 "github.com/dynport/gocloud/aws/ec2" 20 "github.com/evergreen-ci/evergreen/cloud" 21 "github.com/evergreen-ci/evergreen/db/bsonutil" 22 "github.com/evergreen-ci/evergreen/model/host" 23 "github.com/goamz/goamz/aws" 24 "github.com/goamz/goamz/ec2" 25 "github.com/mongodb/grip" 26 "github.com/pkg/errors" 27 ) 28 29 const ( 30 OnDemandProviderName = "ec2" 31 SpotProviderName = "ec2-spot" 32 NameTimeFormat = "20060102150405" 33 SpawnHostExpireDays = 90 34 MciHostExpireDays = 30 35 ) 36 37 type MountPoint struct { 38 VirtualName string `mapstructure:"virtual_name" json:"virtual_name,omitempty" bson:"virtual_name,omitempty"` 39 DeviceName string `mapstructure:"device_name" json:"device_name,omitempty" bson:"device_name,omitempty"` 40 Size int `mapstructure:"size" json:"size,omitempty" bson:"size,omitempty"` 41 } 42 43 var ( 44 // bson fields for the EC2ProviderSettings struct 45 AMIKey = bsonutil.MustHaveTag(EC2ProviderSettings{}, "AMI") 46 InstanceTypeKey = bsonutil.MustHaveTag(EC2ProviderSettings{}, "InstanceType") 47 SecurityGroupKey = bsonutil.MustHaveTag(EC2ProviderSettings{}, "SecurityGroup") 48 KeyNameKey = bsonutil.MustHaveTag(EC2ProviderSettings{}, "KeyName") 49 MountPointsKey = bsonutil.MustHaveTag(EC2ProviderSettings{}, "MountPoints") 50 ) 51 52 var ( 53 // bson fields for the EC2SpotSettings struct 54 BidPriceKey = bsonutil.MustHaveTag(EC2SpotSettings{}, "BidPrice") 55 ) 56 57 var ( 58 // bson fields for the MountPoint struct 59 VirtualNameKey = bsonutil.MustHaveTag(MountPoint{}, "VirtualName") 60 DeviceNameKey = bsonutil.MustHaveTag(MountPoint{}, "DeviceName") 61 SizeKey = bsonutil.MustHaveTag(MountPoint{}, "Size") 62 ) 63 64 // type/consts for price evaluation based on OS 65 type osType string 66 67 const ( 68 osLinux osType = gcec2.DESC_LINUX_UNIX 69 osSUSE osType = "SUSE Linux" 70 osWindows osType = "Windows" 71 ) 72 73 //Utility func to create a create a temporary instance name for a host 74 func generateName(distroId string) string { 75 return "evg_" + distroId + "_" + time.Now().Format(NameTimeFormat) + 76 fmt.Sprintf("_%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) 77 } 78 79 // regionFullname takes the API ID of amazon region and returns the 80 // full region name. For instance, "us-west-1" becomes "US West (N. California)". 81 // This is necessary as the On Demand pricing endpoint uses the full name, unlike 82 // the rest of the API. THIS FUNCTION ONLY HANDLES U.S. REGIONS. 83 func regionFullname(region string) (string, error) { 84 switch region { 85 case "us-east-1": 86 return "US East (N. Virginia)", nil 87 case "us-west-1": 88 return "US West (N. California)", nil 89 case "us-west-2": 90 return "US West (Oregon)", nil 91 } 92 return "", errors.Errorf("region %v not supported for On Demand cost calculation", region) 93 } 94 95 // azToRegion takes an availability zone and returns the region id. 96 func azToRegion(az string) string { 97 // an amazon region is just the availability zone minus the final letter 98 return az[:len(az)-1] 99 } 100 101 // returns the format of os name expected by EC2 On Demand billing data, 102 // bucking the normal AWS API naming scheme. 103 func osBillingName(os osType) string { 104 if os == osLinux { 105 return "Linux" 106 } 107 return string(os) 108 } 109 110 //makeBlockDeviceMapping takes the mount_points settings defined in the distro, 111 //and converts them to ec2.BlockDeviceMapping structs which are usable by goamz. 112 //It returns a non-nil error if any of the fields appear invalid. 113 func makeBlockDeviceMappings(mounts []MountPoint) ([]ec2.BlockDeviceMapping, error) { 114 mappings := []ec2.BlockDeviceMapping{} 115 for _, mount := range mounts { 116 if mount.DeviceName == "" { 117 return nil, errors.Errorf("missing 'device_name': %#v", mount) 118 } 119 if mount.VirtualName == "" { 120 if mount.Size <= 0 { 121 return nil, errors.Errorf("invalid 'size': %#v", mount) 122 } 123 // EBS Storage - device name but no virtual name 124 mappings = append(mappings, ec2.BlockDeviceMapping{ 125 DeviceName: mount.DeviceName, 126 VolumeSize: int64(mount.Size), 127 DeleteOnTermination: true, 128 }) 129 } else { 130 //Instance Storage - virtual name but no size 131 mappings = append(mappings, ec2.BlockDeviceMapping{ 132 DeviceName: mount.DeviceName, 133 VirtualName: mount.VirtualName, 134 }) 135 } 136 } 137 return mappings, nil 138 } 139 140 //helper function for getting an EC2 handle at US east 141 func getUSEast(creds aws.Auth) *ec2.EC2 { 142 client := &http.Client{ 143 // This is the same configuration as the default in 144 // net/http with the disable keep alives option specified. 145 Transport: &http.Transport{ 146 Proxy: http.ProxyFromEnvironment, 147 DisableKeepAlives: true, 148 Dial: (&net.Dialer{ 149 Timeout: 30 * time.Second, 150 KeepAlive: 30 * time.Second, 151 }).Dial, 152 TLSHandshakeTimeout: 10 * time.Second, 153 }, 154 } 155 156 return ec2.NewWithClient(creds, aws.USEast, client) 157 } 158 159 func getEC2KeyOptions(h *host.Host, keyPath string) ([]string, error) { 160 if keyPath == "" { 161 return []string{}, errors.New("No key specified for EC2 host") 162 } 163 opts := []string{"-i", keyPath} 164 for _, opt := range h.Distro.SSHOptions { 165 opts = append(opts, "-o", opt) 166 } 167 return opts, nil 168 } 169 170 //getInstanceInfo returns the full ec2 instance info for the given instance ID. 171 //Note that this is the *instance* id, not the spot request ID, which is different. 172 func getInstanceInfo(ec2Handle *ec2.EC2, instanceId string) (*ec2.Instance, error) { 173 resp, err := ec2Handle.DescribeInstances([]string{instanceId}, nil) 174 if err != nil { 175 return nil, err 176 } 177 178 reservation := resp.Reservations 179 if len(reservation) < 1 { 180 err = errors.Errorf("No reservation found for instance id: %s", instanceId) 181 grip.Error(err) 182 return nil, err 183 } 184 185 instances := reservation[0].Instances 186 if len(instances) < 1 { 187 err = errors.Errorf("'%v' was not found in reservation '%v'", 188 instanceId, resp.Reservations[0].ReservationId) 189 grip.Error(err) 190 return nil, err 191 } 192 193 return &instances[0], nil 194 } 195 196 //ec2StatusToEvergreenStatus returns a "universal" status code based on EC2's 197 //provider-specific status codes. 198 func ec2StatusToEvergreenStatus(ec2Status string) cloud.CloudStatus { 199 switch ec2Status { 200 case EC2StatusPending: 201 return cloud.StatusInitializing 202 case EC2StatusRunning: 203 return cloud.StatusRunning 204 case EC2StatusShuttingdown: 205 return cloud.StatusTerminated 206 case EC2StatusTerminated: 207 return cloud.StatusTerminated 208 case EC2StatusStopped: 209 return cloud.StatusStopped 210 default: 211 return cloud.StatusUnknown 212 } 213 } 214 215 // expireInDays creates an expire-on string in the format YYYY-MM-DD for numDays days 216 // in the future. 217 func expireInDays(numDays int) string { 218 return time.Now().AddDate(0, 0, numDays).Format("2006-01-02") 219 } 220 221 //makeTags populates a map of tags based on a host object, which contain keys 222 //for the user, owner, hostname, and if it's a spawnhost or not. 223 func makeTags(intentHost *host.Host) map[string]string { 224 // get requester host name 225 hostname, err := os.Hostname() 226 if err != nil { 227 hostname = "unknown" 228 } 229 230 // get requester user name 231 var username string 232 user, err := user.Current() 233 if err != nil { 234 username = "unknown" 235 } else { 236 username = user.Name 237 } 238 239 // The expire-on tag is required by MongoDB's AWS reaping policy. 240 // The reaper is an external script that scans every ec2 instance for an expire-on tag, 241 // and if that tag is passed the reaper terminates the host. This reaping occurs to 242 // ensure that any hosts that we forget about or that fail to terminate do not stay alive 243 // forever. 244 expireOn := expireInDays(MciHostExpireDays) 245 if intentHost.UserHost { 246 // If this is a spawn host, use a different expiration date. 247 expireOn = expireInDays(SpawnHostExpireDays) 248 } 249 250 tags := map[string]string{ 251 "name": intentHost.Id, 252 "distro": intentHost.Distro.Id, 253 "evergreen-service": hostname, 254 "username": username, 255 "owner": intentHost.StartedBy, 256 "mode": "production", 257 "start-time": intentHost.CreationTime.Format(NameTimeFormat), 258 "expire-on": expireOn, 259 } 260 261 if intentHost.UserHost { 262 tags["mode"] = "testing" 263 } 264 return tags 265 } 266 267 //attachTags makes a call to EC2 to attach the given map of tags to a resource. 268 func attachTags(ec2Handle *ec2.EC2, 269 tags map[string]string, instance string) error { 270 271 tagSlice := []ec2.Tag{} 272 for tag, value := range tags { 273 tagSlice = append(tagSlice, ec2.Tag{tag, value}) 274 } 275 276 _, err := ec2Handle.CreateTags([]string{instance}, tagSlice) 277 return err 278 } 279 280 // determine how long until a payment is due for the specified host. since ec2 281 // bills per full hour the host has been up this number is just how long until, 282 // the host has been up the next round number of hours 283 func timeTilNextEC2Payment(host *host.Host) time.Duration { 284 285 now := time.Now() 286 287 // the time since the host was created 288 timeSinceCreation := now.Sub(host.CreationTime) 289 290 // the hours since the host was created, rounded up 291 hoursRoundedUp := time.Duration(math.Ceil(timeSinceCreation.Hours())) 292 293 // the next round number of hours the host will have been up - the time 294 // that the next payment will be due 295 nextPaymentTime := host.CreationTime.Add(hoursRoundedUp * time.Hour) 296 297 return nextPaymentTime.Sub(now) 298 299 } 300 301 // ebsRegex extracts EBS Price JSON data from Amazon's UI. 302 var ebsRegex = regexp.MustCompile(`(?s)callback\((.*)\)`) 303 304 // ebsPriceFetcher is an interface for types capable of returning EBS price data. 305 // Data is in the form of map[AVAILABILITY_ZONE]PRICE. 306 type ebsPriceFetcher interface { 307 FetchEBSPrices() (map[string]float64, error) 308 } 309 310 // cachedEBSPriceFetcher is a threadsafe price fetcher that only grabs EBS price 311 // data once during a program's execution. Prices change so infrequently that 312 // this is safe to do. 313 type cachedEBSPriceFetcher struct { 314 prices map[string]float64 315 m sync.Mutex 316 } 317 318 // package-level price fetcher for all requests 319 var pkgEBSFetcher cachedEBSPriceFetcher 320 321 // FetchEBSPrices returns an EBS zone->price map. If the prices aren't cached, 322 // it makes a request to Amazon and caches them before returning. 323 func (cpf *cachedEBSPriceFetcher) FetchEBSPrices() (map[string]float64, error) { 324 cpf.m.Lock() 325 defer cpf.m.Unlock() 326 if prices := cpf.prices; prices != nil { 327 return prices, nil 328 } else { 329 ps, err := fetchEBSPricing() 330 if err != nil { 331 return nil, errors.Wrap(err, "fetching EBS prices") 332 } 333 cpf.prices = ps 334 return ps, nil 335 } 336 } 337 338 // fetchEBSPricing does the dirty work of scraping price information from Amazon. 339 func fetchEBSPricing() (map[string]float64, error) { 340 // there is no true EBS pricing API, so we have to wrangle it from EC2's frontend 341 endpoint := "http://a0.awsstatic.com/pricing/1/ebs/pricing-ebs.js" 342 grip.Debugln("Loading EBS pricing from", endpoint) 343 resp, err := http.Get(endpoint) 344 if resp != nil { 345 defer resp.Body.Close() 346 } 347 if err != nil { 348 return nil, errors.Wrapf(err, "fetching %s", endpoint) 349 } 350 data, err := ioutil.ReadAll(resp.Body) 351 if err != nil { 352 return nil, errors.Wrap(err, "reading response body") 353 } 354 matches := ebsRegex.FindSubmatch(data) 355 if len(matches) < 2 { 356 return nil, errors.Errorf("could not find price JSON in response from %v", endpoint) 357 } 358 // define a one-off type for storing results from the price JSON 359 prices := struct { 360 Config struct { 361 Regions []struct { 362 Region string 363 Types []struct { 364 Name string 365 Values []struct { 366 Prices struct { 367 USD string 368 } 369 } 370 } 371 } 372 } 373 }{} 374 err = json.Unmarshal(matches[1], &prices) 375 if err != nil { 376 return nil, errors.Wrap(err, "parsing price JSON") 377 } 378 379 pricePerRegion := map[string]float64{} 380 for _, r := range prices.Config.Regions { 381 for _, t := range r.Types { 382 // only cache "general purpose" pricing for now 383 if strings.Contains(t.Name, "gp2") { 384 if len(t.Values) == 0 { 385 continue 386 } 387 price, err := strconv.ParseFloat(t.Values[0].Prices.USD, 64) 388 if err != nil { 389 continue 390 } 391 pricePerRegion[r.Region] = price 392 } 393 } 394 } 395 // one final sanity check that we actually pulled information, which will alert 396 // us if, say, Amazon changes the structure of their JSON 397 if len(pricePerRegion) == 0 { 398 return nil, errors.Errorf("unable to parse prices from %v", endpoint) 399 } 400 return pricePerRegion, nil 401 } 402 403 // blockDeviceCosts returns the total price of a slice of BlockDevices over the given duration 404 // by using the EC2 API. 405 func blockDeviceCosts(handle *ec2.EC2, devices []ec2.BlockDevice, dur time.Duration) (float64, error) { 406 cost := 0.0 407 if len(devices) > 0 { 408 volumeIds := []string{} 409 for _, bd := range devices { 410 volumeIds = append(volumeIds, bd.EBS.VolumeId) 411 } 412 vols, err := handle.Volumes(volumeIds, nil) 413 if err != nil { 414 return 0, err 415 } 416 for _, v := range vols.Volumes { 417 // an amazon region is just the availability zone minus the final letter 418 region := azToRegion(v.AvailZone) 419 size, err := strconv.Atoi(v.Size) 420 if err != nil { 421 return 0, errors.Wrap(err, "reading volume size") 422 } 423 p, err := ebsCost(&pkgEBSFetcher, region, size, dur) 424 if err != nil { 425 return 0, errors.Wrapf(err, "EBS volume %v", v.VolumeId) 426 } 427 cost += p 428 } 429 } 430 return cost, nil 431 } 432 433 // ebsCost returns the cost of running an EBS block device for an amount of time in a given size and region. 434 // EBS bills are charged in "GB/Month" units. We consider a month to be 30 days. 435 func ebsCost(pf ebsPriceFetcher, region string, size int, duration time.Duration) (float64, error) { 436 prices, err := pf.FetchEBSPrices() 437 if err != nil { 438 return 0.0, err 439 } 440 price, ok := prices[region] 441 if !ok { 442 return 0.0, errors.Errorf("no EBS price for region '%v'", region) 443 } 444 // price = GB * % of month * 445 month := (time.Hour * 24 * 30) 446 return float64(size) * (float64(duration) / float64(month)) * price, nil 447 } 448 449 // onDemandPriceFetcher is an interface for fetching the hourly price of a given 450 // os/instance/region combination. 451 type onDemandPriceFetcher interface { 452 FetchPrice(os osType, instance, region string) (float64, error) 453 } 454 455 // odInfo is an internal type for keying hosts by the attributes that affect billing. 456 type odInfo struct { 457 os string 458 instance string 459 region string 460 } 461 462 // cachedOnDemandPriceFetcher is a thread-safe onDemandPriceFetcher that caches the results from 463 // Amazon, allowing on long load on first access followed by virtually instant response time. 464 type cachedOnDemandPriceFetcher struct { 465 prices map[odInfo]float64 466 m sync.Mutex 467 } 468 469 // pkgOnDemandPriceFetcher is a package-level cached price fetcher. 470 // Pricing logic uses this by default to speed up price calculations. 471 var pkgOnDemandPriceFetcher cachedOnDemandPriceFetcher 472 473 // Terms is an internal type for loading price API results into. 474 type Terms struct { 475 OnDemand map[string]map[string]struct { 476 PriceDimensions map[string]struct { 477 PricePerUnit struct { 478 USD string 479 } 480 } 481 } 482 } 483 484 // skuPrice digs through the incredibly verbose Amazon price data format 485 // for the USD dollar amount of an SKU. The for loops are for traversing 486 // maps of size one with an unknown key, which is simple to do in a 487 // language like python but really ugly here. 488 func (t Terms) skuPrice(sku string) float64 { 489 for _, v := range t.OnDemand[sku] { 490 for _, p := range v.PriceDimensions { 491 // parse -- ignoring errors 492 val, _ := strconv.ParseFloat(p.PricePerUnit.USD, 64) 493 return val 494 } 495 } 496 return 0 497 } 498 499 // FetchPrice returns the hourly price of a host based on its attributes. A pricing table 500 // is cached after the first communication with Amazon to avoid expensive API calls. 501 func (cpf *cachedOnDemandPriceFetcher) FetchPrice(os osType, instance, region string) (float64, error) { 502 cpf.m.Lock() 503 defer cpf.m.Unlock() 504 if cpf.prices == nil { 505 if err := cpf.cachePrices(); err != nil { 506 return 0, errors.Wrap(err, "loading On Demand price data") 507 } 508 } 509 region, err := regionFullname(region) 510 if err != nil { 511 return 0, err 512 } 513 return cpf.prices[odInfo{ 514 os: osBillingName(os), instance: instance, region: region, 515 }], nil 516 } 517 518 // cachePrices updates the internal cache with Amazon data. 519 func (cpf *cachedOnDemandPriceFetcher) cachePrices() error { 520 cpf.prices = map[odInfo]float64{} 521 // the On Demand pricing API is not part of the normal EC2 API 522 endpoint := "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json" 523 grip.Debugln("Loading On Demand pricing from", endpoint) 524 resp, err := http.Get(endpoint) 525 if resp != nil { 526 defer resp.Body.Close() 527 } 528 if err != nil { 529 return errors.Wrapf(err, "fetching %v", endpoint) 530 } 531 grip.Debug("Parsing On Demand pricing") 532 details := struct { 533 Terms Terms 534 Products map[string]struct { 535 SKU string 536 ProductFamily string 537 Attributes struct { 538 Location string 539 InstanceType string 540 PreInstalledSW string 541 OperatingSystem string 542 Tenancy string 543 LicenseModel string 544 } 545 } 546 }{} 547 if err = json.NewDecoder(resp.Body).Decode(&details); err != nil { 548 return errors.Wrap(err, "parsing response body") 549 } 550 551 for _, p := range details.Products { 552 if p.ProductFamily == "Compute Instance" && 553 p.Attributes.PreInstalledSW == "NA" && 554 p.Attributes.Tenancy == "Shared" && 555 p.Attributes.LicenseModel != "Bring your own license" { 556 // the product description does not include pricing information, 557 // so we must look up the SKU in the "Terms" section. 558 price := details.Terms.skuPrice(p.SKU) 559 cpf.prices[odInfo{ 560 os: p.Attributes.OperatingSystem, 561 instance: p.Attributes.InstanceType, 562 region: p.Attributes.Location, 563 }] = price 564 } 565 } 566 return nil 567 } 568 569 // onDemandCost is a helper for calculating the price of an On Demand instance using the given price fetcher. 570 func onDemandCost(pf onDemandPriceFetcher, os osType, instance, region string, dur time.Duration) (float64, error) { 571 price, err := pf.FetchPrice(os, instance, region) 572 if err != nil { 573 return 0, err 574 } 575 if price == 0 { 576 return 0, errors.New("price not found in EC2 price listings") 577 } 578 return price * float64(dur) / float64(time.Hour), nil 579 }