diff --git a/coster/.editorconfig b/coster/.editorconfig index 5972e96..d34a846 100644 --- a/coster/.editorconfig +++ b/coster/.editorconfig @@ -203,6 +203,10 @@ dotnet_diagnostic.MA0004.severity = none # This is a personal preference dotnet_diagnostic.MA0007.severity = none +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1848 +# This is not high-performance code (quite the contrary) +dotnet_diagnostic.CA1848.severity = none + # CSharp code style settings: [*.cs] # Newline settings diff --git a/coster/AzureVmCoster.sln.DotSettings b/coster/AzureVmCoster.sln.DotSettings index 014e6cd..b4b3ac2 100644 --- a/coster/AzureVmCoster.sln.DotSettings +++ b/coster/AzureVmCoster.sln.DotSettings @@ -1,2 +1,4 @@  + True + True True \ No newline at end of file diff --git a/coster/Directory.Packages.props b/coster/Directory.Packages.props index 926198e..8fef6c9 100644 --- a/coster/Directory.Packages.props +++ b/coster/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/coster/src/AzureVmCoster/AzureVmCoster.csproj b/coster/src/AzureVmCoster/AzureVmCoster.csproj index 05e6453..f8bdca0 100644 --- a/coster/src/AzureVmCoster/AzureVmCoster.csproj +++ b/coster/src/AzureVmCoster/AzureVmCoster.csproj @@ -21,6 +21,7 @@ + diff --git a/coster/src/AzureVmCoster/Models/PriceDirectory.cs b/coster/src/AzureVmCoster/Models/PriceDirectory.cs new file mode 100644 index 0000000..0bbb135 --- /dev/null +++ b/coster/src/AzureVmCoster/Models/PriceDirectory.cs @@ -0,0 +1,11 @@ +namespace AzureVmCoster.Models; + +internal class PriceDirectory +{ + public string Directory { get; } + + public PriceDirectory(string priceDirectory) + { + Directory = priceDirectory; + } +} diff --git a/coster/src/AzureVmCoster/Program.cs b/coster/src/AzureVmCoster/Program.cs index 5c7cfb5..9c45cb3 100644 --- a/coster/src/AzureVmCoster/Program.cs +++ b/coster/src/AzureVmCoster/Program.cs @@ -1,32 +1,37 @@ using AzureVmCoster.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace AzureVmCoster; -public static class Program +#pragma warning disable CA1052 // Used as a parameter type +public class Program +#pragma warning restore CA1052 { - private const string PricingDirectory = @"Pricing\"; - public static async Task Main(string[] args) { + var builder = Host.CreateDefaultBuilder(args); + builder.ConfigureServices(s => s + .AddSingleton(new PriceDirectory(@"Pricing\")) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton()); + var host = builder.Build(); + + var logger = host.Services.GetRequiredService>(); + var priceService = host.Services.GetRequiredService(); + var argumentReader = host.Services.GetRequiredService(); + try { ArgumentNullException.ThrowIfNull(args); - var (inputFilePath, configurationFilePath, culture) = ParseConfiguration(args); - - var inputFile = InputFileValidator.Validate(inputFilePath); - var inputVms = InputVmParser.Parse(inputFile, culture); - - var pricer = new Pricer(PricingDirectory); - pricer.EnsurePricingExists(inputVms); - - var configuration = await CosterConfiguration.FromPathAsync(configurationFilePath); - - var pricings = await new VmPricingParser(PricingDirectory).ParseAsync(); - pricings = Pricer.FilterPricing(pricings, configuration.ExcludedVms); + var (inputFilePath, configurationFilePath, culture) = ParseConfiguration(args, argumentReader); - var pricedVms = Pricer.Price(inputVms, pricings); - PricedVmWriter.Write(inputFile.Name, pricedVms, culture); + await priceService.PriceAsync(inputFilePath, configurationFilePath, culture); return 0; } @@ -34,21 +39,21 @@ public static async Task Main(string[] args) catch (Exception e) #pragma warning restore CA1031 { - Console.WriteLine(e); + logger.LogError(e, "Failed to cost VMs"); return -1; } } - private static (string? inputFilePath, string? configurationFilePath, CultureInfo culture) ParseConfiguration(string[] args) + private static (string? inputVmFilePath, string? configurationFilePath, CultureInfo culture) ParseConfiguration(string[] args, ArgumentReader argumentReader) { - string? inputFilePath = null; + string? inputVmFilePath = null; string? configurationFilePath = null; var culture = Thread.CurrentThread.CurrentCulture; #if DEBUG Console.Write("Input file path: "); - inputFilePath = Console.ReadLine(); - ArgumentReader.StripSurroundingDoubleQuotes(ref inputFilePath); + inputVmFilePath = Console.ReadLine(); + ArgumentReader.StripSurroundingDoubleQuotes(ref inputVmFilePath); Console.Write("Configuration file path (leave blank if not used): "); configurationFilePath = Console.ReadLine(); @@ -62,9 +67,9 @@ private static (string? inputFilePath, string? configurationFilePath, CultureInf culture = new CultureInfo(cultureInput); } #else - (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args); + (inputVmFilePath, configurationFilePath, culture) = argumentReader.Read(args); #endif - return (inputFilePath, configurationFilePath, culture); + return (inputVmFilePath, configurationFilePath, culture); } } diff --git a/coster/src/AzureVmCoster/Services/ArgumentReader.cs b/coster/src/AzureVmCoster/Services/ArgumentReader.cs index ad4ed80..6ae2c17 100644 --- a/coster/src/AzureVmCoster/Services/ArgumentReader.cs +++ b/coster/src/AzureVmCoster/Services/ArgumentReader.cs @@ -1,10 +1,19 @@ +using Microsoft.Extensions.Logging; + namespace AzureVmCoster.Services; -internal static class ArgumentReader +internal class ArgumentReader { - public static (string? inputFilePath, string? configurationFilePath, CultureInfo culture) Read(string[] args) + private readonly ILogger _logger; + + public ArgumentReader(ILogger logger) + { + _logger = logger; + } + + public (string? inputVmFilePath, string? configurationFilePath, CultureInfo culture) Read(string[] args) { - string? inputFilePath = null; + string? inputVmFilePath = null; string? configurationFilePath = null; var culture = Thread.CurrentThread.CurrentCulture; @@ -18,8 +27,8 @@ public static (string? inputFilePath, string? configurationFilePath, CultureInfo break; case "-i": case "--input": - inputFilePath = args[offset + 1]; - StripSurroundingDoubleQuotes(ref inputFilePath); + inputVmFilePath = args[offset + 1]; + StripSurroundingDoubleQuotes(ref inputVmFilePath); break; case "-c": case "--configuration": @@ -27,12 +36,12 @@ public static (string? inputFilePath, string? configurationFilePath, CultureInfo StripSurroundingDoubleQuotes(ref configurationFilePath); break; default: - Console.WriteLine($"'{args[offset]}' is not a known switch, supported values are: '-l', '--culture', '-i', '--input', '-c', '--configuration'"); + _logger.LogWarning("'{UnsupportedArgument}' is not a known switch, supported values are: '-l', '--culture', '-i', '--input', '-c', '--configuration'", args[offset]); break; } } - return (inputFilePath, configurationFilePath, culture); + return (inputVmFilePath, configurationFilePath, culture); } /// diff --git a/coster/src/AzureVmCoster/Services/PriceService.cs b/coster/src/AzureVmCoster/Services/PriceService.cs new file mode 100644 index 0000000..3a7a79f --- /dev/null +++ b/coster/src/AzureVmCoster/Services/PriceService.cs @@ -0,0 +1,28 @@ +namespace AzureVmCoster.Services; + +internal class PriceService +{ + private readonly Pricer _pricer; + private readonly VmPricingParser _vmPricingParser; + private readonly PricedVmWriter _pricedVmWriter; + + public PriceService(Pricer pricer, VmPricingParser vmPricingParser, PricedVmWriter pricedVmWriter) + { + _pricer = pricer; + _vmPricingParser = vmPricingParser; + _pricedVmWriter = pricedVmWriter; + } + + public async Task PriceAsync(string? inputFilePath, string? configurationFilePath, CultureInfo culture) + { + var inputFile = InputFileValidator.Validate(inputFilePath); + var inputVms = InputVmParser.Parse(inputFile, culture); + + var vmPrices = await _vmPricingParser.ParseAsync(); + + var configuration = await CosterConfiguration.FromPathAsync(configurationFilePath); + + var pricedVms = _pricer.Price(inputVms, vmPrices, configuration); + _pricedVmWriter.Write(inputFile.Name, pricedVms, culture); + } +} diff --git a/coster/src/AzureVmCoster/Services/PricedVmWriter.cs b/coster/src/AzureVmCoster/Services/PricedVmWriter.cs index 0110003..100861a 100644 --- a/coster/src/AzureVmCoster/Services/PricedVmWriter.cs +++ b/coster/src/AzureVmCoster/Services/PricedVmWriter.cs @@ -1,20 +1,32 @@ using AzureVmCoster.Models.Csv; using CsvHelper; +using Microsoft.Extensions.Logging; namespace AzureVmCoster.Services; -internal static class PricedVmWriter +internal class PricedVmWriter { - public static void Write(string filename, List pricedVms, CultureInfo culture) + private readonly ILogger _logger; + + public PricedVmWriter(ILogger logger) + { + _logger = logger; + } + + public void Write(string filename, IList pricedVms, CultureInfo culture) { var csvConfiguration = new CsvConfiguration(culture) { Delimiter = "," }; - using var writer = new StreamWriter($@"Out\{filename}"); + var fileInfo = new FileInfo($@"Out\{filename}"); + + using var writer = new StreamWriter(fileInfo.FullName); using var csv = new CsvWriter(writer, csvConfiguration); csv.Context.RegisterClassMap(); csv.WriteRecords(pricedVms); + + _logger.LogInformation("Wrote priced VM to '{OutputFile}'", fileInfo); } } diff --git a/coster/src/AzureVmCoster/Services/Pricer.cs b/coster/src/AzureVmCoster/Services/Pricer.cs index de9be74..b0519b4 100644 --- a/coster/src/AzureVmCoster/Services/Pricer.cs +++ b/coster/src/AzureVmCoster/Services/Pricer.cs @@ -1,22 +1,60 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; namespace AzureVmCoster.Services; internal class Pricer { - private readonly string _pricingDirectory; + private readonly ILogger _logger; - public Pricer(string pricingDirectory) + public Pricer(ILogger logger) { - _pricingDirectory = pricingDirectory; + _logger = logger; } - public void EnsurePricingExists(List vms) + public List Price(IList inputVms, IList vmPrices, CosterConfiguration configuration) + { + EnsurePricingExists(inputVms, vmPrices); + + var filteredVmPrices = FilterPrices(vmPrices, configuration.ExcludedVms); + + var medianCpu = GetCpuMedianForNonZeroValues(inputVms); + var medianRam = GetRamMedianForNonZeroValues(inputVms); + + var orderedVmPrices = filteredVmPrices.OrderBy(p => p.PayAsYouGo).ToList(); + + var pricedVms = new List(); + + foreach (var vm in inputVms) + { + var minCpu = vm.Cpu > 0 ? vm.Cpu : medianCpu; + var minRam = vm.Ram > 0 ? vm.Ram : medianRam; + + var pricing = orderedVmPrices.FirstOrDefault(p => + p.Region.Equals(vm.Region, StringComparison.Ordinal) && + p.OperatingSystem.Equals(vm.OperatingSystem, StringComparison.Ordinal) && + p.Ram >= minRam && + p.VCpu >= minCpu); + + if (pricing == null) + { + _logger.LogWarning("Could not find a matching pricing for VM '{VmName}' ({VmCpu} CPU cores and {VmRam} GB of RAM)", vm.Name, vm.Cpu, vm.Ram); + } + + pricedVms.Add(new PricedVm(vm, pricing)); + } + + return pricedVms; + } + + private static void EnsurePricingExists(IList vms, IList vmPrices) { var missingFiles = vms .Select(vm => new FileIdentifier(vm.Region, vm.OperatingSystem)) .Distinct(new FileIdentifierComparer()) - .Where(fileIdentifier => !File.Exists($"{_pricingDirectory}{fileIdentifier.PricingFilename}")) + .Where(fileIdentifier => !vmPrices.Any(p => + string.Equals(fileIdentifier.Region, p.Region, StringComparison.Ordinal) && + string.Equals(fileIdentifier.OperatingSystem, p.OperatingSystem, StringComparison.Ordinal))) .ToList(); if (missingFiles.Count > 0) @@ -30,61 +68,51 @@ public void EnsurePricingExists(List vms) /// insensitive), if the same instance is present with different regions/operating systems, all occurrences will be /// discarded. /// - /// The list of prices to filter + /// The list of prices to filter /// The list of instances to remove /// The filtered prices - public static IList FilterPricing(IList pricings, IList excludedVms) + private static List FilterPrices(IList vmPrices, IList excludedVms) { - return pricings.Where(p => !excludedVms.Contains(p.Instance, StringComparer.OrdinalIgnoreCase)).ToList(); + return vmPrices.Where(p => !excludedVms.Contains(p.Instance, StringComparer.OrdinalIgnoreCase)).ToList(); } - public static List Price(List vms, IList pricings) + private short GetCpuMedianForNonZeroValues(IList vms) { - var medianCpu = GetCpuMedianForNonZeroValues(vms); - var medianRam = GetRamMedianForNonZeroValues(vms); + var orderedCpus = vms.Where(v => v.Cpu > 0).Select(v => (decimal)v.Cpu).Order().ToList(); - var orderedPricings = pricings.OrderBy(p => p.PayAsYouGo).ToList(); + _logger.LogInformation("CPU is present for {MissingCpuCount} VMs out of {InputVmCount} VMs", orderedCpus.Count, vms.Count); - var pricedVms = new List(); - - foreach (var vm in vms) + if (orderedCpus.Count == 0) { - var minCpu = vm.Cpu > 0 ? vm.Cpu : medianCpu; - var minRam = vm.Ram > 0 ? vm.Ram : medianRam; - - var pricing = orderedPricings.FirstOrDefault(p => - p.Region.Equals(vm.Region, StringComparison.Ordinal) && - p.OperatingSystem.Equals(vm.OperatingSystem, StringComparison.Ordinal) && - p.Ram >= minRam && - p.VCpu >= minCpu); - - if (pricing == null) - { - Console.WriteLine($"Could not find a matching pricing for VM '{vm.Name}' ({vm.Cpu} CPU cores and {vm.Ram} GB of RAM)"); - } - - pricedVms.Add(new PricedVm(vm, pricing)); + throw new ArgumentException("CPU is missing for all input VMs.", nameof(vms)); } - return pricedVms; - } - - private static short GetCpuMedianForNonZeroValues(List vms) - { - var orderedCpus = vms.Where(v => v.Cpu > 0).Select(v => (decimal)v.Cpu).OrderBy(c => c).ToList(); var medianCpu = GetMedian(orderedCpus); return (short)Math.Ceiling(medianCpu); } - private static decimal GetRamMedianForNonZeroValues(List vms) + private decimal GetRamMedianForNonZeroValues(IList vms) { var orderedRams = vms.Where(v => v.Ram > 0).Select(v => v.Ram).OrderBy(r => r).ToList(); + + _logger.LogInformation("RAM is present for {MissingRamCount} VMs out of {InputVmCount} VMs", orderedRams.Count, vms.Count); + + if (orderedRams.Count == 0) + { + throw new ArgumentException("RAM is missing for all input VMs.", nameof(vms)); + } + return GetMedian(orderedRams); } private static decimal GetMedian(IReadOnlyList orderedList) { + if (orderedList.Count == 1) + { + return orderedList[0]; + } + if (orderedList.Count % 2 != 0) { return orderedList[orderedList.Count / 2]; diff --git a/coster/src/AzureVmCoster/Services/VmPricingParser.cs b/coster/src/AzureVmCoster/Services/VmPricingParser.cs index ff2f7ce..52342f8 100644 --- a/coster/src/AzureVmCoster/Services/VmPricingParser.cs +++ b/coster/src/AzureVmCoster/Services/VmPricingParser.cs @@ -4,9 +4,9 @@ internal class VmPricingParser { private readonly string _pricingDirectory; - public VmPricingParser(string pricingDirectory) + public VmPricingParser(PriceDirectory pricingDirectory) { - _pricingDirectory = pricingDirectory; + _pricingDirectory = pricingDirectory.Directory; } public async Task> ParseAsync() diff --git a/coster/tests/AzureVmCosterTests/Models/FileIdentifierTests.cs b/coster/tests/AzureVmCosterTests/Models/FileIdentifierTests.cs index 0cd2810..1e17bcd 100644 --- a/coster/tests/AzureVmCosterTests/Models/FileIdentifierTests.cs +++ b/coster/tests/AzureVmCosterTests/Models/FileIdentifierTests.cs @@ -39,10 +39,7 @@ public static void GivenInvalidFilename_WhenFromFileInfo_ThenThrows(string fileP // Arrange var file = new FileInfo(filePath); - // Act - var actualException = Assert.Throws(() => FileIdentifier.From(file)); - - // Assert - Assert.NotNull(actualException); + // Act & Assert + Assert.Throws(() => FileIdentifier.From(file)); } } diff --git a/coster/tests/AzureVmCosterTests/Services/ArgumentReaderTests.cs b/coster/tests/AzureVmCosterTests/Services/ArgumentReaderTests.cs index 338c315..7bf699d 100644 --- a/coster/tests/AzureVmCosterTests/Services/ArgumentReaderTests.cs +++ b/coster/tests/AzureVmCosterTests/Services/ArgumentReaderTests.cs @@ -1,10 +1,13 @@ using System.Globalization; using AzureVmCoster.Services; +using Microsoft.Extensions.Logging.Abstractions; namespace AzureVmCosterTests.Services; public class ArgumentReaderTests { + private readonly ArgumentReader _target = new(NullLogger.Instance); + [Fact] public void GivenValidArguments_WhenShortNames_ThenReturnExpectedConfiguration() { @@ -12,7 +15,7 @@ public void GivenValidArguments_WhenShortNames_ThenReturnExpectedConfiguration() var args = new[] { "-l", "en-us", "-i", "/tmp/input.json", "-c", "/tmp/config.json" }; // Act - var (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args); + var (inputFilePath, configurationFilePath, culture) = _target.Read(args); // Assert inputFilePath.Should().Be("/tmp/input.json"); @@ -27,7 +30,7 @@ public void GivenDoubleQuotePaths_ThenStripDoubleQuotes() var args = new[] { "-i", @"""C:\tmp\input.json""", "-c", @"""C:\tmp\input\config.json""" }; // Act - var (inputFilePath, configurationFilePath, _) = ArgumentReader.Read(args); + var (inputFilePath, configurationFilePath, _) = _target.Read(args); // Assert inputFilePath.Should().Be(@"C:\tmp\input.json"); @@ -41,7 +44,7 @@ public void GivenUnknownArgument_ThenIgnoreUnknownArgument() var args = new[] { "--unknown", "ignored", "-i", "/tmp/input.json" }; // Act - var (inputFilePath, _, _) = ArgumentReader.Read(args); + var (inputFilePath, _, _) = _target.Read(args); // Assert inputFilePath.Should().Be("/tmp/input.json"); @@ -54,7 +57,7 @@ public void GivenValidArguments_WhenLongNames_ThenReturnExpectedConfiguration() var args = new[] { "--culture", "en-us", "--input", "/tmp/input.json", "--configuration", "/tmp/config.json" }; // Act - var (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args); + var (inputFilePath, configurationFilePath, culture) = _target.Read(args); // Assert inputFilePath.Should().Be("/tmp/input.json"); @@ -80,7 +83,7 @@ void AssertThreadCulture() { Thread.CurrentThread.CurrentCulture = new CultureInfo("pl-pl"); - var (inputFilePath, configurationFilePath, culture) = ArgumentReader.Read(args); + var (inputFilePath, configurationFilePath, culture) = _target.Read(args); // Assert inputFilePath.Should().Be("/tmp/input.json"); diff --git a/coster/tests/AzureVmCosterTests/Services/PricedVmWriterTests.cs b/coster/tests/AzureVmCosterTests/Services/PricedVmWriterTests.cs index 7003041..0317103 100644 --- a/coster/tests/AzureVmCosterTests/Services/PricedVmWriterTests.cs +++ b/coster/tests/AzureVmCosterTests/Services/PricedVmWriterTests.cs @@ -1,24 +1,27 @@ using System.Globalization; using AzureVmCoster.Services; using AzureVmCosterTests.TestInfrastructure; +using Microsoft.Extensions.Logging.Abstractions; namespace AzureVmCosterTests.Services; -public static class PricedVmWriterTests +public class PricedVmWriterTests { private const string CsvHeader = "Region,Name,Operating System,Instance,CPU,RAM,Pay as You Go,Pay as You Go with Azure Hybrid Benefit,One Year Reserved,One Year Reserved with Azure Hybrid Benefit,Three Year Reserved,Three Year Reserved with Azure Hybrid Benefit,Spot,Spot with Azure Hybrid Benefit,One Year Savings plan,One Year Savings plan with Azure Hybrid Benefit,Three Year Savings plan,Three Year Savings plan with Azure Hybrid Benefit"; + private readonly PricedVmWriter _target = new(NullLogger.Instance); + [Fact] - public static void GivenInputVmWithMatchingPricing_WhenWrite_ThenPopulateAllColumns() + public void GivenPricedVm_WhenWrite_ThenPopulateAllColumns() { // Arrange var inputVm = InputVmBuilder.AsUsWestWindowsD2V3Equivalent(); var vmPricing = VmPricingBuilder.AsUsWestWindowsD2V3(); - var vm = new PricedVm(inputVm, vmPricing); + var vm = PricedVmBuilder.From(inputVm, vmPricing); var fileName = $"{Guid.NewGuid():D}.csv"; // Act - PricedVmWriter.Write(fileName, new List { vm }, new CultureInfo("en-au")); + _target.Write(fileName, new List { vm }, new CultureInfo("en-au")); // Assert const string expected = "us-west,map-me-to-d2-v3,windows,D2 v3,2,8,1.1,1.05,0.84,0.81,0.71,0.68,0.95,0.93,0.89,0.86,0.78,0.72"; @@ -29,15 +32,15 @@ public static void GivenInputVmWithMatchingPricing_WhenWrite_ThenPopulateAllColu } [Fact] - public static void GivenInputVmWithNoMatchingPricing_WhenWrite_ThenEmptyPriceColumns() + public void GivenPricedVmWithoutPrice_WhenWrite_ThenEmptyPriceColumns() { // Arrange var inputVm = InputVmBuilder.AsUsWestWindowsD2V3Equivalent(); - var vm = new PricedVm(inputVm, null); + var vm = PricedVmBuilder.WithoutPrice(inputVm); var fileName = $"{Guid.NewGuid():D}.csv"; // Act - PricedVmWriter.Write(fileName, new List { vm }, new CultureInfo("en-au")); + _target.Write(fileName, new List { vm }, new CultureInfo("en-au")); // Assert const string expected = "us-west,map-me-to-d2-v3,windows,,2,8,,,,,,,,,,,,"; diff --git a/coster/tests/AzureVmCosterTests/Services/PricerTests.cs b/coster/tests/AzureVmCosterTests/Services/PricerTests.cs index 50ae97d..faa871c 100644 --- a/coster/tests/AzureVmCosterTests/Services/PricerTests.cs +++ b/coster/tests/AzureVmCosterTests/Services/PricerTests.cs @@ -1,11 +1,13 @@ using AzureVmCoster.Services; using AzureVmCosterTests.TestInfrastructure; +using Microsoft.Extensions.Logging.Abstractions; namespace AzureVmCosterTests.Services; public class PricerTests { - private readonly Pricer _target = new("TestFiles/Pricing/"); + private readonly Pricer _target = new(NullLogger.Instance); + private readonly CosterConfiguration _defaultConfiguration = new(); [Fact] public void GivenMissingRegionAndMissingOperatingSystem_WhenEnsurePricingExists_ThenThrow() @@ -13,14 +15,15 @@ public void GivenMissingRegionAndMissingOperatingSystem_WhenEnsurePricingExists_ // Arrange var vms = new List { - new() {Region = "missing", OperatingSystem = "missing"} + InputVmBuilder.AsUsEastLinuxD2V3Equivalent() + }; + var prices = new List + { + VmPricingBuilder.AsUsWestWindowsD2V3() }; - // Act - var actualException = Assert.Throws(() => _target.EnsurePricingExists(vms)); - - // Assert - Assert.NotNull(actualException); + // Act & Assert + Assert.Throws(() => _target.Price(vms, prices, _defaultConfiguration)); } [Fact] @@ -29,14 +32,18 @@ public void GivenExistingRegionAndExistingOperatingSystem_WhenEnsurePricingExist // Arrange var vms = new List { - new() {Region = "germany-west-central", OperatingSystem = "windows"} + InputVmBuilder.AsUsWestWindowsD2V3Equivalent() + }; + var prices = new List + { + VmPricingBuilder.AsUsWestWindowsD2V3() }; // Act - var action = () => _target.EnsurePricingExists(vms); + var pricedVms = _target.Price(vms, prices, _defaultConfiguration); // Assert - action.Should().NotThrow(); + pricedVms.Should().NotBeEmpty(); } [Fact] @@ -45,14 +52,15 @@ public void GivenExistingRegionAndMissingOperatingSystem_WhenEnsurePricingExists // Arrange var vms = new List { - new() {Region = "germany-west-central", OperatingSystem = "missing"} + InputVmBuilder.AsUsWestLinuxD2V3Equivalent() + }; + var prices = new List + { + VmPricingBuilder.AsUsWestWindowsD2V3() }; - // Act - var actualException = Assert.Throws(() => _target.EnsurePricingExists(vms)); - - // Assert - Assert.NotNull(actualException); + // Act & Assert + Assert.Throws(() => _target.Price(vms, prices, _defaultConfiguration)); } [Fact] @@ -61,75 +69,89 @@ public void GivenMissingRegionAndExistingOperatingSystem_WhenEnsurePricingExists // Arrange var vms = new List { - new() {Region = "missing", OperatingSystem = "windows"} + InputVmBuilder.AsUsEastWindowsD2V3Equivalent() + }; + var prices = new List + { + VmPricingBuilder.AsUsWestWindowsD2V3() }; - // Act - var actualException = Assert.Throws(() => _target.EnsurePricingExists(vms)); - - // Assert - Assert.NotNull(actualException); + // Act & Assert + Assert.Throws(() => _target.Price(vms, prices, _defaultConfiguration)); } [Fact] public void GivenEmptyExcludeList_WhenFilterPricing_ThenNoPriceRemoved() { // Arrange + var vms = new List + { + InputVmBuilder.AsUsWestWindowsD2V3Equivalent() + }; var prices = new List { VmPricingBuilder.AsUsWestWindowsD2V3() }; - var exclusions = new List(); + var configuration = new CosterConfiguration { ExcludedVms = new List() }; // Act - var filteredPrices = Pricer.FilterPricing(prices, exclusions); + var actualPricedVms = _target.Price(vms, prices, configuration); // Assert - filteredPrices.Should().BeEquivalentTo(prices); + var expectedPricedVms = new List { PricedVmBuilder.From(vms[0], prices[0]) }; + actualPricedVms.Should().BeEquivalentTo(expectedPricedVms); } [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 vms = new List + { + InputVmBuilder.AsUsWestWindowsD2V3Equivalent() + }; + var d4V3 = VmPricingBuilder.AsUsWestWindowsD4V3(); + var d2V3Linux = VmPricingBuilder.AsUsWestLinuxD2V3(); + var d2V3UsEast = VmPricingBuilder.AsUsEastWindowsD2V3(); var prices = new List { VmPricingBuilder.AsUsWestWindowsD2V3(), d4V3, d2V3Linux, - d2V3EuWest + d2V3UsEast }; - var exclusions = new List { "D2 v3" }; + var configuration = new CosterConfiguration { ExcludedVms = new List { "D2 v3" } }; // Act - var filteredPrices = Pricer.FilterPricing(prices, exclusions); + var pricedVms = _target.Price(vms, prices, configuration); // Assert - var expected = new List { d4V3 }; - filteredPrices.Should().BeEquivalentTo(expected); + var expected = new List { PricedVmBuilder.From(vms[0], d4V3) }; + pricedVms.Should().BeEquivalentTo(expected); } [Fact] public void GivenExcludeList_WhenFilterPricing_ThenRemoveInstanceWithSameNameCaseInsensitive() { // Arrange + var vms = new List + { + InputVmBuilder.AsUsWestWindowsD2V3Equivalent() + }; + var d4V3 = VmPricingBuilder.AsUsWestWindowsD4V3(); var prices = new List { - VmPricingBuilder.AsUsWestWindowsD2V3() + VmPricingBuilder.AsUsWestWindowsD2V3(), + d4V3 }; - var exclusions = new List { "d2 V3" }; + var configuration = new CosterConfiguration { ExcludedVms = new List { "d2 V3" } }; // Act - var filteredPrices = Pricer.FilterPricing(prices, exclusions); + var actual = _target.Price(vms, prices, configuration); // Assert - filteredPrices.Should().BeEmpty(); + var expected = new List { PricedVmBuilder.From(vms[0], d4V3) }; + actual.Should().BeEquivalentTo(expected); } [Fact] @@ -146,10 +168,10 @@ public void GivenMatchingPrice_WhenPrice_ThenPriceVm() }; // Act - var actualPricedVms = Pricer.Price(vms, prices); + var actualPricedVms = _target.Price(vms, prices, _defaultConfiguration); // Assert - var expectedPricedVms = new List { new(vms[0], prices[0]) }; + var expectedPricedVms = new List { PricedVmBuilder.From(vms[0], prices[0]) }; actualPricedVms.Should().BeEquivalentTo(expectedPricedVms); } @@ -159,15 +181,18 @@ public void GivenNoMatchingPrice_WhenPrice_ThenHandleVmAsNoPrice() // Arrange var vms = new List { - InputVmBuilder.AsUsWestWindowsD2V3Equivalent() + InputVmBuilder.AsUsWestWindowsD4V3Equivalent() + }; + var prices = new List + { + VmPricingBuilder.AsUsWestWindowsD2V3() }; - var prices = new List(); // Act - var actualPricedVms = Pricer.Price(vms, prices); + var actualPricedVms = _target.Price(vms, prices, _defaultConfiguration); // Assert - var expectedPricedVms = new List { new(vms[0], null) }; + var expectedPricedVms = new List { PricedVmBuilder.WithoutPrice(vms[0]) }; actualPricedVms.Should().BeEquivalentTo(expectedPricedVms); } @@ -199,16 +224,16 @@ public void GivenVmWithoutCpuAndWithoutRam_WhenPrice_ThenUseMedianCpuAndRam() }; // Act - var actualPricedVms = Pricer.Price(vms, prices); + var actualPricedVms = _target.Price(vms, prices, _defaultConfiguration); // Assert var expectedPricedVms = new List { - new(vms[0], prices[0]), - new(vms[1], prices[1]), - new(vms[2], prices[2]), - new(vms[3], prices[3]), - new(vmWithoutCpuWithoutRam, medianPrice) + PricedVmBuilder.From(vms[0], prices[0]), + PricedVmBuilder.From(vms[1], prices[1]), + PricedVmBuilder.From(vms[2], prices[2]), + PricedVmBuilder.From(vms[3], prices[3]), + PricedVmBuilder.From(vmWithoutCpuWithoutRam, medianPrice) }; actualPricedVms.Should().BeEquivalentTo(expectedPricedVms); } diff --git a/coster/tests/AzureVmCosterTests/Services/VmPricingParserTests.cs b/coster/tests/AzureVmCosterTests/Services/VmPricingParserTests.cs index 90d6e1e..d998fab 100644 --- a/coster/tests/AzureVmCosterTests/Services/VmPricingParserTests.cs +++ b/coster/tests/AzureVmCosterTests/Services/VmPricingParserTests.cs @@ -8,10 +8,10 @@ public class VmPricingParserTests public async Task GivenValidPrice_ThenParseVm() { // Arrange - var parser = new VmPricingParser("TestFiles/Pricing/"); + var parser = new VmPricingParser(new PriceDirectory("TestFiles/Pricing/")); // Act - var prices = await parser.ParseAsync(); + var actualPrices = await parser.ParseAsync(); // Assert var expectedPrices = new List @@ -37,14 +37,14 @@ public async Task GivenValidPrice_ThenParseVm() SpotWithAzureHybridBenefit = 0.0036m } }; - prices.Should().BeEquivalentTo(expectedPrices); + actualPrices.Should().BeEquivalentTo(expectedPrices); } [Fact] public async Task GivenEmptyPriceFile_ThenHandleGracefully() { // Arrange - var parser = new VmPricingParser("TestFiles/EmptyPricing/"); + var parser = new VmPricingParser(new PriceDirectory("TestFiles/EmptyPricing/")); // Act var prices = await parser.ParseAsync(); diff --git a/coster/tests/AzureVmCosterTests/TestInfrastructure/InputVmBuilder.cs b/coster/tests/AzureVmCosterTests/TestInfrastructure/InputVmBuilder.cs index 56d8f75..59f6b9f 100644 --- a/coster/tests/AzureVmCosterTests/TestInfrastructure/InputVmBuilder.cs +++ b/coster/tests/AzureVmCosterTests/TestInfrastructure/InputVmBuilder.cs @@ -14,6 +14,18 @@ public static InputVm AsUsWestWindowsD2V3Equivalent() }; } + public static InputVm AsUsWestLinuxD2V3Equivalent() + { + return new InputVm + { + Name = "map-me-to-d2-v3", + OperatingSystem = "linux", + Ram = 8, + Region = "us-west", + Cpu = 2 + }; + } + public static InputVm AsUsWestWindowsD4V3Equivalent() { return new InputVm @@ -49,4 +61,28 @@ public static InputVm AsUsWestWindowsD16V3Equivalent() Cpu = 16 }; } + + public static InputVm AsUsEastWindowsD2V3Equivalent() + { + return new InputVm + { + Name = "map-me-to-d2-v3", + OperatingSystem = "windows", + Ram = 8, + Region = "us-east", + Cpu = 2 + }; + } + + public static InputVm AsUsEastLinuxD2V3Equivalent() + { + return new InputVm + { + Name = "map-me-to-d2-v3", + OperatingSystem = "linux", + Ram = 8, + Region = "us-east", + Cpu = 2 + }; + } } diff --git a/coster/tests/AzureVmCosterTests/TestInfrastructure/PricedVmBuilder.cs b/coster/tests/AzureVmCosterTests/TestInfrastructure/PricedVmBuilder.cs new file mode 100644 index 0000000..b0d0fb9 --- /dev/null +++ b/coster/tests/AzureVmCosterTests/TestInfrastructure/PricedVmBuilder.cs @@ -0,0 +1,14 @@ +namespace AzureVmCosterTests.TestInfrastructure; + +internal static class PricedVmBuilder +{ + public static PricedVm From(InputVm vm, VmPricing price) + { + return new PricedVm(vm, price); + } + + public static PricedVm WithoutPrice(InputVm vm) + { + return new PricedVm(vm, vmPricing: null); + } +} diff --git a/coster/tests/AzureVmCosterTests/TestInfrastructure/VmPricingBuilder.cs b/coster/tests/AzureVmCosterTests/TestInfrastructure/VmPricingBuilder.cs index 4b66b7e..51096c0 100644 --- a/coster/tests/AzureVmCosterTests/TestInfrastructure/VmPricingBuilder.cs +++ b/coster/tests/AzureVmCosterTests/TestInfrastructure/VmPricingBuilder.cs @@ -26,6 +26,48 @@ public static VmPricing AsUsWestWindowsD2V3() }; } + public static VmPricing AsUsEastWindowsD2V3() + { + return new VmPricing + { + Instance = "D2 v3", + OperatingSystem = "windows", + Ram = 8, + Region = "us-east", + VCpu = 2, + PayAsYouGo = 0.188m, + PayAsYouGoWithAzureHybridBenefit = 0.096m, + Spot = 0.0250m, + SpotWithAzureHybridBenefit = 0.0128m, + OneYearSavingsPlan = 0.1582m, + OneYearSavingsPlanWithAzureHybridBenefit = 0.0662m, + ThreeYearSavingsPlan = 0.1371m, + ThreeYearSavingsPlanWithAzureHybridBenefit = 0.0451m, + OneYearReserved = 0.1492m, + OneYearReservedWithAzureHybridBenefit = 0.0572m, + ThreeYearReserved = 0.1288m, + ThreeYearReservedWithAzureHybridBenefit = 0.0368m + }; + } + + public static VmPricing AsUsWestLinuxD2V3() + { + return new VmPricing + { + Instance = "D2 v3", + OperatingSystem = "linux", + Ram = 8, + Region = "us-west", + VCpu = 2, + PayAsYouGo = 0.096m, + Spot = 0.0128m, + OneYearSavingsPlan = 0.0662m, + ThreeYearSavingsPlan = 0.0451m, + OneYearReserved = 0.0572m, + ThreeYearReserved = 0.0368m, + }; + } + public static VmPricing AsUsWestWindowsD4V3() { return new VmPricing