diff --git a/azure/plugin.go b/azure/plugin.go index e32a9034..b0edea03 100644 --- a/azure/plugin.go +++ b/azure/plugin.go @@ -144,6 +144,7 @@ func Plugin(ctx context.Context) *plugin.Plugin { "azure_redis_cache": tableAzureRedisCache(ctx), "azure_resource_group": tableAzureResourceGroup(ctx), "azure_resource_link": tableAzureResourceLink(ctx), + "azure_reservation_recommendation": tableAzureReservationRecommendation(ctx), "azure_role_assignment": tableAzureIamRoleAssignment(ctx), "azure_role_definition": tableAzureIamRoleDefinition(ctx), "azure_route_table": tableAzureRouteTable(ctx), diff --git a/azure/table_azure_reservation_recommendation.go b/azure/table_azure_reservation_recommendation.go new file mode 100644 index 00000000..385b80ba --- /dev/null +++ b/azure/table_azure_reservation_recommendation.go @@ -0,0 +1,377 @@ +package azure + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/profiles/latest/consumption/mgmt/consumption" + "github.com/turbot/steampipe-plugin-sdk/v5/grpc/proto" + "github.com/turbot/steampipe-plugin-sdk/v5/plugin/transform" + + "github.com/turbot/steampipe-plugin-sdk/v5/plugin" +) + +//// TABLE DEFINITION + +func tableAzureReservationRecommendation(ctx context.Context) *plugin.Table { + return &plugin.Table{ + Name: "azure_reservation_recommendation", + Description: "Azure Reservation Recommendation", + List: &plugin.ListConfig{ + Hydrate: listReservedInstanceRecomendations, + KeyColumns: plugin.KeyColumnSlice{ + {Name: "look_back_period", Require: plugin.Optional, Operators: []string{"="}}, + {Name: "resource_type", Require: plugin.Optional, Operators: []string{"="}}, + {Name: "scope", Require: plugin.Optional, Operators: []string{"="}}, + }, + }, + Columns: azureColumns([]*plugin.Column{ + { + Name: "name", + Type: proto.ColumnType_STRING, + Description: "The ID that uniquely identifies an event.", + }, + { + Name: "id", + Description: "The full qualified ARM ID of an event.", + Type: proto.ColumnType_STRING, + Transform: transform.FromGo(), + }, + { + Name: "kind", + Description: "Specifies the kind of reservation recommendation.", + Type: proto.ColumnType_STRING, + }, + { + Name: "look_back_period", + Description: "The number of days of usage to look back for recommendation. Allowed values Last7Days, Last30Days, Last60Days and default value is Last7Days.", + Type: proto.ColumnType_STRING, + Transform: transform.FromQual("look_back_period"), + Default: "Last7Days'", + }, + { + Name: "resource_type", + Description: "The type of resource for recommendation. Possible values are: VirtualMachines, SQLDatabases, PostgreSQL, ManagedDisk, MySQL, RedHat, MariaDB, RedisCache, CosmosDB, SqlDataWarehouse, SUSELinux, AppService, BlockBlob, AzureDataExplorer, VMwareCloudSimple and default value is VirtualMachines.", + Type: proto.ColumnType_STRING, + Transform: transform.FromQual("resource_type"), + Default: "VirtualMachines", + }, + { + Name: "scope", + Description: "Shared or single recommendation. allowed values 'Single' or 'Shared' and default value is Single.", + Type: proto.ColumnType_STRING, + Transform: transform.FromQual("scope"), + Default: "Single", + }, + { + Name: "etag", + Description: "The etag for the resource.", + Type: proto.ColumnType_STRING, + }, + { + Name: "type", + Description: "Resource type.", + Type: proto.ColumnType_STRING, + }, + { + Name: "sku", + Description: "Resource sku.", + Type: proto.ColumnType_STRING, + }, + + // JSON fields + { + Name: "legacy_recommendation_properties", + Description: "The legacy recommendation properties.", + Type: proto.ColumnType_JSON, + }, + { + Name: "modern_recommendation_properties", + Description: "The legacy recommendation properties.", + Type: proto.ColumnType_JSON, + }, + + // Steampipe standard columns + { + Name: "title", + Description: ColumnDescriptionTitle, + Type: proto.ColumnType_STRING, + Transform: transform.FromField("Name"), + }, + { + Name: "tags", + Description: ColumnDescriptionTags, + Type: proto.ColumnType_JSON, + }, + { + Name: "akas", + Description: ColumnDescriptionAkas, + Type: proto.ColumnType_JSON, + Transform: transform.FromField("ID").Transform(idToAkas), + }, + + // Azure standard columns + { + Name: "region", + Description: ColumnDescriptionRegion, + Type: proto.ColumnType_STRING, + Transform: transform.FromField("Location").Transform(toLower), + }, + }), + } +} + +type RecomendationInfo struct { + LegacyRecommendationProperties map[string]interface{} + ModernRecommendationProperties map[string]interface{} + ID *string + Name *string + Type *string + Etag *string + Tags map[string]*string + Location *string + Sku *string + Kind consumption.KindBasicReservationRecommendation +} + +//// LIST FUNCTION + +func listReservedInstanceRecomendations(ctx context.Context, d *plugin.QueryData, _ *plugin.HydrateData) (interface{}, error) { + session, err := GetNewSession(ctx, d, "MANAGEMENT") + if err != nil { + return nil, err + } + subscriptionID := session.SubscriptionID + + reservedInstanceClient := consumption.NewReservationRecommendationsClientWithBaseURI(session.ResourceManagerEndpoint, subscriptionID) + reservedInstanceClient.Authorizer = session.Authorizer + + // E.g: properties/scope eq 'Single' AND properties/lookBackPeriod eq 'Last7Days' AND properties/resourceType eq 'VirtualMachines'" + filter := buildReservationRecomendationFilter(d.Quals) + + recommendationScope := "/subscriptions/"+subscriptionID+"/" + result, err := reservedInstanceClient.List(ctx, recommendationScope, filter) + if err != nil { + return nil, err + } + for _, recomendation := range result.Values() { + for _, r := range getReservationRecomendationProperties(recomendation) { + d.StreamListItem(ctx, r) + + // Check if context has been cancelled or if the limit has been hit (if specified) + // if there is a limit, it will return the number of rows required to reach this limit + if d.RowsRemaining(ctx) == 0 { + return nil, nil + } + } + } + + for result.NotDone() { + err = result.NextWithContext(ctx) + if err != nil { + return nil, err + } + for _, recomendation := range result.Values() { + for _, r := range getReservationRecomendationProperties(recomendation) { + d.StreamListItem(ctx, r) + + // Check if context has been cancelled or if the limit has been hit (if specified) + // if there is a limit, it will return the number of rows required to reach this limit + if d.RowsRemaining(ctx) == 0 { + return nil, nil + } + } + } + } + + return nil, err +} + +//// EXTRACT PROPERTIES + +func getReservationRecomendationProperties(data consumption.BasicReservationRecommendation) []*RecomendationInfo { + var results []*RecomendationInfo + lInfo, isLegacy := data.AsLegacyReservationRecommendation() + mInfo, isModern := data.AsModernReservationRecommendation() + info, is := data.AsReservationRecommendation() + if is { + result := &RecomendationInfo{} + result.Etag = info.Etag + result.ID = info.ID + result.Kind = info.Kind + result.Location = info.Location + result.Name = info.Name + result.Sku = info.Sku + result.Tags = info.Tags + result.Type = info.Type + results = append(results, result) + } + if isModern { + result := &RecomendationInfo{} + result.Etag = mInfo.Etag + result.ID = mInfo.ID + result.Kind = mInfo.Kind + result.Location = mInfo.Location + result.Name = mInfo.Name + result.Sku = mInfo.Sku + result.Tags = mInfo.Tags + result.Type = mInfo.Type + result.ModernRecommendationProperties = extractRecomemendationProperties(mInfo.ModernReservationRecommendationProperties) + results = append(results, result) + } + + if isLegacy { + result := &RecomendationInfo{} + result.Etag = lInfo.Etag + result.ID = lInfo.ID + result.Kind = lInfo.Kind + result.Location = lInfo.Location + result.Name = lInfo.Name + result.Sku = lInfo.Sku + result.Tags = lInfo.Tags + result.Type = lInfo.Type + result.LegacyRecommendationProperties = extractRecomemendationProperties(lInfo.LegacyReservationRecommendationProperties) + results = append(results, result) + } + + return results +} + +func extractRecomemendationProperties(r interface{}) map[string]interface{} { + objectMap := make(map[string]interface{}) + switch item := r.(type) { + case *consumption.LegacyReservationRecommendationProperties: + if item != nil { + if item.LookBackPeriod != nil { + objectMap["LookBackPeriod"] = *item.LookBackPeriod + } + if item.InstanceFlexibilityRatio != nil { + objectMap["InstanceFlexibilityRatio"] = *item.InstanceFlexibilityRatio + } + if item.InstanceFlexibilityGroup != nil { + objectMap["InstanceFlexibilityGroup"] = *item.InstanceFlexibilityGroup + } + if item.NormalizedSize != nil { + objectMap["NormalizedSize"] = *item.NormalizedSize + } + if item.RecommendedQuantityNormalized != nil { + objectMap["RecommendedQuantityNormalized"] = *item.RecommendedQuantityNormalized + } + if item.MeterID != nil { + objectMap["MeterID"] = *item.MeterID + } + if item.ResourceType != nil { + objectMap["ResourceType"] = *item.ResourceType + } + if item.Term != nil { + objectMap["Term"] = *item.Term + } + if item.CostWithNoReservedInstances != nil { + objectMap["CostWithNoReservedInstances"] = *item.CostWithNoReservedInstances + } + if item.RecommendedQuantity != nil { + objectMap["RecommendedQuantity"] = *item.RecommendedQuantity + } + if item.TotalCostWithReservedInstances != nil { + objectMap["TotalCostWithReservedInstances"] = *item.TotalCostWithReservedInstances + } + if item.NetSavings != nil { + objectMap["NetSavings"] = *item.NetSavings + } + if item.FirstUsageDate != nil { + objectMap["FirstUsageDate"] = *item.FirstUsageDate + } + if item.Scope != nil { + objectMap["Scope"] = *item.Scope + } + if item.SkuProperties != nil { + objectMap["SkuProperties"] = *item.SkuProperties + } + } + case *consumption.ModernReservationRecommendationProperties: + if item != nil { + if item.Location != nil { + objectMap["Location"] = *item.Location + } + if item.LookBackPeriod != nil { + objectMap["LookBackPeriod"] = *item.LookBackPeriod + } + if item.InstanceFlexibilityRatio != nil { + objectMap["InstanceFlexibilityRatio"] = *item.InstanceFlexibilityRatio + } + if item.InstanceFlexibilityGroup != nil { + objectMap["InstanceFlexibilityGroup"] = *item.InstanceFlexibilityGroup + } + if item.NormalizedSize != nil { + objectMap["NormalizedSize"] = *item.NormalizedSize + } + if item.RecommendedQuantityNormalized != nil { + objectMap["RecommendedQuantityNormalized"] = *item.RecommendedQuantityNormalized + } + if item.MeterID != nil { + objectMap["MeterID"] = *item.MeterID + } + if item.Term != nil { + objectMap["Term"] = *item.Term + } + if item.CostWithNoReservedInstances != nil { + objectMap["CostWithNoReservedInstances"] = *item.CostWithNoReservedInstances + } + if item.RecommendedQuantity != nil { + objectMap["RecommendedQuantity"] = *item.RecommendedQuantity + } + if item.TotalCostWithReservedInstances != nil { + objectMap["TotalCostWithReservedInstances"] = *item.TotalCostWithReservedInstances + } + if item.NetSavings != nil { + objectMap["NetSavings"] = *item.NetSavings + } + if item.FirstUsageDate != nil { + objectMap["FirstUsageDate"] = *item.FirstUsageDate + } + if item.Scope != nil { + objectMap["Scope"] = *item.Scope + } + if item.SkuProperties != nil { + objectMap["SkuProperties"] = *item.SkuProperties + } + if item.SkuName != nil { + objectMap["SkuName"] = *item.SkuName + } + if item.ResourceType != nil { + objectMap["ResourceType"] = *item.ResourceType + } + if item.SubscriptionID != nil { + objectMap["SubscriptionID"] = *item.SubscriptionID + } + } + } + return objectMap +} + +//// BUILD INPUT FILTER FROM QUALS VALUE + +func buildReservationRecomendationFilter(quals plugin.KeyColumnQualMap) string { + filter := "" + + filterQuals := map[string]string{ + "look_back_period": "properties/lookBackPeriod", + "resource_type": "properties/resourceType", + "scope": "properties/scope", + } + + for columnName, filterName := range filterQuals { + if quals[columnName] != nil { + for _, q := range quals[columnName].Quals { + if q.Operator == "=" { + if filter == "" { + filter = filterName + " eq " + "'"+q.Value.GetStringValue()+"'" + } else { + filter += " AND " + filterName + " eq " + "'"+q.Value.GetStringValue()+"'" + } + } + } + } + } + + return filter +} diff --git a/docs/tables/azure_reservation_recommendation.md b/docs/tables/azure_reservation_recommendation.md new file mode 100644 index 00000000..b5b9476b --- /dev/null +++ b/docs/tables/azure_reservation_recommendation.md @@ -0,0 +1,108 @@ +# Table: azure_reservation_recommendation + +Azure Reservations help you save money by committing to one-year or three-year plans for multiple products. Committing allows you to get a discount on the resources you use. Reservations can significantly reduce your resource costs by up to 72% from pay-as-you-go prices. Reservations provide a billing discount and don't affect the runtime state of your resources. After you purchase a reservation, the discount automatically applies to matching resources. + +**Note:** We can filter out the recommendations by using the columns `look_back_period`, `resource_type` or `scope` values in the query parameter. By default the table returns the data of resource type `VirtualMachines` with the scope `Single` for the last seven days of usage to look back for recommendation. + +## Examples + +### Basic info + +```sql +select + name, + id, + region, + scope, + etag, + type +from + azure_reservation_recommendation; +``` + +### Get reservation recommendation details for the last 30 days + +```sql +select + name, + tags, + sku, + look_back_period +from + azure_reservation_recommendation +where + look_back_period = 'Last30Days'; +``` + +### List reservation recommendation of the resource type MySQL + +```sql +select + name, + tags, + sku, + look_back_period, + resource_type +from + azure_reservation_recommendation +where + resource_type = 'MySQL'; +``` + +### Get legacy resrvation recommendation properties + +```sql +select + name, + id, + legacy_recommendation_properties ->> 'LookBackPeriod' as look_back_period, + legacy_recommendation_properties ->> 'InstanceFlexibilityRatio' as instance_flexibility_ratio, + legacy_recommendation_properties ->> 'InstanceFlexibilityGroup' as instance_flexibility_group, + legacy_recommendation_properties ->> 'NormalizedSize' as normalized_size, + legacy_recommendation_properties ->> 'RecommendedQuantityNormalized' as recommended_quantity_normalized, + legacy_recommendation_properties -> 'MeterID' as meter_id, + legacy_recommendation_properties ->> 'ResourceType' as resource_type, + legacy_recommendation_properties ->> 'Term' as term, + legacy_recommendation_properties -> 'CostWithNoReservedInstances' as cost_with_no_reserved_instances, + legacy_recommendation_properties -> 'RecommendedQuantity' as recommended_quantity, + legacy_recommendation_properties -> 'TotalCostWithReservedInstances' as total_cost_with_reserved_instances, + legacy_recommendation_properties -> 'NetSavings' as net_savings, + legacy_recommendation_properties ->> 'FirstUsageDate' as first_usage_date, + legacy_recommendation_properties ->> 'Scope' as scope, + legacy_recommendation_properties -> 'SkuProperties' as sku_properties +from + azure_reservation_recommendation +where + kind = 'legacy'; +``` + +### Get modern resrvation recommendation properties + +```sql +select + name, + id, + modern_recommendation_properties ->> 'Location' as location, + modern_recommendation_properties ->> 'LookBackPeriod' as look_back_period, + modern_recommendation_properties ->> 'InstanceFlexibilityRatio' as instance_flexibility_ratio, + modern_recommendation_properties ->> 'InstanceFlexibilityGroup' as instance_flexibility_group, + modern_recommendation_properties ->> 'NormalizedSize' as normalized_size, + modern_recommendation_properties ->> 'RecommendedQuantityNormalized' as recommended_quantity_normalized, + modern_recommendation_properties -> 'MeterID' as meter_id, + modern_recommendation_properties ->> 'ResourceType' as resource_type, + modern_recommendation_properties ->> 'Term' as term, + modern_recommendation_properties -> 'CostWithNoReservedInstances' as cost_with_no_reserved_instances, + modern_recommendation_properties -> 'RecommendedQuantity' as recommended_quantity, + modern_recommendation_properties -> 'TotalCostWithReservedInstances' as total_cost_with_reserved_instances, + modern_recommendation_properties -> 'NetSavings' as net_savings, + modern_recommendation_properties ->> 'FirstUsageDate' as first_usage_date, + modern_recommendation_properties ->> 'Scope' as scope, + modern_recommendation_properties -> 'SkuProperties' as sku_properties, + modern_recommendation_properties ->> 'SubscriptionID' as subscription_id, + modern_recommendation_properties ->> 'ResourceType' as resource_type, + modern_recommendation_properties ->> 'SkuName' as sku_name +from + azure_reservation_recommendation +where + kind = 'modern'; +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 17fde63a..8546a14c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( github.com/Azure/azure-sdk-for-go v58.0.0+incompatible + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1 github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/recoveryservices/armrecoveryservicesbackup/v3 v3.0.0 github.com/Azure/azure-storage-blob-go v0.12.0 @@ -24,7 +25,6 @@ require ( cloud.google.com/go/storage v1.30.1 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/adal v0.9.10 // indirect @@ -104,6 +104,7 @@ require ( github.com/prometheus/procfs v0.8.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sethvargo/go-retry v0.2.4 // indirect + github.com/shopspring/decimal v1.3.1 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/stevenle/topsort v0.2.0 // indirect diff --git a/go.sum b/go.sum index 68b45d0b..28b398d8 100644 --- a/go.sum +++ b/go.sum @@ -314,8 +314,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= -github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -382,7 +382,6 @@ github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPh github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -664,6 +663,8 @@ github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=