diff --git a/VERSION b/VERSION index acf9bf09db..06eda28ac7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.2.2 \ No newline at end of file +3.2.3 \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Common/Extensions/CollectionExtensions.cs b/src/VirtualClient/VirtualClient.Common/Extensions/CollectionExtensions.cs index 31fd0f1b0d..0ac1e8e177 100644 --- a/src/VirtualClient/VirtualClient.Common/Extensions/CollectionExtensions.cs +++ b/src/VirtualClient/VirtualClient.Common/Extensions/CollectionExtensions.cs @@ -6,6 +6,9 @@ namespace VirtualClient.Common.Extensions using System; using System.Collections.Generic; using System.Linq; + using System.Numerics; + using System.Security.Cryptography; + using System.Text; /// /// Extensions methods for collection classes used as part of the standard @@ -78,6 +81,38 @@ public static void AddRange(this HashSet dictionary, IEnumerable items) } } + /// + /// Computes a repeatable, predictable hash code for the byte array value provided. + /// + /// The string values from which to compute the hash code. + /// A repeatable, predictable hash code. + public static BigInteger ComputeHashCode(this IEnumerable values) + { + if (values?.Any() != true) + { + return -1; + } + + byte[] hashBytes = SHA1.HashData(Encoding.UTF8.GetBytes(string.Join("|", values).ToUpperInvariant())); + + // Normalize Endianness + // SHA-1 output is naturally Big-Endian. + // BigInteger constructor in .NET expects Little-Endian by default. + // To be safe across architectures, we force a specific order. + if (!BitConverter.IsLittleEndian) + { + Array.Reverse(hashBytes); + } + + // Ensure the integer is interpreted as positive + // BigInteger uses the last bit for the sign (positive/negative). + // To ensure a positive number, we append a zero byte. + byte[] unsignedBytes = new byte[hashBytes.Length + 1]; + Buffer.BlockCopy(hashBytes, 0, unsignedBytes, 0, hashBytes.Length); + + return new BigInteger(unsignedBytes); + } + /// /// Extensions returns true/false whether the two dictionary sets are semantically equal. /// diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileElementTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileElementTests.cs index a9f58216bd..a909342496 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileElementTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileElementTests.cs @@ -96,6 +96,178 @@ public void ExecutionProfileElementIsJsonSerializableWithSequentialExecutionDefi SerializationAssert.IsJsonSerializable(element); } + [Test] + public void ExecutionProfileElementRepresentsStringConversionSemanticsCorrectly() + { + ExecutionProfileElement element = new ExecutionProfileElement( + "AnyType1", + new Dictionary + { + { "Parameter1", "ValueA" }, + { "Parameter2", 123.45 }, + { "Parameter3", true }, + { "Parameter4", "00:01:00" } + }, + new Dictionary + { + { "Metadata1", "ValueB" }, + { "Metadata2", 6789.123 }, + { "Metadata3", false }, + { "Metadata4", "00:02:30" } + }); + + element.ComponentType = ComponentType.Monitor; + + string expectedValue = "Type:AnyType1,,,ComponentType:Monitor,,,Metadata:[Metadata1=ValueB;Metadata2=6789.123;Metadata3=False;Metadata4=00:02:30],,,Parameters:[Parameter1=ValueA;Parameter2=123.45;Parameter3=True;Parameter4=00:01:00]"; + string actualValue = element.ToString(); + + Assert.AreEqual(expectedValue, actualValue); + } + + [Test] + public void ExecutionProfileElementRepresentsStringConversionSemanticsCorrectly_2() + { + ExecutionProfileElement element = new ExecutionProfileElement( + "AnyType1", + new Dictionary + { + { "Parameter1", "ValueA" } + }, + new Dictionary + { + { "Metadata1", "ValueB" } + }, + new List + { + new ExecutionProfileElement( + "AnyType2", + new Dictionary + { + { "Parameter1", "ValueC" } + }, + new Dictionary + { + { "Metadata1", "ValueD" } + }) + }); + + element.ComponentType = ComponentType.Action; + element.Components.First().ComponentType = ComponentType.Action; + element.Components.First().Extensions.Add("Extension1", JToken.FromObject("{ 'key1': 'value1', 'key2': [ 'one', 'two'], 'key3': { 'any': 'value', 'in': 'the', 'dictionary': 'true' } }")); + + string expectedValue = + "Type:AnyType1,,,ComponentType:Action,,,Metadata:[Metadata1=ValueB],,,Parameters:[Parameter1=ValueA],,," + + "Components:[(Type:AnyType2,,,ComponentType:Action,,,Metadata:[Metadata1=ValueD],,,Parameters:[Parameter1=ValueC],,,Extensions:[(Extension1={'key1':'value1','key2':['one','two'],'key3':{'any':'value','in':'the','dictionary':'true'}})])]"; + + string actualValue = element.ToString(); + + Assert.AreEqual(expectedValue, actualValue); + } + + [Test] + public void ExecutionProfileElementRepresentsStringConversionSemanticsCorrectly_3() + { + ExecutionProfileElement element = new ExecutionProfileElement( + "AnyType1", + new Dictionary + { + { "Parameter1", "ValueA" } + }, + new Dictionary + { + { "Metadata1", "ValueB" } + }, + new List + { + new ExecutionProfileElement( + "AnyType2", + new Dictionary + { + { "Parameter1", "ValueC" } + }, + new Dictionary + { + { "Metadata1", "ValueD" } + }, + new List + { + new ExecutionProfileElement( + "AnyType3", + new Dictionary + { + { "Parameter1", "ValueE" } + }, + new Dictionary + { + { "Metadata1", "ValueF" } + }) + }) + }); + + element.ComponentType = ComponentType.Action; + string expectedValue = + "Type:AnyType1,,,ComponentType:Action,,,Metadata:[Metadata1=ValueB],,,Parameters:[Parameter1=ValueA],,," + + "Components:[(Type:AnyType2,,,ComponentType:Undefined,,,Metadata:[Metadata1=ValueD],,,Parameters:[Parameter1=ValueC],,,Components:[(Type:AnyType3,,,ComponentType:Undefined,,,Metadata:[Metadata1=ValueF],,,Parameters:[Parameter1=ValueE])])]"; + + string actualValue = element.ToString(); + + Assert.AreEqual(expectedValue, actualValue); + } + + [Test] + public void ExecutionProfileElementRepresentsStringConversionSemanticsCorrectly_4() + { + ExecutionProfileElement element = new ExecutionProfileElement( + "AnyType1", + new Dictionary + { + { "Parameter1", "ValueA" } + }, + new Dictionary + { + { "Metadata1", "ValueB" } + }, + new List + { + new ExecutionProfileElement( + "AnyType2", + new Dictionary + { + { "Parameter1", "ValueC" } + }, + new Dictionary + { + { "Metadata1", "ValueD" } + }, + new List + { + new ExecutionProfileElement( + "AnyType3", + new Dictionary + { + { "Parameter1", "ValueE" } + }, + new Dictionary + { + { "Metadata1", "ValueF" } + }) + }) + }); + + element.ComponentType = ComponentType.Dependency; + element.Extensions.Add("Extension1", JToken.FromObject("{ 'key1': 'value1', 'key2': [ 'one', 'two'], 'key3': { 'any': 'value', 'in': 'the', 'dictionary': 'true' } }")); + + string expectedValue = + "Type:AnyType1,,,ComponentType:Dependency,,,Metadata:[Metadata1=ValueB],,,Parameters:[Parameter1=ValueA],,," + + "Components:[(Type:AnyType2,,,ComponentType:Undefined,,,Metadata:[Metadata1=ValueD],,,Parameters:[Parameter1=ValueC],,," + + "Components:[(Type:AnyType3,,,ComponentType:Undefined,,,Metadata:[Metadata1=ValueF],,,Parameters:[Parameter1=ValueE])])],,," + + "Extensions:[(Extension1={'key1':'value1','key2':['one','two'],'key3':{'any':'value','in':'the','dictionary':'true'}})]"; + + string actualValue = element.ToString(); + + Assert.AreEqual(expectedValue, actualValue); + } + [Test] public void ExecutionProfileElementImplementsHashCodeSemanticsCorrectly() { @@ -104,6 +276,135 @@ public void ExecutionProfileElementImplementsHashCodeSemanticsCorrectly() EqualityAssert.CorrectlyImplementsHashcodeSemantics(() => element, () => element2); } + [Test] + public void ExecutionProfileElementImplementsHashCodeSemanticsCorrectly_2() + { + ExecutionProfileElement element1 = new ExecutionProfileElement( + "AnyType1", + new Dictionary + { + { "Parameter1", "ValueA" }, + { "Parameter2", 123.45 }, + { "Parameter3", true }, + { "Parameter4", "00:01:00" } + }, + new Dictionary + { + { "Metadata1", "ValueB" }, + { "Metadata2", 6789.123 }, + { "Metadata3", false }, + { "Metadata4", "00:02:30" } + }); + + element1.ComponentType = ComponentType.Action; + + ExecutionProfileElement element2 = new ExecutionProfileElement( + "AnyType2", + new Dictionary + { + { "Parameter5", "ValueC" }, + { "Parameter6", 987.65 }, + { "Parameter7", true }, + { "Parameter8", "00:03:00" } + }, + new Dictionary + { + { "Metadata1", "ValueD" }, + { "Metadata2", 777.88 }, + { "Metadata3", false }, + { "Metadata4", "00:04:15" } + }); + + element2.ComponentType = ComponentType.Action; + + EqualityAssert.CorrectlyImplementsHashcodeSemantics(() => element1, () => element2); + } + + [Test] + public void ExecutionProfileElementImplementsHashCodeSemanticsCorrectly_3() + { + ExecutionProfileElement element1 = new ExecutionProfileElement( + "AnyType1", + new Dictionary + { + { "Parameter1", "ValueA" } + }, + new Dictionary + { + { "Metadata1", "ValueB" } + }, + new List + { + new ExecutionProfileElement( + "AnyType2", + new Dictionary + { + { "Parameter1", "ValueC" } + }, + new Dictionary + { + { "Metadata1", "ValueD" } + }, + new List + { + new ExecutionProfileElement( + "AnyType3", + new Dictionary + { + { "Parameter1", "ValueE" } + }, + new Dictionary + { + { "Metadata1", "ValueF" } + }) + }) + }); + + element1.ComponentType = ComponentType.Action; + string here = element1.ToString(); + + ExecutionProfileElement element2 = new ExecutionProfileElement( + "AnyType4", + new Dictionary + { + { "Parameter1", "ValueG" } + }, + new Dictionary + { + { "Metadata1", "ValueH" } + }, + new List + { + new ExecutionProfileElement( + "AnyType5", + new Dictionary + { + { "Parameter1", "ValueI" } + }, + new Dictionary + { + { "Metadata1", "ValueJ" } + }, + new List + { + new ExecutionProfileElement( + "AnyType6", + new Dictionary + { + { "Parameter1", "ValueK" } + }, + new Dictionary + { + { "Metadata1", "ValueL" } + }) + }) + }); + + element2.ComponentType = ComponentType.Action; + + EqualityAssert.CorrectlyImplementsHashcodeSemantics(() => element1, () => element2); + } + [Test] public void ExecutionProfileElementImplementsEqualitySemanticsCorrectly() { diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileTests.cs index 42649dea7e..916dd7939a 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/ExecutionProfileTests.cs @@ -6,16 +6,8 @@ namespace VirtualClient.Contracts using System; using System.Collections.Generic; using System.IO; - using System.IO.Abstractions; using System.Linq; - using System.Reflection; - using System.Text; - using System.Threading.Tasks; using AutoFixture; - using VirtualClient.Common; - using Moq; - using Newtonsoft.Json; - using Newtonsoft.Json.Linq; using NUnit.Framework; using VirtualClient.Common.Contracts; using VirtualClient.TestExtensions; @@ -122,5 +114,92 @@ public void ExecutionProfileImplementsEqualitySemanticsCorrectly() ExecutionProfile profile2 = this.mockFixture.Create(); EqualityAssert.CorrectlyImplementsEqualitySemantics(() => profile, () => profile2); } + + [Test] + [Platform(Include = "64-bit")] // assumes little-endian architecture for hash code generation + [TestCase("TEST-PROFILE-1.json", "279003058894895889018486895561353871838116508168")] + [TestCase("TEST-PROFILE-2.json", "27436979332165279148580205095431808728699242072")] + [TestCase("TEST-PROFILE-4.json", "1326284617660594785167171193396646697439110051452")] + [TestCase("TEST-PROFILE-5.json", "1024016195510512526363371526971708676820695027365")] + [TestCase("TEST-PROFILE-3-PARALLEL.json", "170719133252643968401267092175966399376412682155")] + [TestCase("TEST-PROFILE-2-PARALLEL-LOOP.json", "496529190653064795011428134491477848965424897886")] + public void ExecutionProfileGeneratesPredictableHashCodes(string profileName, string expectedHashCode) + { + // The hash should not change regardless of the number of times the profile is deserialized. + for (int check = 0; check < 10; check++) + { + ExecutionProfile profile = File.ReadAllText(Path.Combine(MockFixture.TestAssemblyDirectory, "Resources", profileName)) + .FromJson(); + + string actualHashCode = profile.GetPredictableHashCode().ToString(); + Assert.AreEqual(expectedHashCode, actualHashCode); + } + } + + [Test] + [Platform(Include = "64-bit")] // assumes little-endian architecture for hash code generation + [TestCase("TEST-PROFILE-1.json", "279003058894895889018486895561353871838116508168")] + [TestCase("TEST-PROFILE-2.json", "594761196296228877452223238967152003599764620204")] + [TestCase("TEST-PROFILE-4.json", "922483698282800856035216844073388800671549310792")] + [TestCase("TEST-PROFILE-5.json", "1024016195510512526363371526971708676820695027365")] + [TestCase("TEST-PROFILE-3-PARALLEL.json", "170719133252643968401267092175966399376412682155")] + [TestCase("TEST-PROFILE-2-PARALLEL-LOOP.json", "926161149126364949119888097719891324792221811485")] + public void ExecutionProfileGeneratesPredictableHashCodesWhenMetadataIsIncluded(string profileName, string expectedHashCode) + { + // The hash should not change regardless of the number of times the profile is deserialized. + for (int check = 0; check < 10; check++) + { + ExecutionProfile profile = File.ReadAllText(Path.Combine(MockFixture.TestAssemblyDirectory, "Resources", profileName)) + .FromJson(); + + string actualHashCode = profile.GetPredictableHashCode(includeMetadata: true).ToString(); + Assert.AreEqual(expectedHashCode, actualHashCode); + } + } + + + [Test] + [Platform(Include = "64-bit")] // assumes little-endian architecture for hash code generation + [TestCase("TEST-PROFILE-4.json", "1326284617660594785167171193396646697439110051452")] + public void ExecutionProfileGeneratesPredictableHashCodesWhenMetadataIsModifiedButExcludedFromHashingMechanics(string profileName, string expectedHashCode) + { + ExecutionProfile profile = File.ReadAllText(Path.Combine(MockFixture.TestAssemblyDirectory, "Resources", profileName)) + .FromJson(); + + string actualHashCode = profile.GetPredictableHashCode(includeMetadata: false).ToString(); + Assert.AreEqual(expectedHashCode, actualHashCode); + + profile.Metadata[profile.Metadata.First().Key] = "ModifiedValue"; + + List components = new List(); + if (profile.Actions?.Any() == true) + { + components.AddRange(profile.Actions); + } + + if (profile.Dependencies?.Any() == true) + { + components.AddRange(profile.Dependencies); + } + + if (profile.Monitors?.Any() == true) + { + components.AddRange(profile.Monitors); + } + + if (components?.Any() == true) + { + foreach (var component in components) + { + if (component.Metadata?.Any() == true) + { + component.Metadata[component.Metadata.First().Key] = "ModifiedValue"; + } + } + } + + string modifiedHashCode = profile.GetPredictableHashCode(includeMetadata: false).ToString(); + Assert.AreEqual(expectedHashCode, modifiedHashCode); + } } } diff --git a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs index e1f3d2d90f..00faaccefd 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs @@ -8,9 +8,14 @@ namespace VirtualClient.Contracts using System.IO; using System.IO.Abstractions; using System.Linq; + using System.Numerics; + using System.Security.Cryptography; using System.Text; + using System.Text.Json; using System.Threading.Tasks; using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + using Newtonsoft.Json.Serialization; using VirtualClient.Common.Contracts; using VirtualClient.Common.Extensions; @@ -129,18 +134,6 @@ public ExecutionProfile(ExecutionProfileYamlShim other) [JsonProperty(PropertyName = "MinimumExecutionInterval", Required = Required.Default, Order = 30)] public TimeSpan? MinimumExecutionInterval { get; } - /// - /// The set of supported platform/architectures for the profile. - /// - [JsonProperty(PropertyName = "SupportedPlatforms", Required = Required.Default, Order = 45)] - public List SupportedPlatforms { get; } - - /// - /// The set of supported operating systems for the profile. - /// - [JsonProperty(PropertyName = "SupportedOperatingSystems", Required = Required.Default, Order = 50)] - public List SupportedOperatingSystems { get; } - /// /// Metadata properties associated with the profile. /// @@ -240,20 +233,84 @@ public override int GetHashCode() return this.ToString().ToUpperInvariant().GetHashCode(); } + /// + /// Calculates a repeatable, predictable hash code of this instance. + /// + /// + /// True/false whether metadata information should be included in the hashing process. Note that metadata does not typically + /// affect the runtime behavior of the profile and is more informational in nature. Therefore, it is not included in the hash by default. + /// + /// A predictable hash code for this instance across CPU architectures. + public BigInteger GetPredictableHashCode(bool includeMetadata = false) + { + SHA256 hashAlgorithm = SHA256.Create(); + + // Note that we ONLY include parts of the execution profile that affect the runtime behavior. We DO NOT + // for example include the metadata because it is informational only. + List hashValues = new List(); + + if (!string.IsNullOrWhiteSpace(this.Description)) + { + hashValues.Add(this.Description.ToUpperInvariant()); + } + + if (this.MinimumExecutionInterval != null) + { + hashValues.Add(this.MinimumExecutionInterval.ToString()); + } + + if (includeMetadata && this.Metadata?.Any() == true) + { + this.Metadata?.ToList().ForEach(metadata => hashValues.Add($"{metadata.Key.ToUpperInvariant()}={metadata.Value}")); + } + + this.Parameters?.ToList().ForEach(parameter => hashValues.Add($"{parameter.Key.ToUpperInvariant()}={parameter.Value}")); + this.Actions?.ForEach(action => hashValues.Add(action.ToString(includeMetadata))); + this.Dependencies?.ForEach(dependency => hashValues.Add(dependency.ToString(includeMetadata))); + this.Monitors?.ForEach(monitor => hashValues.Add(monitor.ToString(includeMetadata))); + + return hashValues.ComputeHashCode(); + } + /// /// Generates a unique string representation of this. /// /// A string representation of this. public override string ToString() { - return new StringBuilder() - .Append(this.Description) - .Append(this.MinimumExecutionInterval) - .AppendJoin(";", this.Parameters.Select(p => $"{p.Key};{p.Value}")) - .AppendJoin(";", this.Metadata.Select(m => $"{m.Key};{m.Value}")) - .AppendJoin(";", this.Actions) - .AppendJoin(";", this.Dependencies) - .AppendJoin(";", this.Monitors).ToString(); + StringBuilder builder = new StringBuilder().Append($"Description:{this.Description}"); + + if (this.MinimumExecutionInterval != null) + { + builder.Append($"|MinimumExecutionInterval:{this.MinimumExecutionInterval}"); + } + + if (this.Metadata?.Any() == true) + { + builder.Append($"|Metadata:[{string.Join(";", this.Metadata.Select(m => $"{m.Key}={m.Value}"))}]"); + } + + if (this.Parameters?.Any() == true) + { + builder.Append($"|Parameters:[{string.Join(";", this.Parameters.Select(p => $"{p.Key}={p.Value}"))}]"); + } + + if (this.Actions?.Any() == true) + { + builder.Append($"|Actions:[{string.Join(",", this.Actions.Select(a => a.ToString()))}]"); + } + + if (this.Dependencies?.Any() == true) + { + builder.Append($"|Dependencies:[{string.Join(",", this.Dependencies.Select(d => d.ToString()))}]"); + } + + if (this.Monitors?.Any() == true) + { + builder.Append($"|Monitors:[{string.Join(",", this.Monitors.Select(m => m.ToString()))}]"); + } + + return builder.ToString().RemoveWhitespace(); } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileElement.cs b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileElement.cs index 983380d739..33b442d509 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileElement.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileElement.cs @@ -135,16 +135,72 @@ public override int GetHashCode() } /// - /// Generates a unique string representation of this. + /// Generates a unique string representation of this instance + /// + /// + /// Example 1 + /// Type:ParallelExecution,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2 + /// + /// + /// Example 2 + /// Type:ParallelExecution,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2,,,Component:(Type:OpenSslExecutor,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2). + /// + /// + /// Example 3 + /// Type:ParallelExecution,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2,,,Component:(Type:OpenSslExecutor,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2),,,Extension:(Contacts={'email':'example@example.com'}) + /// + /// /// /// A string representation of this. public override string ToString() + { + return this.ToString(includeMetadata: true); + } + + /// + /// Generates a unique string representation of this instance + /// + /// + /// Example 1 + /// Type:ParallelExecution,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2 + /// + /// + /// Example 2 + /// Type:ParallelExecution,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2,,,Component:(Type:OpenSslExecutor,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2). + /// + /// + /// Example 3 + /// Type:ParallelExecution,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2,,,Component:(Type:OpenSslExecutor,,,ComponentType:Action,,,Metadata:Key1=Value1;Key2=Value2,,,Parameters:Key1=Value1;Key2=Value2),,,Extension:(Contacts={'email':'example@example.com'}) + /// + /// + /// + /// A string representation of this. + public string ToString(bool includeMetadata) { StringBuilder builder = new StringBuilder(); - builder.Append(this.Type) - .AppendJoin(";", this.Parameters.Select(p => $"{p.Key};{p.Value}")); + builder.Append($"Type:{this.Type},,,ComponentType:{this.ComponentType}"); + + if (includeMetadata && this.Metadata?.Any() == true) + { + builder.Append($",,,Metadata:[{string.Join(";", this.Metadata.Select(m => $"{m.Key}={m.Value}"))}]"); + } + + if (this.Parameters?.Any() == true) + { + builder.Append($",,,Parameters:[{string.Join(";", this.Parameters.Select(p => $"{p.Key}={p.Value}"))}]"); + } + + if (this.Components?.Any() == true) + { + builder.Append($",,,Components:[{string.Join(";", this.Components.Select(c => $"({c.ToString(includeMetadata)})"))}]"); + } + + if (this.Extensions?.Any() == true) + { + builder.Append($",,,Extensions:[{string.Join(";", this.Extensions.Select(e => $"({e.Key}={e.Value?.ToString()})"))}]"); + } - return builder.ToString(); + return builder.ToString().RemoveWhitespace(); } } } diff --git a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileExtensions.cs b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileExtensions.cs index eba406cb2c..01e5030bb3 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileExtensions.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileExtensions.cs @@ -127,6 +127,27 @@ public static bool IsTargetedScenario(this ExecutionProfileElement element, IEnu return isTargeted; } + /// + /// Merges the set of profiles together into a single profile. This merges profile parameters, metadata, + /// actions, dependencies and monitors. + /// + /// The profiles to merge. + /// A single merged profile containing the contents of all profiles. + public static ExecutionProfile Merge(this IEnumerable profiles) + { + ExecutionProfile profile = profiles.First(); + + if (profiles.Count() > 1) + { + foreach (ExecutionProfile additionalProfile in profiles.Skip(1)) + { + profile = profile.MergeWith(additionalProfile); + } + } + + return profile; + } + /// /// Merges the two profiles together into a single profile. This merges profile parameters, metadata, /// actions, dependencies and monitors. Note that the profile description remains unchanged. diff --git a/src/VirtualClient/VirtualClient.Contracts/Metadata/MetadataContract.cs b/src/VirtualClient/VirtualClient.Contracts/Metadata/MetadataContract.cs index 47f269ff72..bc0856ef8e 100644 --- a/src/VirtualClient/VirtualClient.Contracts/Metadata/MetadataContract.cs +++ b/src/VirtualClient/VirtualClient.Contracts/Metadata/MetadataContract.cs @@ -92,6 +92,11 @@ public class MetadataContract /// internal const string ExecutionProfileDescription = "executionProfileDescription"; + /// + /// A hash code for the execution profile. + /// + internal const string ExecutionProfileHash = "executionProfileHash"; + /// /// The name of the profile that describes the overall execution workflow. /// diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index e63e397d3e..7da2b71cf5 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs @@ -785,7 +785,7 @@ await this.Logger.LogMessageAsync($"{this.TypeName}.Execute", telemetryContext, EventContext cancelContext = telemetryContext.Clone() .AddContext("executionCancelled", true); - this.Logger.LogMessage($"{this.TypeName}.ExecutionCancelled", LogLevel.Warning, cancelContext); + this.Logger.LogMessage($"{this.TypeName}.ExecutionCancelled", LogLevel.Information, cancelContext); } } catch (Exception) diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs index f7de94adef..4ddc2eb042 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs @@ -884,7 +884,7 @@ public async Task ProfileExecutorLogsExplicitTelemetryWhenExperimentTimeoutIsRea Assert.IsTrue(explicitTimeout.IsTimedOut); - var timeoutMessages = this.mockFixture.Logger.MessagesLogged("ProfileExecutor.ExperimentTimeoutReached"); + var timeoutMessages = this.mockFixture.Logger.MessagesLogged("ProfileExecutor.ExecutionTimeout"); Assert.IsNotEmpty(timeoutMessages); Assert.AreEqual(1, timeoutMessages.Count()); diff --git a/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs b/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs index 11dcf1a7b9..f6037d924e 100644 --- a/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs +++ b/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs @@ -268,14 +268,14 @@ public async Task ExecuteAsync(ProfileTiming timing, CancellationToken cancellat } // If we timeout or a reboot is requested, we will request all background processes to cancel/exit. - if (timing.IsTimedOut) + if (timing.Timeout != null && timing.IsTimedOut) { EventContext timeoutContext = EventContext.Persisted() .AddContext("timeout", timing.Duration) .AddContext("timeoutTimestamp", timing.Timeout); - this.Logger.LogMessage($"{nameof(ProfileExecutor)}.ExperimentTimeoutReached", LogLevel.Warning, timeoutContext); - ConsoleLogger.Default.LogWarning("Profile: Experiment timeout reached. Canceling in-progress workloads."); + this.Logger.LogMessage($"{nameof(ProfileExecutor)}.ExecutionTimeout", LogLevel.Information, timeoutContext); + ConsoleLogger.Default.LogWarning("Profile: Execution timeout reached."); } await tokenSource.CancelAsync(); diff --git a/src/VirtualClient/VirtualClient.Main/ConvertProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ConvertProfileCommand.cs index 40ed9c4200..5ed34b84bb 100644 --- a/src/VirtualClient/VirtualClient.Main/ConvertProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ConvertProfileCommand.cs @@ -69,7 +69,7 @@ protected override async Task ExecuteAsync(string[] args, IServiceCollectio foreach (string filePath in profiles) { string profileName = Path.GetFileName(filePath); - ExecutionProfile profile = await this.ReadExecutionProfileAsync(filePath, dependencies, cancellationToken); + ExecutionProfile profile = await this.InitializeProfileAsync(filePath, dependencies, cancellationToken); if (profile.ProfileFormat == "JSON") { diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index aef26e512e..26796c1de3 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -9,7 +9,9 @@ namespace VirtualClient using System.IO; using System.IO.Abstractions; using System.Linq; + using System.Numerics; using System.Runtime.InteropServices; + using System.Security.Cryptography; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -452,52 +454,9 @@ protected override IServiceCollection InitializeDependencies(string[] args, Plat } /// - /// Initializes the profile that will be executed. + /// Initializes the execution profile from the file path provided. /// - protected async Task InitializeProfilesAsync(IEnumerable profiles, IServiceCollection dependencies, CancellationToken cancellationToken) - { - List allProfiles = new List(); - ExecutionProfile profile = await this.ReadExecutionProfileAsync(profiles.First(), dependencies, cancellationToken) - .ConfigureAwait(false); - - await this.InitializeProfileAsync(profile, dependencies); - - if (profiles.Count() > 1) - { - foreach (string additionalProfile in profiles.Skip(1)) - { - ExecutionProfile otherProfile = await this.ReadExecutionProfileAsync(additionalProfile, dependencies, cancellationToken) - .ConfigureAwait(false); - - await this.InitializeProfileAsync(otherProfile, dependencies); - profile = profile.MergeWith(otherProfile); - } - } - - ISystemManagement systemManagement = dependencies.GetService(); - - // Adding file upload monitoring if the user has supplied a content store or Proxy Api Uri. - if (this.ContentStore != null || this.ProxyApiUri != null) - { - string fileUploadMonitorProfilePath = systemManagement.PlatformSpecifics.GetProfilePath(ExecuteProfileCommand.FileUploadMonitorProfile); - ExecutionProfile fileUploadMonitorProfile = await this.ReadExecutionProfileAsync(fileUploadMonitorProfilePath, dependencies, cancellationToken) - .ConfigureAwait(false); - - await this.InitializeProfileAsync(fileUploadMonitorProfile, dependencies); - profile = profile.MergeWith(fileUploadMonitorProfile); - } - - MetadataContract.Persist( - profile.Metadata.Keys.ToDictionary(key => key, entry => profile.Metadata[entry] as object).ObscureSecrets(), - MetadataContract.DefaultCategory); - - return profile; - } - - /// - /// Loads/reads the execution profile file provided to the Virtual Client on the command line. - /// - protected async Task ReadExecutionProfileAsync(string path, IServiceCollection dependencies, CancellationToken cancellationToken) + protected async Task InitializeProfileAsync(string path, IServiceCollection dependencies, CancellationToken cancellationToken) { // string profilePath = path; ExecutionProfile profile = null; @@ -525,11 +484,38 @@ protected async Task ReadExecutionProfileAsync(string path, IS profile = new ExecutionProfile(profileShim); profile.ProfileFormat = "YAML"; } + + await this.InitializeProfileAsync(profile, dependencies); } return profile; } + /// + /// Loads/reads the execution profile files provided to the Virtual Client on the command line. + /// + protected async Task> InitializeProfilesAsync(IEnumerable profilePaths, IServiceCollection dependencies, CancellationToken cancellationToken) + { + List profiles = new List(); + foreach (string path in profilePaths) + { + ExecutionProfile profile = await this.InitializeProfileAsync(path, dependencies, cancellationToken); + profiles.Add(new ExecutionProfileDescriptor(path, profile)); + } + + ISystemManagement systemManagement = dependencies.GetService(); + + // Adding file upload monitoring if the user has supplied a content store or Proxy Api Uri. + if (this.ContentStore != null || this.ProxyApiUri != null) + { + string fileUploadMonitorProfilePath = systemManagement.PlatformSpecifics.GetProfilePath(ExecuteProfileCommand.FileUploadMonitorProfile); + ExecutionProfile fileUploadMonitorProfile = await this.InitializeProfileAsync(fileUploadMonitorProfilePath, dependencies, cancellationToken); + profiles.Add(new ExecutionProfileDescriptor(ExecuteProfileCommand.FileUploadMonitorProfile, fileUploadMonitorProfile)); + } + + return profiles; + } + /// /// Initializes the global/persistent telemetry properties that will be included /// with all telemetry emitted from the Virtual Client. @@ -565,13 +551,20 @@ protected override void SetGlobalTelemetryProperties(string[] args) /// Initializes the global/persistent telemetry properties that will be included /// with all telemetry emitted from the Virtual Client. /// - protected void SetGlobalTelemetryProperties(ExecutionProfile profile) + protected void SetGlobalTelemetryProperties(IEnumerable profileDescriptors) { // Additional persistent/global telemetry properties in addition to the ones // added on application startup. EventContext.PersistentProperties.AddRange(new Dictionary { - [MetadataContract.ExecutionProfileDescription] = profile.Description + [MetadataContract.ExecutionProfileDescription] = profileDescriptors.First().Profile.Description, + + // e.g. + // [ + // "PERF-CPU-OPENSSL,1379108519360051496811242709812062010091621048031", + // "MONITORS-STANDARD,871556463443511354564085041804477113899614667690" + // ] + [MetadataContract.ExecutionProfileHash] = profileDescriptors.Select(d => $"{d.ProfileName},{d.Profile.GetPredictableHashCode(includeMetadata: false)}").ToArray() }); } @@ -647,7 +640,7 @@ private async Task DiscoverExtensionsAsync(IPackageManager p return await packageManager.DiscoverExtensionsAsync(cancellationToken); } - private async Task ExecuteProfileDependenciesInstallationAsync(IEnumerable profiles, IServiceCollection dependencies, CancellationTokenSource cancellationTokenSource) + private async Task ExecuteProfileDependenciesInstallationAsync(IEnumerable profilePaths, IServiceCollection dependencies, CancellationTokenSource cancellationTokenSource) { CancellationToken cancellationToken = cancellationTokenSource.Token; IFileSystem fileSystem = dependencies.GetService(); @@ -658,8 +651,12 @@ private async Task ExecuteProfileDependenciesInstallationAsync(IEnumerable profileDescriptors = await this.InitializeProfilesAsync(profilePaths, dependencies, cancellationToken); + ExecutionProfile profile = profileDescriptors.Select(d => d.Profile).Merge(); + this.SetGlobalTelemetryProperties(profileDescriptors); + + // Refresh the telemetry context. + telemetryContext = EventContext.Persisted(); telemetryContext.AddContext("executionProfileActions", profile.Actions?.Select(d => new { @@ -679,8 +676,6 @@ private async Task ExecuteProfileDependenciesInstallationAsync(IEnumerable(this.Layout); @@ -710,18 +705,16 @@ private async Task ExecuteProfileDependenciesInstallationAsync(IEnumerable profiles, IServiceCollection dependencies, CancellationTokenSource cancellationTokenSource) + private async Task ExecuteProfileAsync(IEnumerable profilePaths, IServiceCollection dependencies, CancellationTokenSource cancellationTokenSource) { CancellationToken cancellationToken = cancellationTokenSource.Token; IFileSystem fileSystem = dependencies.GetService(); @@ -732,8 +725,12 @@ private async Task ExecuteProfileAsync(IEnumerable profiles, IServiceCol // The user can supply more than 1 profile on the command line. The individual profiles will be merged // into a single profile for execution. - ExecutionProfile profile = await this.InitializeProfilesAsync(profiles, dependencies, cancellationToken) - .ConfigureAwait(false); + IEnumerable profileDescriptors = await this.InitializeProfilesAsync(profilePaths, dependencies, cancellationToken); + ExecutionProfile profile = profileDescriptors.Select(d => d.Profile).Merge(); + this.SetGlobalTelemetryProperties(profileDescriptors); + + // Refresh the telemetry context. + telemetryContext = EventContext.Persisted(); telemetryContext.AddContext("executionProfileActions", profile.Actions?.Select(d => new { @@ -764,8 +761,6 @@ private async Task ExecuteProfileAsync(IEnumerable profiles, IServiceCol } } - this.SetGlobalTelemetryProperties(profile); - if (this.Layout != null) { dependencies.AddSingleton(this.Layout); @@ -955,5 +950,21 @@ private void Validate(IServiceCollection dependencies, ExecutionProfile profile) "iterations (e.g. --iterations) is not supported."); } } + + internal class ExecutionProfileDescriptor + { + public ExecutionProfileDescriptor(string profileName, ExecutionProfile profile) + { + profileName.ThrowIfNullOrWhiteSpace(nameof(profileName)); + profile.ThrowIfNull(nameof(profile)); + + this.ProfileName = Path.GetFileNameWithoutExtension(profileName); + this.Profile = profile; + } + + public string ProfileName { get; } + + public ExecutionProfile Profile { get; } + } } } diff --git a/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs index e69672c718..8f6e184815 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs @@ -158,8 +158,8 @@ public async Task RunProfileCommandCreatesTheExpectedProfile_DefaultScenario() .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(defaultMonitorProfile)), It.IsAny())) .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, defaultMonitorProfile))); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profiles,this.mockFixture.Dependencies, CancellationToken.None) - .ConfigureAwait(false); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.AreEqual(2, profile.Actions.Count); Assert.AreEqual(1, profile.Dependencies.Count); @@ -193,8 +193,8 @@ public async Task RunProfileCommandAddsTheExpectedMetadataToProfile() .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(defaultMonitorProfile)), It.IsAny())) .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, defaultMonitorProfile))); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None) - .ConfigureAwait(false); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); bool isCommandMetadataSubset = this.command.Metadata.All(kvp => profile.Metadata.TryGetValue(kvp.Key, out var value) && value.Equals(kvp.Value)); @@ -225,8 +225,8 @@ public async Task RunProfileCommandAddsTheExpectedParametersToProfile() .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(defaultMonitorProfile)), It.IsAny())) .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, defaultMonitorProfile))); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None) - .ConfigureAwait(false); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); bool isCommandParametersSubset = this.command.Parameters.All(kvp => profile.Parameters.TryGetValue(kvp.Key, out var value) && value.Equals(kvp.Value)); @@ -259,8 +259,8 @@ public async Task RunProfileCommandCreatesTheExpectedProfile_DefaultMonitorProfi .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(defaultMonitorProfile)), It.IsAny())) .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, defaultMonitorProfile))); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None) - .ConfigureAwait(false); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsTrue(profile.Actions.Any()); Assert.IsTrue(profile.Actions.Count == 2); @@ -289,8 +289,8 @@ public async Task RunProfileCommandCreatesTheExpectedProfile_ProfileWithActionsD .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(profile1)), It.IsAny())) .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, profile1))); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None) - .ConfigureAwait(false); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsTrue(profile.Actions.Any()); Assert.IsTrue(profile.Actions.Count == 2); @@ -332,8 +332,8 @@ public async Task RunProfileCommandCreatesTheExpectedProfile_ProfilesWithMonitor .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(defaultMonitorProfile)), It.IsAny())) .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, defaultMonitorProfile))); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None) - .ConfigureAwait(false); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsFalse(profile.Actions.Any()); Assert.IsTrue(profile.Dependencies.Any()); @@ -361,7 +361,8 @@ public async Task RunProfileCommandSupportsParametersOnListInProfileNoConditions }; IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsFalse((bool)profile.Parameters["Parameter1"]); Assert.AreEqual("base1", profile.Parameters["Parameter2"].ToString()); @@ -393,7 +394,8 @@ public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario1_F }; IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsFalse((bool)profile.Parameters["Parameter1"]); Assert.AreEqual("base2", profile.Parameters["Parameter2"].ToString()); @@ -425,7 +427,8 @@ public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario2_S }; IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsTrue((bool)profile.Parameters["Parameter1"]); Assert.AreEqual("base3", profile.Parameters["Parameter2"].ToString()); @@ -457,7 +460,8 @@ public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario3_T }; IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsFalse((bool)profile.Parameters["Parameter1"]); Assert.AreEqual("base4", profile.Parameters["Parameter2"].ToString()); @@ -490,7 +494,8 @@ public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario4_P }; IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); - ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + ExecutionProfile profile = (await this.command.InitializeProfilesAsync(profiles, this.mockFixture.Dependencies, CancellationToken.None)) + .Select(d => d.Profile).Merge(); Assert.IsFalse((bool)profile.Parameters["Parameter1"]); Assert.AreEqual("base4", profile.Parameters["Parameter2"].ToString()); @@ -520,7 +525,7 @@ private class TestRunProfileCommand : ExecuteProfileCommand return base.EvaluateProfilesAsync(dependencies, initialize, cancellationToken); } - public new Task InitializeProfilesAsync(IEnumerable profiles, IServiceCollection dependencies, CancellationToken cancellationToken) + public new Task> InitializeProfilesAsync(IEnumerable profiles, IServiceCollection dependencies, CancellationToken cancellationToken) { return base.InitializeProfilesAsync(profiles, dependencies, cancellationToken); }