Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cost By Tags #66

Merged
merged 3 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ EXAMPLES:
azure-cost dailyCosts --dimension MeterCategory
azure-cost budgets -s 00000000-0000-0000-0000-000000000000
azure-cost detectAnomalies --dimension ResourceId --recent-activity-days 4

azure-cost costByTag --tag cost-center

OPTIONS:
-h, --help Prints help information
Expand All @@ -79,6 +79,7 @@ OPTIONS:
COMMANDS:
accumulatedCost Show the accumulated cost details
costByResource Show the cost details by resource
costByTag Show the cost details by the provided tag key(s)
dailyCosts Show the daily cost by a given dimension
detectAnomalies Detect anomalies and trends
budgets Get the available budgets
Expand Down Expand Up @@ -163,6 +164,18 @@ A resource can be in multiple resource locations, like Intercontinental and West

You can parse out the resource name, group name and subscription id from the ResourceId field. The format is `/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}`.

### Cost By Tag

This will retrieve the cost of the subscription by the provided tag key(s). So if you tag your resources with, e.g. a `cost-center` or a `creator`, you can retrieve the cost of the subscription by those tags. You can specify multiple tags by using the `--tag` parameter multiple times.

```bash
azure-cost costByTag --tag cost-center --tag creator
```

The csv, json(c) and console output will render the results, either hierarchically or flattened in the case of the csv export.

If you require more formatters, let me know!

### Daily Costs

The daily overview fetches the cost of the subscription for each day in the specified period. It will show the total cost of the day and the cost per dimension. The dimension is the resource group by default, but you can specify a different one using the `--dimension` parameter.
Expand Down
146 changes: 146 additions & 0 deletions src/Commands/CostByTag/CostByTagCommand.cs
Original file line number Diff line number Diff line change
@@ -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<CostByTagSettings>
{
private readonly ICostRetriever _costRetriever;

private readonly Dictionary<OutputFormat, BaseOutputFormatter> _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)

Check warning on line 36 in src/Commands/CostByTag/CostByTagCommand.cs

View workflow job for this annotation

GitHub Actions / build

The result of the expression is always 'false' since a value of type 'DateOnly' is never equal to 'null' of type 'DateOnly?'
{
return ValidationResult.Error("The from date must be specified when the timeframe is set to Custom.");
}

if (settings.To == null)

Check warning on line 41 in src/Commands/CostByTag/CostByTagCommand.cs

View workflow job for this annotation

GitHub Actions / build

The result of the expression is always 'false' since a value of type 'DateOnly' is never equal to 'null' of type 'DateOnly?'
{
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<int> 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<CostResourceItem> resources = new List<CostResourceItem>();

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<string, Dictionary<string, List<CostResourceItem>>> GetResourcesByTag(
IEnumerable<CostResourceItem> resources, params string[] tags)
{
var resourcesByTag =
new Dictionary<string, Dictionary<string, List<CostResourceItem>>>(StringComparer.OrdinalIgnoreCase);

foreach (var tag in tags)
{
resourcesByTag[tag] = new Dictionary<string, List<CostResourceItem>>(StringComparer.OrdinalIgnoreCase);
}

foreach (var resource in resources)
{
foreach (var tag in tags)
{
var resourceTags = new Dictionary<string, string>(resource.Tags, StringComparer.OrdinalIgnoreCase);

if (resourceTags.ContainsKey(tag))
{
var tagValue = resourceTags[tag];
if (!resourcesByTag[tag].ContainsKey(tagValue))
{
resourcesByTag[tag][tagValue] = new List<CostResourceItem>();
}

resourcesByTag[tag][tagValue].Add(resource);
}
}
}

return resourcesByTag;
}
}
12 changes: 12 additions & 0 deletions src/Commands/CostByTag/CostByTagSettings.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
}
12 changes: 11 additions & 1 deletion src/CostApi/CostResourceItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,14 @@ namespace AzureCostCli.CostApi;

public record CostResourceItem(double Cost, double CostUSD, string ResourceId, string ResourceType,
string ResourceLocation, string ChargeType, string ResourceGroupName, string PublisherType, string?
ServiceName, string? ServiceTier, string? Meter, Dictionary<string, string> Tags, string Currency);
ServiceName, string? ServiceTier, string? Meter, Dictionary<string, string> Tags, string Currency);

public static class CostResourceItemExtensions
{
// Function to extract the name of the resource from the resource id
public static string GetResourceName(this CostResourceItem resource)
{
var parts = resource.ResourceId.Split('/');
return parts.Last();
}
}
1 change: 1 addition & 0 deletions src/OutputFormatters/BaseOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public abstract class BaseOutputFormatter
public abstract Task WriteDailyCost(DailyCostSettings settings, IEnumerable<CostDailyItem> dailyCosts);
public abstract Task WriteAnomalyDetectionResults(DetectAnomalySettings settings, List<AnomalyDetectionResult> anomalies);
public abstract Task WriteRegions(RegionsSettings settings, IReadOnlyCollection<AzureRegion> regions);
public abstract Task WriteCostByTag(CostByTagSettings settings, Dictionary<string, Dictionary<string, List<CostResourceItem>>> byTags);
}

public record AccumulatedCostDetails(
Expand Down
61 changes: 61 additions & 0 deletions src/OutputFormatters/ConsoleOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -521,5 +521,66 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection<

return Task.CompletedTask;
}

public override Task WriteCostByTag(CostByTagSettings settings, Dictionary<string, Dictionary<string, List<CostResourceItem>>> 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));
return Task.CompletedTask;
}

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}[/]");
var table = new Table();
table.Border(TableBorder.Rounded);
table.AddColumn("Name");
table.AddColumn("Resource Group");
table.AddColumn("Type");
table.AddColumn("Location");
table.AddColumn("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)
);
}

// 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)
);

subNode.AddNode(table);
}

}

AnsiConsole.Write(tree);

return Task.CompletedTask;
}
}

30 changes: 30 additions & 0 deletions src/OutputFormatters/CsvOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@
return ExportToCsv(settings.SkipHeader, regions);
}

public override Task WriteCostByTag(CostByTagSettings settings, Dictionary<string, Dictionary<string, List<CostResourceItem>>> byTags)
{
// Flatten the hierarchy to a single list, including the tag and value
var resourcesWithTagAndValue = new List<dynamic>();
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<object> resources)
{
var config = new CsvConfiguration(CultureInfo.CurrentCulture)
Expand All @@ -66,7 +96,7 @@

public class CustomDoubleConverter : DoubleConverter
{
public override string ConvertToString(object value, IWriterRow row, MemberMapData memberMapData)

Check warning on line 99 in src/OutputFormatters/CsvOutputFormatter.cs

View workflow job for this annotation

GitHub Actions / build

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).
{
double number = (double)value;
return number.ToString("F8", CultureInfo.InvariantCulture);
Expand Down
9 changes: 8 additions & 1 deletion src/OutputFormatters/JsonOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection<

return Task.CompletedTask;
}


public override Task WriteCostByTag(CostByTagSettings settings, Dictionary<string, Dictionary<string, List<CostResourceItem>>> byTags)
{
WriteJson(settings, byTags);

return Task.CompletedTask;
}


private static void WriteJson(CostSettings settings, object items)
{
Expand Down
5 changes: 5 additions & 0 deletions src/OutputFormatters/MarkdownOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,9 @@ public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection<
{
throw new NotImplementedException();
}

public override Task WriteCostByTag(CostByTagSettings settings, Dictionary<string, Dictionary<string, List<CostResourceItem>>> byTags)
{
throw new NotImplementedException();
}
}
9 changes: 6 additions & 3 deletions src/OutputFormatters/TextOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,17 @@ public override Task WriteAnomalyDetectionResults(DetectAnomalySettings settings

Console.WriteLine();
}




return Task.CompletedTask;
}

public override Task WriteRegions(RegionsSettings settings, IReadOnlyCollection<AzureRegion> regions)
{
throw new NotImplementedException();
}

public override Task WriteCostByTag(CostByTagSettings settings, Dictionary<string, Dictionary<string, List<CostResourceItem>>> byTags)
{
throw new NotImplementedException();
}
}
4 changes: 4 additions & 0 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
config.AddExample(new[] { "dailyCosts", "--dimension", "MeterCategory" });
config.AddExample(new[] { "budgets", "-s", "00000000-0000-0000-0000-000000000000" });
config.AddExample(new[] { "detectAnomalies", "--dimension", "ResourceId", "--recent-activity-days", "4" });
config.AddExample(new[] { "costByTag", "--tag", "cost-center" });

#if DEBUG
config.PropagateExceptions();
Expand All @@ -64,6 +65,9 @@

config.AddCommand<CostByResourceCommand>("costByResource")
.WithDescription("Show the cost details by resource.");

config.AddCommand<CostByTagCommand>("costByTag")
.WithDescription("Show the cost details by the provided tag key(s).");

config.AddCommand<DetectAnomalyCommand>("detectAnomalies")
.WithDescription("Detect anomalies and trends.");
Expand Down
Loading
Loading