diff --git a/README.md b/README.md index 73e8101..7b0ff16 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/exporter/exporter.go b/exporter/exporter.go index 741e95e..96c730d 100644 --- a/exporter/exporter.go +++ b/exporter/exporter.go @@ -3,6 +3,7 @@ package exporter import ( "context" "regexp" + "strconv" "sync" "sync/atomic" "time" @@ -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 @@ -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 @@ -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, @@ -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", @@ -83,6 +93,7 @@ func NewExporter(pds []string, oss []string, regions []string, lifecycle []strin e.initGauges() e.getInstances() + return &e, nil } @@ -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. @@ -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) @@ -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)) diff --git a/exporter/ondemand.go b/exporter/ondemand.go index 545fa32..1668ab0 100644 --- a/exporter/ondemand.go +++ b/exporter/ondemand.go @@ -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"), @@ -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) } diff --git a/exporter/savingplan.go b/exporter/savingplan.go new file mode 100644 index 0000000..2043aab --- /dev/null +++ b/exporter/savingplan.go @@ -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) +} diff --git a/exporter/spot.go b/exporter/spot.go index 4c3e870..c400f47 100644 --- a/exporter/spot.go +++ b/exporter/spot.go @@ -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() { diff --git a/go.mod b/go.mod index e3338da..4f90e46 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 30444ca..0afdab2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 5099440..5d8fd51 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,8 @@ var ( regions = flag.String("regions", "", "Comma separated list of AWS regions to get pricing for (defaults to *all*)") lifecycle = flag.String("lifecycle", "", "Comma separated list of Lifecycles (spot or ondemand) to get pricing for (defaults to *all*)") cache = flag.Int("cache", 0, "How long should the results be cached, in seconds (defaults to *0*)") - instanceRegexes = flag.String("instance-regexes", "", "Comma separated list of instance type regexes (defaults to *all*)") + instanceRegexes = flag.String("instance-regexes", "", "Comma separated list of instance types regexes (defaults to *all*)") + savingPlanTypes = flag.String("saving-plan-types", "", "Comma separated list of saving plans types (defaults to *none)") ) func init() { @@ -41,7 +42,7 @@ func init() { } func main() { - log.Infof("Starting AWS EC2 Price exporter. [log-level=%s, regions=%s, product-descriptions=%s, operating-systems=%s, cache=%d, lifecycle=%s, instance-regexes=%s]", *rawLevel, *regions, *productDescriptions, *operatingSystems, *cache, *lifecycle, *instanceRegexes) + log.Infof("Starting AWS EC2 Price exporter. [log-level=%s, regions=%s, product-descriptions=%s, operating-systems=%s, cache=%d, lifecycle=%s, instance-regexes=%s, saving-plan-types=%s]", *rawLevel, *regions, *productDescriptions, *operatingSystems, *cache, *lifecycle, *instanceRegexes, *savingPlanTypes) var reg []string if len(*regions) == 0 { @@ -82,9 +83,15 @@ func main() { return } + spt := splitAndTrim(*savingPlanTypes) + validateProductDesc(pds) validateOperatingSystems(oss) - exporter, err := exporter.NewExporter(pds, oss, reg, lc, *cache, instRegCompiled) + validateSavingPlanTypes(spt) + + exporter.NewExporter(pds, oss, reg, lc, *cache, instRegCompiled, spt) + + exporter, err := exporter.NewExporter(pds, oss, reg, lc, *cache, instRegCompiled, spt) if err != nil { log.Fatal(err) } @@ -128,6 +135,17 @@ func validateOperatingSystems(oss []string) { } } +func validateSavingPlanTypes(spt []string) { + for _, plan := range spt { + if plan != "" && + plan != "Compute" && + plan != "EC2Instance" && + plan != "SageMaker" { + log.Fatalf("SavingPlan type '%s' is not recognized. Available SavingPlans types: Compute, EC2Instance, SageMaker", plan) + } + } +} + func rootHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`