Skip to content

Commit

Permalink
feat: add Saving Plans
Browse files Browse the repository at this point in the history
  • Loading branch information
maso7 committed May 24, 2023
1 parent 9a2db9d commit 400811a
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 21 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Usage of ./spot-price-exporter:
Comma separated list of Lifecycles (spot or ondemand) to get pricing for (defaults to *all*)
-instance-regexes string
Comma separated list of instance type regexes (defaults to *all*)
-saving-plan-types string
Comma separated list of saving plans types (defaults to *none)
```

## Installing the Chart
Expand Down
54 changes: 38 additions & 16 deletions exporter/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package exporter
import (
"context"
"regexp"
"strconv"
"sync"
"sync/atomic"
"time"
Expand All @@ -13,6 +14,10 @@ import (
log "github.com/sirupsen/logrus"
)

const (
AwsMaxResultsPerPage int32 = 100
)

// Exporter implements the prometheus.Exporter interface, and exports AWS Spot Price metrics.
type Exporter struct {
productDescriptions []string
Expand All @@ -25,6 +30,7 @@ type Exporter struct {
pricingMetrics map[string]*prometheus.GaugeVec
instances map[string]Instance
instanceRegexes []*regexp.Regexp
savingPlanTypes []string
awsCfg aws.Config
cache int
nextScrape time.Time
Expand All @@ -42,12 +48,15 @@ type scrapeResult struct {
InstanceLifecycle string
ProductDescription string
OperatingSystem string
SavingPlanOption string
SavingPlanDuration int
SavingPlanType string
Memory string
VCpu string
}

// NewExporter returns a new exporter of AWS EC2 Price metrics.
func NewExporter(pds []string, oss []string, regions []string, lifecycle []string, cache int, instanceRegexes []*regexp.Regexp) (*Exporter, error) {
func NewExporter(pds []string, oss []string, regions []string, lifecycle []string, cache int, instanceRegexes []*regexp.Regexp, savingPlanTypes []string) (*Exporter, error) {

e := Exporter{
productDescriptions: pds,
Expand All @@ -56,6 +65,7 @@ func NewExporter(pds []string, oss []string, regions []string, lifecycle []strin
lifecycle: lifecycle,
cache: cache,
instanceRegexes: instanceRegexes,
savingPlanTypes: savingPlanTypes,
nextScrape: time.Now(),
duration: prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "aws_pricing",
Expand Down Expand Up @@ -83,6 +93,7 @@ func NewExporter(pds []string, oss []string, regions []string, lifecycle []strin

e.initGauges()
e.getInstances()

return &e, nil
}

Expand All @@ -92,19 +103,19 @@ func (e *Exporter) initGauges() {
Namespace: "aws_pricing",
Name: "ec2",
Help: "Current price of the instance type.",
}, []string{"instance_lifecycle", "instance_type", "region", "availability_zone", "product_description", "operating_system", "memory", "vcpu"})
}, []string{"instance_lifecycle", "instance_type", "region", "availability_zone", "product_description", "operating_system", "saving_plan_option", "saving_plan_duration", "saving_plan_type", "memory", "vcpu"})

e.pricingMetrics["ec2_memory"] = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "aws_pricing",
Name: "ec2_memory",
Help: "Price of each GB of memory of the instance.",
}, []string{"instance_lifecycle", "instance_type", "region", "availability_zone"})
}, []string{"instance_lifecycle", "instance_type", "region", "availability_zone", "saving_plan_option", "saving_plan_duration", "saving_plan_type"})

e.pricingMetrics["ec2_vcpu"] = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "aws_pricing",
Name: "ec2_vcpu",
Help: "Price of each VCPU of the instance.",
}, []string{"instance_lifecycle", "instance_type", "region", "availability_zone"})
}, []string{"instance_lifecycle", "instance_type", "region", "availability_zone", "saving_plan_option", "saving_plan_duration", "saving_plan_type"})
}

// Describe outputs metric descriptions.
Expand Down Expand Up @@ -181,6 +192,11 @@ func (e *Exporter) scrape(scrapes chan<- scrapeResult) {
if contains(e.lifecycle, "ondemand") {
e.getOnDemandPricing(region, scrapes)
}

if len(e.savingPlanTypes) != 0 {
e.getSavingPlanPricing(region, scrapes)
}

return

}(region)
Expand Down Expand Up @@ -214,21 +230,27 @@ func (e *Exporter) setPricingMetrics(scrapes <-chan scrapeResult) {
var labels prometheus.Labels
if name == "ec2" {
labels = map[string]string{
"instance_lifecycle": scr.InstanceLifecycle,
"instance_type": scr.InstanceType,
"region": scr.Region,
"availability_zone": scr.AvailabilityZone,
"product_description": scr.ProductDescription,
"operating_system": scr.OperatingSystem,
"memory": scr.Memory,
"vcpu": scr.VCpu,
"instance_lifecycle": scr.InstanceLifecycle,
"instance_type": scr.InstanceType,
"region": scr.Region,
"availability_zone": scr.AvailabilityZone,
"product_description": scr.ProductDescription,
"operating_system": scr.OperatingSystem,
"saving_plan_option": scr.SavingPlanOption,
"saving_plan_duration": strconv.Itoa(scr.SavingPlanDuration),
"saving_plan_type": scr.SavingPlanType,
"memory": scr.Memory,
"vcpu": scr.VCpu,
}
} else if name == "ec2_memory" || name == "ec2_vcpu" {
labels = map[string]string{
"instance_lifecycle": scr.InstanceLifecycle,
"instance_type": scr.InstanceType,
"region": scr.Region,
"availability_zone": scr.AvailabilityZone,
"instance_lifecycle": scr.InstanceLifecycle,
"instance_type": scr.InstanceType,
"region": scr.Region,
"availability_zone": scr.AvailabilityZone,
"saving_plan_option": scr.SavingPlanOption,
"saving_plan_duration": strconv.Itoa(scr.SavingPlanDuration),
"saving_plan_type": scr.SavingPlanType,
}
}
e.pricingMetrics[name].With(labels).Set(float64(scr.Value))
Expand Down
4 changes: 2 additions & 2 deletions exporter/ondemand.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func (e *Exporter) getOnDemandPricing(region string, scrapes chan<- scrapeResult
pricingSvc,
&pricing.GetProductsInput{
ServiceCode: aws.String("AmazonEC2"),
MaxResults: aws.Int32(100),
MaxResults: aws.Int32(AwsMaxResultsPerPage),
Filters: []pricingtypes.Filter{
{
Field: aws.String("regionCode"),
Expand Down Expand Up @@ -66,7 +66,7 @@ func (e *Exporter) getOnDemandPricing(region string, scrapes chan<- scrapeResult
pricelist, err := pag.NextPage(context.TODO())

if err != nil {
log.WithError(err).Errorf("error while fetching spot price history [region=%s]", region)
log.WithError(err).Errorf("error while fetching ondemand price [region=%s]", region)
atomic.AddUint64(&e.errorCount, 1)
}

Expand Down
161 changes: 161 additions & 0 deletions exporter/savingplan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package exporter

import (
"context"
"strconv"
"sync/atomic"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/savingsplans"
savingsplansTypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types"
log "github.com/sirupsen/logrus"
)

type savingPlanProperties struct {
Region string
InstanceType string
InstanceFamily string
ProductDescription string
Tenancy string
}

func (e *Exporter) getSavingPlanPricing(region string, scrapes chan<- scrapeResult) {
tmpCfg := e.awsCfg

client := savingsplans.NewFromConfig(tmpCfg)

params := &savingsplans.DescribeSavingsPlansOfferingRatesInput{
MaxResults: *aws.Int32((AwsMaxResultsPerPage)),
SavingsPlanTypes: convertSavingsPlanType(e.savingPlanTypes),
ServiceCodes: []savingsplansTypes.SavingsPlanRateServiceCode{"AmazonEC2"},
Filters: []savingsplansTypes.SavingsPlanOfferingRateFilterElement{
{
Name: savingsplansTypes.SavingsPlanRateFilterAttributeRegion,
Values: []string{region},
},
{
Name: savingsplansTypes.SavingsPlanRateFilterAttributeTenancy,
Values: []string{"shared"},
},
{
Name: savingsplansTypes.SavingsPlanRateFilterAttributeProductDescription,
Values: e.productDescriptions,
},
},
}

savingPlanList := make([]savingsplansTypes.SavingsPlanOfferingRate, 0)

for {
resp, err := client.DescribeSavingsPlansOfferingRates(context.TODO(), params)

if err != nil {
log.WithError(err).Errorf("error while fetching saving plans [region=%s]", region)
atomic.AddUint64(&e.errorCount, 1)
}

savingPlanList = append(savingPlanList, resp.SearchResults...)

if *resp.NextToken == "" {
break
}

params.NextToken = resp.NextToken
}

for _, plan := range savingPlanList {
planProperties := convertPropertiesToStruct(plan.Properties)

if !isMatchAny(e.instanceRegexes, planProperties.InstanceType) {
log.Debugf("Skipping instance type: %s", planProperties.InstanceType)
continue
}

value, err := strconv.ParseFloat(*plan.Rate, 64)
if err != nil {
log.WithError(err).Errorf("error while parsing saving plan price value from API response [region=%s, type=%s]", region, planProperties.InstanceType)
atomic.AddUint64(&e.errorCount, 1)
}
log.Debugf("Creating new metric: ec2{region=%s, instance_type=%s, product_description=%s} = %v.", region, planProperties.InstanceType, planProperties.ProductDescription, value)

vcpu, memory := e.getNormalizedCost(value, planProperties.InstanceType)
scrapes <- scrapeResult{
Name: "ec2",
Value: value,
Region: region,
InstanceType: planProperties.InstanceType,
InstanceLifecycle: "ondemand",
ProductDescription: planProperties.ProductDescription,
SavingPlanOption: string(plan.SavingsPlanOffering.PaymentOption),
SavingPlanDuration: SecondsToYears(plan.SavingsPlanOffering.DurationSeconds),
SavingPlanType: string(plan.SavingsPlanOffering.PlanType),
Memory: e.getInstanceMemory(planProperties.InstanceType),
VCpu: e.getInstanceVCpu(planProperties.InstanceType),
}
scrapes <- scrapeResult{
Name: "ec2_memory",
Value: memory,
Region: region,
InstanceType: planProperties.InstanceType,
InstanceLifecycle: "ondemand",
SavingPlanOption: string(plan.SavingsPlanOffering.PaymentOption),
SavingPlanDuration: SecondsToYears(plan.SavingsPlanOffering.DurationSeconds),
SavingPlanType: string(plan.SavingsPlanOffering.PlanType),
}
scrapes <- scrapeResult{
Name: "ec2_vcpu",
Value: vcpu,
Region: region,
InstanceType: planProperties.InstanceType,
InstanceLifecycle: "ondemand",
SavingPlanOption: string(plan.SavingsPlanOffering.PaymentOption),
SavingPlanDuration: SecondsToYears(plan.SavingsPlanOffering.DurationSeconds),
SavingPlanType: string(plan.SavingsPlanOffering.PlanType),
}
}
}

func convertSavingsPlanType(spt []string) []savingsplansTypes.SavingsPlanType {
result := make([]savingsplansTypes.SavingsPlanType, 0)

for _, v := range spt {
result = append(result, savingsplansTypes.SavingsPlanType(v))
}

return result
}

func convertPropertiesToStruct(properties []savingsplansTypes.SavingsPlanOfferingRateProperty) savingPlanProperties {
result := savingPlanProperties{}

for _, property := range properties {
if property.Name != nil && property.Value != nil {
switch *property.Name {
case string(savingsplansTypes.SavingsPlanRatePropertyKeyRegion):
result.Region = *property.Value
case string(savingsplansTypes.SavingsPlanRatePropertyKeyInstanceType):
result.InstanceType = *property.Value
case string(savingsplansTypes.SavingsPlanRatePropertyKeyInstanceFamily):
result.InstanceFamily = *property.Value
case string(savingsplansTypes.SavingsPlanRatePropertyKeyProductDescription):
result.ProductDescription = *property.Value
case string(savingsplansTypes.SavingsPlanRatePropertyKeyTenancy):
result.Tenancy = *property.Value
}
}
}

return result
}

func SecondsToYears(seconds int64) int {
const secondsPerYear = 31536000 // seconds in 1 year

years := seconds / secondsPerYear

if years != 1 && years != 3 {
panic("Value could be only 1 or 3.")
}

return int(years)
}
1 change: 1 addition & 0 deletions exporter/spot.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func (e *Exporter) getSpotPricing(region string, scrapes chan<- scrapeResult) {
ec2Svc,
&ec2.DescribeSpotPriceHistoryInput{
StartTime: aws.Time(time.Now()),
MaxResults: aws.Int32(AwsMaxResultsPerPage),
ProductDescriptions: e.productDescriptions,
})
for pag.HasMorePages() {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect
github.com/aws/aws-sdk-go-v2/service/savingsplans v1.12.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.10 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/pricing v1.17.5 h1:89yKwg+Kn3jgjcpxzmbZYH0O
github.com/aws/aws-sdk-go-v2/service/pricing v1.17.5/go.mod h1:1YtXjD073MNbQvowCxfSsdhGUCJQOt04FVDcs8uYCmI=
github.com/aws/aws-sdk-go-v2/service/pricing v1.19.5 h1:27pEWARJW4+l8J5Ph+VYhtfloK4bO4EAh9NZflNPNuc=
github.com/aws/aws-sdk-go-v2/service/pricing v1.19.5/go.mod h1:0M3RD4kWATK59uPAopcN+fPzFtLixgPuSJ2oXEUuX6E=
github.com/aws/aws-sdk-go-v2/service/savingsplans v1.12.10 h1:ohbm2l0hBxEQIcjwo/uXr9mVqoxt8hMUDk8JX+/cnao=
github.com/aws/aws-sdk-go-v2/service/savingsplans v1.12.10/go.mod h1:RR7D+zgjUGkadImm7gtG9iBZ1FROKVf4/cjS7Q3x9oo=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.28 h1:gItLq3zBYyRDPmqAClgzTH8PBjDQGeyptYGHIwtYYNA=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.28/go.mod h1:wo/B7uUm/7zw/dWhBJ4FXuw1sySU5lyIhVg1Bu2yL9A=
github.com/aws/aws-sdk-go-v2/service/sso v1.12.9 h1:GAiaQWuQhQQui76KjuXeShmyXqECwQ0mGRMc/rwsL+c=
Expand Down
Loading

0 comments on commit 400811a

Please sign in to comment.