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

     1  package ec2
     2  
     3  import (
     4  	"fmt"
     5  	"strconv"
     6  	"strings"
     7  	"time"
     8  
     9  	awssdk "github.com/aws/aws-sdk-go/aws"
    10  	"github.com/aws/aws-sdk-go/aws/credentials"
    11  	"github.com/aws/aws-sdk-go/aws/session"
    12  	ec2sdk "github.com/aws/aws-sdk-go/service/ec2"
    13  	"github.com/evergreen-ci/evergreen"
    14  	"github.com/evergreen-ci/evergreen/cloud"
    15  	"github.com/evergreen-ci/evergreen/hostutil"
    16  	"github.com/evergreen-ci/evergreen/model/distro"
    17  	"github.com/evergreen-ci/evergreen/model/host"
    18  	"github.com/evergreen-ci/evergreen/util"
    19  	"github.com/goamz/goamz/aws"
    20  	"github.com/goamz/goamz/ec2"
    21  	"github.com/mitchellh/mapstructure"
    22  	"github.com/mongodb/grip"
    23  	"github.com/pkg/errors"
    24  )
    25  
    26  const (
    27  	SpotStatusOpen     = "open"
    28  	SpotStatusActive   = "active"
    29  	SpotStatusClosed   = "closed"
    30  	SpotStatusCanceled = "cancelled"
    31  	SpotStatusFailed   = "failed"
    32  
    33  	EC2ErrorSpotRequestNotFound = "InvalidSpotInstanceRequestID.NotFound"
    34  )
    35  
    36  // EC2SpotManager implements the CloudManager interface for Amazon EC2 Spot
    37  type EC2SpotManager struct {
    38  	awsCredentials *aws.Auth
    39  }
    40  
    41  type EC2SpotSettings struct {
    42  	BidPrice float64 `mapstructure:"bid_price" json:"bid_price,omitempty" bson:"bid_price,omitempty"`
    43  
    44  	AMI          string       `mapstructure:"ami" json:"ami,omitempty" bson:"ami,omitempty"`
    45  	InstanceType string       `mapstructure:"instance_type" json:"instance_type,omitempty" bson:"instance_type,omitempty"`
    46  	KeyName      string       `mapstructure:"key_name" json:"key_name,omitempty" bson:"key_name,omitempty"`
    47  	MountPoints  []MountPoint `mapstructure:"mount_points" json:"mount_points,omitempty" bson:"mount_points,omitempty"`
    48  
    49  	// this is the security group name in EC2 classic and the security group ID in VPC (eg. sg-xxxx)
    50  	SecurityGroup string `mapstructure:"security_group" json:"security_group,omitempty" bson:"security_group,omitempty"`
    51  	// only set in VPC (eg. subnet-xxxx)
    52  	SubnetId string `mapstructure:"subnet_id" json:"subnet_id,omitempty" bson:"subnet_id,omitempty"`
    53  	// this is set to true if the security group is part of a vpc
    54  	IsVpc bool `mapstructure:"is_vpc" json:"is_vpc,omitempty" bson:"is_vpc,omitempty"`
    55  }
    56  
    57  func (self *EC2SpotSettings) Validate() error {
    58  	if self.BidPrice <= 0 {
    59  		return errors.New("Bid price must be greater than zero")
    60  	}
    61  
    62  	if self.AMI == "" {
    63  		return errors.New("AMI must not be blank")
    64  	}
    65  
    66  	if self.InstanceType == "" {
    67  		return errors.New("Instance size must not be blank")
    68  	}
    69  
    70  	if self.SecurityGroup == "" {
    71  		return errors.New("Security group must not be blank")
    72  	}
    73  	if self.KeyName == "" {
    74  		return errors.New("Key name must not be blank")
    75  	}
    76  
    77  	_, err := makeBlockDeviceMappings(self.MountPoints)
    78  	return errors.WithStack(err)
    79  }
    80  
    81  //Configure loads necessary credentials or other settings from the global config
    82  //object.
    83  func (cloudManager *EC2SpotManager) Configure(settings *evergreen.Settings) error {
    84  	if settings.Providers.AWS.Id == "" || settings.Providers.AWS.Secret == "" {
    85  		return errors.New("AWS ID/Secret must not be blank")
    86  	}
    87  	cloudManager.awsCredentials = &aws.Auth{
    88  		AccessKey: settings.Providers.AWS.Id,
    89  		SecretKey: settings.Providers.AWS.Secret,
    90  	}
    91  	return nil
    92  }
    93  
    94  func (*EC2SpotManager) GetSettings() cloud.ProviderSettings {
    95  	return &EC2SpotSettings{}
    96  }
    97  
    98  // determine how long until a payment is due for the host
    99  func (cloudManager *EC2SpotManager) TimeTilNextPayment(host *host.Host) time.Duration {
   100  	return timeTilNextEC2Payment(host)
   101  }
   102  
   103  func (cloudManager *EC2SpotManager) GetSSHOptions(h *host.Host, keyPath string) ([]string, error) {
   104  	return getEC2KeyOptions(h, keyPath)
   105  }
   106  
   107  func (cloudManager *EC2SpotManager) IsUp(host *host.Host) (bool, error) {
   108  	instanceStatus, err := cloudManager.GetInstanceStatus(host)
   109  	if err != nil {
   110  		err = errors.Wrapf(err, "Failed to check if host %v is up", host.Id)
   111  		grip.Error(err)
   112  		return false, err
   113  	}
   114  
   115  	if instanceStatus == cloud.StatusRunning {
   116  		return true, nil
   117  	} else {
   118  		return false, nil
   119  	}
   120  }
   121  
   122  func (cloudManager *EC2SpotManager) OnUp(host *host.Host) error {
   123  	tags := makeTags(host)
   124  	tags["spot"] = "true" // mark this as a spot instance
   125  	spotReq, err := cloudManager.describeSpotRequest(host.Id)
   126  	if err != nil {
   127  		return errors.WithStack(err)
   128  	}
   129  	grip.Debugf("Running initialization function for host '%v' with id: %v",
   130  		host.Id, spotReq.InstanceId)
   131  	if spotReq.InstanceId == "" {
   132  		err = errors.Errorf("Could not retrieve instanceID for filled SpotRequest '%s'", host.Id)
   133  		grip.Error(err)
   134  		return err
   135  	}
   136  	return attachTags(getUSEast(*cloudManager.awsCredentials), tags, spotReq.InstanceId)
   137  }
   138  
   139  func (cloudManager *EC2SpotManager) IsSSHReachable(host *host.Host, keyPath string) (bool, error) {
   140  	sshOpts, err := cloudManager.GetSSHOptions(host, keyPath)
   141  	if err != nil {
   142  		return false, err
   143  	}
   144  	reachable, err := hostutil.CheckSSHResponse(host, sshOpts)
   145  	grip.Debugf("Checking host '%v' ssh reachability: %t", host.Id, reachable)
   146  
   147  	return reachable, err
   148  }
   149  
   150  //GetInstanceStatus returns an mci-universal status code for the status of
   151  //an ec2 spot-instance host. For unfulfilled spot requests, the behavior
   152  //is as follows:
   153  // Spot request open or active, but unfulfilled -> StatusPending
   154  // Spot request closed or canceled             -> StatusTerminated
   155  // Spot request failed due to bidding/capacity  -> StatusFailed
   156  //
   157  // For a *fulfilled* spot request (the spot request has an instance ID)
   158  // the status returned will be the status of the instance that fulfilled it,
   159  // matching the behavior used in cloud/providers/ec2/ec2.go
   160  func (cloudManager *EC2SpotManager) GetInstanceStatus(host *host.Host) (cloud.CloudStatus, error) {
   161  	spotDetails, err := cloudManager.describeSpotRequest(host.Id)
   162  	if err != nil {
   163  		err = errors.Wrapf(err, "failed to get spot request info for %v", host.Id)
   164  		grip.Error(err)
   165  		return cloud.StatusUnknown, err
   166  	}
   167  
   168  	//Spot request has been fulfilled, so get status of the instance itself
   169  	if spotDetails.InstanceId != "" {
   170  		ec2Handle := getUSEast(*cloudManager.awsCredentials)
   171  		instanceInfo, err := getInstanceInfo(ec2Handle, spotDetails.InstanceId)
   172  		if err != nil {
   173  			err = errors.Wrap(err, "Got an error checking spot details")
   174  			grip.Error(err)
   175  			return cloud.StatusUnknown, err
   176  		}
   177  		return ec2StatusToEvergreenStatus(instanceInfo.State.Name), nil
   178  	}
   179  
   180  	//Spot request is not fulfilled. Either it's failed/closed for some reason,
   181  	//or still pending evaluation
   182  	switch spotDetails.State {
   183  	case SpotStatusOpen:
   184  		return cloud.StatusPending, nil
   185  	case SpotStatusActive:
   186  		return cloud.StatusPending, nil
   187  	case SpotStatusClosed:
   188  		return cloud.StatusTerminated, nil
   189  	case SpotStatusCanceled:
   190  		return cloud.StatusTerminated, nil
   191  	case SpotStatusFailed:
   192  		return cloud.StatusFailed, nil
   193  	default:
   194  		grip.Errorf("Unexpected status code in spot req: %v", spotDetails.State)
   195  		return cloud.StatusUnknown, nil
   196  	}
   197  }
   198  
   199  func (cloudManager *EC2SpotManager) CanSpawn() (bool, error) {
   200  	return true, nil
   201  }
   202  
   203  func (cloudManager *EC2SpotManager) GetDNSName(host *host.Host) (string, error) {
   204  	spotDetails, err := cloudManager.describeSpotRequest(host.Id)
   205  	if err != nil {
   206  		err = errors.Errorf("failed to get spot request info for %v: %+v", host.Id, err)
   207  		grip.Error(err)
   208  		return "", err
   209  	}
   210  
   211  	//Spot request has not been fulfilled yet, so there is still no DNS name
   212  	if spotDetails.InstanceId == "" {
   213  		return "", nil
   214  	}
   215  
   216  	//Spot request is fulfilled, find the instance info and get DNS info
   217  	ec2Handle := getUSEast(*cloudManager.awsCredentials)
   218  	instanceInfo, err := getInstanceInfo(ec2Handle, spotDetails.InstanceId)
   219  	if err != nil {
   220  		return "", err
   221  	}
   222  	return instanceInfo.DNSName, nil
   223  }
   224  
   225  func (cloudManager *EC2SpotManager) SpawnInstance(d *distro.Distro, hostOpts cloud.HostOptions) (*host.Host, error) {
   226  	if d.Provider != SpotProviderName {
   227  		return nil, errors.Errorf("Can't spawn instance of %v for distro %v: provider is %v", SpotProviderName, d.Id, d.Provider)
   228  	}
   229  	ec2Handle := getUSEast(*cloudManager.awsCredentials)
   230  
   231  	//Decode and validate the ProviderSettings into the ec2-specific ones.
   232  	ec2Settings := &EC2SpotSettings{}
   233  	if err := mapstructure.Decode(d.ProviderSettings, ec2Settings); err != nil {
   234  		return nil, errors.Wrapf(err, "Error decoding params for distro %s", d.Id)
   235  	}
   236  
   237  	if err := ec2Settings.Validate(); err != nil {
   238  		return nil, errors.Wrapf(err, "Invalid EC2 spot settings in distro %s", d.Id)
   239  	}
   240  
   241  	blockDevices, err := makeBlockDeviceMappings(ec2Settings.MountPoints)
   242  	if err != nil {
   243  		return nil, err
   244  	}
   245  
   246  	instanceName := generateName(d.Id)
   247  	intentHost := cloud.NewIntent(*d, instanceName, SpotProviderName, hostOpts)
   248  	intentHost.InstanceType = ec2Settings.InstanceType
   249  
   250  	// record this 'intent host'
   251  	if err := intentHost.Insert(); err != nil {
   252  		err = errors.Wrapf(err, "Could not insert intent host '%v'", intentHost.Id)
   253  		grip.Error(err)
   254  		return nil, err
   255  	}
   256  
   257  	grip.Debugf("Inserted intent host '%v' for distro '%v' to signal instance spawn intent",
   258  		instanceName, d.Id)
   259  
   260  	spotRequest := &ec2.RequestSpotInstances{
   261  		SpotPrice:      fmt.Sprintf("%v", ec2Settings.BidPrice),
   262  		InstanceCount:  1,
   263  		ImageId:        ec2Settings.AMI,
   264  		KeyName:        ec2Settings.KeyName,
   265  		InstanceType:   ec2Settings.InstanceType,
   266  		SecurityGroups: ec2.SecurityGroupNames(ec2Settings.SecurityGroup),
   267  		BlockDevices:   blockDevices,
   268  	}
   269  
   270  	// if the spot instance is a vpc then set the appropriate fields
   271  	if ec2Settings.IsVpc {
   272  		spotRequest.SecurityGroups = ec2.SecurityGroupIds(ec2Settings.SecurityGroup)
   273  		spotRequest.AssociatePublicIpAddress = true
   274  		spotRequest.SubnetId = ec2Settings.SubnetId
   275  	}
   276  
   277  	spotResp, err := ec2Handle.RequestSpotInstances(spotRequest)
   278  	if err != nil {
   279  		//Remove the intent host if the API call failed
   280  		if err := intentHost.Remove(); err != nil {
   281  			grip.Errorf("Failed to remove intent host %s: %+v", intentHost.Id, err)
   282  		}
   283  		err = errors.Wrapf(err, "Failed starting spot instance for distro '%s' on intent host %s",
   284  			d.Id, intentHost.Id)
   285  		grip.Error(err)
   286  		return nil, err
   287  	}
   288  
   289  	spotReqRes := spotResp.SpotRequestResults[0]
   290  	if spotReqRes.State != SpotStatusOpen && spotReqRes.State != SpotStatusActive {
   291  		err = errors.Errorf("Spot request %v was found in  state %v on intent host %v",
   292  			spotReqRes.SpotRequestId, spotReqRes.State, intentHost.Id)
   293  		grip.Error(err)
   294  		return nil, err
   295  	}
   296  
   297  	intentHost.Id = spotReqRes.SpotRequestId
   298  	err = intentHost.Insert()
   299  	if err != nil {
   300  		err = errors.Wrapf(err, "Could not insert updated host info with id %v for intent host %v",
   301  			intentHost.Id, instanceName)
   302  		grip.Error(err)
   303  		return nil, err
   304  	}
   305  
   306  	grip.Debugf("Inserting updated intent host %v with request id: %v and name %v",
   307  		intentHost.Id, spotReqRes.SpotRequestId, instanceName)
   308  	//find the old intent host and remove it, since we now have the real
   309  	//host doc successfully stored.
   310  	oldIntenthost, err := host.FindOne(host.ById(instanceName))
   311  	if err != nil {
   312  		err = errors.Wrapf(err, "Can't locate record inserted for intended host '%s' "+
   313  			"due to error", instanceName)
   314  		grip.Error(err)
   315  		return nil, err
   316  	}
   317  	if oldIntenthost == nil {
   318  		err = errors.Errorf("Can't locate record inserted for intended host '%s'",
   319  			instanceName)
   320  		grip.Error(err)
   321  		return nil, err
   322  	}
   323  
   324  	grip.Debugf("Removing old intent host %v with id: %v and name %v",
   325  		oldIntenthost.Id, spotReqRes.SpotRequestId, instanceName)
   326  	err = oldIntenthost.Remove()
   327  	if err != nil {
   328  		err = errors.Wrapf(err, "Could not remove intent host '%s'", oldIntenthost.Id, err)
   329  		grip.Error(err)
   330  		return nil, err
   331  	}
   332  
   333  	// create some tags based on user, hostname, owner, time, etc.
   334  	tags := makeTags(intentHost)
   335  
   336  	// attach the tags to this instance
   337  	err = errors.Wrapf(attachTags(ec2Handle, tags, intentHost.Id),
   338  		"unable to attach tags for $s", intentHost.Id)
   339  
   340  	grip.Error(err)
   341  	grip.DebugWhenf(err == nil, "attached tag name '%s' for '%s'",
   342  		instanceName, intentHost.Id)
   343  
   344  	return intentHost, nil
   345  }
   346  
   347  func (cloudManager *EC2SpotManager) TerminateInstance(host *host.Host) error {
   348  	// terminate the instance
   349  	if host.Status == evergreen.HostTerminated {
   350  		err := errors.Errorf("Can not terminate %s; already marked as terminated", host.Id)
   351  		grip.Error(err)
   352  		return err
   353  	}
   354  
   355  	spotDetails, err := cloudManager.describeSpotRequest(host.Id)
   356  	if err != nil {
   357  		ec2err, ok := err.(*ec2.Error)
   358  		if ok && ec2err.Code == EC2ErrorSpotRequestNotFound {
   359  			// EC2 says the spot request is not found - assume this means amazon
   360  			// terminated our spot instance
   361  			grip.Warningf("EC2 could not find spot instance '%s', marking as terminated: [%+v]",
   362  				host.Id, ec2err)
   363  			return errors.WithStack(host.Terminate())
   364  		}
   365  		err = errors.Wrapf(err, "Couldn't terminate, failed to get spot request info for %s",
   366  			host.Id)
   367  		grip.Error(err)
   368  		return err
   369  	}
   370  
   371  	grip.Infoln("Canceling spot request", host.Id)
   372  	//First cancel the spot request
   373  	ec2Handle := getUSEast(*cloudManager.awsCredentials)
   374  
   375  	resp, err := ec2Handle.CancelSpotRequests([]string{host.Id})
   376  	grip.Debugf("host=%s, cancelResp=%+v", host.Id, resp)
   377  	if err != nil {
   378  		err = errors.Wrapf(err, "Failed to cancel spot request for host %s", host.Id)
   379  		grip.Error(err)
   380  		return err
   381  	}
   382  
   383  	//Canceling the spot request doesn't terminate the instance that fulfilled it,
   384  	// if it was fulfilled. We need to terminate the instance explicitly
   385  	if spotDetails.InstanceId != "" {
   386  		grip.Infof("Spot request %s canceled, now terminating instance %s",
   387  			spotDetails.InstanceId, host.Id)
   388  		resp, err := ec2Handle.TerminateInstances([]string{spotDetails.InstanceId})
   389  		if err != nil {
   390  			err = errors.Wrapf(err, "Failed to terminate host %s", host.Id)
   391  			grip.Error(err)
   392  			return err
   393  		}
   394  
   395  		for idx, stateChange := range resp.StateChanges {
   396  			grip.Debugf("change=%d, host=%s, state=[%+v]", idx, host.Id, stateChange)
   397  			grip.Infof("Terminated %s", stateChange.InstanceId)
   398  		}
   399  	} else {
   400  		grip.Infof("Spot request %s canceled (no instances have fulfilled it)", host.Id)
   401  	}
   402  
   403  	// set the host status as terminated and update its termination time
   404  	return errors.WithStack(host.Terminate())
   405  }
   406  
   407  // describeSpotRequest gets infomration about a spot request
   408  // Note that if the SpotRequestResult object returned has a non-blank InstanceId
   409  // field, this indicates that the spot request has been fulfilled.
   410  func (cloudManager *EC2SpotManager) describeSpotRequest(spotReqId string) (*ec2.SpotRequestResult, error) {
   411  	ec2Handle := getUSEast(*cloudManager.awsCredentials)
   412  	resp, err := ec2Handle.DescribeSpotRequests([]string{spotReqId}, nil)
   413  	if err != nil {
   414  		return nil, errors.WithStack(err)
   415  	}
   416  	if resp == nil {
   417  		err = errors.Errorf("Received a nil response from EC2 looking up spot request %v",
   418  			spotReqId)
   419  		grip.Error(err)
   420  		return nil, err
   421  	}
   422  	if len(resp.SpotRequestResults) != 1 {
   423  		err = errors.Errorf("Expected one spot request info, but got %d",
   424  			len(resp.SpotRequestResults))
   425  		grip.Error(err)
   426  		return nil, err
   427  	}
   428  	return &resp.SpotRequestResults[0], nil
   429  }
   430  
   431  // CostForDuration computes the currency amount it costs to use the given host between a start and end time.
   432  // The Spot prices estimation takes both spot prices and EBS prices into account. Here's a breakdown:
   433  //
   434  // Spot prices are determined by a fluctuating price market. We set a bid price and get a host if the
   435  // "market" price is lower than that. We are billed by what the current spot price is, and then charged
   436  // the current spot price once our hour billing cycle is up, and so on. This calculator ONLY returns
   437  // the cost of the time used between the start and end times, it does not account for unused host time.
   438  //
   439  // EBS volumes are charged on a per-gigabyte-per-month rate for usage, rounded to the nearest hour.
   440  // There is no EBS price API, so we scrape it from Amazon's UI. This could unexpectedly break in the
   441  // future, but, so far, the JSON we are loading hasn't changed format in half a decade. EBS spending
   442  // for a single task ends up being virtually nothing compared to the machine price, but those fractions
   443  // of cents will add up over time.
   444  //
   445  // CostForDuration returns the total cost and any errors that occur.
   446  func (cloudManager *EC2SpotManager) CostForDuration(h *host.Host, start, end time.Time) (float64, error) {
   447  	// sanity check
   448  	if end.Before(start) || util.IsZeroTime(start) || util.IsZeroTime(end) {
   449  		return 0, errors.New("task timing data is malformed")
   450  	}
   451  
   452  	// grab instance details from EC2
   453  	spotDetails, err := cloudManager.describeSpotRequest(h.Id)
   454  	if err != nil {
   455  		return 0, err
   456  	}
   457  	ec2Handle := getUSEast(*cloudManager.awsCredentials)
   458  	instance, err := getInstanceInfo(ec2Handle, spotDetails.InstanceId)
   459  	if err != nil {
   460  		return 0, err
   461  	}
   462  	os := osLinux
   463  	if strings.Contains(h.Distro.Arch, "windows") {
   464  		os = osWindows
   465  	}
   466  	ebsCost, err := blockDeviceCosts(ec2Handle, instance.BlockDevices, end.Sub(start))
   467  	if err != nil {
   468  		return 0, errors.Wrap(err, "calculating block device costs")
   469  	}
   470  	spotCost, err := cloudManager.calculateSpotCost(instance, os, start, end)
   471  	if err != nil {
   472  		return 0, err
   473  	}
   474  	return spotCost + ebsCost, nil
   475  }
   476  
   477  // calculateSpotCost is a helper for fetching spot price history and computing the
   478  // cost of a task across a host's billing cycles.
   479  func (cloudManager *EC2SpotManager) calculateSpotCost(
   480  	i *ec2.Instance, os osType, start, end time.Time) (float64, error) {
   481  	launchTime, err := time.Parse(time.RFC3339, i.LaunchTime)
   482  	if err != nil {
   483  		return 0, errors.Wrap(err, "reading instance launch time")
   484  	}
   485  	rates, err := cloudManager.describeHourlySpotPriceHistory(
   486  		i.InstanceType, i.AvailabilityZone, os, launchTime, end)
   487  	if err != nil {
   488  		return 0, err
   489  	}
   490  	return spotCostForRange(start, end, rates), nil
   491  }
   492  
   493  // spotRate is an internal type for simplifying Amazon's price history responses.
   494  type spotRate struct {
   495  	Time  time.Time
   496  	Price float64
   497  }
   498  
   499  // spotCostForRange determines the price of a range of spot price history.
   500  // The hostRates parameter is expected to be a slice of (time, price) pairs
   501  // representing every hour billing cycle. The function iterates through billing
   502  // cycles, adding up the total cost of the time span across them.
   503  //
   504  // This problem, incidentally, may be a good algorithms interview question ;)
   505  func spotCostForRange(start, end time.Time, rates []spotRate) float64 {
   506  	cost := 0.0
   507  	cur := start
   508  	// this loop adds up the cost of a task over all the billing periods
   509  	// it ran within.
   510  	for i := range rates {
   511  		// if our start time is after the current billing range, keep skipping
   512  		// ahead until we find the starting range.
   513  		if i+1 < len(rates) && cur.After(rates[i+1].Time) {
   514  			continue
   515  		}
   516  		// if the task's end happens before the end of this billing period,
   517  		// we only want to calculate the cost between the billing start
   518  		// and task end, then exit; we also do this if we're in the last rate bucket.
   519  		if i+1 == len(rates) || end.Before(rates[i+1].Time) {
   520  			cost += float64(end.Sub(cur)) / float64(time.Hour) * rates[i].Price
   521  			break
   522  		}
   523  		// in the default case, we get the duration between our current time
   524  		// and the next billing period, and multiply that duration by the current price.
   525  		cost += float64(rates[i+1].Time.Sub(cur)) / float64(time.Hour) * rates[i].Price
   526  		cur = rates[i+1].Time
   527  	}
   528  	return cost
   529  }
   530  
   531  // describeHourlySpotPriceHistory talks to Amazon to get spot price history, then
   532  // simplifies that history into hourly billing rates starting from the supplied
   533  // start time. Returns a slice of hour-separated spot prices or any errors that occur.
   534  func (cloudManager *EC2SpotManager) describeHourlySpotPriceHistory(
   535  	iType string, zone string, os osType, start, end time.Time) ([]spotRate, error) {
   536  	ses, err := session.NewSession()
   537  	if err != nil {
   538  		return nil, errors.Wrap(err, "problem getting aws session")
   539  	}
   540  
   541  	svc := ec2sdk.New(ses, &awssdk.Config{
   542  		Region: awssdk.String(aws.USEast.Name),
   543  		Credentials: credentials.NewCredentials(&credentials.StaticProvider{
   544  			credentials.Value{
   545  				AccessKeyID:     cloudManager.awsCredentials.AccessKey,
   546  				SecretAccessKey: cloudManager.awsCredentials.SecretKey,
   547  			},
   548  		}),
   549  	})
   550  	// expand times to contain the full runtime of the host
   551  	startFilter, endFilter := start.Add(-5*time.Hour), end.Add(time.Hour)
   552  	osStr := string(os)
   553  	filter := &ec2sdk.DescribeSpotPriceHistoryInput{
   554  		InstanceTypes:       []*string{&iType},
   555  		ProductDescriptions: []*string{&osStr},
   556  		AvailabilityZone:    &zone,
   557  		StartTime:           &startFilter,
   558  		EndTime:             &endFilter,
   559  	}
   560  	// iterate through all pages of results (the helper that does this for us appears to be broken)
   561  	history := []*ec2sdk.SpotPrice{}
   562  	for {
   563  		h, err := svc.DescribeSpotPriceHistory(filter)
   564  		if err != nil {
   565  			return nil, errors.WithStack(err)
   566  		}
   567  		history = append(history, h.SpotPriceHistory...)
   568  		if *h.NextToken != "" {
   569  			filter.NextToken = h.NextToken
   570  		} else {
   571  			break
   572  		}
   573  	}
   574  	// this loop samples the spot price history (which includes updates for every few minutes)
   575  	// into hourly billing periods. The price we are billed for an hour of spot time is the
   576  	// current price at the start of the hour. Amazon returns spot price history sorted in
   577  	// decreasing time order. We iterate backwards through the list to
   578  	// pretend the ordering to increasing time.
   579  	prices := []spotRate{}
   580  	i := len(history) - 1
   581  	for i >= 0 {
   582  		// add the current hourly price if we're in the last result bucket
   583  		// OR our billing hour starts the same time as the data (very rare)
   584  		// OR our billing hour starts after the current bucket but before the next one
   585  		if i == 0 || start.Equal(*history[i].Timestamp) ||
   586  			start.After(*history[i].Timestamp) && start.Before(*history[i-1].Timestamp) {
   587  			price, err := strconv.ParseFloat(*history[i].SpotPrice, 64)
   588  			if err != nil {
   589  				return nil, errors.Wrap(err, "parsing spot price")
   590  			}
   591  			prices = append(prices, spotRate{Time: start, Price: price})
   592  			// we increment the hour but stay on the same price history index
   593  			// in case the current spot price spans more than one hour
   594  			start = start.Add(time.Hour)
   595  			if start.After(end) {
   596  				break
   597  			}
   598  		} else {
   599  			// continue iterating through our price history whenever we
   600  			// aren't matching the next billing hour
   601  			i--
   602  		}
   603  	}
   604  	return prices, nil
   605  }