From 1ff109292d21dd528304e18edebb237a19badaff Mon Sep 17 00:00:00 2001 From: Michiel van Oudheusden Date: Fri, 6 Oct 2023 00:17:09 +0200 Subject: [PATCH] Added docs --- README.md | 83 +++++++ src/Commands/WhatIf/RegionWhatIfCommand.cs | 86 +++---- src/CostApi/AzureCostApiRetriever.cs | 77 +++--- src/CostApi/ICostRetriever.cs | 3 +- .../ConsoleOutputFormatter.cs | 235 ++++++++++-------- src/Program.cs | 15 +- 6 files changed, 305 insertions(+), 194 deletions(-) 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/WhatIf/RegionWhatIfCommand.cs b/src/Commands/WhatIf/RegionWhatIfCommand.cs index e6f07a4..93f7d7d 100644 --- a/src/Commands/WhatIf/RegionWhatIfCommand.cs +++ b/src/Commands/WhatIf/RegionWhatIfCommand.cs @@ -14,12 +14,8 @@ public class RegionWhatIfCommand : AsyncCommand 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 RegionWhatIfCommand(IPriceRetriever priceRetriever, ICostRetriever costRetriever) { _priceRetriever = priceRetriever; @@ -65,7 +61,6 @@ public override async Task ExecuteAsync(CommandContext context, WhatIfSetti // Fetch the costs from the Azure Cost Management API IEnumerable resources; - Dictionary> pricesByRegion = new(); await AnsiConsole.Status() @@ -75,14 +70,14 @@ await AnsiConsole.Status() settings.Debug, subscriptionId, "", - settings.Timeframe, 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" } ) + .Where(a => a.properties is + { consumedService: "Microsoft.Compute", meterDetails.meterCategory: "Virtual Machines" }) .GroupBy(a => a.properties.resourceId) .Select(a => new UsageDetails { @@ -110,7 +105,7 @@ await AnsiConsole.Status() additionalInfo = a.First().properties.additionalInfo, billingCurrency = a.First().properties.billingCurrency, billingProfileId = a.First().properties.billingProfileId, - offerId= a.First().properties.offerId, + offerId = a.First().properties.offerId, chargeType = a.First().properties.chargeType, resourceLocation = a.First().properties.resourceLocation, resourceId = a.First().properties.resourceId, @@ -126,25 +121,21 @@ await AnsiConsole.Status() 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()); - - + 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 @@ -154,38 +145,33 @@ await _outputFormatters[settings.Output] return 0; } -private Dictionary> _priceCache = new(); + 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)) + private async Task> FetchPricesForAllRegions(string skuName, string meterId, + string currency = "USD") { - 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; -} + // 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/CostApi/AzureCostApiRetriever.cs b/src/CostApi/AzureCostApiRetriever.cs index 8cc6c38..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,46 +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, TimeframeType timeFrame, DateOnly from, - DateOnly to) + + 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 = "properties/usageStart ge '" + from.ToString("yyyy-MM-dd") + "' and properties/usageEnd le '" + - to.ToString("yyyy-MM-dd") + "'";//" and properties/consumedService eq 'Microsoft.Compute'"; - - if (!string.IsNullOrEmpty(filter)) - uri = new Uri($"{uri}&$filter={filter}", 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) + + while (uri != null) { var response = await ExecuteCallToCostApi(includeDebugOutput, null, uri); - UsageDetailsResponse payload = await response.Content.ReadFromJsonAsync() ?? new UsageDetailsResponse(); + UsageDetailsResponse payload = await response.Content.ReadFromJsonAsync() ?? + new UsageDetailsResponse(); items.AddRange(payload.value); - uri = payload.nextLink!=null?new Uri(payload.nextLink, UriKind.Relative):null; + uri = payload.nextLink != null ? new Uri(payload.nextLink, UriKind.Relative) : null; } - + return items; } @@ -936,7 +943,6 @@ public class UsageDetails public UsageProperties properties { get; set; } } - public class UsageProperties { public string billingPeriodStartDate { get; set; } @@ -973,5 +979,4 @@ public class MeterDetails 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/ICostRetriever.cs b/src/CostApi/ICostRetriever.cs index e8e2acf..f95d329 100644 --- a/src/CostApi/ICostRetriever.cs +++ b/src/CostApi/ICostRetriever.cs @@ -29,8 +29,7 @@ Task> RetrieveCostForResources(bool settingsDebug, Task> RetrieveBudgets(bool settingsDebug, Guid subscriptionId); Task> RetrieveUsageDetails(bool includeDebugOutput, - Guid subscriptionId, string filter, TimeframeType timeFrame, DateOnly from, - DateOnly to); + 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/OutputFormatters/ConsoleOutputFormatter.cs b/src/OutputFormatters/ConsoleOutputFormatter.cs index 9a2411b..425900c 100644 --- a/src/OutputFormatters/ConsoleOutputFormatter.cs +++ b/src/OutputFormatters/ConsoleOutputFormatter.cs @@ -66,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) @@ -158,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() @@ -183,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); @@ -216,7 +214,6 @@ public override Task WriteCostByResource(CostByResourceSettings settings, IEnume } treeNode.AddNode(subTable); - } AnsiConsole.Write(tree); @@ -224,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); } @@ -294,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(); @@ -303,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)); @@ -377,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); } } @@ -495,7 +502,7 @@ public override Task WriteAnomalyDetectionResults(DetectAnomalySettings settings return Task.CompletedTask; } - + public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection regions) { var table = new Table(); @@ -506,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}[/]"); @@ -551,57 +562,62 @@ 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) + 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+"[/]"); - - + + 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)}[/]"); - + 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"); @@ -612,41 +628,62 @@ public override Task WritePricesPerRegion(WhatIfSettings settings, Dictionarya.RetailPrice)) + + 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"); - + 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; - + 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 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("") - ); + 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; - } - -} + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 387a991..a87574a 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -85,17 +85,18 @@ config.AddCommand("budgets") .WithDescription("Get the available budgets."); - config.AddBranch("prices", add => - { - add.AddCommand("list").WithDescription("List prices"); - add.SetDescription("Use the Azure Price catalog"); - add.HideBranch(); - }); + // 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 difference if the resources would have run in a different region."); + 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"); });