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 }