From 19eead8fb045bf858621425d38b1a7f22a487b29 Mon Sep 17 00:00:00 2001 From: Michiel van Oudheusden Date: Sat, 5 Aug 2023 01:39:33 +0200 Subject: [PATCH] Initial structure of costByTag command --- src/Commands/CostByTag/CostByTagCommand.cs | 146 ++++++++++++++++++ src/Commands/CostByTag/CostByTagSettings.cs | 12 ++ src/OutputFormatters/BaseOutputFormatter.cs | 1 + .../ConsoleOutputFormatter.cs | 5 + src/OutputFormatters/CsvOutputFormatter.cs | 30 ++++ src/OutputFormatters/JsonOutputFormatter.cs | 9 +- .../MarkdownOutputFormatter.cs | 5 + src/OutputFormatters/TextOutputFormatter.cs | 5 + src/Program.cs | 3 + 9 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/Commands/CostByTag/CostByTagCommand.cs create mode 100644 src/Commands/CostByTag/CostByTagSettings.cs diff --git a/src/Commands/CostByTag/CostByTagCommand.cs b/src/Commands/CostByTag/CostByTagCommand.cs new file mode 100644 index 0000000..8396c95 --- /dev/null +++ b/src/Commands/CostByTag/CostByTagCommand.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using System.Text.Json; +using AzureCostCli.Commands.ShowCommand; +using AzureCostCli.Commands.ShowCommand.OutputFormatters; +using AzureCostCli.CostApi; +using AzureCostCli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace AzureCostCli.Commands.CostByResource; + +public class CostByTagCommand : AsyncCommand +{ + private readonly ICostRetriever _costRetriever; + + private readonly Dictionary _outputFormatters = new(); + + public CostByTagCommand(ICostRetriever costRetriever) + { + _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 ValidationResult Validate(CommandContext context, CostByTagSettings settings) + { + // Validate if the timeframe is set to Custom, then the from and to dates must be specified and the from date must be before the to date + if (settings.Timeframe == TimeframeType.Custom) + { + if (settings.From == null) + { + return ValidationResult.Error("The from date must be specified when the timeframe is set to Custom."); + } + + if (settings.To == null) + { + return ValidationResult.Error("The to date must be specified when the timeframe is set to Custom."); + } + + if (settings.From > settings.To) + { + return ValidationResult.Error("The from date must be before the to date."); + } + } + + return ValidationResult.Success(); + } + + public override async Task ExecuteAsync(CommandContext context, CostByTagSettings settings) + { + // Show version + if (settings.Debug) + AnsiConsole.WriteLine($"Version: {typeof(CostByResourceCommand).Assembly.GetName().Version}"); + + + // 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, + true, + settings.Timeframe, + settings.From, + settings.To); + }); + + var byTags = GetResourcesByTag(resources, settings.Tags.ToArray()); + + // Write the output + await _outputFormatters[settings.Output] + .WriteCostByTag(settings, byTags); + + return 0; + } + + private Dictionary>> GetResourcesByTag( + IEnumerable resources, params string[] tags) + { + var resourcesByTag = + new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + foreach (var tag in tags) + { + resourcesByTag[tag] = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + foreach (var resource in resources) + { + foreach (var tag in tags) + { + var resourceTags = new Dictionary(resource.Tags, StringComparer.OrdinalIgnoreCase); + + if (resourceTags.ContainsKey(tag)) + { + var tagValue = resourceTags[tag]; + if (!resourcesByTag[tag].ContainsKey(tagValue)) + { + resourcesByTag[tag][tagValue] = new List(); + } + + resourcesByTag[tag][tagValue].Add(resource); + } + } + } + + return resourcesByTag; + } +} \ No newline at end of file diff --git a/src/Commands/CostByTag/CostByTagSettings.cs b/src/Commands/CostByTag/CostByTagSettings.cs new file mode 100644 index 0000000..6019cbd --- /dev/null +++ b/src/Commands/CostByTag/CostByTagSettings.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace AzureCostCli.Commands.ShowCommand; + +public class CostByTagSettings : CostSettings +{ + + [CommandOption("--tag")] + [Description("The tags to return, for example: Cost Center or Owner. You can specify multiple tags by using the --tag option multiple times.")] + public string[] Tags { get; set; } = Array.Empty(); +} \ No newline at end of file diff --git a/src/OutputFormatters/BaseOutputFormatter.cs b/src/OutputFormatters/BaseOutputFormatter.cs index 3d283d3..a82977b 100644 --- a/src/OutputFormatters/BaseOutputFormatter.cs +++ b/src/OutputFormatters/BaseOutputFormatter.cs @@ -14,6 +14,7 @@ public abstract class BaseOutputFormatter public abstract Task WriteDailyCost(DailyCostSettings settings, IEnumerable dailyCosts); 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 record AccumulatedCostDetails( diff --git a/src/OutputFormatters/ConsoleOutputFormatter.cs b/src/OutputFormatters/ConsoleOutputFormatter.cs index 3feb958..550e818 100644 --- a/src/OutputFormatters/ConsoleOutputFormatter.cs +++ b/src/OutputFormatters/ConsoleOutputFormatter.cs @@ -521,5 +521,10 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection< return Task.CompletedTask; } + + public override Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags) + { + throw new NotImplementedException(); + } } diff --git a/src/OutputFormatters/CsvOutputFormatter.cs b/src/OutputFormatters/CsvOutputFormatter.cs index eb9a477..3b7186a 100644 --- a/src/OutputFormatters/CsvOutputFormatter.cs +++ b/src/OutputFormatters/CsvOutputFormatter.cs @@ -40,6 +40,36 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection< return ExportToCsv(settings.SkipHeader, regions); } + public override Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags) + { + // Flatten the hierarchy to a single list, including the tag and value + var resourcesWithTagAndValue = new List(); + foreach (var (tag, value) in byTags) + { + foreach (var (tagValue, resources) in value) + { + foreach (var resource in resources) + { + dynamic expando = new ExpandoObject(); + expando.Tag = tag; + expando.Value = tagValue; + expando.ResourceId = resource.ResourceId; + expando.ResourceType = resource.ResourceType; + expando.ResourceGroup = resource.ResourceGroupName; + expando.ResourceLocation = resource.ResourceLocation; + expando.Cost = resource.Cost; + expando.Currency = resource.Currency; + expando.CostUsd = resource.CostUSD; + + resourcesWithTagAndValue.Add(expando); + } + } + } + + + return ExportToCsv(settings.SkipHeader, resourcesWithTagAndValue); + } + 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 800b3f7..c902a2d 100644 --- a/src/OutputFormatters/JsonOutputFormatter.cs +++ b/src/OutputFormatters/JsonOutputFormatter.cs @@ -83,7 +83,14 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection< return Task.CompletedTask; } - + + public override Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags) + { + WriteJson(settings, byTags); + + return Task.CompletedTask; + } + private static void WriteJson(CostSettings settings, object items) { diff --git a/src/OutputFormatters/MarkdownOutputFormatter.cs b/src/OutputFormatters/MarkdownOutputFormatter.cs index 5db7feb..48f7354 100644 --- a/src/OutputFormatters/MarkdownOutputFormatter.cs +++ b/src/OutputFormatters/MarkdownOutputFormatter.cs @@ -328,4 +328,9 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection< { throw new NotImplementedException(); } + + public override Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/OutputFormatters/TextOutputFormatter.cs b/src/OutputFormatters/TextOutputFormatter.cs index 3651a00..f6f5f31 100644 --- a/src/OutputFormatters/TextOutputFormatter.cs +++ b/src/OutputFormatters/TextOutputFormatter.cs @@ -210,4 +210,9 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection< { throw new NotImplementedException(); } + + public override Task WriteCostByTag(CostByTagSettings settings, Dictionary>> byTags) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 187cd5f..a75349e 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -64,6 +64,9 @@ config.AddCommand("costByResource") .WithDescription("Show the cost details by resource."); + + config.AddCommand("costByTag") + .WithDescription("Show the cost details by tag."); config.AddCommand("detectAnomalies") .WithDescription("Detect anomalies and trends.");