From 7bcc5d0e29150ab14c8021dfcc6f686e052dac09 Mon Sep 17 00:00:00 2001 From: ItsAJ1005 Date: Wed, 17 Sep 2025 02:21:50 +0530 Subject: [PATCH 1/2] Add - cost analysis, budgets --- analysis/cost-analysis.js | 288 +++++++++++++++++++ analysis/cost-per-request.js | 229 +++++++++++++++ infrastructure/modules/budgets/main.tf | 80 ++++++ infrastructure/modules/budgets/outputs.tf | 36 +++ infrastructure/modules/budgets/variables.tf | 82 ++++++ policies/budget-compliance-policy.json | 133 +++++++++ policies/restrict-expensive-skus-policy.json | 186 ++++++++++++ runbooks/mitigation_runbook.ps1 | 270 +++++++++++++++++ scripts/set-budget.sh | 270 +++++++++++++++++ scripts/test-budget-webhook.sh | 233 +++++++++++++++ 10 files changed, 1807 insertions(+) create mode 100644 analysis/cost-analysis.js create mode 100644 analysis/cost-per-request.js create mode 100644 infrastructure/modules/budgets/main.tf create mode 100644 infrastructure/modules/budgets/outputs.tf create mode 100644 infrastructure/modules/budgets/variables.tf create mode 100644 policies/budget-compliance-policy.json create mode 100644 policies/restrict-expensive-skus-policy.json create mode 100644 runbooks/mitigation_runbook.ps1 create mode 100644 scripts/set-budget.sh create mode 100644 scripts/test-budget-webhook.sh diff --git a/analysis/cost-analysis.js b/analysis/cost-analysis.js new file mode 100644 index 0000000..a13a733 --- /dev/null +++ b/analysis/cost-analysis.js @@ -0,0 +1,288 @@ +#!/usr/bin/env node + +const { DefaultAzureCredential } = require('@azure/identity'); +const { CostManagementClient } = require('@azure/arm-costmanagement'); +const { SubscriptionClient } = require('@azure/arm-subscriptions'); +const { program } = require('commander'); +const moment = require('moment'); +const fs = require('fs'); +const path = require('path'); + +// Configure command line options +program + .requiredOption('--from ', 'Start date (YYYY-MM-DD)') + .requiredOption('--to ', 'End date (YYYY-MM-DD)') + .option('--subscription-id ', 'Azure subscription ID (default: all accessible subscriptions)') + .option('--resource-group ', 'Filter by resource group') + .option('--tag ', 'Filter by tag (format: key=value)') + .option('--output ', 'Output format: json, csv, table', 'json') + .option('--output-file ', 'Output file path') + .parse(process.argv); + +const options = program.opts(); + +// Validate date format +function isValidDate(dateString) { + return moment(dateString, 'YYYY-MM-DD', true).isValid(); +} + +if (!isValidDate(options.from) || !isValidDate(options.to)) { + console.error('Error: Invalid date format. Please use YYYY-MM-DD'); + process.exit(1); +} + +const startDate = moment(options.from).startOf('day').toISOString(); +const endDate = moment(options.to).endOf('day').toISOString(); + +// Initialize Azure clients +const credential = new DefaultAzureCredential(); +const costClient = new CostManagementClient(credential); + +// Helper function to format currency +function formatCurrency(amount, currency = 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(amount); +} + +// Helper function to process cost data +function processCostData(data) { + if (!data || !data.rows) { + console.warn('No cost data found for the specified criteria'); + return []; + } + + const columns = data.columns.map(col => col.name); + return data.rows.map(row => { + const entry = {}; + row.forEach((value, index) => { + entry[columns[index]] = value; + }); + return entry; + }); +} + +// Get cost data for a subscription +async function getSubscriptionCosts(subscriptionId, scope) { + try { + const query = { + type: 'Usage', + timeframe: 'Custom', + timePeriod: { + from: new Date(startDate), + to: new Date(endDate) + }, + dataset: { + granularity: 'Daily', + aggregation: { + totalCost: { + name: 'Cost', + function: 'Sum' + }, + totalCostUSD: { + name: 'CostUSD', + function: 'Sum' + } + }, + grouping: [ + { + type: 'Dimension', + name: 'ResourceGroup' + }, + { + type: 'Dimension', + name: 'ServiceName' + }, + { + type: 'Dimension', + name: 'ResourceId' + } + ] + } + }; + + // Add tag filter if specified + if (options.tag) { + const [key, value] = options.tag.split('='); + if (key && value) { + query.dataset.filter = { + tags: { + name: key, + operator: 'In', + values: [value] + } + }; + } + } + + // Add resource group filter if specified + if (options.resourceGroup) { + if (!query.dataset.filter) { + query.dataset.filter = {}; + } + query.dataset.filter.and = [ + ...(query.dataset.filter.and || []), + { + dimensions: { + name: 'ResourceGroupName', + operator: 'In', + values: [options.resourceGroup] + } + } + ]; + } + + const result = await costClient.query.usage(scope, query); + return processCostData(result); + } catch (error) { + console.error(`Error fetching cost data for subscription ${subscriptionId}:`, error.message); + return []; + } +} + +// Main function +async function main() { + try { + let subscriptions = []; + + // Get subscription(s) + if (options.subscriptionId) { + subscriptions = [{ subscriptionId: options.subscriptionId }]; + } else { + const subscriptionClient = new SubscriptionClient(credential); + for await (const subscription of subscriptionClient.subscriptions.list()) { + subscriptions.push(subscription); + } + } + + if (subscriptions.length === 0) { + console.error('No accessible subscriptions found'); + process.exit(1); + } + + // Get costs for each subscription + let allCosts = []; + for (const sub of subscriptions) { + const scope = `/subscriptions/${sub.subscriptionId}`; + console.log(`Fetching cost data for subscription: ${sub.displayName || sub.subscriptionId}`); + + const costs = await getSubscriptionCosts(sub.subscriptionId, scope); + allCosts = [...allCosts, ...costs]; + } + + // Process and output results + if (allCosts.length === 0) { + console.log('No cost data found for the specified criteria'); + return; + } + + // Aggregate costs by resource group and service + const aggregated = allCosts.reduce((acc, item) => { + const key = `${item.ResourceGroup || 'NoRG'}|${item.ServiceName || 'Unknown'}`; + if (!acc[key]) { + acc[key] = { + resourceGroup: item.ResourceGroup || 'No Resource Group', + service: item.ServiceName || 'Unknown', + cost: 0, + costUSD: 0 + }; + } + acc[key].cost += parseFloat(item.Cost || 0); + acc[key].costUSD += parseFloat(item.CostUSD || 0); + return acc; + }, {}); + + const results = Object.values(aggregated).sort((a, b) => b.costUSD - a.costUSD); + + // Calculate totals + const totalCost = results.reduce((sum, item) => sum + item.cost, 0); + const totalCostUSD = results.reduce((sum, item) => sum + item.costUSD, 0); + + // Prepare output + const output = { + metadata: { + query: { + from: startDate, + to: endDate, + subscriptionId: options.subscriptionId || 'all', + resourceGroup: options.resourceGroup || 'all', + tag: options.tag || 'none' + }, + totals: { + cost: totalCost, + costUSD: totalCostUSD, + currency: 'USD', + resourceGroups: new Set(results.map(r => r.resourceGroup)).size, + services: new Set(results.map(r => r.service)).size + } + }, + results: results.map(item => ({ + resourceGroup: item.resourceGroup, + service: item.service, + cost: item.cost, + costUSD: item.costUSD, + percentage: (item.costUSD / totalCostUSD) * 100 + })) + }; + + // Output results + let outputStr; + switch (options.output.toLowerCase()) { + case 'csv': + outputStr = 'Resource Group,Service,Cost,Cost (USD),Percentage\n'; + output.results.forEach(item => { + outputStr += `"${item.resourceGroup}","${item.service}",${item.cost},${item.costUSD},${item.percentage.toFixed(2)}%\n`; + }); + outputStr += `\nTotal,,${output.metadata.totals.cost},${output.metadata.totals.costUSD},100%`; + break; + + case 'table': + console.log('\nCost Analysis Report'); + console.log('==================='); + console.log(`Period: ${moment(startDate).format('MMM D, YYYY')} to ${moment(endDate).format('MMM D, YYYY')}`); + console.log(`Subscriptions: ${subscriptions.length}`); + console.log(`Total Cost: ${formatCurrency(output.metadata.totals.costUSD)}\n`); + + console.log('Cost by Resource Group and Service:'); + console.log('----------------------------------'); + console.log('Resource Group'.padEnd(30) + 'Service'.padEnd(30) + 'Cost (USD)'.padStart(15) + ' %'.padStart(8)); + console.log('-'.repeat(85)); + + output.results.forEach(item => { + console.log( + item.resourceGroup.padEnd(30).substring(0, 30) + + item.service.padEnd(30).substring(0, 30) + + formatCurrency(item.costUSD).padStart(15) + + item.percentage.toFixed(1).padStart(8) + '%' + ); + }); + + console.log('\nReport generated at: ' + new Date().toISOString()); + return; // Skip file output for table format + + case 'json': + default: + outputStr = JSON.stringify(output, null, 2); + } + + // Write to file or console + if (options.outputFile) { + const outputPath = path.resolve(process.cwd(), options.outputFile); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, outputStr); + console.log(`Report saved to: ${outputPath}`); + } else { + console.log(outputStr); + } + + } catch (error) { + console.error('Error generating cost report:', error.message); + process.exit(1); + } +} + +// Run the script +main(); diff --git a/analysis/cost-per-request.js b/analysis/cost-per-request.js new file mode 100644 index 0000000..b066dd2 --- /dev/null +++ b/analysis/cost-per-request.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +const { DefaultAzureCredential } = require('@azure/identity'); +const { ConsumptionManagementClient } = require('@azure/arm-consumption'); +const { program } = require('commander'); +const moment = require('moment'); +const fs = require('fs'); +const path = require('path'); + +// Configure command line options +program + .requiredOption('--from ', 'Start date (YYYY-MM-DD)') + .requiredOption('--to ', 'End date (YYYY-MM-DD)') + .requiredOption('--subscription-id ', 'Azure subscription ID') + .option('--resource-group ', 'Filter by resource group') + .option('--variant ', 'Filter by variant tag (e.g., control, optimized)') + .option('--output ', 'Output format: json, csv, table', 'json') + .option('--output-file ', 'Output file path') + .parse(process.argv); + +const options = program.opts(); + +// Validate date format +function isValidDate(dateString) { + return moment(dateString, 'YYYY-MM-DD', true).isValid(); +} + +if (!isValidDate(options.from) || !isValidDate(options.to)) { + console.error('Error: Invalid date format. Please use YYYY-MM-DD'); + process.exit(1); +} + +const startDate = moment(options.from).startOf('day').toISOString(); +const endDate = moment(options.to).endOf('day').toISOString(); + +// Initialize Azure clients +const credential = new DefaultAzureCredential(); +const consumptionClient = new ConsumptionManagementClient(credential, options.subscriptionId); + +// Helper function to format currency +function formatCurrency(amount, currency = 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + minimumFractionDigits: 4, + maximumFractionDigits: 6 + }).format(amount); +} + +// Helper function to format large numbers +function formatNumber(num) { + return new Intl.NumberFormat('en-US').format(num); +} + +// Get request count from Application Insights +async function getRequestCount(timeStart, timeEnd, cloudRoleName) { + // This is a placeholder - you'll need to implement actual Application Insights query + // For now, we'll return a mock value + return { + count: 1000000, // Mock value - replace with actual query + successCount: 980000, + failedCount: 20000, + avgDurationMs: 150 + }; +} + +// Get cost data +async function getCostData() { + try { + const scope = `/subscriptions/${options.subscriptionId}`; + const filter = `properties/usageStart ge '${startDate}' and properties/usageEnd le '${endDate}'`; + + // Add resource group filter if specified + let expandedFilter = filter; + if (options.resourceGroup) { + expandedFilter += ` and properties/resourceGroup eq '${options.resourceGroup}'`; + } + + // Add variant tag filter if specified + if (options.variant) { + expandedFilter += ` and tags/any(t: t eq 'variant:${options.variant}')`; + } + + const usageDetails = []; + let iterator = consumptionClient.usageDetails.list(scope, { expand: 'properties/tags', filter: expandedFilter }); + + console.log('Fetching cost data...'); + for await (const item of iterator) { + usageDetails.push(item); + } + + return usageDetails; + } catch (error) { + console.error('Error fetching cost data:', error.message); + throw error; + } +} + +// Calculate cost per request metrics +async function calculateMetrics() { + try { + // Get cost data + const costData = await getCostData(); + + if (costData.length === 0) { + console.log('No cost data found for the specified criteria'); + return null; + } + + // Calculate total cost + const totalCost = costData.reduce((sum, item) => { + const cost = parseFloat(item.costInBillingCurrency || item.cost || 0); + return sum + (isNaN(cost) ? 0 : cost); + }, 0); + + // Get request count + const requestStats = await getRequestCount( + startDate, + endDate, + options.variant + ); + + // Calculate metrics + const costPerMillion = (totalCost / requestStats.count) * 1000000; + const costPerSuccess = (totalCost / requestStats.successCount) * 1000000; + + return { + period: { + start: startDate, + end: endDate, + days: moment(endDate).diff(moment(startDate), 'days') + 1 + }, + requestStats, + cost: { + total: totalCost, + currency: costData[0]?.billingCurrencyCode || 'USD', + perMillionRequests: costPerMillion, + perMillionSuccess: costPerSuccess + }, + resources: { + count: new Set(costData.map(item => item.resourceId)).size, + resourceGroups: new Set(costData.map(item => item.resourceGroup)).size + }, + variant: options.variant || 'all', + resourceGroup: options.resourceGroup || 'all' + }; + } catch (error) { + console.error('Error calculating metrics:', error.message); + throw error; + } +} + +// Generate report +async function generateReport() { + try { + const metrics = await calculateMetrics(); + + if (!metrics) { + return; + } + + // Prepare output + const output = { + metadata: { + generatedAt: new Date().toISOString(), + subscriptionId: options.subscriptionId, + resourceGroup: options.resourceGroup || 'all', + variant: options.variant || 'all' + }, + metrics + }; + + // Output results + let outputStr; + switch (options.output.toLowerCase()) { + case 'csv': + outputStr = 'Period Start,Period End,Days,Total Requests,Successful Requests,Failed Requests,Total Cost,Cost per 1M Requests,Cost per 1M Success\n'; + outputStr += `"${metrics.period.start}","${metrics.period.end}",${metrics.period.days},`; + outputStr += `${metrics.requestStats.count},${metrics.requestStats.successCount},${metrics.requestStats.failedCount},`; + outputStr += `${metrics.cost.total},${metrics.cost.perMillionRequests},${metrics.cost.perMillionSuccess}`; + break; + + case 'table': + console.log('\nCost per Request Analysis'); + console.log('========================='); + console.log(`Period: ${moment(metrics.period.start).format('MMM D, YYYY')} to ${moment(metrics.period.end).format('MMM D, YYYY')} (${metrics.period.days} days)`); + console.log(`Variant: ${metrics.variant}`); + console.log(`Resource Group: ${metrics.resourceGroup}`); + console.log(`Resources: ${metrics.resources.count} resources across ${metrics.resources.resourceGroups} resource groups\n`); + + console.log('Request Statistics:'); + console.log('------------------'); + console.log(`Total Requests: ${formatNumber(metrics.requestStats.count)}`); + console.log(`Successful: ${formatNumber(metrics.requestStats.successCount)} (${((metrics.requestStats.successCount / metrics.requestStats.count) * 100).toFixed(2)}%)`); + console.log(`Failed: ${formatNumber(metrics.requestStats.failedCount)} (${((metrics.requestStats.failedCount / metrics.requestStats.count) * 100).toFixed(2)}%)`); + console.log(`Avg. Duration: ${metrics.requestStats.avgDurationMs} ms\n`); + + console.log('Cost Analysis:'); + console.log('--------------'); + console.log(`Total Cost: ${formatCurrency(metrics.cost.total, metrics.cost.currency)}`); + console.log(`Cost per 1M reqs: ${formatCurrency(metrics.cost.perMillionRequests, metrics.cost.currency)}`); + console.log(`Cost per 1M OK: ${formatCurrency(metrics.cost.perMillionSuccess, metrics.cost.currency)}`); + + console.log('\nReport generated at: ' + new Date().toISOString()); + return; // Skip file output for table format + + case 'json': + default: + outputStr = JSON.stringify(output, null, 2); + } + + // Write to file or console + if (options.outputFile) { + const outputPath = path.resolve(process.cwd(), options.outputFile); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, outputStr); + console.log(`Report saved to: ${outputPath}`); + } else { + console.log(outputStr); + } + + } catch (error) { + console.error('Error generating report:', error.message); + process.exit(1); + } +} + +// Run the script +generateReport(); diff --git a/infrastructure/modules/budgets/main.tf b/infrastructure/modules/budgets/main.tf new file mode 100644 index 0000000..8eded26 --- /dev/null +++ b/infrastructure/modules/budgets/main.tf @@ -0,0 +1,80 @@ +# Azure Budget Module +# This module creates an Azure Budget with configurable thresholds and action groups + +resource "azurerm_consumption_budget_subscription" "budget" { + name = var.budget_name + subscription_id = data.azurerm_subscription.current.subscription_id + amount = var.budget_amount + time_grain = var.time_grain + + time_period { + start_date = formatdate("YYYY-MM-01T00:00:00Z", timeadd(timestamp(), "-24h")) + end_date = var.end_date + } + + dynamic "notification" { + for_each = var.notifications + + content { + enabled = notification.value.enabled + threshold = notification.value.threshold + operator = notification.value.operator + threshold_type = notification.value.threshold_type + + contact_emails = notification.value.contact_emails + + dynamic "contact_groups" { + for_each = notification.value.action_group_ids != null ? [1] : [] + content { + action_group_id = notification.value.action_group_ids + } + } + + contact_roles = notification.value.contact_roles + } + } + + dynamic "filter" { + for_each = var.filters != null ? [var.filters] : [] + + content { + dynamic "dimension" { + for_each = filter.value.dimensions != null ? filter.value.dimensions : [] + + content { + name = dimension.value.name + operator = dimension.value.operator + values = dimension.value.values + } + } + + dynamic "tag" { + for_each = filter.value.tags != null ? filter.value.tags : [] + + content { + name = tag.value.name + operator = tag.value.operator + values = tag.value.values + } + } + } + } + + lifecycle { + ignore_changes = [ + time_period[0].start_date + ] + } +} + +# Data source to get current subscription ID +data "azurerm_subscription" "current" {} + +# Outputs +output "budget_id" { + value = azurerm_consumption_budget_subscription.budget.id +} + +output "budget_name" { + value = azurerm_consumption_budget_subscription.budget.name +} diff --git a/infrastructure/modules/budgets/outputs.tf b/infrastructure/modules/budgets/outputs.tf new file mode 100644 index 0000000..32a7d40 --- /dev/null +++ b/infrastructure/modules/budgets/outputs.tf @@ -0,0 +1,36 @@ +output "id" { + description = "The ID of the budget" + value = azurerm_consumption_budget_subscription.budget.id +} + +output "name" { + description = "The name of the budget" + value = azurerm_consumption_budget_subscription.budget.name +} + +output "amount" { + description = "The amount of the budget" + value = azurerm_consumption_budget_subscription.budget.amount +} + +output "time_grain" { + description = "The time grain of the budget" + value = azurerm_consumption_budget_subscription.budget.time_grain +} + +output "time_period" { + description = "The time period of the budget" + value = azurerm_consumption_budget_subscription.budget.time_period +} + +output "notifications" { + description = "The notifications configured for the budget" + value = azurerm_consumption_budget_subscription.budget.notification + sensitive = true +} + +output "filters" { + description = "The filters applied to the budget" + value = azurerm_consumption_budget_subscription.budget.filter + sensitive = true +} diff --git a/infrastructure/modules/budgets/variables.tf b/infrastructure/modules/budgets/variables.tf new file mode 100644 index 0000000..7f34051 --- /dev/null +++ b/infrastructure/modules/budgets/variables.tf @@ -0,0 +1,82 @@ +variable "budget_name" { + description = "The name of the budget" + type = string +} + +variable "budget_amount" { + description = "The amount of the budget" + type = number +} + +variable "time_grain" { + description = "The time covered by a budget. Valid values include Monthly, Quarterly, Annually, BillingMonth, BillingQuarter, BillingAnnual, or Custom." + type = string + default = "Monthly" + + validation { + condition = contains(["Monthly", "Quarterly", "Annually", "BillingMonth", "BillingQuarter", "BillingAnnual", "Custom"], var.time_grain) + error_message = "The time_grain must be one of: Monthly, Quarterly, Annually, BillingMonth, BillingQuarter, BillingAnnual, or Custom." + } +} + +variable "start_date" { + description = "The start date of the budget in YYYY-MM-DD format. If not provided, defaults to the first day of the current month." + type = string + default = null +} + +variable "end_date" { + description = "The end date of the budget in YYYY-MM-DD format. If not provided, defaults to 10 years from now." + type = string + default = null +} + +variable "notifications" { + description = "List of notifications to be sent when budget is exceeded" + type = list(object({ + enabled = bool + threshold = number + operator = string + threshold_type = string + contact_emails = list(string) + action_group_ids = optional(list(string)) + contact_roles = optional(list(string)) + })) + + validation { + condition = alltrue([ + for n in var.notifications : contains(["EqualTo", "GreaterThan", "GreaterThanOrEqualTo"], n.operator) + ]) + error_message = "The operator must be one of: EqualTo, GreaterThan, or GreaterThanOrEqualTo." + } + + validation { + condition = alltrue([ + for n in var.notifications : contains(["Actual", "Forecasted"], n.threshold_type) + ]) + error_message = "The threshold_type must be either Actual or Forecasted." + } +} + +variable "filters" { + description = "Filter the budget by resources, resource groups, or tags" + type = object({ + dimensions = optional(list(object({ + name = string + operator = string + values = list(string) + }))) + tags = optional(list(object({ + name = string + operator = string + values = list(string) + }))) + }) + default = null +} + +variable "tags" { + description = "A mapping of tags to assign to the budget" + type = map(string) + default = {} +} diff --git a/policies/budget-compliance-policy.json b/policies/budget-compliance-policy.json new file mode 100644 index 0000000..387784f --- /dev/null +++ b/policies/budget-compliance-policy.json @@ -0,0 +1,133 @@ +{ + "properties": { + "displayName": "Enforce Budget Compliance", + "policyType": "Custom", + "mode": "Indexed", + "description": "This policy ensures resources comply with budget constraints by denying creation if it would exceed the defined budget.", + "metadata": { + "version": "1.0.0", + "category": "Cost" + }, + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + }, + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "defaultValue": "Deny" + }, + "budgetAmount": { + "type": "Integer", + "metadata": { + "displayName": "Monthly Budget Amount (USD)", + "description": "The maximum allowed monthly spend in USD" + }, + "defaultValue": 1000 + }, + "excludedResourceGroups": { + "type": "Array", + "metadata": { + "displayName": "Excluded Resource Groups", + "description": "List of resource groups to exclude from budget enforcement" + }, + "defaultValue": [] + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Resources/subscriptions/resourceGroups" + }, + { + "not": { + "field": "name", + "in": "[parameters('excludedResourceGroups')]" + } + }, + { + "value": "[subscriptionResourceId('Microsoft.Consumption/budgets', 'enforced-budget')]", + "exists": "false" + } + ] + }, + "then": { + "effect": "[parameters('effect')]", + "details": { + "type": "Microsoft.Consumption/budgets", + "name": "enforced-budget", + "existenceCondition": { + "field": "Microsoft.Consumption/budgets/amount", + "equals": "[parameters('budgetAmount')]" + }, + "roleDefinitionIds": [ + "/providers/microsoft.authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + ], + "deployment": { + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "name": "enforced-budget", + "type": "Microsoft.Consumption/budgets", + "apiVersion": "2019-10-01", + "properties": { + "category": "Cost", + "amount": "[parameters('budgetAmount')]", + "timeGrain": "Monthly", + "timePeriod": { + "startDate": "[utcNow('yyyy-MM-dd')]T00:00:00Z", + "endDate": "[utcDateTimeAdd(utcNow(), 'P1Y')]T00:00:00Z" + }, + "notifications": { + "Actual_GreaterThan_90": { + "enabled": true, + "operator": "GreaterThan", + "threshold": 90, + "contactEmails": [ + "billing@example.com" + ], + "contactRoles": [ + "Owner" + ], + "contactGroups": [], + "thresholdType": "Actual" + }, + "Forecasted_GreaterThan_100": { + "enabled": true, + "operator": "GreaterThan", + "threshold": 100, + "contactEmails": [ + "billing@example.com" + ], + "contactRoles": [ + "Owner" + ], + "contactGroups": [], + "thresholdType": "Forecasted" + } + } + } + } + ] + }, + "parameters": {} + } + } + } + } + } + } +} diff --git a/policies/restrict-expensive-skus-policy.json b/policies/restrict-expensive-skus-policy.json new file mode 100644 index 0000000..7eeeb93 --- /dev/null +++ b/policies/restrict-expensive-skus-policy.json @@ -0,0 +1,186 @@ +{ + "properties": { + "displayName": "Restrict Expensive SKUs", + "policyType": "Custom", + "mode": "All", + "description": "This policy restricts the deployment of expensive VM and database SKUs to control costs.", + "metadata": { + "version": "1.0.0", + "category": "Cost" + }, + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + }, + "allowedValues": [ + "Deny", + "Audit", + "Disabled" + ], + "defaultValue": "Deny" + }, + "excludedResourceGroups": { + "type": "Array", + "metadata": { + "displayName": "Excluded Resource Groups", + "description": "List of resource groups to exclude from this policy" + }, + "defaultValue": [] + }, + "maxCoresPerVM": { + "type": "Integer", + "metadata": { + "displayName": "Maximum vCPUs per VM", + "description": "Maximum number of vCPUs allowed for a single VM" + }, + "defaultValue": 8 + }, + "maxMemoryGBPerVM": { + "type": "Integer", + "metadata": { + "displayName": "Maximum Memory per VM (GB)", + "description": "Maximum amount of memory in GB allowed for a single VM" + }, + "defaultValue": 32 + }, + "allowedVMSeries": { + "type": "Array", + "metadata": { + "displayName": "Allowed VM Series", + "description": "List of allowed VM series (e.g., Standard_B, Standard_D, etc.)" + }, + "defaultValue": [ + "Standard_B", + "Standard_D", + "Standard_DS" + ] + }, + "allowedSQLTiers": { + "type": "Array", + "metadata": { + "displayName": "Allowed SQL Database Tiers", + "description": "List of allowed SQL Database tiers" + }, + "defaultValue": [ + "Basic", + "Standard", + "GeneralPurpose", + "BusinessCritical" + ] + }, + "allowedSQLServiceObjectives": { + "type": "Array", + "metadata": { + "displayName": "Allowed SQL Database Service Objectives", + "description": "List of allowed SQL Database service objectives" + }, + "defaultValue": [ + "Basic", + "S0", "S1", "S2", "S3", "S4", "S6", "S7", "S9", "S12", + "P1", "P2", "P4", "P6", "P11", "P15", + "GP_Gen5_2", "GP_Gen5_4", "GP_Gen5_6", "GP_Gen5_8", "GP_Gen5_10", "GP_Gen5_12", "GP_Gen5_14", "GP_Gen5_16", "GP_Gen5_18", "GP_Gen5_20", "GP_Gen5_24", "GP_Gen5_32", "GP_Gen5_40", "GP_Gen5_80" + ] + }, + "allowedRegions": { + "type": "Array", + "metadata": { + "displayName": "Allowed Regions", + "description": "List of allowed regions for resource deployment" + }, + "defaultValue": [ + "eastus", + "westus2", + "centralus" + ] + } + }, + "policyRule": { + "if": { + "anyOf": [ + { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + { + "not": { + "field": "Microsoft.Compute/virtualMachines/imageOffer", + "in": ["WindowsServer", "RHEL", "UbuntuServer", "CentOS"] + } + }, + { + "not": { + "field": "name", + "like": "*test*" + } + }, + { + "not": { + "field": "Microsoft.Compute/virtualMachines/hardwareProfile.vmSize", + "like": "Standard_DS1_v2" + } + }, + { + "not": { + "field": "location", + "in": "[parameters('allowedRegions')]" + } + } + ] + }, + { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + { + "value": "[greater(length(field('Microsoft.Compute/virtualMachines/hardwareProfile.vmSize')), 0)]", + "equals": true + }, + { + "value": "[greater(int(extract(field('Microsoft.Compute/virtualMachines/hardwareProfile.vmSize'), 'Standard_*_', 1, 1)), parameters('maxCoresPerVM'))]", + "equals": true + } + ] + }, + { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Sql/servers/databases" + }, + { + "not": { + "field": "Microsoft.Sql/servers/databases/sku.tier", + "in": "[parameters('allowedSQLTiers')]" + } + } + ] + }, + { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Sql/servers/databases" + }, + { + "not": { + "field": "Microsoft.Sql/servers/databases/requestedServiceObjectiveName", + "in": "[parameters('allowedSQLServiceObjectives')]" + } + } + ] + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + } + } +} diff --git a/runbooks/mitigation_runbook.ps1 b/runbooks/mitigation_runbook.ps1 new file mode 100644 index 0000000..3e73447 --- /dev/null +++ b/runbooks/mitigation_runbook.ps1 @@ -0,0 +1,270 @@ +<# +.SYNOPSIS + Azure Automation Runbook to mitigate costs when budget thresholds are exceeded. +.DESCRIPTION + This runbook is triggered by an Azure Alert when a budget threshold is reached. + It performs cost-saving actions such as scaling down non-essential resources. + + Actions performed: + 1. Scale down non-production VMs + 2. Pause non-essential databases + 3. Scale down App Service plans + 4. Send notification of actions taken + +.PARAMETER WebhookData + The webhook data that triggered the alert. + +.NOTES + Version: 1.0 + Author: OptimaCore Team + Creation Date: 2023-11-15 + Prerequisites: Azure Automation Account with Run As Account + Appropriate RBAC permissions to manage resources +#> + +param( + [Parameter(Mandatory=$false)] + [object] $WebhookData +) + +# Error action preference +$ErrorActionPreference = "Stop" + +# Import required modules +#Requires -Modules @{ModuleName="Az.Accounts"; ModuleVersion="2.0.0"} +#Requires -Modules @{ModuleName="Az.Compute"; ModuleVersion="4.0.0"} +#Requires -Modules @{ModuleName="Az.Sql"; ModuleVersion="2.0.0"} +#Requires -Modules @{ModuleName="Az.Websites"; ModuleVersion="2.0.0"} + +try { + # Get the current execution context + $connectionName = "AzureRunAsConnection" + $servicePrincipalConnection = Get-AutomationConnection -Name $connectionName + + # Log in to Azure with service principal + $null = Connect-AzAccount ` + -ServicePrincipal ` + -TenantId $servicePrincipalConnection.TenantId ` + -ApplicationId $servicePrincipalConnection.ApplicationId ` + -CertificateThumbprint $servicePrincipalConnection.CertificateThumbprint + + # Set the subscription context if needed + # $subscriptionId = "your-subscription-id" + # Set-AzContext -SubscriptionId $subscriptionId + + # Initialize output variables + $actionsTaken = @() + $skippedResources = @() + $errors = @() + + # Process webhook data if provided + if ($WebhookData) { + Write-Output "Processing webhook data..." + $WebhookBody = $WebhookData.RequestBody | ConvertFrom-Json + + # Extract budget information from the alert + $budgetData = $WebhookBody.data + $budgetName = $budgetData.budgetName + $budgetAmount = $budgetData.budgetAmount + $currentSpend = $budgetData.currentSpend + $spendPercentage = $budgetData.spentPercentage + $subscriptionId = $budgetData.subscriptionId + $resourceGroupName = $budgetData.resourceGroupName + + Write-Output "Budget Alert Details:" + Write-Output "Budget: $budgetName" + Write-Output "Amount: $budgetAmount" + Write-Output "Current Spend: $currentSpend ($spendPercentage%)" + Write-Output "Subscription: $subscriptionId" + Write-Output "Resource Group: $resourceGroupName" + + # Set the subscription context based on the alert + if ($subscriptionId) { + Set-AzContext -Subscription $subscriptionId | Out-Null + } + } else { + Write-Output "No webhook data provided. Running in test mode with default parameters." + $budgetName = "Test Budget" + $budgetAmount = 1000 + $currentSpend = 950 + $spendPercentage = 95 + $subscriptionId = (Get-AzContext).Subscription.Id + } + + # Define resource tags to identify non-production resources + $nonProdTags = @{ + "environment" = @("dev", "test", "staging", "non-prod", "sandbox") + "shutdown" = @("true", "yes") + } + + # 1. Scale down non-production VMs + Write-Output "`nChecking for non-production VMs to scale down..." + $vms = Get-AzVM -Status | Where-Object { + $_.PowerState -eq "VM running" -and + ($_.Tags.Keys | ForEach-Object { $_.ToLower() }) -contains "environment" -and + $nonProdTags["environment"] -contains $_.Tags["environment"].ToLower() + } + + foreach ($vm in $vms) { + try { + Write-Output "Stopping VM: $($vm.Name) (Resource Group: $($vm.ResourceGroupName))" + $stopResult = Stop-AzVM -ResourceGroupName $vm.ResourceGroupName -Name $vm.Name -Force -AsJob + $actionsTaken += "Stopped VM: $($vm.Name) (Resource Group: $($vm.ResourceGroupName))" + } catch { + $errorMsg = "Error stopping VM $($vm.Name): $($_.Exception.Message)" + Write-Error $errorMsg + $errors += $errorMsg + } + } + + # 2. Pause non-essential databases + Write-Output "`nChecking for non-essential databases to pause..." + $servers = Get-AzSqlServer + + foreach ($server in $servers) { + $dbs = Get-AzSqlDatabase -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName | + Where-Object { $_.DatabaseName -notin ("master", "model") } + + foreach ($db in $dbs) { + try { + $tags = (Get-AzResource -ResourceId $db.ResourceId).Tags + $isNonProd = $false + + # Check if database has non-prod tag + if ($tags) { + $envTag = $tags.GetEnumerator() | Where-Object { $_.Key -eq "environment" -and $nonProdTags["environment"] -contains $_.Value.ToLower() } + $shutdownTag = $tags.GetEnumerator() | Where-Object { $_.Key -eq "shutdown" -and $nonProdTags["shutdown"] -contains $_.Value.ToLower() } + $isNonProd = $envTag -or $shutdownTag + } + + # Skip databases that are already paused or are not non-prod + if (-not $isNonProd -or $db.CurrentServiceObjectiveName -eq "Paused") { + $skippedResources += "Skipped database: $($db.DatabaseName) (Server: $($server.ServerName))" + continue + } + + # Pause the database + Write-Output "Pausing database: $($db.DatabaseName) (Server: $server.ServerName)" + $pauseResult = Suspend-AzSqlDatabase -ResourceGroupName $server.ResourceGroupName ` + -ServerName $server.ServerName ` + -DatabaseName $db.DatabaseName ` + -AsJob + + $actionsTaken += "Paused database: $($db.DatabaseName) (Server: $server.ServerName)" + } catch { + $errorMsg = "Error pausing database $($db.DatabaseName): $($_.Exception.Message)" + Write-Error $errorMsg + $errors += $errorMsg + } + } + } + + # 3. Scale down App Service plans + Write-Output "`nChecking for non-production App Service plans to scale down..." + $appServicePlans = Get-AzAppServicePlan | Where-Object { + $_.Sku.Tier -notin ("Free", "Shared") -and + ($_.Tags.Keys | ForEach-Object { $_.ToLower() }) -contains "environment" -and + $nonProdTags["environment"] -contains $_.Tags["environment"].ToLower() + } + + foreach ($plan in $appServicePlans) { + try { + $currentTier = $plan.Sku.Tier + $currentSize = $plan.Sku.Size + + # Skip if already at minimum size + if ($currentTier -eq "Basic" -and $currentSize -eq "B1") { + $skippedResources += "Skipped App Service Plan (already at minimum size): $($plan.Name)" + continue + } + + # Determine new tier and size + $newTier = if ($currentTier -eq "Standard") { "Basic" } else { $currentTier } + $newSize = if ($currentTier -eq "Basic") { "B1" } else { $currentSize } + + # Scale down the App Service plan + Write-Output "Scaling down App Service Plan: $($plan.Name) from $currentTier $currentSize to $newTier $newSize" + + $result = Set-AzAppServicePlan -ResourceGroupName $plan.ResourceGroup ` + -Name $plan.Name ` + -Tier $newTier ` + -NumberofWorkers 1 ` + -WorkerSize $newSize + + $actionsTaken += "Scaled down App Service Plan: $($plan.Name) to $newTier $newSize" + } catch { + $errorMsg = "Error scaling down App Service Plan $($plan.Name): $($_.Exception.Message)" + Write-Error $errorMsg + $errors += $errorMsg + } + } + + # 4. Send notification of actions taken + Write-Output "`nMitigation actions completed." + + # Prepare notification details + $notificationDetails = @{ + budgetName = $budgetName + budgetAmount = $budgetAmount + currentSpend = $currentSpend + spendPercentage = $spendPercentage + subscriptionId = $subscriptionId + resourceGroup = $resourceGroupName + timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + actionsTaken = $actionsTaken + skippedResources = $skippedResources + errors = $errors + } + + # Convert to JSON for output + $notificationJson = $notificationDetails | ConvertTo-Json -Depth 5 + + # Output the results (could be sent to a webhook, Logic App, etc.) + Write-Output "Notification Details:" + Write-Output $notificationJson + + # Example: Send to a webhook + $webhookUrl = Get-AutomationVariable -Name 'CostAlertWebhookUrl' -ErrorAction SilentlyContinue + + if ($webhookUrl) { + try { + $body = @{ + title = "Budget Alert: $budgetName" + text = "Budget threshold of $spendPercentage% reached ($$currentSpend of $$budgetAmount)" + actions = $actionsTaken + errors = $errors + timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + } | ConvertTo-Json -Depth 5 + + Invoke-RestMethod -Uri $webhookUrl -Method Post -Body $body -ContentType "application/json" + Write-Output "Notification sent to webhook" + } catch { + Write-Error "Failed to send webhook notification: $_" + } + } else { + Write-Output "No webhook URL configured. Skipping notification." + } + + # Return the results + $result = @{ + status = "Completed" + actionsTaken = $actionsTaken + errors = $errors + timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + } + + return $result | ConvertTo-Json -Depth 5 + +} catch { + $errorMsg = $_.Exception.Message + Write-Error "Error in runbook: $errorMsg" + + # Return error details + $result = @{ + status = "Failed" + error = $errorMsg + timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + } + + return $result | ConvertTo-Json -Depth 5 +} diff --git a/scripts/set-budget.sh b/scripts/set-budget.sh new file mode 100644 index 0000000..3a2a10c --- /dev/null +++ b/scripts/set-budget.sh @@ -0,0 +1,270 @@ +#!/bin/bash + +# set-budget.sh - Azure Budget Management Script +# This script creates or updates an Azure budget with configurable alerts and actions + +set -e + +# Default values +SUBSCRIPTION_ID="" +BUDGET_NAME="" +BUDGET_AMOUNT=0 +RESOURCE_GROUP="" +START_DATE=$(date +%Y-%m-01) # First day of current month +END_DATE=$(date -d "+1 year" +%Y-%m-01) # One year from now +TIME_GRAIN="Monthly" +CONTACT_EMAILS=() +ACTION_GROUPS=() +THRESHOLDS=(50 80 100) # Default threshold percentages +FILTERS="" +DRY_RUN=false +DEBUG=false + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +debug() { + if [ "$DEBUG" = true ]; then + echo -e "[DEBUG] $1" + fi +} + +# Show usage information +usage() { + echo "Usage: $0 --name NAME --amount AMOUNT [options]" + echo "" + echo "Required parameters:" + echo " -n, --name NAME Name of the budget" + echo " -a, --amount AMOUNT Budget amount in USD" + echo "" + echo "Options:" + echo " -s, --subscription ID Azure subscription ID (default: current subscription)" + echo " -g, --resource-group RG Resource group to scope the budget (default: subscription level)" + echo " --start-date DATE Start date in YYYY-MM-DD format (default: first day of current month)" + echo " --end-date DATE End date in YYYY-MM-DD format (default: one year from now)" + echo " --time-grain GRAIN Time grain: Monthly, Quarterly, Annually (default: Monthly)" + echo " --email EMAIL Email address to send alerts to (can be specified multiple times)" + echo " --action-group ID Action group ID for alerts (can be specified multiple times)" + echo " --thresholds N1,N2,... Comma-separated list of threshold percentages (default: 50,80,100)" + echo " --filter FILTER JMESPath filter for resources (e.g., \"tag eq 'environment=production'\")" + echo " --dry-run Show what would be done without making changes" + echo " --debug Enable debug output" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " # Create a monthly budget of $1000 for a resource group" + echo " $0 --name dev-budget --amount 1000 --resource-group my-rg" + echo "" + echo " # Create a budget with custom thresholds and notifications" + echo " $0 --name prod-budget --amount 5000 --email admin@example.com --thresholds 30,70,90" + exit 1 +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -n|--name) + BUDGET_NAME="$2" + shift; shift + ;; + -a|--amount) + BUDGET_AMOUNT="$2" + shift; shift + ;; + -s|--subscription) + SUBSCRIPTION_ID="$2" + shift; shift + ;; + -g|--resource-group) + RESOURCE_GROUP="$2" + shift; shift + ;; + --start-date) + START_DATE="$2" + shift; shift + ;; + --end-date) + END_DATE="$2" + shift; shift + ;; + --time-grain) + TIME_GRAIN="$2" + shift; shift + ;; + --email) + CONTACT_EMAILS+=("$2") + shift; shift + ;; + --action-group) + ACTION_GROUPS+=("$2") + shift; shift + ;; + --thresholds) + IFS=',' read -r -a THRESHOLDS <<< "$2" + shift; shift + ;; + --filter) + FILTER="$2" + shift; shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --debug) + DEBUG=true + set -x + shift + ;; + -h|--help) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +# Validate required parameters +if [ -z "$BUDGET_NAME" ] || [ -z "$BUDGET_AMOUNT" ]; then + log_error "Budget name and amount are required" + usage +fi + +# Validate amount is a number +if ! [[ "$BUDGET_AMOUNT" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + log_error "Budget amount must be a number" + exit 1 +fi + +# Validate time grain +if ! [[ "$TIME_GRAIN" =~ ^(Monthly|Quarterly|Annually)$ ]]; then + log_error "Time grain must be one of: Monthly, Quarterly, Annually" + exit 1 +fi + +# Validate thresholds +for threshold in "${THRESHOLDS[@]}"; do + if ! [[ "$threshold" =~ ^[0-9]+$ ]] || [ "$threshold" -lt 1 ] || [ "$threshold" -gt 100 ]; then + log_error "Thresholds must be integers between 1 and 100" + exit 1 + fi +done + +# Sort thresholds in ascending order +IFS=$'\n' THRESHOLDS=($(sort -n <<<"${THRESHOLDS[*]}")) +unset IFS + +# Build the base command +BASE_CMD="az consumption budget" +if [ -n "$SUBSCRIPTION_ID" ]; then + BASE_CMD="$BASE_CMD --subscription $SUBSCRIPTION_ID" +fi + +# Set the scope +SCOPE="/subscriptions/$(az account show --query id -o tsv)" +if [ -n "$RESOURCE_GROUP" ]; then + SCOPE="$SCOPE/resourceGroups/$RESOURCE_GROUP" +fi + +# Check if budget already exists +get_budget() { + $BASE_CMD show --name "$BUDGET_NAME" --resource-group "${RESOURCE_GROUP}" --output json 2>/dev/null || echo "" +} + +BUDGET_EXISTS=$(get_budget) + +# Prepare notifications +NOTIFICATIONS=() +for threshold in "${THRESHOLDS[@]}"; do + NOTIFICATION={\"enabled\":true,\"operator\":\"GreaterThan\",\"threshold\":$threshold,\"contactEmails\":[ + + # Add contact emails if any + if [ ${#CONTACT_EMAILS[@]} -gt 0 ]; then + for email in "${CONTACT_EMAILS[@]}"; do + NOTIFICATION+="\"$email\"," + done + NOTIFICATION=${NOTIFICATION%?} # Remove trailing comma + fi + + NOTIFICATION+="],\"contactRoles\":[\"Owner\"],\"contactGroups\":[ + + # Add action groups if any + if [ ${#ACTION_GROUPS[@]} -gt 0 ]; then + for group in "${ACTION_GROUPS[@]}"; do + NOTIFICATION+="\"$group\"," + done + NOTIFICATION=${NOTIFICATION%?} # Remove trailing comma + fi + + NOTIFICATION+="]}" + NOTIFICATIONS+=("$NOTIFICATION") +done + +# Build the create/update command +BUDGET_CMD="$BASE_CMD create --name \"$BUDGET_NAME\" --amount $BUDGET_AMOUNT \ + --time-grain $TIME_GRAIN --start-date $START_DATE --end-date $END_DATE \ + --category Cost --scope \"$SCOPE\" --reset-period $TIME_GRAIN" + +# Add notifications +for notification in "${NOTIFICATIONS[@]}"; do + BUDGET_CMD+=" --notifications \"$notification\"" +done + +# Add filters if specified +if [ -n "$FILTER" ]; then + BUDGET_CMD+=" --filters \"$FILTER\"" +fi + +# Add resource group if specified +if [ -n "$RESOURCE_GROUP" ]; then + BUDGET_CMD+=" --resource-group \"$RESOURCE_GROUP\"" +fi + +# Execute or dry run +if [ "$DRY_RUN" = true ]; then + log_info "[DRY RUN] Would execute:" + echo "$BUDGET_CMD" +else + log_info "Creating/updating budget '$BUDGET_NAME' with amount $BUDGET_AMOUNT" + + # Check if update is needed + if [ -n "$BUDGET_EXISTS" ]; then + log_info "Budget '$BUDGET_NAME' already exists. Updating..." + BUDGET_CMD=$(echo "$BUDGET_CMD" | sed 's/create/update/g') + fi + + # Execute the command + eval "$BUDGET_CMD" + + if [ $? -eq 0 ]; then + log_info "Budget '$BUDGET_NAME' created/updated successfully" + else + log_error "Failed to create/update budget" + exit 1 + fi + + # Show the created/updated budget + log_info "Budget details:" + get_budget | jq . +fi + +exit 0 diff --git a/scripts/test-budget-webhook.sh b/scripts/test-budget-webhook.sh new file mode 100644 index 0000000..25aac3b --- /dev/null +++ b/scripts/test-budget-webhook.sh @@ -0,0 +1,233 @@ +#!/bin/bash + +# test-budget-webhook.sh - Test Azure Budget Webhook +# This script sends a test notification to a webhook URL to simulate a budget alert + +set -e + +# Default values +WEBHOOK_URL="" +BUDGET_NAME="Test Budget" +BUDGET_AMOUNT=1000 +CURRENT_SPEND=850 +THRESHOLD=85 +SUBSCRIPTION_ID="" +RESOURCE_GROUP="" +DEBUG=false + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +debug() { + if [ "$DEBUG" = true ]; then + echo -e "[DEBUG] $1" + fi +} + +# Show usage information +usage() { + echo "Usage: $0 --webhook-url URL [options]" + echo "" + echo "Required parameters:" + echo " --webhook-url URL Webhook URL to send the test notification to" + echo "" + echo "Options:" + echo " --name NAME Budget name (default: 'Test Budget')" + echo " --amount AMOUNT Budget amount (default: 1000)" + echo " --spend AMOUNT Current spend amount (default: 850)" + echo " --threshold PERCENT Threshold percentage (default: 85)" + echo " --subscription ID Azure subscription ID" + echo " --resource-group RG Resource group name" + echo " --debug Enable debug output" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " # Basic test with default values" + echo " $0 --webhook-url https://example.com/webhook" + echo "" + echo " # Test with custom values" + echo " $0 --webhook-url https://example.com/webhook --name 'Prod Budget' --amount 5000 --spend 4500 --threshold 90" + exit 1 +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + --webhook-url) + WEBHOOK_URL="$2" + shift; shift + ;; + --name) + BUDGET_NAME="$2" + shift; shift + ;; + --amount) + BUDGET_AMOUNT="$2" + shift; shift + ;; + --spend) + CURRENT_SPEND="$2" + shift; shift + ;; + --threshold) + THRESHOLD="$2" + shift; shift + ;; + --subscription) + SUBSCRIPTION_ID="$2" + shift; shift + ;; + --resource-group) + RESOURCE_GROUP="$2" + shift; shift + ;; + --debug) + DEBUG=true + set -x + shift + ;; + -h|--help) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +# Validate required parameters +if [ -z "$WEBHOOK_URL" ]; then + log_error "Webhook URL is required" + usage +fi + +# Validate amounts are numbers +for var in BUDGET_AMOUNT CURRENT_SPEND THRESHOLD; do + if ! [[ "${!var}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + log_error "$var must be a number" + exit 1 + fi +done + +# Calculate percentage +PERCENTAGE=$(awk "BEGIN {printf \"%.2f\", ($CURRENT_SPEND / $BUDGET_AMOUNT) * 100}") + +# Set subscription ID if not provided +if [ -z "$SUBSCRIPTION_ID" ]; then + SUBSCRIPTION_ID=$(az account show --query id -o tsv 2>/dev/null || echo "") + if [ -z "$SUBSCRIPTION_ID" ]; then + log_error "Failed to get subscription ID. Please login to Azure CLI first or provide --subscription parameter." + exit 1 + fi + debug "Using current subscription: $SUBSCRIPTION_ID" +fi + +# Create a unique operation ID +OPERATION_ID=$(uuidgen) +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") + +# Build the webhook payload +PAYLOAD=$(cat </dev/null) + +# Extract status code and response body +HTTP_STATUS=$(echo "$RESPONSE" | tail -n1) +RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d') + +# Check if the request was successful +if [[ $HTTP_STATUS -ge 200 && $HTTP_STATUS -lt 300 ]]; then + log_info "Webhook sent successfully (Status: $HTTP_STATUS)" + if [ "$DEBUG" = true ]; then + echo -e "${YELLOW}Response:${NC}" + echo "$RESPONSE_BODY" | jq . + fi +else + log_error "Failed to send webhook (Status: $HTTP_STATUS)" + if [ -n "$RESPONSE_BODY" ]; then + echo -e "${YELLOW}Error details:${NC}" + echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY" + fi + exit 1 +fi + +exit 0 From df31695daa26734853b0062b6b9a43e68e74db3e Mon Sep 17 00:00:00 2001 From: ItsAJ1005 Date: Wed, 17 Sep 2025 02:33:55 +0530 Subject: [PATCH 2/2] Update - env example --- .env.example | 29 +++++++ policies/budget-compliance-policy.json | 40 ++++++--- policies/restrict-expensive-skus-policy.json | 23 ++---- runbooks/mitigation_runbook.ps1 | 38 ++++++++- scripts/set-budget.sh | 85 ++++++++++++++++++-- scripts/test-budget-webhook.sh | 59 ++++++++++---- 6 files changed, 217 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index c5450d1..9ff1626 100644 --- a/.env.example +++ b/.env.example @@ -89,3 +89,32 @@ SENTRY_DSN=your-sentry-dsn # ==================================== RATE_LIMIT_WINDOW_MS=900000 # 15 minutes RATE_LIMIT_MAX=100 # Max requests per window per IP + +# ==================================== +# Azure Cost Management & Budgets +# ==================================== + +# Budget Compliance Policy +BUDGET_ALERT_EMAILS=admin@example.com,billing@example.com +BUDGET_ALERT_ROLES=Owner,Contributor +DEFAULT_BUDGET_AMOUNT=1000 +DEFAULT_BUDGET_TIME_GRAIN=Monthly +DEFAULT_BUDGET_THRESHOLDS=50,80,100 + +# Cost Alert Webhook +COST_ALERT_WEBHOOK_URL=https://your-webhook-url.example.com/cost-alerts + +# Non-production Environment Tags +NON_PROD_ENV_TAGS=dev,test,staging,non-prod,sandbox +NON_PROD_SHUTDOWN_TAGS=true,yes + +# Allowed VM SKUs (comma-separated) +ALLOWED_VM_SERIES=Standard_B,Standard_D,Standard_DS +MAX_VM_CORES=8 +MAX_VM_MEMORY_GB=32 + +# Allowed SQL Database Tiers (comma-separated) +ALLOWED_SQL_TIERS=Basic,Standard,GeneralPurpose,BusinessCritical + +# Allowed Azure Regions (comma-separated) +ALLOWED_AZURE_REGIONS=eastus,westus2,centralus diff --git a/policies/budget-compliance-policy.json b/policies/budget-compliance-policy.json index 387784f..057259a 100644 --- a/policies/budget-compliance-policy.json +++ b/policies/budget-compliance-policy.json @@ -22,6 +22,30 @@ ], "defaultValue": "Deny" }, + "alertEmails": { + "type": "String", + "metadata": { + "displayName": "Alert Email Addresses", + "description": "Comma-separated list of email addresses to receive budget alerts" + }, + "defaultValue": "[parameters('alertEmails')" + }, + "alertRoles": { + "type": "String", + "metadata": { + "displayName": "Alert Contact Roles", + "description": "Comma-separated list of Azure roles to receive budget alerts" + }, + "defaultValue": "[parameters('alertRoles')" + }, + "thresholds": { + "type": "String", + "metadata": { + "displayName": "Budget Thresholds", + "description": "Comma-separated list of threshold percentages for budget alerts" + }, + "defaultValue": "[parameters('thresholds')" + }, "budgetAmount": { "type": "Integer", "metadata": { @@ -96,12 +120,8 @@ "enabled": true, "operator": "GreaterThan", "threshold": 90, - "contactEmails": [ - "billing@example.com" - ], - "contactRoles": [ - "Owner" - ], + "contactEmails": "[split(parameters('alertEmails'), ',')", + "contactRoles": "[split(parameters('alertRoles'), ',')", "contactGroups": [], "thresholdType": "Actual" }, @@ -109,12 +129,8 @@ "enabled": true, "operator": "GreaterThan", "threshold": 100, - "contactEmails": [ - "billing@example.com" - ], - "contactRoles": [ - "Owner" - ], + "contactEmails": "[split(parameters('alertEmails'), ',')", + "contactRoles": "[split(parameters('alertRoles'), ',')", "contactGroups": [], "thresholdType": "Forecasted" } diff --git a/policies/restrict-expensive-skus-policy.json b/policies/restrict-expensive-skus-policy.json index 7eeeb93..d6c9d04 100644 --- a/policies/restrict-expensive-skus-policy.json +++ b/policies/restrict-expensive-skus-policy.json @@ -36,7 +36,7 @@ "displayName": "Maximum vCPUs per VM", "description": "Maximum number of vCPUs allowed for a single VM" }, - "defaultValue": 8 + "defaultValue": "[parameters('maxCoresPerVM')" }, "maxMemoryGBPerVM": { "type": "Integer", @@ -44,7 +44,7 @@ "displayName": "Maximum Memory per VM (GB)", "description": "Maximum amount of memory in GB allowed for a single VM" }, - "defaultValue": 32 + "defaultValue": "[parameters('maxMemoryGBPerVM')" }, "allowedVMSeries": { "type": "Array", @@ -52,11 +52,7 @@ "displayName": "Allowed VM Series", "description": "List of allowed VM series (e.g., Standard_B, Standard_D, etc.)" }, - "defaultValue": [ - "Standard_B", - "Standard_D", - "Standard_DS" - ] + "defaultValue": "[split(parameters('allowedVMSeries'), ',')" }, "allowedSQLTiers": { "type": "Array", @@ -64,12 +60,7 @@ "displayName": "Allowed SQL Database Tiers", "description": "List of allowed SQL Database tiers" }, - "defaultValue": [ - "Basic", - "Standard", - "GeneralPurpose", - "BusinessCritical" - ] + "defaultValue": "[split(parameters('allowedSQLTiers'), ',')" }, "allowedSQLServiceObjectives": { "type": "Array", @@ -90,11 +81,7 @@ "displayName": "Allowed Regions", "description": "List of allowed regions for resource deployment" }, - "defaultValue": [ - "eastus", - "westus2", - "centralus" - ] + "defaultValue": "[split(parameters('allowedRegions'), ',')" } }, "policyRule": { diff --git a/runbooks/mitigation_runbook.ps1 b/runbooks/mitigation_runbook.ps1 index 3e73447..5f3d280 100644 --- a/runbooks/mitigation_runbook.ps1 +++ b/runbooks/mitigation_runbook.ps1 @@ -85,16 +85,29 @@ try { } else { Write-Output "No webhook data provided. Running in test mode with default parameters." $budgetName = "Test Budget" - $budgetAmount = 1000 - $currentSpend = 950 + $budgetAmount = if ($env:DEFAULT_BUDGET_AMOUNT) { [int]$env:DEFAULT_BUDGET_AMOUNT } else { 1000 } $spendPercentage = 95 + $currentSpend = [int]($budgetAmount * $spendPercentage / 100) # 95% of budget $subscriptionId = (Get-AzContext).Subscription.Id } + # Get non-production tags from environment variables with defaults + $nonProdEnvTags = if ($env:NON_PROD_ENV_TAGS) { + $env:NON_PROD_ENV_TAGS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + } else { + @("dev", "test", "staging", "non-prod", "sandbox") + } + + $nonProdShutdownTags = if ($env:NON_PROD_SHUTDOWN_TAGS) { + $env:NON_PROD_SHUTDOWN_TAGS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + } else { + @("true", "yes") + } + # Define resource tags to identify non-production resources $nonProdTags = @{ - "environment" = @("dev", "test", "staging", "non-prod", "sandbox") - "shutdown" = @("true", "yes") + "environment" = $nonProdEnvTags + "shutdown" = $nonProdShutdownTags } # 1. Scale down non-production VMs @@ -259,6 +272,23 @@ try { $errorMsg = $_.Exception.Message Write-Error "Error in runbook: $errorMsg" + try { + # Try to send error notification if webhook is configured + $webhookUrl = Get-AutomationVariable -Name 'CostAlertWebhookUrl' -ErrorAction SilentlyContinue + if ($webhookUrl) { + $errorBody = @{ + title = "Budget Mitigation Runbook Failed" + text = "Error: $errorMsg" + timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + status = "Failed" + } | ConvertTo-Json -Depth 5 + + Invoke-RestMethod -Uri $webhookUrl -Method Post -Body $errorBody -ContentType "application/json" + } + } catch { + Write-Warning "Failed to send error notification: $_" + } + # Return error details $result = @{ status = "Failed" diff --git a/scripts/set-budget.sh b/scripts/set-budget.sh index 3a2a10c..406a396 100644 --- a/scripts/set-budget.sh +++ b/scripts/set-budget.sh @@ -8,14 +8,59 @@ set -e # Default values SUBSCRIPTION_ID="" BUDGET_NAME="" -BUDGET_AMOUNT=0 +BUDGET_AMOUNT=${DEFAULT_BUDGET_AMOUNT:-1000} RESOURCE_GROUP="" START_DATE=$(date +%Y-%m-01) # First day of current month END_DATE=$(date -d "+1 year" +%Y-%m-01) # One year from now -TIME_GRAIN="Monthly" -CONTACT_EMAILS=() +TIME_GRAIN=${DEFAULT_BUDGET_TIME_GRAIN:-Monthly} + +# Function to safely split strings into arrays +safe_split() { + local input="$1" + local delimiter="${2:-,}" + local -n arr_ref="$3" + + if [ -z "$input" ]; then + arr_ref=() + return + fi + + # Handle both Unix and Windows line endings + input=$(echo "$input" | tr -d '\r') + + # Split the string into an array + IFS="$delimiter" read -r -a arr_ref <<< "$input" + + # Trim whitespace from each element + for i in "${!arr_ref[@]}"; do + arr_ref[$i]=$(echo "${arr_ref[$i]}" | xargs) + done +} + +# Set default contact emails if not provided +safe_split "${BUDGET_ALERT_EMAILS}" "," CONTACT_EMAILS + +# Set default thresholds if not provided +if [ -z "${DEFAULT_BUDGET_THRESHOLDS}" ]; then + THRESHOLDS=(50 80 100) # Default threshold percentages +else + safe_split "${DEFAULT_BUDGET_THRESHOLDS}" "," THRESHOLDS +fi + +# Validate thresholds are numbers +for threshold in "${THRESHOLDS[@]}"; do + if ! [[ "$threshold" =~ ^[0-9]+$ ]]; then + echo "Error: Invalid threshold value '$threshold'. Must be a number." + exit 1 + fi + + if [ "$threshold" -lt 1 ] || [ "$threshold" -gt 100 ]; then + echo "Error: Threshold must be between 1 and 100. Got: $threshold" + exit 1 + fi +done + ACTION_GROUPS=() -THRESHOLDS=(50 80 100) # Default threshold percentages FILTERS="" DRY_RUN=false DEBUG=false @@ -173,11 +218,35 @@ done IFS=$'\n' THRESHOLDS=($(sort -n <<<"${THRESHOLDS[*]}")) unset IFS +# Function to run Azure CLI commands with error handling +run_az_command() { + local cmd="az $1" + if [ -n "$SUBSCRIPTION_ID" ]; then + cmd="$cmd --subscription $SUBSCRIPTION_ID" + fi + + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] Would execute: $cmd" + return 0 + fi + + if [ "$DEBUG" = true ]; then + echo "[DEBUG] Executing: $cmd" + fi + + eval "$cmd" + local status=$? + + if [ $status -ne 0 ]; then + echo "Error executing: $cmd" + return $status + fi + + return 0 +} + # Build the base command -BASE_CMD="az consumption budget" -if [ -n "$SUBSCRIPTION_ID" ]; then - BASE_CMD="$BASE_CMD --subscription $SUBSCRIPTION_ID" -fi +BASE_CMD="consumption budget" # Set the scope SCOPE="/subscriptions/$(az account show --query id -o tsv)" diff --git a/scripts/test-budget-webhook.sh b/scripts/test-budget-webhook.sh index 25aac3b..ac14314 100644 --- a/scripts/test-budget-webhook.sh +++ b/scripts/test-budget-webhook.sh @@ -5,16 +5,47 @@ set -e +# Function to safely get environment variables with defaults +safe_get_env() { + local var_name="$1" + local default_value="$2" + local value="${!var_name:-$default_value}" + + # Remove any carriage returns that might be present in Windows environments + echo "$value" | tr -d '\r' +} + # Default values -WEBHOOK_URL="" -BUDGET_NAME="Test Budget" -BUDGET_AMOUNT=1000 -CURRENT_SPEND=850 -THRESHOLD=85 +WEBHOOK_URL=$(safe_get_env "COST_ALERT_WEBHOOK_URL" "") +BUDGET_NAME=$(safe_get_env "BUDGET_NAME" "Test Budget") +BUDGET_AMOUNT=$(safe_get_env "DEFAULT_BUDGET_AMOUNT" "1000") +THRESHOLD=$(safe_get_env "DEFAULT_BUDGET_THRESHOLDS" "85") + +# Calculate current spend as 85% of budget if not provided +if [ -z "${CURRENT_SPEND:-}" ]; then + CURRENT_SPEND=$((${BUDGET_AMOUNT} * 85 / 100)) +else + CURRENT_SPEND=$(safe_get_env "CURRENT_SPEND" "") +fi + SUBSCRIPTION_ID="" RESOURCE_GROUP="" DEBUG=false +# Validate numeric values +for var in BUDGET_AMOUNT CURRENT_SPEND THRESHOLD; do + if ! [[ "${!var}" =~ ^[0-9]+$ ]]; then + echo "Error: $var must be a number. Got: ${!var}" >&2 + exit 1 + fi +done + +# Validate threshold is between 1-100 +if [ "$THRESHOLD" -lt 1 ] || [ "$THRESHOLD" -gt 100 ]; then + echo "Error: THRESHOLD must be between 1 and 100. Got: $THRESHOLD" >&2 + exit 1 +fi + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -113,19 +144,17 @@ while [[ $# -gt 0 ]]; do esac done -# Validate required parameters +# Validate webhook URL is provided and valid if [ -z "$WEBHOOK_URL" ]; then - log_error "Webhook URL is required" - usage + echo -e "${RED}Error: Webhook URL is required${NC}" >&2 + echo "Please provide a webhook URL using --webhook-url or set COST_ALERT_WEBHOOK_URL environment variable" >&2 + exit 1 fi -# Validate amounts are numbers -for var in BUDGET_AMOUNT CURRENT_SPEND THRESHOLD; do - if ! [[ "${!var}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then - log_error "$var must be a number" - exit 1 - fi -done +# Validate the webhook URL +if ! validate_url "$WEBHOOK_URL"; then + exit 1 +fi # Calculate percentage PERCENTAGE=$(awk "BEGIN {printf \"%.2f\", ($CURRENT_SPEND / $BUDGET_AMOUNT) * 100}")