Skip to content

Commit

Permalink
Add ability to discard VMs
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielweyer committed Oct 19, 2024
1 parent c84af0f commit 4be2da2
Show file tree
Hide file tree
Showing 10 changed files with 206 additions and 23 deletions.
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,26 @@ In `Release` mode:
```powershell
> cd .\coster\src\AzureVmCoster
> dotnet run --configuration Release -- --input <input-path> --culture <culture>
> dotnet run --configuration Release -- --input <input-path> --configuration <configuration-path>
> dotnet run --configuration Release -- -i <input-path> -l <culture>
> dotnet run --configuration Release -- -i <input-path>
```

The `culture` is optional.

You can exclude VMs by providing a configuration file, see [Coster configuration](#coster-configuration).

In `Debug` mode

```powershell
> cd .\coster\src\AzureVmCoster
> dotnet run --configuration Debug
Input file path: <input-path>
Configuration file path (leave blank if not used):
Culture (leave blank for system default):
```

You'll need to provide the `<input-path>` when prompted, the `culture` is optional.
You'll need to provide the `<input-path>` when prompted, the configuration file path and culture are optional.

`<input-path>` should point to a `CSV` file with the following fields:

Expand Down Expand Up @@ -352,6 +356,50 @@ Supported OS/Software:
- `sql-server-standard` (SQL Server Standard)
- `sql-server-web` (SQL Server Web)

## Coster configuration

The only configuration available currently is `excludedVms`, it takes an array of instance names. These instances won't be considered when costing VMs.

For example, if you want to discard all burstable VMs, you can use the below configuration file:

```json
{
"excludedVms": [
"B2ts v2",
"B2ls v2",
"B2s v2",
"B4ls v2",
"B4s v2",
"B8ls v2",
"B8s v2",
"B16ls v2",
"B16s v2",
"B32ls v2",
"B32s v2",
"B2ats v2",
"B2als v2",
"B2as v2",
"B4als v2",
"B4as v2",
"B8als v2",
"B8as v2",
"B16als v2",
"B16as v2",
"B32als v2",
"B32as v2",
"B1s",
"B1ms",
"B2s",
"B2ms",
"B4ms",
"B8ms",
"B12ms",
"B16ms",
"B20ms"
]
}
```

## Notes and references

<a id="closest-currency-1">01.</a> Euro is used for countries which don't have their currency listed, are [part of the European Union but not part of the Eurozone][european-union].
Expand Down
7 changes: 7 additions & 0 deletions coster/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ dotnet_diagnostic.IDE2004.severity = warning
# The data I'm matching to is in lower case
dotnet_diagnostic.CA1308.severity = none

# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2007
# This is not a library. The code is only used in a console app where there is no SynchronizationContext
dotnet_diagnostic.CA2007.severity = none
# https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0004.md
# This is not a library. The code is only used in a console app where there is no SynchronizationContext
dotnet_diagnostic.MA0004.severity = none

# CSharp code style settings:
[*.cs]
# Newline settings
Expand Down
6 changes: 6 additions & 0 deletions coster/src/AzureVmCoster/Models/CosterConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AzureVmCoster.Models;

internal sealed class CosterConfiguration
{
public IList<string> ExcludedVms { get; set; } = default!;
}
50 changes: 40 additions & 10 deletions coster/src/AzureVmCoster/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,19 @@ public static class Program
{
private const string PricingDirectory = @"Pricing\";

public static int Main(string[] args)
public static async Task<int> Main(string[] args)
{
ArgumentNullException.ThrowIfNull(args);

var (inputFilePath, culture) = ParseConfiguration(args);
var (inputFilePath, configurationFilePath, culture) = ParseConfiguration(args);

if (string.IsNullOrWhiteSpace(inputFilePath))
{
Console.WriteLine("The input file path is required: -i <file-path>");
return -1;
}

if (inputFilePath.StartsWith('"'))
{
inputFilePath = inputFilePath.Replace("\"", "", StringComparison.Ordinal);
}
StripSurroundingDoubleQuotes(ref inputFilePath);

var inputFile = new FileInfo(inputFilePath);

Expand All @@ -37,28 +34,42 @@ public static int Main(string[] args)
return -1;
}

var configuration = new CosterConfiguration();

if (!string.IsNullOrWhiteSpace(configurationFilePath))
{
StripSurroundingDoubleQuotes(ref configurationFilePath);

configuration = await JsonReader.DeserializeAsync<CosterConfiguration>(configurationFilePath) ?? new CosterConfiguration();
}

var inputVms = InputVmParser.Parse(inputFile, culture);

var pricer = new Pricer(PricingDirectory);
pricer.EnsurePricingExists(inputVms);

var pricings = new VmPricingParser(PricingDirectory).Parse();
var pricings = await new VmPricingParser(PricingDirectory).ParseAsync();
pricings = Pricer.FilterPricing(pricings, configuration.ExcludedVms);

var pricedVms = Pricer.Price(inputVms, pricings);
PricedVmWriter.Write(inputFile.Name, pricedVms, culture);

return 0;
}

private static (string? inputFilePath, CultureInfo culture) ParseConfiguration(string[] args)
private static (string? inputFilePath, string? configurationFilePath, CultureInfo culture) ParseConfiguration(string[] args)
{
string? inputFilePath = null;
string? configurationFilePath = null;
var culture = Thread.CurrentThread.CurrentCulture;

#if DEBUG
Console.Write("Input file path: ");
inputFilePath = Console.ReadLine();

Console.Write("Configuration file path (leave blank if not used): ");
configurationFilePath = Console.ReadLine();

Console.Write("Culture (leave blank for system default): ");
var cultureInput = Console.ReadLine();

Expand All @@ -79,13 +90,32 @@ private static (string? inputFilePath, CultureInfo culture) ParseConfiguration(s
case "--input":
inputFilePath = args[offset + 1];
break;
case "-c":
case "--configuration":
configurationFilePath = args[offset + 1];
break;
default:
Console.WriteLine($"'{args[offset]}' is not a known switch, supported values are: '-l', '--culture', '-i', '--input'");
Console.WriteLine($"'{args[offset]}' is not a known switch, supported values are: '-l', '--culture', '-i', '--input', '-c', '--configuration'");
break;
}
}
#endif

return (inputFilePath, culture);
return (inputFilePath, configurationFilePath, culture);
}

/// <summary>
/// Strip starting and trailing double quotes if present.
///
/// When copying a path from the Explorer, Windows surrounds the path with double quotes so that it remains usable
/// if a space is present.
/// </summary>
/// <param name="filePath">The reference will be assigned to only if the path starts with ".</param>
private static void StripSurroundingDoubleQuotes(ref string filePath)
{
if (filePath.StartsWith('"'))
{
filePath = filePath.Replace("\"", "", StringComparison.Ordinal);
}
}
}
19 changes: 19 additions & 0 deletions coster/src/AzureVmCoster/Services/JsonReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

namespace AzureVmCoster.Services;

[SuppressMessage("Usage", "MA0004:Use Task.ConfigureAwait")]
public static class JsonReader
{
private static readonly JsonSerializerOptions _options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

public static async Task<T?> DeserializeAsync<T>(string fileName)
{
await using var openStream = File.OpenRead(fileName);
return await JsonSerializer.DeserializeAsync<T>(openStream, _options);
}
}
15 changes: 14 additions & 1 deletion coster/src/AzureVmCoster/Services/Pricer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,20 @@ public void EnsurePricingExists(List<InputVm> vms)
}
}

public static List<PricedVm> Price(List<InputVm> vms, List<VmPricing> pricings)
/// <summary>
/// Discard the prices contained in <paramref name="excludedVms"/>. The instances are discarded by name (case
/// insensitive), if the same instance is present with different regions/operating systems, all occurrences will be
/// discarded.
/// </summary>
/// <param name="pricings">The list of prices to filter</param>
/// <param name="excludedVms">The list of instances to remove</param>
/// <returns>The filtered prices</returns>
public static IList<VmPricing> FilterPricing(IList<VmPricing> pricings, IList<string> excludedVms)
{
return pricings.Where(p => !excludedVms.Contains(p.Instance, StringComparer.OrdinalIgnoreCase)).ToList();
}

public static List<PricedVm> Price(List<InputVm> vms, IList<VmPricing> pricings)
{
var medianCpu = GetCpuMedianForNonZeroValues(vms);
var medianRam = GetRamMedianForNonZeroValues(vms);
Expand Down
11 changes: 2 additions & 9 deletions coster/src/AzureVmCoster/Services/VmPricingParser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Text.Json;

namespace AzureVmCoster.Services;

internal class VmPricingParser
Expand All @@ -11,23 +9,18 @@ public VmPricingParser(string pricingDirectory)
_pricingDirectory = pricingDirectory;
}

public List<VmPricing> Parse()
public async Task<IList<VmPricing>> ParseAsync()
{
var pricingFiles = Directory.GetFiles(_pricingDirectory, "*.json");

var allVmPricing = new List<VmPricing>();

JsonSerializerOptions options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

foreach (var pricingFile in pricingFiles)
{
var fileInfo = new FileInfo(pricingFile);
var fileIdentifier = FileIdentifier.From(fileInfo);

var fileVmPricing = JsonSerializer.Deserialize<List<VmPricing>>(File.ReadAllText(pricingFile), options);
var fileVmPricing = await JsonReader.DeserializeAsync<List<VmPricing>>(pricingFile);

if (fileVmPricing == null || fileVmPricing.Count == 0)
{
Expand Down
5 changes: 5 additions & 0 deletions coster/tests/AzureVmCosterTests/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
root = false

[*.cs]
# VSTHRD200: Use `Async` naming convention
dotnet_diagnostic.VSTHRD200.severity = none
62 changes: 62 additions & 0 deletions coster/tests/AzureVmCosterTests/Services/PricerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AzureVmCoster.Services;
using AzureVmCosterTests.TestInfrastructure;

namespace AzureVmCosterTests.Services;

Expand Down Expand Up @@ -74,4 +75,65 @@ public void GivenMissingRegionAndExistingOperatingSystem_WhenEnsurePricingExists
// Assert
Assert.NotNull(actualException);
}

[Fact]
public void GivenEmptyExcludeList_WhenFilterPricing_ThenNoPriceRemoved()
{
// Arrange
var prices = new List<VmPricing>
{
VmPricingBuilder.AsUsWestWindowsD2V3()
};
var exclusions = new List<string>();

// Act
var filteredPrices = Pricer.FilterPricing(prices, exclusions);

// Assert
filteredPrices.Should().BeEquivalentTo(prices);
}

[Fact]
public void GivenExcludeList_WhenFilterPricing_ThenRemoveInstanceWithSameName()
{
// Arrange
var d4V3 = VmPricingBuilder.AsUsWestWindowsD2V3();
d4V3.Instance = "D4 v3";
var d2V3Linux = VmPricingBuilder.AsUsWestWindowsD2V3();
d2V3Linux.OperatingSystem = "linux";
var d2V3EuWest = VmPricingBuilder.AsUsWestWindowsD2V3();
d2V3EuWest.Region = "europe-west";
var prices = new List<VmPricing>
{
VmPricingBuilder.AsUsWestWindowsD2V3(),
d4V3,
d2V3Linux,
d2V3EuWest
};
var exclusions = new List<string> { "D2 v3" };

// Act
var filteredPrices = Pricer.FilterPricing(prices, exclusions);

// Assert
var expected = new List<VmPricing> { d4V3 };
filteredPrices.Should().BeEquivalentTo(expected);
}

[Fact]
public void GivenExcludeList_WhenFilterPricing_ThenRemoveInstanceWithSameNameCaseInsensitive()
{
// Arrange
var prices = new List<VmPricing>
{
VmPricingBuilder.AsUsWestWindowsD2V3()
};
var exclusions = new List<string> { "d2 V3" };

// Act
var filteredPrices = Pricer.FilterPricing(prices, exclusions);

// Assert
filteredPrices.Should().BeEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ public class VmPricingParserTests
private readonly VmPricingParser _parser = new("TestPricing/");

[Fact]
public void GivenValidPrice_ThenParseVm()
public async Task GivenValidPrice_ThenParseVm()
{
// Act
var prices = _parser.Parse();
var prices = await _parser.ParseAsync();

// Assert
var expectedPrices = new List<VmPricing>
Expand Down

0 comments on commit 4be2da2

Please sign in to comment.