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  }