diff --git a/README.md b/README.md index 35d6671..2431056 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ COMMANDS: detectAnomalies Detect anomalies and trends budgets Get the available budgets regions Get the available Azure regions + what-if Run what-if scenarios ``` @@ -245,6 +246,88 @@ azure-cost regions > Not all the formatters are supported for this command. Let me know if there is a need. +### What-if scenarios + +This command allows you to run what-if scenarios. It will show the cost of the subscription if you would make changes to either usage or rates. + +#### Regions + +The what-if regions command compares your virtual machines in the given subscription with prices of the same virtual machine in different regions. It will show the current cost and the cost if you would move the virtual machine to a different region. + +```bash +azure-cost what-if regions +``` + +A typical output to the console might be like this: + +```bash +Prices per region for a914b3f4-fe8b-4e94-a0d3-2938540d59c6 between 01/09/2023 and 05/10/2023 +└── Resource: idlemachinetest + ├── Group: TEST-IDLE-MACHINE + ├── Product: Virtual Machines BS Series - B2ms - EU West + ├── Total quantity: 244 (100 Hours) + ├── Current cost: 21,93 EUR + └── ╭──────────────────┬─────────────────────────┬─────────────────────────┬───────────┬─────────────────────────┬──────────────────┬────────────────────────┬───────────────────╮ + │ Region │ Retail Price │ Cost │ Deviation │ 1 Year Savings Plan │ 1 Year Deviation │ 3 Years Savings Plan │ 3 Years Deviation │ + ├──────────────────┼─────────────────────────┼─────────────────────────┼───────────┼─────────────────────────┼──────────────────┼────────────────────────┼───────────────────┤ + │ US West 2 │ 0,079151 EUR │ 19,31 EUR │ -13,33% │ 0,053308 EUR │ -41,63% │ 0,035666 EUR │ -60,95% │ + │ US East 2 │ 0,079151 EUR │ 19,31 EUR │ -13,33% │ 0,062664 EUR │ -31,39% │ 0,042291 EUR │ -53,69% │ + │ US West 3 │ 0,079151 EUR │ 19,31 EUR │ -13,33% │ 0,062664 EUR │ -31,39% │ 0,042291 EUR │ -53,69% │ + │ US East │ 0,079151 EUR │ 19,31 EUR │ -13,33% │ 0,053308 EUR │ -41,63% │ 0,035666 EUR │ -60,95% │ + │ US North Central │ 0,079151 EUR │ 19,31 EUR │ -13,33% │ 0,053308 EUR │ -41,63% │ 0,035666 EUR │ -60,95% │ + │ SE Central │ 0,082196 EUR │ 20,06 EUR │ -10,00% │ 0,062773 EUR │ -31,27% │ 0,043186 EUR │ -52,71% │ + │ IN West Jio │ 0,085240 EUR │ 20,80 EUR │ -6,67% │ 0,057563 EUR │ -36,97% │ 0,037557 EUR │ -58,88% │ + │ IN Central Jio │ 0,085240 EUR │ 20,80 EUR │ -6,67% │ 0,057563 EUR │ -36,97% │ 0,037557 EUR │ -58,88% │ + │ IN Central │ 0,085240 EUR │ 20,80 EUR │ -6,67% │ 0,057580 EUR │ -36,95% │ 0,037548 EUR │ -58,89% │ + │ EU North │ 0,086572 EUR │ 21,12 EUR │ -5,21% │ 0,066106 EUR │ -27,62% │ 0,045476 EUR │ -50,21% │ + │ CA East │ 0,088284 EUR │ 21,54 EUR │ -3,33% │ 0,059645 EUR │ -34,69% │ 0,039922 EUR │ -56,29% │ + │ CA Central │ 0,088284 EUR │ 21,54 EUR │ -3,33% │ 0,059645 EUR │ -34,69% │ 0,039922 EUR │ -56,29% │ + │ UK West │ 0,089426 EUR │ 21,82 EUR │ -2,08% │ 0,070798 EUR │ -22,48% │ 0,047780 EUR │ -47,68% │ + │ UK South │ 0,089806 EUR │ 21,91 EUR │ -1,67% │ 0,068028 EUR │ -25,51% │ 0,047651 EUR │ -47,82% │ + │ FR Central │ 0,089806 EUR │ 21,91 EUR │ -1,67% │ 0,068028 EUR │ -25,51% │ 0,047651 EUR │ -47,82% │ + │ IT North │ 0,091329 EUR │ 22,28 EUR │ 0,00% │ │ │ │ │ + │ DE West Central │ 0,091329 EUR │ 22,28 EUR │ 0,00% │ 0,070734 EUR │ -22,55% │ 0,048724 EUR │ -46,65% │ + │ EU West │ 0,091329 EUR │ 22,28 EUR │ 0,00% │ 0,070725 EUR │ -22,56% │ 0,048733 EUR │ -46,64% │ + │ US Gov Virginia │ 0,092851 EUR │ 22,66 EUR │ 1,67% │ │ │ │ │ + │ US Gov AZ │ 0,092851 EUR │ 22,66 EUR │ 1,67% │ │ │ │ │ + │ US West │ 0,094373 EUR │ 23,03 EUR │ 3,33% │ 0,074498 EUR │ -18,43% │ 0,049140 EUR │ -46,19% │ + │ US Central │ 0,094944 EUR │ 23,17 EUR │ 3,96% │ 0,075167 EUR │ -17,70% │ 0,050728 EUR │ -44,46% │ + │ US South Central │ 0,094944 EUR │ 23,17 EUR │ 3,96% │ 0,075167 EUR │ -17,70% │ 0,050728 EUR │ -44,46% │ + │ US West Central │ 0,094944 EUR │ 23,17 EUR │ 3,96% │ 0,075167 EUR │ -17,70% │ 0,050728 EUR │ -44,46% │ + │ AE North │ 0,094944 EUR │ 23,17 EUR │ 3,96% │ 0,072546 EUR │ -20,57% │ 0,049874 EUR │ -45,39% │ + │ QA Central │ 0,095134 EUR │ 23,21 EUR │ 4,17% │ 0,072682 EUR │ -20,42% │ 0,049974 EUR │ -45,28% │ + │ IL Central │ 0,095134 EUR │ 23,21 EUR │ 4,17% │ 0,064691 EUR │ -29,17% │ 0,043762 EUR │ -52,08% │ + │ KR South │ 0,098939 EUR │ 24,14 EUR │ 8,33% │ 0,078330 EUR │ -14,23% │ 0,052863 EUR │ -42,12% │ + │ KR Central │ 0,098939 EUR │ 24,14 EUR │ 8,33% │ 0,064202 EUR │ -29,70% │ 0,045413 EUR │ -50,28% │ + │ CH North │ 0,100461 EUR │ 24,51 EUR │ 10,00% │ 0,079535 EUR │ -12,91% │ 0,053677 EUR │ -41,23% │ + │ AU Central │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,073484 EUR │ -19,54% │ 0,050945 EUR │ -44,22% │ + │ AU Central 2 │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,073484 EUR │ -19,54% │ 0,050945 EUR │ -44,22% │ + │ AU Southeast │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,079837 EUR │ -12,58% │ 0,053880 EUR │ -41,00% │ + │ NO East │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,079837 EUR │ -12,58% │ 0,053880 EUR │ -41,00% │ + │ PL Central │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,078092 EUR │ -14,49% │ 0,053809 EUR │ -41,08% │ + │ AP Southeast │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,068048 EUR │ -25,49% │ 0,044623 EUR │ -51,14% │ + │ AU East │ 0,100842 EUR │ 24,61 EUR │ 10,42% │ 0,073484 EUR │ -19,54% │ 0,050945 EUR │ -44,22% │ + │ ZA North │ 0,102745 EUR │ 25,07 EUR │ 12,50% │ 0,078435 EUR │ -14,12% │ 0,053941 EUR │ -40,94% │ + │ JA East │ 0,103696 EUR │ 25,30 EUR │ 13,54% │ 0,076071 EUR │ -16,71% │ 0,053435 EUR │ -41,49% │ + │ SE South │ 0,106550 EUR │ 26,00 EUR │ 16,67% │ 0,084356 EUR │ -7,64% │ 0,056930 EUR │ -37,66% │ + │ AP East │ 0,111307 EUR │ 27,16 EUR │ 21,87% │ 0,075076 EUR │ -17,80% │ 0,049264 EUR │ -46,06% │ + │ US Gov TX │ 0,111782 EUR │ 27,27 EUR │ 22,39% │ │ │ │ │ + │ IN South │ 0,112258 EUR │ 27,39 EUR │ 22,92% │ 0,088875 EUR │ -2,69% │ 0,059979 EUR │ -34,33% │ + │ IN West │ 0,113209 EUR │ 27,62 EUR │ 23,96% │ 0,089628 EUR │ -1,86% │ 0,060488 EUR │ -33,77% │ + │ JA West │ 0,114161 EUR │ 27,86 EUR │ 25,00% │ 0,090381 EUR │ -1,04% │ 0,060996 EUR │ -33,21% │ + │ DE North │ 0,118917 EUR │ 29,02 EUR │ 30,21% │ 0,094147 EUR │ 3,09% │ 0,063538 EUR │ -30,43% │ + │ AE Central │ 0,118917 EUR │ 29,02 EUR │ 30,21% │ 0,094147 EUR │ 3,09% │ 0,063538 EUR │ -30,43% │ + │ BR South │ 0,127479 EUR │ 31,10 EUR │ 39,58% │ 0,078540 EUR │ -14,00% │ 0,057786 EUR │ -36,73% │ + │ FR South │ 0,128431 EUR │ 31,34 EUR │ 40,62% │ 0,101679 EUR │ 11,33% │ 0,068621 EUR │ -24,86% │ + │ ZA West │ 0,129025 EUR │ 31,48 EUR │ 41,27% │ 0,102149 EUR │ 11,85% │ 0,068938 EUR │ -24,52% │ + │ NO West │ 0,130333 EUR │ 31,80 EUR │ 42,71% │ 0,103185 EUR │ 12,98% │ 0,069637 EUR │ -23,75% │ + │ CH West │ 0,131037 EUR │ 31,97 EUR │ 43,48% │ 0,103742 EUR │ 13,59% │ 0,070013 EUR │ -23,34% │ + │ BR Southeast │ 0,166484 EUR │ 40,62 EUR │ 82,29% │ 0,131806 EUR │ 44,32% │ 0,088953 EUR │ -2,60% │ + ╰──────────────────┴─────────────────────────┴─────────────────────────┴───────────┴─────────────────────────┴──────────────────┴────────────────────────┴───────────────────╯ +``` + +You can also output to csv or json for further processing. It uses the `usageDetails` endpoint, which provided different type of data than the `query` endpoint. + ## Filter With the `--filter` option you can pass in one or more properties to filter on. diff --git a/src/Commands/CostSettings.cs b/src/Commands/CostSettings.cs index e39e1f1..915b516 100644 --- a/src/Commands/CostSettings.cs +++ b/src/Commands/CostSettings.cs @@ -3,7 +3,14 @@ namespace AzureCostCli.Commands; -public class CostSettings : LogCommandSettings +public interface ICostSettings +{ + bool SkipHeader { get; set; } + OutputFormat Output { get; set; } + string Query { get; set; } +} + +public class CostSettings : LogCommandSettings, ICostSettings { [CommandOption("-s|--subscription")] [Description("The subscription id to use. Will try to fetch the active id if not specified.")] diff --git a/src/Commands/DailyCost/DailyCost.cs b/src/Commands/DailyCost/DailyCost.cs index 04e4100..eeec5a8 100644 --- a/src/Commands/DailyCost/DailyCost.cs +++ b/src/Commands/DailyCost/DailyCost.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; -using System.Text.Json; using AzureCostCli.Commands.ShowCommand.OutputFormatters; using AzureCostCli.CostApi; using AzureCostCli.Infrastructure; diff --git a/src/Commands/Prices/ListPricesCommand.cs b/src/Commands/Prices/ListPricesCommand.cs new file mode 100644 index 0000000..97843f0 --- /dev/null +++ b/src/Commands/Prices/ListPricesCommand.cs @@ -0,0 +1,34 @@ +using AzureCostCli.Commands.ShowCommand.OutputFormatters; +using AzureCostCli.CostApi; +using Spectre.Console.Cli; + +namespace AzureCostCli.Commands.Prices; + +public class ListPricesCommand: AsyncCommand +{ + private readonly IPriceRetriever _priceRetriever; + + private readonly Dictionary _outputFormatters = new(); + + + public ListPricesCommand(IPriceRetriever priceRetriever) + { + _priceRetriever = priceRetriever; + + // Add the output formatters + _outputFormatters.Add(OutputFormat.Console, new ConsoleOutputFormatter()); + _outputFormatters.Add(OutputFormat.Json, new JsonOutputFormatter()); + _outputFormatters.Add(OutputFormat.Jsonc, new JsonOutputFormatter()); + _outputFormatters.Add(OutputFormat.Text, new TextOutputFormatter()); + _outputFormatters.Add(OutputFormat.Markdown, new MarkdownOutputFormatter()); + _outputFormatters.Add(OutputFormat.Csv, new CsvOutputFormatter()); + } + + public override async Task ExecuteAsync(CommandContext context, PricesSettings settings) + { + + var prices = await _priceRetriever.GetAzurePricesAsync(); + + return 0; + } +} \ No newline at end of file diff --git a/src/Commands/Prices/PricesSettings.cs b/src/Commands/Prices/PricesSettings.cs new file mode 100644 index 0000000..b6ff61d --- /dev/null +++ b/src/Commands/Prices/PricesSettings.cs @@ -0,0 +1,6 @@ +namespace AzureCostCli.Commands.Prices; + +public class PricesSettings:CostSettings +{ + +} \ No newline at end of file diff --git a/src/Commands/WhatIf/DevTestWhatIfCommand.cs b/src/Commands/WhatIf/DevTestWhatIfCommand.cs new file mode 100644 index 0000000..733228b --- /dev/null +++ b/src/Commands/WhatIf/DevTestWhatIfCommand.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using AzureCostCli.Commands.ShowCommand.OutputFormatters; +using AzureCostCli.CostApi; +using AzureCostCli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace AzureCostCli.Commands.WhatIf; + +public class DevTestWhatIfCommand : AsyncCommand +{ + private readonly IPriceRetriever _priceRetriever; + private readonly ICostRetriever _costRetriever; + + private readonly Dictionary _outputFormatters = new(); + + private ConcurrentDictionary _cache = new(); + private ConcurrentDictionary _locks = new(); + + private TimeSpan _cacheLifetime = TimeSpan.FromHours(1); // Cache lifetime can be adjusted as needed + + public DevTestWhatIfCommand(IPriceRetriever priceRetriever, ICostRetriever costRetriever) + { + _priceRetriever = priceRetriever; + _costRetriever = costRetriever; + + // Add the output formatters + _outputFormatters.Add(OutputFormat.Console, new ConsoleOutputFormatter()); + _outputFormatters.Add(OutputFormat.Json, new JsonOutputFormatter()); + _outputFormatters.Add(OutputFormat.Jsonc, new JsonOutputFormatter()); + _outputFormatters.Add(OutputFormat.Text, new TextOutputFormatter()); + _outputFormatters.Add(OutputFormat.Markdown, new MarkdownOutputFormatter()); + _outputFormatters.Add(OutputFormat.Csv, new CsvOutputFormatter()); + } + + public override async Task ExecuteAsync(CommandContext context, WhatIfSettings settings) + { + // Get the subscription ID from the settings + var subscriptionId = settings.Subscription; + + if (subscriptionId == Guid.Empty) + { + // Get the subscription ID from the Azure CLI + try + { + if (settings.Debug) + AnsiConsole.WriteLine( + "No subscription ID specified. Trying to retrieve the default subscription ID from Azure CLI."); + + subscriptionId = Guid.Parse(AzCommand.GetDefaultAzureSubscriptionId()); + + if (settings.Debug) + AnsiConsole.WriteLine($"Default subscription ID retrieved from az cli: {subscriptionId}"); + + settings.Subscription = subscriptionId; + } + catch (Exception e) + { + AnsiConsole.WriteException(new ArgumentException( + "Missing subscription ID. Please specify a subscription ID or login to Azure CLI.", e)); + return -1; + } + } + + // Fetch the costs from the Azure Cost Management API + IEnumerable resources = new List(); + + + + await AnsiConsole.Status() + .StartAsync("Fetching cost data for resources...", async ctx => + { + resources = await _costRetriever.RetrieveCostForResources( + settings.Debug, + subscriptionId, settings.Filter, + settings.Metric, + false, + settings.Timeframe, + settings.From, + settings.To); + + ctx.Status = "Running What-If analysis..."; + + List tasks = new List(); + + foreach (var resource in resources) + { + tasks.Add(Task.Run(async () => + { + var serviceName = resource.ServiceName; + var location = resource.ResourceLocation; + var currency = resource.Currency; + + // Skip if any required parameter is missing + if (string.IsNullOrWhiteSpace(serviceName) || string.IsNullOrWhiteSpace(location)) return; + + var devTestPrice = await GetDevTestPrice(serviceName, location, currency); + + if (devTestPrice.HasValue) // && devTestPrice < resource.Cost) + { + Console.WriteLine($"Resource ID {resource.ResourceId} could have saved {resource.Cost - devTestPrice} {currency} with DevTest pricing."); + } + })); + } + + // Wait for all tasks to complete + await Task.WhenAll(tasks); + + }); + + + return 0; + } + + private async Task GetDevTestPrice(string serviceName, string location, string currency) + { + // Use the service name, location, and currency as the cache key + string cacheKey = $"{serviceName}:{location}:{currency}"; + + // Check if the cache entry exists and if it's not expired + if (_cache.TryGetValue(cacheKey, out CacheEntry cacheEntry) && cacheEntry.Expiry > DateTime.Now) + { + return cacheEntry.Price; + } + + // Get or create a new lock for this cache key + SemaphoreSlim mylock = _locks.GetOrAdd(cacheKey, k => new SemaphoreSlim(1, 1)); + + // Use the semaphore to ensure only one thread at a time can update a given cache entry + await mylock.WaitAsync(); + + try + { + // Check the cache again, in case another thread updated the entry while this thread was waiting for the lock + if (_cache.TryGetValue(cacheKey, out cacheEntry) && cacheEntry.Expiry > DateTime.Now) + { + return cacheEntry.Price; + } + + // If the price is not in the cache or it's expired, get it from the API + string filter = + $"priceType eq 'DevTestConsumption' and Location eq '{location}' and serviceName eq '{serviceName}'"; + IEnumerable devTestPrices = await _priceRetriever.GetAzurePricesAsync(filter); + var devTestPriceRecord = devTestPrices.FirstOrDefault(); + double? price = devTestPriceRecord?.RetailPrice; + + // Store the price in the cache with an expiry time + _cache[cacheKey] = new CacheEntry { Price = price, Expiry = DateTime.Now.Add(_cacheLifetime) }; + + // Return the price, or null if there is no DevTest price + return price; + } + finally + { + mylock.Release(); + } + } +} + + +public class CacheEntry +{ + public double? Price { get; set; } + public DateTime Expiry { get; set; } +} \ No newline at end of file diff --git a/src/Commands/WhatIf/RegionWhatIfCommand.cs b/src/Commands/WhatIf/RegionWhatIfCommand.cs new file mode 100644 index 0000000..93f7d7d --- /dev/null +++ b/src/Commands/WhatIf/RegionWhatIfCommand.cs @@ -0,0 +1,177 @@ +using System.Collections.Concurrent; +using AzureCostCli.Commands.ShowCommand.OutputFormatters; +using AzureCostCli.CostApi; +using AzureCostCli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace AzureCostCli.Commands.WhatIf; + +// Run what-if scenarios to check price difference if the resources would have run in a different region +public class RegionWhatIfCommand : AsyncCommand +{ + private readonly IPriceRetriever _priceRetriever; + private readonly ICostRetriever _costRetriever; + + private readonly Dictionary _outputFormatters = new(); + + + public RegionWhatIfCommand(IPriceRetriever priceRetriever, ICostRetriever costRetriever) + { + _priceRetriever = priceRetriever; + _costRetriever = costRetriever; + + // Add the output formatters + _outputFormatters.Add(OutputFormat.Console, new ConsoleOutputFormatter()); + _outputFormatters.Add(OutputFormat.Json, new JsonOutputFormatter()); + _outputFormatters.Add(OutputFormat.Jsonc, new JsonOutputFormatter()); + _outputFormatters.Add(OutputFormat.Text, new TextOutputFormatter()); + _outputFormatters.Add(OutputFormat.Markdown, new MarkdownOutputFormatter()); + _outputFormatters.Add(OutputFormat.Csv, new CsvOutputFormatter()); + } + + public override async Task ExecuteAsync(CommandContext context, WhatIfSettings settings) + { + // Get the subscription ID from the settings + var subscriptionId = settings.Subscription; + + if (subscriptionId == Guid.Empty) + { + // Get the subscription ID from the Azure CLI + try + { + if (settings.Debug) + AnsiConsole.WriteLine( + "No subscription ID specified. Trying to retrieve the default subscription ID from Azure CLI."); + + subscriptionId = Guid.Parse(AzCommand.GetDefaultAzureSubscriptionId()); + + if (settings.Debug) + AnsiConsole.WriteLine($"Default subscription ID retrieved from az cli: {subscriptionId}"); + + settings.Subscription = subscriptionId; + } + catch (Exception e) + { + AnsiConsole.WriteException(new ArgumentException( + "Missing subscription ID. Please specify a subscription ID or login to Azure CLI.", e)); + return -1; + } + } + + // Fetch the costs from the Azure Cost Management API + IEnumerable resources; + Dictionary> pricesByRegion = new(); + + await AnsiConsole.Status() + .StartAsync("Fetching cost data for resources...", async ctx => + { + resources = await _costRetriever.RetrieveUsageDetails( + settings.Debug, + subscriptionId, + "", + settings.From, + settings.To); + + // We need to group the resources by resource id AND product as we get for the same resource multiple items for each day + // However, we do need to make sure we sum the quantity and cost + resources = resources + .Where(a => a.properties is + { consumedService: "Microsoft.Compute", meterDetails.meterCategory: "Virtual Machines" }) + .GroupBy(a => a.properties.resourceId) + .Select(a => new UsageDetails + { + id = a.Key, + name = a.First().name, + type = a.First().type, + kind = a.First().kind, + tags = a.First().tags, + properties = new UsageProperties + { + meterDetails = new MeterDetails + { + meterCategory = a.First().properties.meterDetails.meterCategory, + unitOfMeasure = a.First().properties.meterDetails.unitOfMeasure, + meterName = a.First().properties.meterDetails.meterName, + meterSubCategory = a.First().properties.meterDetails.meterSubCategory, + }, + quantity = a.Sum(b => b.properties.quantity), + consumedService = a.First().properties.consumedService, + cost = a.Sum(b => b.properties.cost), + meterId = a.First().properties.meterId, + resourceGroup = a.First().properties.resourceGroup, + frequency = a.First().properties.frequency, + product = a.First().properties.product, + additionalInfo = a.First().properties.additionalInfo, + billingCurrency = a.First().properties.billingCurrency, + billingProfileId = a.First().properties.billingProfileId, + offerId = a.First().properties.offerId, + chargeType = a.First().properties.chargeType, + resourceLocation = a.First().properties.resourceLocation, + resourceId = a.First().properties.resourceId, + resourceName = a.First().properties.resourceName, + billingProfileName = a.First().properties.billingProfileName, + unitPrice = a.First().properties.unitPrice, + effectivePrice = a.First().properties.effectivePrice, + billingPeriodStartDate = a.First().properties.billingPeriodStartDate, + billingPeriodEndDate = a.First().properties.billingPeriodEndDate, + publisherType = a.First().properties.publisherType, + isAzureCreditEligible = a.First().properties.isAzureCreditEligible, + subscriptionName = a.First().properties.subscriptionName, + subscriptionId = a.First().properties.subscriptionId, + } + }); + + ctx.Status = "Running What-If analysis..."; + + List tasks = new List(); + + foreach (var resource in resources) + { + string skuName = resource.properties.meterDetails.meterName; + ctx.Status = "Fetching prices for " + skuName; + + var items = await FetchPricesForAllRegions(skuName, resource.properties.meterId, + resource.properties.billingCurrency); + + pricesByRegion.Add(resource, items.ToList()); + } + }); + + // Write the output + await _outputFormatters[settings.Output] + .WritePricesPerRegion(settings, pricesByRegion); + + return 0; + } + + private Dictionary> _priceCache = new(); + + private async Task> FetchPricesForAllRegions(string skuName, string meterId, + string currency = "USD") + { + // Cachekey + var cacheKey = skuName + ":" + meterId + ":" + currency; + + // Check if prices for the given SKU name exist in the cache + if (_priceCache.TryGetValue(cacheKey, out var regions)) + { + return regions; + } + + string filter = $"serviceName eq 'Virtual Machines' and skuName eq '{skuName}' and type eq 'Consumption'"; + IEnumerable prices = await _priceRetriever.GetAzurePricesAsync(currency, filter); + + // find the item by meterId and use that to determine the actual product name + // if we do not do that, we end up with both windows and linux machines + var actualItem = prices.FirstOrDefault(a => a.MeterId == meterId); + + if (actualItem is not null) + prices = prices.Where(a => a.ProductName == actualItem.ProductName); + + // Store the fetched prices in the cache + _priceCache[cacheKey] = prices; + + return prices; + } +} \ No newline at end of file diff --git a/src/Commands/WhatIf/WhatIfSettings.cs b/src/Commands/WhatIf/WhatIfSettings.cs new file mode 100644 index 0000000..34eeee2 --- /dev/null +++ b/src/Commands/WhatIf/WhatIfSettings.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace AzureCostCli.Commands.WhatIf; + +public class WhatIfSettings : CommandSettings, ICostSettings //:CostSettings +{ + [CommandOption("--debug")] + [Description("Increase logging verbosity to show all debug logs.")] + [DefaultValue(false)] + public bool Debug { get; set; } + + [CommandOption("-s|--subscription")] + [Description("The subscription id to use. Will try to fetch the active id if not specified.")] + public Guid Subscription { get; set; } + + [CommandOption("-o|--output")] + [Description("The output format to use. Defaults to Console (Console, Json, JsonC, Text, Markdown, Csv)")] + public OutputFormat Output { get; set; } = OutputFormat.Console; + + [CommandOption("-t|--timeframe")] + [Description( "The timeframe to use for the costs. Defaults to BillingMonthToDate. When set to Custom, specify the from and to dates using the --from and --to options")] + public TimeframeType Timeframe { get; set; } = TimeframeType.BillingMonthToDate; + + [CommandOption("--from")] + [Description("The start date to use for the costs. Defaults to the first day of the previous month.")] + public DateOnly From { get; set; } = DateOnly.FromDateTime( new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).AddMonths(-1)); + + [CommandOption("--to")] + [Description("The end date to use for the costs. Defaults to the current date.")] + public DateOnly To { get; set; } = DateOnly.FromDateTime(DateTime.UtcNow); + + [CommandOption("--others-cutoff")] + [Description("The number of items to show before collapsing the rest into an 'Others' item.")] + [DefaultValue(10)] + public int OthersCutoff { get; set; } = 10; + + [CommandOption("--query")] + [Description("JMESPath query string, applicable for the Json output only. See http://jmespath.org/ for more information and examples.")] + public string Query { get; set; } = string.Empty; + + [CommandOption("--useUSD")] + [Description("Force the use of USD for the currency. Defaults to false to use the currency returned by the API.")] + [DefaultValue(false)] + public bool UseUSD { get; set; } + + [CommandOption("--skipHeader")] + [Description("Skip header creation for specific output formats. Useful when appending the output from multiple runs into one file. Defaults to false.")] + [DefaultValue(false)] + public bool SkipHeader { get; set; } + + [CommandOption("--filter")] + [Description("Filter the output by the specified properties. Defaults to no filtering and can be multiple values.")] + public string[] Filter { get; set; } + + [CommandOption("-m|--metric")] + [Description("The metric to use for the costs. Defaults to ActualCost. (ActualCost, AmortizedCost)")] + [DefaultValue(MetricType.ActualCost)] + public MetricType Metric { get; set; } = MetricType.ActualCost; +} \ No newline at end of file diff --git a/src/CostApi/AzureCostApiRetriever.cs b/src/CostApi/AzureCostApiRetriever.cs index d26cd2b..932109c 100644 --- a/src/CostApi/AzureCostApiRetriever.cs +++ b/src/CostApi/AzureCostApiRetriever.cs @@ -15,7 +15,7 @@ public class AzureCostApiRetriever : ICostRetriever { private readonly HttpClient _client; private bool _tokenRetrieved; - + public enum DimensionNames { PublisherType, @@ -41,7 +41,6 @@ public AzureCostApiRetriever(IHttpClientFactory httpClientFactory) _client = httpClientFactory.CreateClient("CostApi"); } - private async Task RetrieveToken(bool includeDebugOutput) { @@ -216,7 +215,7 @@ public async Task> RetrieveCosts(bool includeDebugOutput, public async Task> RetrieveCostByServiceName(bool includeDebugOutput, - Guid subscriptionId, string[] filter,MetricType metric, TimeframeType timeFrame, DateOnly from, DateOnly to) + Guid subscriptionId, string[] filter, MetricType metric, TimeframeType timeFrame, DateOnly from, DateOnly to) { var uri = new Uri( $"/subscriptions/{subscriptionId}/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000", @@ -289,7 +288,7 @@ public async Task> RetrieveCostByServiceName(bool inc } public async Task> RetrieveCostByLocation(bool includeDebugOutput, Guid subscriptionId, - string[] filter,MetricType metric, + string[] filter, MetricType metric, TimeframeType timeFrame, DateOnly from, DateOnly to) { var uri = new Uri( @@ -363,7 +362,7 @@ public async Task> RetrieveCostByLocation(bool includ } public async Task> RetrieveCostByResourceGroup(bool includeDebugOutput, - Guid subscriptionId, string[] filter,MetricType metric, + Guid subscriptionId, string[] filter, MetricType metric, TimeframeType timeFrame, DateOnly from, DateOnly to) { var uri = new Uri( @@ -620,7 +619,8 @@ public async Task> RetrieveForecastedCosts(bool includeDeb } public async Task> RetrieveCostForResources(bool includeDebugOutput, - Guid subscriptionId, string[] filter, MetricType metric, bool excludeMeterDetails, TimeframeType timeFrame, DateOnly from, + Guid subscriptionId, string[] filter, MetricType metric, bool excludeMeterDetails, TimeframeType timeFrame, + DateOnly from, DateOnly to) { var uri = new Uri( @@ -628,7 +628,7 @@ public async Task> RetrieveCostForResources(bool i UriKind.Relative); object grouping; - if (excludeMeterDetails==false) + if (excludeMeterDetails == false) grouping = new[] { new @@ -713,7 +713,7 @@ public async Task> RetrieveCostForResources(bool i } }; } - + var payload = new { type = metric.ToString(), @@ -761,12 +761,12 @@ public async Task> RetrieveCostForResources(bool i string chargeType = row[5].GetString(); string resourceGroupName = row[6].GetString(); string publisherType = row[7].GetString(); - - string serviceName = excludeMeterDetails?null:row[8].GetString(); - string serviceTier = excludeMeterDetails?null:row[9].GetString(); - string meter = excludeMeterDetails?null:row[10].GetString(); - - int tagsColumn = excludeMeterDetails?8:11; + + string serviceName = excludeMeterDetails ? null : row[8].GetString(); + string serviceTier = excludeMeterDetails ? null : row[9].GetString(); + string meter = excludeMeterDetails ? null : row[10].GetString(); + + int tagsColumn = excludeMeterDetails ? 8 : 11; // Assuming row[tagsColumn] contains the tags array var tagsArray = row[tagsColumn].EnumerateArray().ToArray(); @@ -782,8 +782,8 @@ public async Task> RetrieveCostForResources(bool i tags[key] = value; } } - - int currencyColumn = excludeMeterDetails?9:12; + + int currencyColumn = excludeMeterDetails ? 9 : 12; string currency = row[currencyColumn].GetString(); CostResourceItem item = new CostResourceItem(cost, costUSD, resourceId, resourceType, resourceLocation, @@ -796,17 +796,53 @@ public async Task> RetrieveCostForResources(bool i { // As we do not care about the meter details, we still have the possibility of resources with the same, but having multiple locations like Intercontinental, Unknown and Unassigned // We need to aggregate these resources together and show the total cost for the resource, the resource locations need to be combined as well. So it can become West Europe, Intercontinental - + var aggregatedItems = new List(); var groupedItems = items.GroupBy(x => x.ResourceId); foreach (var groupedItem in groupedItems) { - var aggregatedItem = new CostResourceItem(groupedItem.Sum(x => x.Cost), groupedItem.Sum(x => x.CostUSD), groupedItem.Key, groupedItem.First().ResourceType, string.Join(", ", groupedItem.Select(x => x.ResourceLocation)), groupedItem.First().ChargeType, groupedItem.First().ResourceGroupName, groupedItem.First().PublisherType, null, null, null, groupedItem.First().Tags, groupedItem.First().Currency); + var aggregatedItem = new CostResourceItem(groupedItem.Sum(x => x.Cost), groupedItem.Sum(x => x.CostUSD), + groupedItem.Key, groupedItem.First().ResourceType, + string.Join(", ", groupedItem.Select(x => x.ResourceLocation)), groupedItem.First().ChargeType, + groupedItem.First().ResourceGroupName, groupedItem.First().PublisherType, null, null, null, + groupedItem.First().Tags, groupedItem.First().Currency); aggregatedItems.Add(aggregatedItem); } - + return aggregatedItems; } + + return items; + } + + public async Task> RetrieveUsageDetails(bool includeDebugOutput, + Guid subscriptionId, string filter, DateOnly from, DateOnly to) + { + var uri = new Uri( + $"/subscriptions/{subscriptionId}/providers/Microsoft.Consumption/usageDetails?api-version=2023-05-01&$expand=meterDetails&metric=usage&$top=5000", + UriKind.Relative); + + filter = (!string.IsNullOrWhiteSpace(filter) + ? filter + " AND " + : "") +"properties/usageStart ge '" + from.ToString("yyyy-MM-dd") + "' and properties/usageEnd le '" + + to.ToString("yyyy-MM-dd") + "'"; + + + uri = new Uri($"{uri}&$filter={filter}", UriKind.Relative); + + var items = new List(); + + while (uri != null) + { + var response = await ExecuteCallToCostApi(includeDebugOutput, null, uri); + + UsageDetailsResponse payload = await response.Content.ReadFromJsonAsync() ?? + new UsageDetailsResponse(); + + items.AddRange(payload.value); + uri = payload.nextLink != null ? new Uri(payload.nextLink, UriKind.Relative) : null; + } + return items; } @@ -889,4 +925,58 @@ public async Task> RetrieveBudgets(bool includeDebugOutp return budgetItems; } +} + +public class UsageDetailsResponse +{ + public UsageDetails[] value { get; set; } + public string? nextLink { get; set; } +} + +public class UsageDetails +{ + public string kind { get; set; } + public string id { get; set; } + public string name { get; set; } + public string type { get; set; } + public Dictionary tags { get; set; } + public UsageProperties properties { get; set; } +} + +public class UsageProperties +{ + public string billingPeriodStartDate { get; set; } + public string billingPeriodEndDate { get; set; } + public string billingProfileId { get; set; } + public string billingProfileName { get; set; } + public string subscriptionId { get; set; } + public string subscriptionName { get; set; } + public string date { get; set; } + public string product { get; set; } + public string meterId { get; set; } + public double quantity { get; set; } + public double effectivePrice { get; set; } + public double cost { get; set; } + public double unitPrice { get; set; } + public string billingCurrency { get; set; } + public string resourceLocation { get; set; } + public string consumedService { get; set; } + public string resourceId { get; set; } + public string resourceName { get; set; } + public string additionalInfo { get; set; } + public string resourceGroup { get; set; } + public string offerId { get; set; } + public bool isAzureCreditEligible { get; set; } + public string publisherType { get; set; } + public string chargeType { get; set; } + public string frequency { get; set; } + public MeterDetails meterDetails { get; set; } +} + +public class MeterDetails +{ + public string meterName { get; set; } + public string meterCategory { get; set; } + public string meterSubCategory { get; set; } + public string unitOfMeasure { get; set; } } \ No newline at end of file diff --git a/src/CostApi/AzurePriceRetriever.cs b/src/CostApi/AzurePriceRetriever.cs new file mode 100644 index 0000000..67ef34d --- /dev/null +++ b/src/CostApi/AzurePriceRetriever.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureCostCli.CostApi; + +public class AzurePriceRetriever : IPriceRetriever +{ + private readonly HttpClient _client; + + public AzurePriceRetriever(IHttpClientFactory httpClientFactory) + { + _client = httpClientFactory.CreateClient("PriceApi"); + } + + public async Task> GetAzurePricesAsync(string currencyCode = "USD", string? filter = null) + { + var prices = new List(); + string? url = "https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview¤cyCode='" + currencyCode + "'"; + + // Append the filter to the URL if it's provided + if (!string.IsNullOrWhiteSpace(filter)) + { + url += "&$filter=" + filter; + } + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + while (url != null) + { + var response = await _client.GetAsync(url); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var data = JsonSerializer.Deserialize(content, options); + + if (data.Items != null) + { + prices.AddRange(data.Items); + } + + url = data.NextPageLink; // get the next page link + } + else + { + throw new Exception($"Failed to get data from API. Status code: {response.StatusCode}"); + } + } + + return prices; + } +} + +public class PriceRecord +{ + public string CurrencyCode { get; set; } + public double TierMinimumUnits { get; set; } + public double RetailPrice { get; set; } + public double UnitPrice { get; set; } + public string ArmRegionName { get; set; } + public string Location { get; set; } + public DateTime EffectiveStartDate { get; set; } + public string MeterId { get; set; } + public string MeterName { get; set; } + public string ProductId { get; set; } + public string SkuId { get; set; } + public string ProductName { get; set; } + public string SkuName { get; set; } + public string ServiceName { get; set; } + public string ServiceId { get; set; } + public string ServiceFamily { get; set; } + public string UnitOfMeasure { get; set; } + public string Type { get; set; } + public bool IsPrimaryMeterRegion { get; set; } + public string ArmSkuName { get; set; } + public List SavingsPlan { get; set; } + public string ReservationTerm { get; set; } // Only present in some records + + public string Sku + { + get + { + var sku = $"{SkuId}/{MeterId}"; + + if (ServiceName is "Virtual Machines" or "Azure App Service") { + sku = $"{ProductId}/{ArmSkuName}/{MeterId}"; + } + + return sku; + } + } + +} + +public class SavingsPlan +{ + public double UnitPrice { get; set; } + public double RetailPrice { get; set; } + public string Term { get; set; } +} + + + public class PriceData + { + [JsonPropertyName("Items")] + public List Items { get; set; } + + [JsonPropertyName("NextPageLink")] + public string? NextPageLink { get; set; } + } diff --git a/src/CostApi/ICostRetriever.cs b/src/CostApi/ICostRetriever.cs index e376630..f95d329 100644 --- a/src/CostApi/ICostRetriever.cs +++ b/src/CostApi/ICostRetriever.cs @@ -27,6 +27,9 @@ Task> RetrieveForecastedCosts(bool includeDebugOutput, Gui Task> RetrieveCostForResources(bool settingsDebug, Guid subscriptionId, string[] filter, MetricType metric, bool excludeMeterDetails,TimeframeType settingsTimeframe, DateOnly from, DateOnly to); Task> RetrieveBudgets(bool settingsDebug, Guid subscriptionId); + + Task> RetrieveUsageDetails(bool includeDebugOutput, + Guid subscriptionId, string filter, DateOnly from, DateOnly to); Task> RetrieveDailyCost(bool settingsDebug, Guid subscriptionId, string[] filter,MetricType metric, string dimension, TimeframeType settingsTimeframe, DateOnly settingsFrom, DateOnly settingsTo); } diff --git a/src/CostApi/IPriceRetriever.cs b/src/CostApi/IPriceRetriever.cs new file mode 100644 index 0000000..a5f1124 --- /dev/null +++ b/src/CostApi/IPriceRetriever.cs @@ -0,0 +1,6 @@ +namespace AzureCostCli.CostApi; + +public interface IPriceRetriever +{ + Task> GetAzurePricesAsync(string currencyCode = "USD", string? filter = null); +} \ No newline at end of file diff --git a/src/Infrastructure/PollyExtensions.cs b/src/Infrastructure/PollyExtensions.cs new file mode 100644 index 0000000..107f768 --- /dev/null +++ b/src/Infrastructure/PollyExtensions.cs @@ -0,0 +1,27 @@ +using System.Net; +using Polly; + +namespace AzureCostCli.Infrastructure; + +public class PollyExtensions +{ + private static string RetryAfterHeader = "x-ms-ratelimit-microsoft.costmanagement-clienttype-retry-after"; + private static string RetryAfterHeader2 = "x-ms-ratelimit-microsoft.costmanagement-entity-retry-after"; + + public static IAsyncPolicy GetRetryAfterPolicy() + { + return Policy.HandleResult + (msg => msg.StatusCode == HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: (_, response, _) => + response.Result.Headers.TryGetValues(RetryAfterHeader, + out var seconds) + ? TimeSpan.FromSeconds(int.Parse(seconds.First())) + : response.Result.Headers.TryGetValues(RetryAfterHeader2, + out var seconds2) + ? TimeSpan.FromSeconds(int.Parse(seconds2.First())): TimeSpan.FromSeconds(5), + onRetryAsync: (msg, time, retries, context) => Task.CompletedTask + ); + } +} \ No newline at end of file diff --git a/src/OutputFormatters/BaseOutputFormatter.cs b/src/OutputFormatters/BaseOutputFormatter.cs index a82977b..7bd53fd 100644 --- a/src/OutputFormatters/BaseOutputFormatter.cs +++ b/src/OutputFormatters/BaseOutputFormatter.cs @@ -1,4 +1,5 @@ using AzureCostCli.Commands.Regions; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; namespace AzureCostCli.Commands.ShowCommand.OutputFormatters; @@ -15,6 +16,7 @@ public abstract class BaseOutputFormatter public abstract Task WriteAnomalyDetectionResults(DetectAnomalySettings settings, List anomalies); public abstract Task WriteRegions(RegionsSettings settings, IReadOnlyCollection regions); public abstract Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags); + public abstract Task WritePricesPerRegion(WhatIfSettings settings, Dictionary> pricesByRegion); } public record AccumulatedCostDetails( diff --git a/src/OutputFormatters/ConsoleOutputFormatter.cs b/src/OutputFormatters/ConsoleOutputFormatter.cs index 26aae4c..425900c 100644 --- a/src/OutputFormatters/ConsoleOutputFormatter.cs +++ b/src/OutputFormatters/ConsoleOutputFormatter.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using AzureCostCli.Commands.Regions; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; using AzureCostCli.Infrastructure; using AzureCostCli.OutputFormatters.SpectreConsole; @@ -65,12 +66,12 @@ public override Task WriteAccumulatedCost(AccumulatedCostSettings settings, // Add some rows - table.AddRow(new Markup("[green bold]" + todayTitle + ":[/]"),new Money(costToday,currency)); + table.AddRow(new Markup("[green bold]" + todayTitle + ":[/]"), new Money(costToday, currency)); table.AddRow(new Markup("[green bold]" + yesterdayTitle + ":[/]"), new Money(costYesterday, currency)); table.AddRow(new Markup("[blue bold]Since start of " + todaysDate.ToString("MMM") + ":[/]"), new Money(costSinceStartOfCurrentMonth, currency)); table.AddRow(new Markup("[yellow bold]Last 7 days:[/]"), new Money(costLastSevenDays, currency)); - table.AddRow(new Markup("[yellow bold]Last 30 days:[/]"), new Money(costLastThirtyDays,currency)); + table.AddRow(new Markup("[yellow bold]Last 30 days:[/]"), new Money(costLastThirtyDays, currency)); var accumulatedCostChart = new BarChart() .Width(60) @@ -157,20 +158,18 @@ public override Task WriteAccumulatedCost(AccumulatedCostSettings settings, rootTable.AddRow(subTable); AnsiConsole.Write(rootTable); - + return Task.CompletedTask; } public override Task WriteCostByResource(CostByResourceSettings settings, IEnumerable resources) { - // When we have meter details, we output the tree, otherwise we output a table if (settings.ExcludeMeterDetails == false) { - var tree = new Tree("Cost by resources"); tree.Guide(TreeGuide.Line); - + foreach (var resource in resources.OrderByDescending(a => a.Cost)) { var table = new Table() @@ -182,14 +181,14 @@ public override Task WriteCostByResource(CostByResourceSettings settings, IEnume .AddColumn("Tags") .AddColumn("Cost", column => column.RightAligned()); - table.AddRow(new Markup("[bold]"+resource.ResourceId.Split('/').Last().EscapeMarkup()+"[/]"), + table.AddRow(new Markup("[bold]" + resource.ResourceId.Split('/').Last().EscapeMarkup() + "[/]"), new Markup(resource.ResourceType.EscapeMarkup()), new Markup(resource.ResourceLocation.EscapeMarkup()), new Markup(resource.ResourceGroupName.EscapeMarkup()), - resource.Tags.Any()?new JsonText(JsonSerializer.Serialize( resource.Tags)):new Markup(""), + resource.Tags.Any() ? new JsonText(JsonSerializer.Serialize(resource.Tags)) : new Markup(""), settings.UseUSD ? new Money(resource.CostUSD, "USD") - : new Money(resource.Cost,resource.Currency)); + : new Money(resource.Cost, resource.Currency)); var treeNode = tree.AddNode(table); @@ -215,7 +214,6 @@ public override Task WriteCostByResource(CostByResourceSettings settings, IEnume } treeNode.AddNode(subTable); - } AnsiConsole.Write(tree); @@ -223,29 +221,26 @@ public override Task WriteCostByResource(CostByResourceSettings settings, IEnume else { var table = new Table() - .RoundedBorder().Expand() - .AddColumn("Resource") - .AddColumn("Resource Type") - .AddColumn("Location") - .AddColumn("Resource group name") - .AddColumn("Tags") - .AddColumn("Cost", column => column.Width(15).RightAligned()); - + .RoundedBorder().Expand() + .AddColumn("Resource") + .AddColumn("Resource Type") + .AddColumn("Location") + .AddColumn("Resource group name") + .AddColumn("Tags") + .AddColumn("Cost", column => column.Width(15).RightAligned()); + foreach (var resource in resources.OrderByDescending(a => a.Cost)) { - - table.AddRow(new Markup("[bold]"+resource.ResourceId.Split('/').Last().EscapeMarkup()+"[/]"), + table.AddRow(new Markup("[bold]" + resource.ResourceId.Split('/').Last().EscapeMarkup() + "[/]"), new Markup(resource.ResourceType.EscapeMarkup()), new Markup(resource.ResourceLocation.EscapeMarkup()), new Markup(resource.ResourceGroupName.EscapeMarkup()), - resource.Tags.Any()?new JsonText(JsonSerializer.Serialize( resource.Tags)):new Markup(""), + resource.Tags.Any() ? new JsonText(JsonSerializer.Serialize(resource.Tags)) : new Markup(""), settings.UseUSD ? new Money(resource.CostUSD, "USD") - : new Money(resource.Cost,resource.Currency)); - - + : new Money(resource.Cost, resource.Currency)); } - + AnsiConsole.Write(table); } @@ -293,6 +288,13 @@ public override Task WriteBudgets(BudgetsSettings settings, IEnumerable dailyCosts) { + + if (dailyCosts.Any()==false) + { + AnsiConsole.MarkupLine("[red]No data found[/]"); + return Task.CompletedTask; + } + const string othersLabel = "(others)"; var t = new Table(); @@ -302,7 +304,7 @@ public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable g.Name) .ToList(); -// Calculate the maximum daily cost + // Calculate the maximum daily cost var maxDailyCost = dailyCosts.GroupBy(a => a.Date) .Max(group => group.Sum(item => topItems.Contains(item.Name) ? (settings.UseUSD ? item.CostUsd : item.Cost) : 0)); @@ -376,10 +378,10 @@ public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable c.Date >= a.DetectionDate.AddDays(-3) && c.Date <= a.DetectionDate.AddDays(3)); + var relevantDays = a.Data.Where(c => + c.Date >= a.DetectionDate.AddDays(-3) && c.Date <= a.DetectionDate.AddDays(3)); var chart = new BarChart(); chart.Width = 50; - - foreach (var costData in relevantDays.OrderByDescending(c=>c.Date)) + + foreach (var costData in relevantDays.OrderByDescending(c => c.Date)) { - chart.AddItem(costData.Date == a.DetectionDate ? $"[bold]{costData.Date.ToString(CultureInfo.CurrentCulture)}[/]": $"[dim]{costData.Date.ToString(CultureInfo.CurrentCulture)}[/]", Math.Round(costData.Cost,2), costData.Date == a.DetectionDate ? Color.Red : Color.Green); + chart.AddItem( + costData.Date == a.DetectionDate + ? $"[bold]{costData.Date.ToString(CultureInfo.CurrentCulture)}[/]" + : $"[dim]{costData.Date.ToString(CultureInfo.CurrentCulture)}[/]", + Math.Round(costData.Cost, 2), + costData.Date == a.DetectionDate ? Color.Red : Color.Green); } - + subNode.AddNode(chart); } } @@ -494,7 +502,7 @@ public override Task WriteAnomalyDetectionResults(DetectAnomalySettings settings return Task.CompletedTask; } - + public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection regions) { var table = new Table(); @@ -505,41 +513,45 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection< table.AddColumn("Location"); table.AddColumn("Sustainability"); table.AddColumn("Compliance"); - - foreach (var region in regions.OrderBy(a=>a.continent).ThenBy(a=>a.geographyId)) + + foreach (var region in regions.OrderBy(a => a.continent).ThenBy(a => a.geographyId)) { table.AddRow( - new Markup(region.continent), - new Markup(region.geographyId), - new Markup((region.isOpen ? "[green]" : "[red]")+region.displayName+"[/]\n[dim]("+region.id+")[/]"), + new Markup(region.continent), + new Markup(region.geographyId), + new Markup((region.isOpen ? "[green]" : "[red]") + region.displayName + "[/]\n[dim](" + region.id + + ")[/]"), new Markup(region.location), new Markup(string.Join(", ", region.sustainabilityIds)), - new Markup(string.Join(", ", region.complianceIds.OrderBy(a=>a)))); + new Markup(string.Join(", ", region.complianceIds.OrderBy(a => a)))); } AnsiConsole.Write(table); - + return Task.CompletedTask; } - public override Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags) + public override Task WriteCostByTag(CostByTagSettings settings, + Dictionary>> byTags) { // When no tags are found, output no results and stop if (byTags.Count == 0) { AnsiConsole.WriteLine(); - AnsiConsole.WriteLine("No resources found with one of the tags in the list: "+ string.Join(',',settings.Tags)); + AnsiConsole.WriteLine("No resources found with one of the tags in the list: " + + string.Join(',', settings.Tags)); return Task.CompletedTask; } - var tree = new Tree("[green bold]Cost by Tag[/] for [bold]"+settings.Subscription+"[/] between [bold]"+settings.From+"[/] and [bold]"+settings.To+"[/]"); + var tree = new Tree("[green bold]Cost by Tag[/] for [bold]" + settings.Subscription + "[/] between [bold]" + + settings.From + "[/] and [bold]" + settings.To + "[/]"); AnsiConsole.WriteLine(); - - + + foreach (var tag in byTags) { var n = tree.AddNode($"[dim]key[/]: [bold]{tag.Key}[/]"); - + foreach (var tagValue in tag.Value) { var subNode = n.AddNode($"[dim]value[/]: [bold]{tagValue.Key}[/]"); @@ -550,37 +562,128 @@ public override Task WriteCostByTag(CostByTagSettings settings, Dictionarya.Cost)) + + foreach (var costResourceItem in tagValue.Value.OrderByDescending(a => a.Cost)) { table.AddRow( new Markup(costResourceItem.GetResourceName()), new Markup(costResourceItem.ResourceGroupName), new Markup(costResourceItem.ResourceType), new Markup(costResourceItem.ResourceLocation), - settings.UseUSD ? new Money( costResourceItem.CostUSD, "USD") : - new Money(costResourceItem.Cost, costResourceItem.Currency) - ); + settings.UseUSD + ? new Money(costResourceItem.CostUSD, "USD") + : new Money(costResourceItem.Cost, costResourceItem.Currency) + ); } - + // End with a total row table.AddRow( new Markup(""), new Markup(""), new Markup(""), new Markup("[bold]Total[/]"), - settings.UseUSD ? new Money(tagValue.Value.Sum(a=>a.CostUSD),"USD") : - new Money(tagValue.Value.Sum(a=>a.Cost), tagValue.Value.First().Currency) - ); - + settings.UseUSD + ? new Money(tagValue.Value.Sum(a => a.CostUSD), "USD") + : new Money(tagValue.Value.Sum(a => a.Cost), tagValue.Value.First().Currency) + ); + subNode.AddNode(table); } - } - + AnsiConsole.Write(tree); - + return Task.CompletedTask; } -} + public override Task WritePricesPerRegion(WhatIfSettings settings, + Dictionary> pricesByRegion) + { + // Loop through each resource in the pricesByRegion dictionary + // Output the name of the resource, and then a table with the prices per region + // Highlight the current region in the table + + var tree = new Tree("[green bold]Prices per region[/] for [bold]" + settings.Subscription + + "[/] between [bold]" + settings.From + "[/] and [bold]" + settings.To + "[/]"); + + + foreach (var (resource, prices) in pricesByRegion) + { + var n = tree.AddNode($"[dim]Resource[/]: [bold]{resource.properties.resourceName}[/]"); + + n.AddNode($"[dim]Group[/]: [bold]{resource.properties.resourceGroup}[/]"); + n.AddNode($"[dim]Product[/]: [bold]{resource.properties.product}[/]"); + n.AddNode( + $"[dim]Total quantity[/]: [bold]{resource.properties.quantity}[/] ({resource.properties.meterDetails.unitOfMeasure})"); + n.AddNode( + $"[dim]Current cost[/]: [bold]{Money.FormatMoney(resource.properties.quantity * resource.properties.effectivePrice, resource.properties.billingCurrency)}[/]"); + + var resourceTable = new Table(); + resourceTable.Border(TableBorder.Rounded); + resourceTable.AddColumn("Region"); + resourceTable.AddColumn("Retail Price"); + resourceTable.AddColumn("Cost"); + resourceTable.AddColumn("Deviation"); // The percentage higher or lower from the current region + resourceTable.AddColumn("1 Year Savings Plan"); + resourceTable.AddColumn("1 Year Deviation"); + resourceTable.AddColumn("3 Years Savings Plan"); + resourceTable.AddColumn("3 Years Deviation"); + + foreach (var price in prices.OrderBy(a => a.RetailPrice)) + { + // Calculate the deviation, so compared to the current region of the resource, determine how much higher or lower the price is in percentage + // This allows us to compare the different regions to the one currently in use. + var deviation = + (price.RetailPrice - prices.First(a => a.Location == resource.properties.resourceLocation) + .RetailPrice) / prices.First(a => a.Location == resource.properties.resourceLocation) + .RetailPrice * 100; + var oneYearSavingsPlan = price.SavingsPlan?.FirstOrDefault(a => a.Term == "1 Year"); + var threeYearSavingsPlan = price.SavingsPlan?.FirstOrDefault(a => a.Term == "3 Years"); + + // Also add the deviations for the savings plans + var oneYearSavingsPlanDeviation = oneYearSavingsPlan != null + ? (oneYearSavingsPlan.RetailPrice - + prices.First(a => a.Location == resource.properties.resourceLocation).RetailPrice) / + prices.First(a => a.Location == resource.properties.resourceLocation).RetailPrice * 100 + : 0; + var threeYearSavingsPlanDeviation = threeYearSavingsPlan != null + ? (threeYearSavingsPlan.RetailPrice - + prices.First(a => a.Location == resource.properties.resourceLocation).RetailPrice) / + prices.First(a => a.Location == resource.properties.resourceLocation).RetailPrice * 100 + : 0; + + resourceTable.AddRow( + new Markup(price.Location == resource.properties.resourceLocation + ? $"[bold green]{price.Location}[/]" + : price.Location), + new Money(price.RetailPrice, price.CurrencyCode, 6), + new Money(price.RetailPrice * resource.properties.quantity, price.CurrencyCode), + deviation > 0 ? new Markup($"[red]{deviation:N2}%[/]") : new Markup($"[green]{deviation:N2}%[/]"), + oneYearSavingsPlan != null + ? new Money(oneYearSavingsPlan.RetailPrice, price.CurrencyCode, 6) + : new Markup(""), + oneYearSavingsPlan != null + ? (oneYearSavingsPlanDeviation > 0 + ? new Markup($"[red]{oneYearSavingsPlanDeviation:N2}%[/]") + : new Markup($"[green]{oneYearSavingsPlanDeviation:N2}%[/]")) + : new Markup(""), + threeYearSavingsPlan != null + ? new Money(threeYearSavingsPlan.RetailPrice, price.CurrencyCode, 6) + : new Markup(""), + threeYearSavingsPlan != null + ? (threeYearSavingsPlanDeviation > 0 + ? new Markup($"[red]{threeYearSavingsPlanDeviation:N2}%[/]") + : new Markup($"[green]{threeYearSavingsPlanDeviation:N2}%[/]")) + : new Markup("") + ); + } + + n.AddNode(resourceTable); + } + + AnsiConsole.Write(tree); + + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/OutputFormatters/CsvOutputFormatter.cs b/src/OutputFormatters/CsvOutputFormatter.cs index 3b7186a..1476c8a 100644 --- a/src/OutputFormatters/CsvOutputFormatter.cs +++ b/src/OutputFormatters/CsvOutputFormatter.cs @@ -1,6 +1,7 @@ using System.Dynamic; using System.Globalization; using AzureCostCli.Commands.Regions; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; using CsvHelper; using CsvHelper.Configuration; @@ -70,6 +71,44 @@ public override Task WriteCostByTag(CostByTagSettings settings, Dictionary> pricesByRegion) +{ + // Flatten the dictionary to a single list + // We need to end up with the properties of the CostResourceItem and then each column for each region in the pricesByRegion + + // Get the list of regions + var regions = pricesByRegion.Select(a => a.Value.Select(b => b.Location)).SelectMany(a => a).Distinct().OrderBy(a => a).ToList(); + + // Create the list of properties for the CSV + var properties = typeof(UsageDetails).GetProperties().Select(a => a.Name).ToList(); + properties.AddRange(regions); + + // Create the list of objects to be written to the CSV + var resources = new List(); + foreach (var (resource, prices) in pricesByRegion) + { + dynamic expando = new ExpandoObject(); + foreach (var property in typeof(UsageDetails).GetProperties()) + { + ((IDictionary)expando)[property.Name] = property.GetValue(resource); + } + + foreach (var region in regions) + { + var price = prices.FirstOrDefault(a => a.Location == region); + ((IDictionary)expando)[region] = price?.RetailPrice; + } + + resources.Add(expando); + } + + // Write the CSV + return ExportToCsv(settings.SkipHeader, resources); +} + + + + private static Task ExportToCsv(bool skipHeader, IEnumerable resources) { var config = new CsvConfiguration(CultureInfo.CurrentCulture) diff --git a/src/OutputFormatters/JsonOutputFormatter.cs b/src/OutputFormatters/JsonOutputFormatter.cs index c902a2d..47ce635 100644 --- a/src/OutputFormatters/JsonOutputFormatter.cs +++ b/src/OutputFormatters/JsonOutputFormatter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using AzureCostCli.Commands.Regions; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; using DevLab.JmesPath; using Spectre.Console; @@ -91,8 +92,21 @@ public override Task WriteCostByTag(CostByTagSettings settings, Dictionary> pricesByRegion) + { + // We need to convert the dictionary to a list of objects with the properties of the CostResourceItem and then having a list of regions with their name and price + var output = pricesByRegion.Select(a => new + { + UsageDetails = a.Key, + Regions = a.Value + }); + WriteJson(settings, output); + + return Task.CompletedTask; + } + - private static void WriteJson(CostSettings settings, object items) + private static void WriteJson(ICostSettings settings, object items) { var options = new JsonSerializerOptions { WriteIndented = true }; diff --git a/src/OutputFormatters/MarkdownOutputFormatter.cs b/src/OutputFormatters/MarkdownOutputFormatter.cs index 48f7354..e801edf 100644 --- a/src/OutputFormatters/MarkdownOutputFormatter.cs +++ b/src/OutputFormatters/MarkdownOutputFormatter.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using AzureCostCli.Commands.Regions; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; using AzureCostCli.Infrastructure; @@ -333,4 +334,9 @@ public override Task WriteCostByTag(CostByTagSettings settings, Dictionary> pricesByRegion) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/OutputFormatters/SpectreConsole/Money.cs b/src/OutputFormatters/SpectreConsole/Money.cs index 6a4eec5..02bd2c3 100644 --- a/src/OutputFormatters/SpectreConsole/Money.cs +++ b/src/OutputFormatters/SpectreConsole/Money.cs @@ -8,9 +8,9 @@ public class Money : Renderable { private readonly Markup _paragraph; - public Money(double amount, string currency, Style? style = null, Justify justify = Justify.Right) + public Money(double amount, string currency, int precision=2, Style? style = null, Justify justify = Justify.Right) { - _paragraph = new Markup(FormatMoney(amount, currency), style) + _paragraph = new Markup(FormatMoney(amount, currency, precision), style) { Justification = justify }; @@ -22,24 +22,28 @@ protected override IEnumerable Render(RenderOptions options, int maxWid return ((IRenderable)_paragraph).Render(options, maxWidth); } - public static string FormatMoney(double amount, string currency) - { - // Get current culture info - var cultureInfo = CultureInfo.CurrentCulture; - // Get culture specific decimal separator - var decimalSeparator = cultureInfo.NumberFormat.NumberDecimalSeparator; - // Get culture specific thousand separator - var thousandSeparator = cultureInfo.NumberFormat.NumberGroupSeparator; - - // Split the amount into integer and fraction parts - var amountParts = amount.ToString("N2", cultureInfo).Split(decimalSeparator); - string amountInteger = amountParts[0]; - string amountFraction = amountParts.Length > 1 ? amountParts[1] : "00"; - - // Prepare styled string - string styledAmount = - $"[bold dim]{amountInteger}[/]{decimalSeparator}[dim]{amountFraction}[/] [green]{currency}[/]"; - - return styledAmount; - } + public static string FormatMoney(double amount, string currency, int precision = 2) +{ + // Get current culture info + var cultureInfo = CultureInfo.CurrentCulture; + // Get culture specific decimal separator + var decimalSeparator = cultureInfo.NumberFormat.NumberDecimalSeparator; + // Get culture specific thousand separator + var thousandSeparator = cultureInfo.NumberFormat.NumberGroupSeparator; + + // Format the amount with the specified precision + string formattedAmount = amount.ToString($"N{precision}", cultureInfo); + + // Split the formatted amount into integer and fraction parts + var amountParts = formattedAmount.Split(decimalSeparator); + string amountInteger = amountParts[0]; + string amountFraction = amountParts.Length > 1 ? amountParts[1] : new string('0', precision); + + // Prepare styled string + string styledAmount = + $"[bold dim]{amountInteger}[/]{decimalSeparator}[dim]{amountFraction}[/] [green]{currency}[/]"; + + return styledAmount; +} + } \ No newline at end of file diff --git a/src/OutputFormatters/TextOutputFormatter.cs b/src/OutputFormatters/TextOutputFormatter.cs index 670be4b..07aaf62 100644 --- a/src/OutputFormatters/TextOutputFormatter.cs +++ b/src/OutputFormatters/TextOutputFormatter.cs @@ -1,5 +1,6 @@ using System.Globalization; using AzureCostCli.Commands.Regions; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; using AzureCostCli.Infrastructure; @@ -213,4 +214,10 @@ public override Task WriteCostByTag(CostByTagSettings settings, Dictionary> pricesByRegion) + { + throw new NotImplementedException(); + } + } \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 1a0811d..a87574a 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,8 +1,10 @@ using System.ComponentModel; using AzureCostCli.Commands; using AzureCostCli.Commands.CostByResource; +using AzureCostCli.Commands.Prices; using AzureCostCli.Commands.Regions; using AzureCostCli.Commands.ShowCommand; +using AzureCostCli.Commands.WhatIf; using AzureCostCli.CostApi; using AzureCostCli.Infrastructure; using AzureCostCli.Infrastructure.TypeConvertors; @@ -17,7 +19,14 @@ { client.BaseAddress = new Uri("https://management.azure.com/"); client.DefaultRequestHeaders.Add("Accept", "application/json"); -}).AddPolicyHandler(PollyPolicyExtensions.GetRetryAfterPolicy()); +}).AddPolicyHandler(PollyExtensions.GetRetryAfterPolicy()); + +// And one for the price API +registrations.AddHttpClient("PriceApi", client => +{ + client.BaseAddress = new Uri("https://prices.azure.com/"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); +}).AddPolicyHandler(PollyExtensions.GetRetryAfterPolicy()); registrations.AddHttpClient("RegionsApi", client => { @@ -27,6 +36,7 @@ registrations.AddTransient(); +registrations.AddTransient(); registrations.AddTransient(); var registrar = new TypeRegistrar(registrations); @@ -75,8 +85,24 @@ config.AddCommand("budgets") .WithDescription("Get the available budgets."); + // Disable for now + // config.AddBranch("prices", add => + // { + // add.AddCommand("list").WithDescription("List prices"); + // add.SetDescription("Use the Azure Price catalog"); + // add.HideBranch(); + // }); + + config.AddBranch("what-if", add => + { + // add.AddCommand("devtest").WithDescription("Run what-if scenarios for DevTest subscriptions"); + add.AddCommand("region").WithDescription("Run what-if scenarios to check price differences if the resources would have run in a different region. Only applies to VMs."); + add.SetDescription("Run what-if scenarios"); + }); + config.AddCommand("regions") .WithDescription("Get the available Azure regions."); + config.ValidateExamples(); });