diff --git a/cost/azure/savings_plan/utilization/CHANGELOG.md b/cost/azure/savings_plan/utilization/CHANGELOG.md new file mode 100644 index 0000000000..a1ed544621 --- /dev/null +++ b/cost/azure/savings_plan/utilization/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v0.1.0 + +- Initial release diff --git a/cost/azure/savings_plan/utilization/README.md b/cost/azure/savings_plan/utilization/README.md new file mode 100644 index 0000000000..f9922d7f86 --- /dev/null +++ b/cost/azure/savings_plan/utilization/README.md @@ -0,0 +1,44 @@ +# Azure Savings Plan Utilization + +## What It Does + +This Policy Template leverages the Azure Billing API ([Savings Plans By Savings Plan Order](https://learn.microsoft.com/en-us/rest/api/billing/savings-plans/list-by-savings-plan-order?view=rest-billing-2024-04-01&tabs=HTTP)) to report on Savings Plan utilization. It will notify only if utilization of a Savings Plan falls below the value specified in the `Maximum Savings Plan Utilization Threshold` field. It examines the Savings Plan utilization for the prior 1 day, 7 days or 30 days in making this determination. + +## Input Parameters + +This policy has the following input parameters required when launching the policy. + +- *Email Addresses* - Email addresses of the recipients you wish to notify when new incidents are created. +- *Azure Endpoint* - The endpoint to send Azure API requests to. Recommended to leave this at default unless using this policy with Azure China. +- *Look Back Period* - Number of days of prior Azure Savings Plan usage to analyze. +- *Maximum Savings Plan Utilization Threshold (%)* - Show Savings Plans with utilization below this value. + +## Policy Actions + +The following policy actions are taken on any resources found to be out of compliance. + +- Sends an email report + +## Prerequisites + +This Policy Template uses [Credentials](https://docs.flexera.com/flexera/EN/Automation/ManagingCredentialsExternal.htm) for authenticating to datasources -- in order to apply this policy you must have a Credential registered in the system that is compatible with this policy. If there are no Credentials listed when you apply the policy, please contact your Flexera Org Admin and ask them to register a Credential that is compatible with this policy. The information below should be consulted when creating the credential(s). + +### Credential configuration + +For administrators [creating and managing credentials](https://docs.flexera.com/flexera/EN/Automation/ManagingCredentialsExternal.htm) to use with this policy, the following information is needed: + +- [**Azure Resource Manager Credential**](https://docs.flexera.com/flexera/EN/Automation/ProviderCredentials.htm#automationadmin_109256743_1124668) (*provider=azure_rm*) which has the following permissions: + - `Microsoft.Billing/billingAccounts/read` + +- [**Flexera Credential**](https://docs.flexera.com/flexera/EN/Automation/ProviderCredentials.htm) (*provider=flexera*) which has the following roles: + - `billing_center_viewer` + +The [Provider-Specific Credentials](https://docs.flexera.com/flexera/EN/Automation/ProviderCredentials.htm) page in the docs has detailed instructions for setting up Credentials for the most common providers. + +## Supported Clouds + +- Azure + +## Cost + +This Policy Template does not incur any cloud costs. diff --git a/cost/azure/savings_plan/utilization/azure_savings_plan_utilization.pt b/cost/azure/savings_plan/utilization/azure_savings_plan_utilization.pt new file mode 100644 index 0000000000..fe3add5dad --- /dev/null +++ b/cost/azure/savings_plan/utilization/azure_savings_plan_utilization.pt @@ -0,0 +1,520 @@ +name "Azure Savings Plan Utilization" +rs_pt_ver 20180301 +type "policy" +short_description "Reports the utilization of Azure Savings Plans within the Azure estate. NOTE: These Utilization metrics are generated by Azure. See the [README](https://github.com/flexera-public/policy_templates/tree/master/cost/azure/savings_plan/utilization) and [docs.flexera.com/flexera/EN/Automation](https://docs.flexera.com/flexera/EN/Automation/AutomationGS.htm) to learn more." +long_description "" +severity "medium" +category "Cost" +default_frequency "monthly" +info( + version: "0.1.0", + provider: "Azure", + service: "Compute", + policy_set: "Savings Plans" +) + +############################################################################### +# Parameters +############################################################################### + +parameter "param_email" do + type "list" + category "Policy Settings" + label "Email Addresses" + description "Email addresses of the recipients you wish to notify" + default [] +end + +parameter "param_azure_endpoint" do + type "string" + category "Policy Settings" + label "Azure Endpoint" + description "Select the API endpoint to use for Azure. Use default value of management.azure.com unless using Azure China." + allowed_values "management.azure.com", "management.chinacloudapi.cn" + default "management.azure.com" +end + +parameter "param_utilization_threshold" do + type "number" + category "Policy Settings" + label "Maximum Savings Plan Utilization Threshold (%)" + description "Show Savings Plans with utilization below this value" + min_value 1 + max_value 100 + default 100 +end + +parameter "param_lookback_days" do + type "number" + category "Savings Plan Settings" + label "Look Back Period" + description "Number of days of prior Azure Savings Plan usage to analyze." + allowed_values 1, 7, 30 # Allowed values as per Azure API + default 30 +end + +############################################################################### +# Authentication +############################################################################### + +credentials "auth_azure" do + schemes "oauth2" + label "Azure" + description "Select the Azure Resource Manager Credential from the list." + tags "provider=azure_rm" +end + +credentials "auth_flexera" do + schemes "oauth2" + label "Flexera" + description "Select Flexera One OAuth2 credentials" + tags "provider=flexera" +end + +############################################################################### +# Pagination +############################################################################### + +pagination "pagination_azure" do + get_page_marker do + body_path "nextLink" + end + set_page_marker do + uri true + end +end + +############################################################################### +# Datasources & Scripts +############################################################################### + +# Get applied policy metadata for use later +datasource "ds_applied_policy" do + request do + auth $auth_flexera + host rs_governance_host + path join(["/api/governance/projects/", rs_project_id, "/applied_policies/", policy_id]) + header "Api-Version", "1.0" + end +end + +# Get currency reference +datasource "ds_currency_reference" do + request do + host "raw.githubusercontent.com" + path "/flexera-public/policy_templates/master/data/currency/currency_reference.json" + header "User-Agent", "RS Policies" + end +end + +# Get list of Billing Accounts +datasource "ds_billing_accounts" do + request do + auth $auth_azure + host $param_azure_endpoint + path "/providers/Microsoft.Billing/billingAccounts" + query "api-version", "2024-04-01" + end + result do + encoding "json" + collect jmes_path(response, "value") do + field "id", jmes_path(col_item, "id") + field "name", jmes_path(col_item, "name") + field "agreementType", jmes_path(col_item, "properties.agreementType") + field "currency", jmes_path(col_item, "enrollmentDetails.currency") + end + end +end + +# Get list of Billing Profiles (if Type is Microsoft Customer Agreement) +datasource "ds_billing_profiles" do + iterate $ds_billing_accounts + request do + auth $auth_azure + host $param_azure_endpoint + path join(["/providers/Microsoft.Billing/billingAccounts/", val(iter_item, "name"), "/billingProfiles" ]) + query "api-version", "2020-05-01" + ignore_status [400] # Required for policy to not error for EA Customers (as EA Customers do not have concept of billing profile) + end + result do + encoding "json" + collect jmes_path(response, "value") do + field "billingAccountId", val(iter_item, "id") + field "billingAccountName", val(iter_item, "name") + field "agreementType", val(iter_item, "agreementType") + field "billingAccountCurrency", val(iter_item, "currency") + field "billingProfileId", jmes_path(col_item, "id") + field "billingProfileName", jmes_path(col_item, "name") + field "billingProfileCurrency", jmes_path(col_item, "properties.currency") + end + end +end + +# Identify whether Billing Account is Enterprise Agreement (EA) or Microsoft Customer Agreement (MCA) +datasource "ds_billing_details" do + run_script $js_billing_details, $ds_billing_profiles, $ds_billing_accounts +end + +script "js_billing_details", type: "javascript" do + parameters "ds_billing_details", "ds_billing_accounts" + result "result" + code <<-EOS + result = [] + + _.each(ds_billing_accounts, function(acc) { + if (acc.agreementType == "EnterpriseAgreement") { + result.push({ + "billingAccountId": acc.id, + "billingAccountName": acc.name, + "billingProfileId": "N/A", + "billingProfileName": "N/A", + "agreementType": acc.agreementType, + "currency": acc.currency + }) + } else { + var billing_profiles = _.filter(ds_billing_details, function(prof) { return acc.name == prof.billingAccountName }) + _.each(billing_profiles, function(prof) { + result.push({ + "billingAccountId": prof.billingAccountId, + "billingAccountName": prof.billingAccountName, + "billingProfileId": prof.billingProfileId, + "billingProfileName": prof.billingProfileName, + "agreementType": prof.agreementType, + "currency": prof.billingProfileCurrency + }) + }) + } + }) + EOS +end + +# Get list of Savings Plan Order details +datasource "ds_savings_plan_details" do + iterate $ds_billing_details + request do + run_script $js_savings_plan_details, iter_item, $param_azure_endpoint + end + result do + encoding "json" + collect jmes_path(response, "value[*]") do + field "billingAccountId", val(iter_item, "billingAccountId") + field "billingAccountName", val(iter_item, "billingAccountName") + field "billingProfileId", val(iter_item, "billingProfileId") + field "billingProfileName", val(iter_item, "billingProfileName") + field "agreementType", val(iter_item, "agreementType") + field "savingsPlanOrderId", jmes_path(col_item, "id") + field "savingsPlanOrderName", jmes_path(col_item, "name") + end + end +end + +script "js_savings_plan_details", type: "javascript" do + parameters "ds_billing_detail", "param_azure_endpoint" + result "request" + code <<-EOS + + // Define whether $filter query_param is required (only required if MCA) + filter_query = "" + + if (ds_billing_detail.billingProfileId && ds_billing_detail.billingProfileId != "N/A") { + filter_query = "properties/billingProfileId eq '" + ds_billing_detail.billingProfileId + "'" + } + + query_params = { + "api-version": "2024-04-01" + } + + if (filter_query != "") { + query_params["$filter"] = filter_query + } + + // Create API request + var request = { + auth: "auth_azure", + pagination: "pagination_azure", + host: param_azure_endpoint, + path: ds_billing_detail.billingAccountId + "/savingsPlanOrders", + query_params: query_params, + headers: { + "User-Agent": "RS Policies" + } + } + EOS +end + +# Get list of Savings Plans for each Savings Plan Order +datasource "ds_savings_plan_order_details" do + iterate $ds_savings_plan_details + request do + auth $auth_azure + host $param_azure_endpoint + path join([val(iter_item, "savingsPlanOrderId"), "/savingsPlans"]) + query "api-version", "2024-04-01" + end + result do + encoding "json" + collect jmes_path(response, "value[*]") do + field "billingAccountId", val(iter_item, "billingAccountId") + field "billingAccountName", val(iter_item, "billingAccountName") + field "billingProfileId", val(iter_item, "billingProfileId") + field "billingProfileName", val(iter_item, "billingProfileName") + field "agreementType", val(iter_item, "agreementType") + field "savingsPlanOrderId", val(iter_item, "savingsPlanOrderId") + field "savingsPlanOrderName", val(iter_item, "savingsPlanOrderName") + field "billingPlan", jmes_path(col_item, "properties.billingPlan") + field "skuName", jmes_path(col_item, "sku.name") + field "productCode", jmes_path(col_item, "properties.productCode") + field "term", jmes_path(col_item, "properties.term") + field "scopeType", jmes_path(col_item, "properties.userFriendlyAppliedScopeType") + field "scopeProperties", jmes_path(col_item, "properties.appliedScopeProperties") + field "purchaseDate", jmes_path(col_item, "properties.purchaseDateTime") + field "benefitStartDate", jmes_path(col_item, "properties.benefitStartTime") + field "effectiveDate", jmes_path(col_item, "properties.effectiveDate") + field "expirationDate", jmes_path(col_item, "properties.expiryDateTime") + field "savingsPlanId", jmes_path(col_item, "id") + field "savingsPlanName", jmes_path(col_item, "name") + field "commitmentAmount", jmes_path(col_item, "properties.commitment.amount") + field "commitmentGranularity", jmes_path(col_item, "properties.commitment.grain") + field "commitmentCurrency", jmes_path(col_item, "properties.commitment.currencyCode") + field "autoRenew", jmes_path(col_item, "properties.renew") + field "utilization", jmes_path(col_item, "properties.utilization") + end + end +end + +# Filter Savings Plans based on user-defined Lookback Days parameter +datasource "ds_filtered_savings_plans" do + run_script $js_filtered_savings_plans, $ds_savings_plan_order_details, $param_lookback_days +end + +script "js_filtered_savings_plans", type: "javascript" do + parameters "ds_savings_plan_order_details", "param_lookback_days" + result "result" + code <<-EOS + result = _.map(ds_savings_plan_order_details, function(sp) { + + // Applied Scope Properties + if (sp.scopeProperties) { + if (sp.scopeProperties.managementGroupId) { + sp["scopeDetailId"] = sp.scopeProperties.managementGroupId + sp["scopeDetailName"] = sp.scopeProperties.displayName + } else if (sp.scopeProperties.subscriptionId) { + sp["scopeDetailId"] = sp.scopeProperties.subscriptionId + sp["scopeDetailName"] = sp.scopeProperties.displayName + } + + sp = _.omit(sp, "scopeProperties") + } else { + sp["scopeDetailId"] = "N/A" + sp["scopeDetailName"] = "N/A" + } + + // Utilization + if (sp.utilization ) { + if (sp.utilization.trend) { + sp["utilizationTrend"] = sp.utilization.trend + } + + if (_.size(sp.utilization.aggregates) > 0) { + // Add the trend and utilization values to the savings plan object + // This makes it easier to path to in the export + _.each(sp.utilization.aggregates, function(agg) { + sp["utilization_" + agg.grain + agg.grainUnit] = agg.value + }) + } + + utilization_aggregate = _.find(sp.utilization.aggregates, function(agg) { + return agg.grain == param_lookback_days + }) + + if (utilization_aggregate != undefined) { + sp["utilizationAmount"] = utilization_aggregate.value + sp["utilizationAmountUnit"] = utilization_aggregate.valueUnit + } + + sp = _.omit(sp, "utilization") + } + + return sp + }) + EOS +end + +# Organize data for Policy Incident +datasource "ds_sp_data_for_incident" do + run_script $js_sp_data_for_incident, $ds_filtered_savings_plans, $ds_currency_reference, $ds_applied_policy, $param_utilization_threshold +end + +script "js_sp_data_for_incident", type: "javascript" do + parameters "ds_filtered_savings_plans", "ds_currency_reference", "ds_applied_policy", "param_utilization_threshold" + result "result" + code <<-EOS + + // function for formatting spend number + function formatNumber(number, separator) { + var numString = number.toString(); + var values = numString.split("."); + var result = '' + while ( values[0].length > 3 ) { + var chunk = values[0].substr(-3) + values[0] = values[0].substr(0, values[0].length - 3) + result = separator + chunk + result + } + if ( values[0].length > 0 ) { + result = values[0] + result + } + if ( values[1] == undefined ) { + return result; + } + return result + "." + values[1]; + } + + // format costs with currency symbol and thousands separator + var cur = "", separator = "" + if ( _.size(ds_filtered_savings_plans) > 0 && ds_filtered_savings_plans[0].commitmentCurrency !== undefined ) { + if ( ds_currency_reference[ ds_filtered_savings_plans[0].commitmentCurrency ] !== undefined ) { + cur = ds_currency_reference[ ds_filtered_savings_plans[0].commitmentCurrency ].symbol + if ( ds_currency_reference[ ds_filtered_savings_plans[0].commitmentCurrency ].t_separator !== undefined ) { + separator = ds_currency_reference[ ds_filtered_savings_plans[0].commitmentCurrency ].t_separator + } else { + separator = "" + } + } else { + cur = "" + separator = "" + } + } else { + cur = "$" + separator = "," + } + + filtered_sps = _.reject(ds_filtered_savings_plans, function(sp) { + if (sp.utilizationAmount != null) { + return Number(sp.utilizationAmount) > param_utilization_threshold + } else { + return sp + } + }) + + total_wastage = 0 + _.each(filtered_sps, function(sp) { + if (sp.commitmentAmount && sp.commitmentAmount != null) { + total_wastage += Number(sp.commitmentAmount) + } + + sp["policyName"] = ds_applied_policy.name + }) + + total_wastage = cur + " " + formatNumber((Math.round(total_wastage * 100) / 100), separator) + + result = { + "savings_plan_details": filtered_sps, + "message": "The total estimated hourly wasted spend is " + total_wastage + } + EOS +end + +############################################################################### +# Policy +############################################################################### + +policy "pol_azure_sp_utilization" do + validate $ds_sp_data_for_incident do + summary_template "{{ with index data.savings_plan_details 0 }}{{ .policyName }}{{ end }}: {{ len data.savings_plan_details }} Azure Savings Plans - Last {{ parameters.param_lookback_days}} days" + detail_template <<-EOS +# Azure Savings Plan Utilization +{{ data.message }} + +{{ range data.savings_plan_details }} +### Savings Plan {{ .savingsPlanName }} for {{.scopeType}}: {{.scopeDetailId}} + +**Utilization:** + +- Last 1 Days: {{ .utilization_1days }}% +- Last 7 Days: {{ .utilization_7days }}% +- Last 30 Days: {{ .utilization_30days }}% +- Trend: {{ .utilizationTrend }} + +**Commitment:** + +- Term: {{ .term }} +- Scope: {{ .scopeType }} +- Amount: {{ .commitmentAmount }} +- Currency: {{ .commitmentCurrency }} +- Granularity: {{ .commitmentGranularity }} +___ +{{ end }} +###### Policy Applied in Account: {{ rs_project_name }} (Account ID: {{ rs_project_id }}) within Org: {{ rs_org_name }} (Org ID: {{ rs_org_id }}) +EOS + check eq(size(val(data, "savings_plan_details")), 0) + escalate $esc_email + export "savings_plan_details" do + resource_level false + field "agreementType" do + label "Agreement Type" + end + field "billingAccountId" do + label "Billing Account/Enrollment ID" + end + field "savingsPlanId" do + label "Savings Plan ID" + end + field "savingsPlanName" do + label "Savings Plan Name" + end + field "savingsPlanOrderId" do + label "Savings Plan Order ID" + end + field "savingsPlanOrderName" do + label "Savings Plan Order Name" + end + field "scopeType" do + label "Applied Scope Type" + end + field "scopeDetailId" do + label "Applied Scope Management Group/Subscription ID" + end + field "scopeDetailName" do + label "Applied Scope Management Group/Subscription Name" + end + field "skuName" do + label "SKU Name" + end + field "term" do + label "Term" + end + field "purchaseDate" do + label "Savings Plan Purchase Date" + end + field "expirationDate" do + label "Savings Plan Expiration Date" + end + field "utilizationAmount" do + label "Utilization Amount" + end + field "utilizationAmountUnit" do + label "Utilization Amount Unit" + end + field "commitmentAmount" do + label "Commitment Amount" + end + field "commitmentCurrency" do + label "Commitment Currency" + end + field "commitmentGranularity" do + label "Commitment Granularity" + end + end + end +end + +############################################################################### +# Escalations +############################################################################### + +escalation "esc_email" do + automatic true + label "Send Email" + description "Sends incident email" + email $param_email +end diff --git a/data/policy_permissions_list/master_policy_permissions_list.json b/data/policy_permissions_list/master_policy_permissions_list.json index 5a1da7f5ae..b06d591563 100644 --- a/data/policy_permissions_list/master_policy_permissions_list.json +++ b/data/policy_permissions_list/master_policy_permissions_list.json @@ -3636,6 +3636,33 @@ } ] }, + { + "id": "./cost/azure/savings_plan/utilization/azure_savings_plan_utilization.pt", + "name": "Azure Savings Plan Utilization", + "version": "0.1.0", + "providers": [ + { + "name": "azure_rm", + "permissions": [ + { + "name": "Microsoft.Billing/billingAccounts/read", + "read_only": true, + "required": true + } + ] + }, + { + "name": "flexera", + "permissions": [ + { + "name": "billing_center_viewer", + "read_only": true, + "required": true + } + ] + } + ] + }, { "id": "./cost/azure/savings_realized/azure_savings_realized.pt", "name": "Azure Savings Realized from Reservations", diff --git a/data/policy_permissions_list/master_policy_permissions_list.yaml b/data/policy_permissions_list/master_policy_permissions_list.yaml index 5cae103bdd..d02de73630 100644 --- a/data/policy_permissions_list/master_policy_permissions_list.yaml +++ b/data/policy_permissions_list/master_policy_permissions_list.yaml @@ -2123,6 +2123,20 @@ - name: billing_center_viewer read_only: true required: true +- id: "./cost/azure/savings_plan/utilization/azure_savings_plan_utilization.pt" + name: Azure Savings Plan Utilization + version: 0.1.0 + :providers: + - :name: azure_rm + :permissions: + - name: Microsoft.Billing/billingAccounts/read + read_only: true + required: true + - :name: flexera + :permissions: + - name: billing_center_viewer + read_only: true + required: true - id: "./cost/azure/savings_realized/azure_savings_realized.pt" name: Azure Savings Realized from Reservations version: 3.9.0 diff --git a/tools/policy_master_permission_generation/validated_policy_templates.yaml b/tools/policy_master_permission_generation/validated_policy_templates.yaml index e4ac445430..d754c321c2 100644 --- a/tools/policy_master_permission_generation/validated_policy_templates.yaml +++ b/tools/policy_master_permission_generation/validated_policy_templates.yaml @@ -127,6 +127,7 @@ validated_policy_templates: - "./cost/azure/rightsize_sql_storage/azure_rightsize_sql_storage.pt" - "./cost/azure/rightsize_synapse_sql_pools/azure_rightsize_synapse_sql_pools.pt" - "./cost/azure/savings_plan/recommendations/azure_savings_plan_recommendations.pt" +- "./cost/azure/savings_plan/utilization/azure_savings_plan_utilization.pt" - "./cost/azure/savings_realized/azure_savings_realized.pt" - "./cost/azure/schedule_instance/azure_schedule_instance.pt" - "./cost/azure/sql_servers_without_elastic_pool/azure_sql_servers_without_elastic_pool.pt"