diff --git a/src/VirtualClient/VirtualClient.Common/Contracts/ParameterDictionaryListJsonConverter.cs b/src/VirtualClient/VirtualClient.Common/Contracts/ParameterDictionaryListJsonConverter.cs new file mode 100644 index 0000000000..8036861610 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/Contracts/ParameterDictionaryListJsonConverter.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.Contracts +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + using VirtualClient.Common.Extensions; + + /// + /// Provides a JSON converter that can handle the serialization/deserialization of + /// objects where T is with string keys and values. + /// + public class ParameterDictionaryListJsonConverter : JsonConverter + { + private static readonly Type ParameterDictionaryListType = typeof(List>); + private static readonly ParameterDictionaryJsonConverter DictionaryConverter = new ParameterDictionaryJsonConverter(); + + /// + /// Returns true/false whether the object type is supported for JSON serialization/deserialization. + /// + /// The type of object to serialize/deserialize. + /// + /// True if the object is supported, false if not. + /// + public override bool CanConvert(Type objectType) + { + return objectType == ParameterDictionaryListType; + } + + /// + /// Reads the JSON text from the reader and converts it into a of + /// object instance. + /// + /// Contains the JSON text defining the list of dictionaries object. + /// The type of object (in practice this will only be a list of dictionaries type). + /// Unused. + /// Unused. + /// + /// A deserialized list of dictionaries object converted from JSON text. + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader == null) + { + throw new ArgumentException("The reader parameter is required.", nameof(reader)); + } + + List> list = new List>(); + if (reader.TokenType == JsonToken.StartArray) + { + JArray array = JArray.Load(reader); + foreach (JToken item in array) + { + if (item.Type == JTokenType.Object) + { + IDictionary dictionary = new Dictionary(); + ReadDictionaryEntries(item, dictionary); + list.Add(dictionary); + } + } + } + + return list; + } + + /// + /// Writes a list of dictionaries object to JSON text. + /// + /// Handles the writing of the JSON text. + /// The list of dictionaries object to serialize to JSON text. + /// The JSON serializer handling the serialization to JSON text. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.ThrowIfNull(nameof(writer)); + serializer.ThrowIfNull(nameof(serializer)); + + List> list = value as List>; + if (list != null) + { + writer.WriteStartArray(); + foreach (var dictionary in list) + { + WriteDictionaryEntries(writer, dictionary, serializer); + } + + writer.WriteEndArray(); + } + } + + private static void ReadDictionaryEntries(JToken jsonObject, IDictionary dictionary) + { + IEnumerable children = jsonObject.Children(); + if (children.Any()) + { + foreach (JToken child in children) + { + if (child.Type == JTokenType.Property) + { + if (child.First != null) + { + JValue propertyValue = child.First as JValue; + IConvertible settingValue = propertyValue?.Value as IConvertible; + + // JSON properties that have periods (.) in them will have a path representation + // like this: ['this.is.a.path']. We have to account for that when adding the key + // to the dictionary. The key we want to add is 'this.is.a.path' + string key = child.Path; + int lastDotIndex = key.LastIndexOf('.'); + if (lastDotIndex >= 0) + { + key = key.Substring(lastDotIndex + 1); + } + + dictionary.Add(key, settingValue); + } + } + } + } + } + + private static void WriteDictionaryEntries(JsonWriter writer, IDictionary dictionary, JsonSerializer serializer) + { + writer.WriteStartObject(); + if (dictionary.Count > 0) + { + foreach (KeyValuePair entry in dictionary) + { + writer.WritePropertyName(entry.Key); + serializer.Serialize(writer, entry.Value); + } + } + + writer.WriteEndObject(); + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs index 18e94697c3..f6a4b74d76 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfile.cs @@ -35,7 +35,8 @@ public ExecutionProfile( IEnumerable dependencies, IEnumerable monitors, IDictionary metadata, - IDictionary parameters) + IDictionary parameters, + List> parametersOn = null) { description.ThrowIfNullOrWhiteSpace(nameof(description)); @@ -62,6 +63,10 @@ public ExecutionProfile( ? new Dictionary(parameters, StringComparer.OrdinalIgnoreCase) : new Dictionary(StringComparer.OrdinalIgnoreCase); + this.ParametersOn = parametersOn != null + ? parametersOn + : new List>(); + if (this.Actions?.Any() == true) { this.Actions.ForEach(action => action.ComponentType = ComponentType.Action); @@ -90,7 +95,8 @@ public ExecutionProfile(ExecutionProfile other) other?.Dependencies, other?.Monitors, other?.Metadata, - other?.Parameters) + other?.Parameters, + other?.ParametersOn) { } @@ -106,7 +112,8 @@ public ExecutionProfile(ExecutionProfileYamlShim other) other?.Dependencies?.Select(d => new ExecutionProfileElement(d)), other?.Monitors?.Select(m => new ExecutionProfileElement(m)), other?.Metadata, - other?.Parameters) + other?.Parameters, + other?.ParametersOn) { } @@ -148,6 +155,13 @@ public ExecutionProfile(ExecutionProfileYamlShim other) [JsonConverter(typeof(ParameterDictionaryJsonConverter))] public IDictionary Parameters { get; } + /// + /// List of parameter dictionaries that are associated with the profile. + /// + [JsonProperty(PropertyName = "ParametersOn", Required = Required.Default, Order = 75)] + [JsonConverter(typeof(ParameterDictionaryListJsonConverter))] + public List> ParametersOn { get; } + /// /// Workload actions to run as part of the profile execution. /// diff --git a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileYamlShim.cs b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileYamlShim.cs index 47671baac6..7b96e44f73 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileYamlShim.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ExecutionProfileYamlShim.cs @@ -30,6 +30,7 @@ public ExecutionProfileYamlShim() this.Monitors = new List(); this.Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); this.Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + this.ParametersOn = new List>(); } /// @@ -68,6 +69,11 @@ public ExecutionProfileYamlShim(ExecutionProfile other) this.Parameters.AddRange(other.Parameters); } + if (other?.ParametersOn?.Any() == true) + { + this.ParametersOn.AddRange(other.ParametersOn); + } + if (this.Actions?.Any() == true) { this.Actions.ForEach(action => action.ComponentType = ComponentType.Action); @@ -108,6 +114,12 @@ public ExecutionProfileYamlShim(ExecutionProfile other) [YamlMember(Alias = "parameters", Order = 20, ScalarStyle = ScalarStyle.Plain)] public IDictionary Parameters { get; set; } + /// + /// Collection of parameters that are associated with the profile. + /// + [YamlMember(Alias = "parameters_on", Order = 25, ScalarStyle = ScalarStyle.Plain)] + public List> ParametersOn { get; set; } + /// /// Workload actions to run as part of the profile execution. /// diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExpressionEvaluatorTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExpressionEvaluatorTests.cs index 113abaf562..1524c9dc36 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExpressionEvaluatorTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExpressionEvaluatorTests.cs @@ -428,6 +428,29 @@ public async Task ProfileExpressionEvaluatorSupportsPlatformReferencesOnWindowsS } } + [Test] + [TestCase(PlatformID.Unix, Architecture.Arm64, "arm64")] + [TestCase(PlatformID.Unix, Architecture.X64, "x64")] + [TestCase(PlatformID.Win32NT, Architecture.Arm64, "arm64")] + [TestCase(PlatformID.Win32NT, Architecture.X64, "x64")] + public async Task ProfileExpressionEvaluatorSupportsArchitectureReferences(PlatformID platform, Architecture architecture, string expectedValue) + { + this.SetupDefaults(platform, architecture); + + Dictionary expressions = new Dictionary + { + { "{Architecture}", expectedValue }, + { "--arch={Architecture}", $"--arch={expectedValue}" } + }; + + foreach (var entry in expressions) + { + string expectedExpression = entry.Value; + string actualExpression = await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, entry.Key); + Assert.AreEqual(expectedExpression, actualExpression); + } + } + [Test] public async Task ProfileExpressionEvaluatorHandlesCasesWherePlatformSpecificPackagePathAndPlatformAreUsedTogether() { @@ -923,14 +946,14 @@ public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInP // {calculate(calculate(512 / (4 / 2)) ? "Yes" : "No")} Dictionary parameters = new Dictionary { - { "BUILD_TLS", "{calculate({IsTLSEnabled} ? \"yes\" : \"no\" )}" }, - { "IsTLSEnabled" , true } + { "BUILD_TLS", "{calculate({BindToCores} ? \"yes\" : \"no\" )}" }, + { "BindToCores" , true }, }; await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); Assert.AreEqual("yes", parameters["BUILD_TLS"]); - Assert.AreEqual(true, parameters["IsTLSEnabled"]); + Assert.AreEqual(true, parameters["BindToCores"]); } [Test] @@ -1025,6 +1048,110 @@ public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInP Assert.AreEqual(true, parameters["IsTLSEnabled"]); } + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_7() + { + this.SetupDefaults(PlatformID.Unix); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate(\"{PerformanceLibraryVersion}\" == \"25.01\")} ? \"Yes\" : \"No\")}" }, + { "PerformanceLibraryVersion", "25.01" } + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("Yes", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_8() + { + this.SetupDefaults(PlatformID.Unix); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate(\"{PerformanceLibraryVersion}\" == \"25.01\")} ? \"Yes\" : \"No\")}" }, + { "PerformanceLibraryVersion", "25.02" } + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("No", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_9() + { + this.SetupDefaults(PlatformID.Unix); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate((\"{PerformanceLibraryVersion}\" == \"25.01\") && (\"{platform}\" == \"linux-x64\"))} ? \"Yes\" : \"No\")}" }, + { "PerformanceLibraryVersion", "25.01" } + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("Yes", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_10() + { + this.SetupDefaults(PlatformID.Unix); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate((\"{PerformanceLibraryVersion}\" == \"25.01\") && (\"{platform}\" == \"linux-x64\"))} ? \"Yes\" : \"No\")}" }, + { "PerformanceLibraryVersion", "25.02" } + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("No", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_11() + { + this.SetupDefaults(PlatformID.Unix, Architecture.X64); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate(\"{specProfile}\" == \"fprate\")} ? \"-O3 -flto -march=native\" : {calculate({calculate(\"{specProfile}\" == \"intrate\")} ? {calculate({calculate(\"{Architecture}\" == \"x64\")} ? \"-O2 -flto -march=core-avx2\" : \"-O2 -flto -march=armv8.2-a\")} : \"-O3 -march=native\")})}" }, + { "specProfile", "fprate" }, + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("-O3 -flto -march=native", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_12() + { + this.SetupDefaults(PlatformID.Unix, Architecture.X64); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate(\"{specProfile}\" == \"fprate\")} ? \"-O3 -flto -march=native\" : {calculate({calculate(\"{specProfile}\" == \"intrate\")} ? {calculate({calculate(\"{Architecture}\" == \"x64\")} ? \"-O2 -flto -march=core-avx2\" : \"-O2 -flto -march=armv8.2-a\")} : \"-O3 -march=native\")})}" }, + { "specProfile", "intrate" }, + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("-O2 -flto -march=core-avx2", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_13() + { + this.SetupDefaults(PlatformID.Unix, Architecture.Arm64); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate(\"{specProfile}\" == \"fprate\")} ? \"-O3 -flto -march=native\" : {calculate({calculate(\"{specProfile}\" == \"intrate\")} ? {calculate({calculate(\"{Architecture}\" == \"x64\")} ? \"-O2 -flto -march=core-avx2\" : \"-O2 -flto -march=armv8.2-a\")} : \"-O3 -march=native\")})}" }, + { "specProfile", "intrate" }, + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("-O2 -flto -march=armv8.2-a", parameters["Flags"]); + } + + [Test] + public async Task ProfileExpressionEvaluatorSupportsTernaryFunctionReferencesInParameterSets_Scenario_14() + { + this.SetupDefaults(PlatformID.Unix, Architecture.Arm64); + Dictionary parameters = new Dictionary + { + { "Flags", "{calculate({calculate(\"{specProfile}\" == \"fprate\")} ? \"-O3 -flto -march=native\" : {calculate({calculate(\"{specProfile}\" == \"intrate\")} ? {calculate({calculate(\"{Architecture}\" == \"x64\")} ? \"-O2 -flto -march=core-avx2\" : \"-O2 -flto -march=armv8.2-a\")} : \"-O3 -march=native\")})}" }, + { "specProfile", "special" }, + }; + await ProfileExpressionEvaluator.Instance.EvaluateAsync(this.mockFixture.Dependencies, parameters); + Assert.AreEqual("-O3 -march=native", parameters["Flags"]); + } + [Test] public async Task ProfileExpressionEvaluatorSupportsFunctionReferencesInParameterSets_Scenario_1() { diff --git a/src/VirtualClient/VirtualClient.Core/ProfileExpressionEvaluator.cs b/src/VirtualClient/VirtualClient.Core/ProfileExpressionEvaluator.cs index c93899f79d..a1ec937727 100644 --- a/src/VirtualClient/VirtualClient.Core/ProfileExpressionEvaluator.cs +++ b/src/VirtualClient/VirtualClient.Core/ProfileExpressionEvaluator.cs @@ -6,6 +6,7 @@ namespace VirtualClient using System; using System.Collections.Generic; using System.Linq; + using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -36,6 +37,13 @@ public class ProfileExpressionEvaluator : IExpressionEvaluator @"\{calculate\(\s*((?:\d{1,2}(?:\.\d{2})?:\d{2}:\d{2}\s*[\+\-]\s*)*\d{1,2}(?:\.\d{2})?:\d{2}:\d{2})\s*\)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + // e.g. + // {calculate(False ? "Yes" : {calculate(True ? "Yes2" : "No")})} + // {calculate(False ? {calculate(True ? "Yes2" : "No")} : "No")} + private static readonly Regex CalculateNestedTernaryExpression = new Regex( + @"\{calculate\(((?:[^?{}]|\{(?:[^{}]|\{[^{}]*\})*\})+)\s*\?\s*((?:[^:{}]|\{(?:[^{}]|\{[^{}]*\})*\})+)\s*:\s*((?:[^){}]|\{(?:[^{}]|\{[^{}]*\})*\})+)\)\}", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + // e.g. // {calculate({IsTLSEnabled} ? "Yes" : "No")} // (([^?]+)\s*\?\s*([^:]+)\s*:\s*([^)]+)) @@ -48,7 +56,15 @@ public class ProfileExpressionEvaluator : IExpressionEvaluator // Expression: {calculate(512 > 2)} // Expression: {calculate(512 != {LogicalCoreCount})} private static readonly Regex CalculateComparisionExpression = new Regex( - @"\{calculate\((\d+\s*(?:==|!=|<|>|<=|>=|&&|\|\|)\s*\d+)\)\}", + @"\{calculate\(((?:\d+|""[^""]*""|\{[^}]+\})\s*(?:==|!=|<|>|<=|>=|&&|\|\|)\s*(?:\d+|""[^""]*""|\{[^}]+\}))\)\}", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // e.g. + // Expression: {calculate({IsServer} && {IsEnabled})} + // Expression: {calculate({IsServer} && ({CoreCount} > 4))} + // Expression: {calculate(({CoreCount} > 4) && ({MemoryGB} > 8))} + private static readonly Regex CalculateLogicalExpression = new Regex( + @"\{calculate\(((?:[^&\|\(\){}]|\([^\)]*\)|\{[^}]+\})+\s*(?:&&|\|\|)\s*(?:[^&\|\(\){}]|\([^\)]*\)|\{[^}]+\})+)\)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); // e.g. @@ -81,6 +97,12 @@ public class ProfileExpressionEvaluator : IExpressionEvaluator @"\{Platform\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + // e.g. + // {Architecture} + private static readonly Regex ArchitectureExpression = new Regex( + @"\{Architecture\}", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + // e.g. // {LogicalCoreCount} private static readonly Regex LogicalCoreCountExpression = new Regex( @@ -188,6 +210,35 @@ public class ProfileExpressionEvaluator : IExpressionEvaluator Outcome = evaluatedExpression }); }), + // Expression: {Architecture} + // Resolves to the current CPU architecture (e.g. Arm64, X64) + new Func, string, Task>((dependencies, parameters, expression) => + { + bool isMatched = false; + string evaluatedExpression = expression; + MatchCollection matches = ProfileExpressionEvaluator.ArchitectureExpression.Matches(expression); + + if (matches?.Any() == true) + { + isMatched = true; + ISystemManagement systemManagement = dependencies.GetService(); + Architecture architecture = systemManagement.CpuArchitecture; + + foreach (Match match in matches) + { + evaluatedExpression = Regex.Replace( + evaluatedExpression, + match.Value, + architecture.ToString().ToLower()); + } + } + + return Task.FromResult(new EvaluationResult + { + IsMatched = isMatched, + Outcome = evaluatedExpression + }); + }), // Expression: {PackagePath:xyz} // Resolves to the path to the package folder location (e.g. /home/users/virtualclient/packages/redis). new Func, string, Task>(async (dependencies, parameters, expression) => @@ -572,6 +623,99 @@ public class ProfileExpressionEvaluator : IExpressionEvaluator Outcome = evaluatedExpression }; }), + // Expression: {calculate({IsServer} && {IsEnabled})} + // Expression: {calculate({IsServer} && ({CoreCount} > 4))} + // **IMPORTANT** + // This expression evaluation must come before the comparison expression evaluator. + new Func, string, Task>(async (dependencies, parameters, expression) => + { + bool isMatched = false; + string evaluatedExpression = expression; + MatchCollection matches = ProfileExpressionEvaluator.CalculateLogicalExpression.Matches(expression); + + if (matches?.Any() == true) + { + isMatched = true; + foreach (Match match in matches) + { + string function = match.Groups[1].Value; + // Preprocess the function to handle variable references + function = PreprocessComparisonExpression(function); + + // Evaluate the logical expression + bool result = await Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.EvaluateAsync(function); + evaluatedExpression = evaluatedExpression.Replace(match.Value, result.ToString()); + } + } + + return new EvaluationResult + { + IsMatched = isMatched, + Outcome = evaluatedExpression + }; + }), + // Expression: {calculate(False ? "Yes" : {calculate(True ? "Yes2" : "No")})} + // Expression: {calculate(False ? {calculate(True ? "Yes2" : "No")} : "No")} + // **IMPORTANT** + // This expression evaluation MUST come after the standard ternary expression evaluator. + new Func, string, Task>(async (dependencies, parameters, expression) => + { + bool isMatched = false; + string evaluatedExpression = expression; + MatchCollection matches = ProfileExpressionEvaluator.CalculateNestedTernaryExpression.Matches(expression); + + if (matches?.Any() == true) + { + isMatched = true; + foreach (Match match in matches) + { + string function = match.Groups[1].Value; + string trueExpression = match.Groups[2].Value; + string falseExpression = match.Groups[3].Value; + + // First, we need to pre-evaluate any nested calculate expressions inside the condition, + // true branch, or false branch + EvaluationResult conditionEvaluation = await EvaluateExpressionAsync(dependencies, parameters, function, CancellationToken.None); + function = conditionEvaluation.Outcome; + + EvaluationResult trueEvaluation = await EvaluateExpressionAsync(dependencies, parameters, trueExpression, CancellationToken.None); + trueExpression = trueEvaluation.Outcome; + + EvaluationResult falseEvaluation = await EvaluateExpressionAsync(dependencies, parameters, falseExpression, CancellationToken.None); + falseExpression = falseEvaluation.Outcome; + + // Now construct the full ternary expression with evaluated parts + // Make sure strings are properly quoted for C# script evaluation + string ternaryExpression = string.Empty; + + // Check if the expressions already contain quotes + bool trueIsQuoted = trueExpression.Trim().StartsWith("\"") && trueExpression.Trim().EndsWith("\""); + bool falseIsQuoted = falseExpression.Trim().StartsWith("\"") && falseExpression.Trim().EndsWith("\""); + + // Add quotes if they don't already exist + string quotedTrueExpression = trueIsQuoted ? trueExpression : $"\"{trueExpression.Trim()}\""; + string quotedFalseExpression = falseIsQuoted ? falseExpression : $"\"{falseExpression.Trim()}\""; + + ternaryExpression = $"{function} ? {quotedTrueExpression} : {quotedFalseExpression}"; + + // Convert "True" and "False" to lowercase for C# script evaluation + ternaryExpression = Regex.Replace(ternaryExpression, @"(?<=\b)(True|False)(?=\s*\?)", m => + { + return m.Value.ToLower(); + }); + + // Evaluate the ternary expression + string result = await Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.EvaluateAsync(ternaryExpression); + evaluatedExpression = evaluatedExpression.Replace(match.Value, result.ToString()); + } + } + + return new EvaluationResult + { + IsMatched = isMatched, + Outcome = evaluatedExpression + }; + }), // Expression: {calculate({IsTLSEnabled} ? "Yes" : "No")} // Expression: {calculate(calculate(512 == 2) ? "Yes" : "No")} // **IMPORTANT** @@ -825,6 +969,33 @@ private static async Task EvaluateWellKnownExpressionsAsync(IServiceCollec return expressionsFound; } + private static string PreprocessComparisonExpression(string function) + { + // Find variables in braces and replace with their proper string representation + var variablePattern = new Regex(@"\{([^}]+)\}"); + + // For each variable found, determine if it should be treated as a string or a boolean value + return variablePattern.Replace(function, match => + { + string value = match.Value; + bool isBooleanContext = false; + + // Check if this variable is used in a boolean context (directly next to && or ||) + if (value.Contains("true", StringComparison.OrdinalIgnoreCase) || + value.Contains("false", StringComparison.OrdinalIgnoreCase) || + function.Contains($"{value} &&") || + function.Contains($"&& {value}") || + function.Contains($"{value} ||") || + function.Contains($"|| {value}")) + { + isBooleanContext = true; + } + + // If in boolean context, don't add quotes + return isBooleanContext ? value : $"\"{value}\""; + }); + } + private class EvaluationResult { public bool IsMatched { get; set; } diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index 93581cf9b5..954e201242 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -3,8 +3,15 @@ namespace VirtualClient { + using Azure.Storage.Blobs; + using Microsoft.CodeAnalysis; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + using Polly; using System; using System.Collections.Generic; + using System.ComponentModel; using System.Globalization; using System.IO; using System.IO.Abstractions; @@ -15,12 +22,6 @@ namespace VirtualClient using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; - using Azure.Storage.Blobs; - using Microsoft.CodeAnalysis; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - using Newtonsoft.Json; - using Polly; using VirtualClient.Common; using VirtualClient.Common.Contracts; using VirtualClient.Common.Extensions; @@ -382,7 +383,7 @@ protected async Task InitializeProfilesAsync(IEnumerable 1) { @@ -391,7 +392,7 @@ protected async Task InitializeProfilesAsync(IEnumerable InitializeProfilesAsync(IEnumerable + /// Processes conditional parameter sets in the profile's ParametersOn property. + /// + /// The execution profile to process. + /// The service dependencies. + /// A task that represents the asynchronous operation. + private async Task ProcessParametersOnAsync(ExecutionProfile profile, IServiceCollection dependencies) + { + try + { + if (profile.ParametersOn?.Any() == true && dependencies.TryGetService(out IExpressionEvaluator evaluator)) + { + ILogger logger = dependencies.GetService(); + EventContext telemetryContext = EventContext.Persisted(); + + // Add telemetry context for debugging + telemetryContext.AddContext("profileDescription", profile.Description); + telemetryContext.AddContext("parametersOnCount", profile.ParametersOn.Count); + + logger?.LogMessage($"{nameof(ExecuteProfileCommand)}.ProcessParametersOn.Starting", telemetryContext); + + // Store all condition keys for cleanup later + List conditionKeys = new List(); + + // Iterate through each conditional parameter set + for (int i = 0; i < profile.ParametersOn.Count; i++) + { + var parametersOn = profile.ParametersOn[i]; + + // Extract the condition and add it to profile.Parameters with a unique key + if (parametersOn.TryGetValue("Condition", out IConvertible condition)) + { + string conditionKey = $"Condition_{i}"; + profile.Parameters[conditionKey] = condition; + conditionKeys.Add(conditionKey); + + telemetryContext.AddContext($"condition_{i}", condition.ToString()); + } + } + + // Evaluate all parameters including the conditions + await evaluator.EvaluateAsync(dependencies, profile.Parameters); + + // Fill back the condition values from profile.Par + + // Track which condition matched (if any) + bool matchFound = false; + + // Check which condition is true and apply its parameters + for (int i = 0; i < profile.ParametersOn.Count && !matchFound; i++) + { + string conditionKey = $"Condition_{i}"; + if (profile.Parameters.TryGetValue(conditionKey, out IConvertible evaluatedCondition) && + bool.TryParse(evaluatedCondition.ToString(), out bool conditionResult) && + conditionResult) + { + var parametersOn = profile.ParametersOn[i]; + + // Apply the parameters from the matching conditional set + // Skip the "Condition" key itself + foreach (var parameter in parametersOn.Where(p => !string.Equals(p.Key, "Condition", StringComparison.OrdinalIgnoreCase))) + { + if(profile.Parameters.ContainsKey(parameter.Key)) + { + profile.Parameters[parameter.Key] = parameter.Value; + telemetryContext.AddContext($"applied_{parameter.Key}", parameter.Value?.ToString()); + } + } + + // We found a match, no need to check other conditions + matchFound = true; + } + } + + // Clean up all temporary condition keys + foreach (string conditionKey in conditionKeys) + { + // Fill the Condition value to the respective parameter set based on the index + if (profile.ParametersOn.Count > 0 && profile.ParametersOn.Count > conditionKeys.IndexOf(conditionKey)) + { + profile.ParametersOn[conditionKeys.IndexOf(conditionKey)]["Condition"] = profile.Parameters[conditionKey]; + } + + profile.Parameters.Remove(conditionKey); + + } + + logger?.LogMessage( + $"{nameof(ExecuteProfileCommand)}.ProcessParametersOn.{(matchFound ? "MatchFound" : "NoMatchFound")}", + telemetryContext); + } + } + catch (Exception exc) + { + throw new NotSupportedException( + $"Error processing ParametersOn conditional parameters: {exc.Message}", + exc); + } + } + private Task LoadExtensionsBinariesAsync(PlatformExtensions extensions, CancellationToken cancellationToken) { return Task.Run(() => diff --git a/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs index cc3be0869e..74048e4100 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/ExecuteProfileCommandTests.cs @@ -411,6 +411,157 @@ public async Task RunProfileCommandCreatesTheExpectedProfile_ProfilesWithMonitor profile.Monitors.Select(a => a.Parameters["Scenario"].ToString())); } + [Test] + public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario1() + { + // Create a new profile with ParametersOn list for testing + string profile1 = "TEST-WORKLOAD-PROFILE-3.json"; + List profiles = new List { this.mockFixture.GetProfilesPath(profile1) }; + + // Setup: + // Read the actual profile content from the local file system. + this.mockFixture.File + .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(profile1)), It.IsAny())) + .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, profile1))); + + this.command.Profiles = new List + { + new DependencyProfileReference("TEST-WORKLOAD-PROFILE-3.json") + }; + + // Act: Load and initialize the profile + IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); + ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + + // Assert: Verify that the ParametersOn list was correctly deserialized + Assert.IsNotNull(profile.ParametersOn); + Assert.AreEqual(2, profile.ParametersOn.Count); + + // Verify first ParametersOn entry + var firstParams = profile.ParametersOn[0]; + Assert.AreEqual("true", firstParams["Condition"].ToString()); + Assert.AreEqual("conditional3", firstParams["Parameter3"].ToString()); + Assert.AreEqual("conditional4", firstParams["Parameter4"].ToString()); + + // Verify second ParametersOn entry + var secondParams = profile.ParametersOn[1]; + Assert.AreEqual("false", secondParams["Condition"].ToString()); + Assert.AreEqual("conditional5", secondParams["Parameter3"].ToString()); + Assert.AreEqual("conditional6", secondParams["Parameter4"].ToString()); + + // Also verify that the regular parameters are correctly loaded + // Since Parameter2 defaults to "base2", first condition is true + Assert.AreEqual(4, profile.Parameters.Count); + Assert.IsFalse((bool)profile.Parameters["Parameter1"]); + Assert.AreEqual("base2", profile.Parameters["Parameter2"].ToString()); + Assert.AreEqual("conditional3", profile.Parameters["Parameter3"].ToString()); + Assert.AreEqual("conditional4", profile.Parameters["Parameter4"].ToString()); + } + + [Test] + public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario2() + { + // Create a new profile with ParametersOn list for testing + string profile1 = "TEST-WORKLOAD-PROFILE-3.json"; + List profiles = new List { this.mockFixture.GetProfilesPath(profile1) }; + + this.command.Parameters = new Dictionary(); + + // User providing a parameter through command line to override the profile value + this.command.Parameters.Add("Parameter2", "base3"); + + // Setup: + // Read the actual profile content from the local file system. + this.mockFixture.File + .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(profile1)), It.IsAny())) + .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, profile1))); + + this.command.Profiles = new List + { + new DependencyProfileReference("TEST-WORKLOAD-PROFILE-3.json") + }; + + // Act: Load and initialize the profile + IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); + ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + + // Assert: Verify that the ParametersOn list was correctly deserialized + Assert.IsNotNull(profile.ParametersOn); + Assert.AreEqual(2, profile.ParametersOn.Count); + + // Verify first ParametersOn entry + var firstParams = profile.ParametersOn[0]; + Assert.AreEqual("false", firstParams["Condition"].ToString()); + Assert.AreEqual("conditional3", firstParams["Parameter3"].ToString()); + Assert.AreEqual("conditional4", firstParams["Parameter4"].ToString()); + + // Verify second ParametersOn entry + var secondParams = profile.ParametersOn[1]; + Assert.AreEqual("true", secondParams["Condition"].ToString()); + Assert.AreEqual("conditional5", secondParams["Parameter3"].ToString()); + Assert.AreEqual("conditional6", secondParams["Parameter4"].ToString()); + + // Also verify that the regular parameters are correctly loaded + // Since Parameter2 is now "base3", second condition is true + Assert.AreEqual(4, profile.Parameters.Count); + Assert.IsFalse((bool)profile.Parameters["Parameter1"]); + Assert.AreEqual("base3", profile.Parameters["Parameter2"].ToString()); + Assert.AreEqual("conditional5", profile.Parameters["Parameter3"].ToString()); + Assert.AreEqual("conditional6", profile.Parameters["Parameter4"].ToString()); + } + + [Test] + public async Task RunProfileCommandSupportsParametersOnListInProfile_Scenario3() + { + // Create a new profile with ParametersOn list for testing + string profile1 = "TEST-WORKLOAD-PROFILE-3.json"; + List profiles = new List { this.mockFixture.GetProfilesPath(profile1) }; + + this.command.Parameters = new Dictionary(); + + // User providing a parameter through command line to override the profile value + this.command.Parameters.Add("Parameter2", "base99"); + + // Setup: + // Read the actual profile content from the local file system. + this.mockFixture.File + .Setup(file => file.ReadAllTextAsync(It.Is(file => file.EndsWith(profile1)), It.IsAny())) + .ReturnsAsync(File.ReadAllText(this.mockFixture.Combine(ExecuteProfileCommandTests.ProfilesDirectory, profile1))); + + this.command.Profiles = new List + { + new DependencyProfileReference("TEST-WORKLOAD-PROFILE-3.json") + }; + + // Act: Load and initialize the profile + IEnumerable profilePaths = await this.command.EvaluateProfilesAsync(this.mockFixture.Dependencies); + ExecutionProfile profile = await this.command.InitializeProfilesAsync(profilePaths, this.mockFixture.Dependencies, CancellationToken.None); + + // Assert: Verify that the ParametersOn list was correctly deserialized + Assert.IsNotNull(profile.ParametersOn); + Assert.AreEqual(2, profile.ParametersOn.Count); + + // Verify first ParametersOn entry + var firstParams = profile.ParametersOn[0]; + Assert.AreEqual("false", firstParams["Condition"].ToString()); + Assert.AreEqual("conditional3", firstParams["Parameter3"].ToString()); + Assert.AreEqual("conditional4", firstParams["Parameter4"].ToString()); + + // Verify second ParametersOn entry + var secondParams = profile.ParametersOn[1]; + Assert.AreEqual("false", secondParams["Condition"].ToString()); + Assert.AreEqual("conditional5", secondParams["Parameter3"].ToString()); + Assert.AreEqual("conditional6", secondParams["Parameter4"].ToString()); + + // Also verify that the regular parameters are correctly loaded + // Since Parameter2 is "base99", no condition matches, so base values remain + Assert.AreEqual(4, profile.Parameters.Count); + Assert.IsFalse((bool)profile.Parameters["Parameter1"]); + Assert.AreEqual("base99", profile.Parameters["Parameter2"].ToString()); + Assert.AreEqual("base3", profile.Parameters["Parameter3"].ToString()); + Assert.AreEqual("base4", profile.Parameters["Parameter4"].ToString()); + } + private class TestRunProfileCommand : ExecuteProfileCommand { public new PlatformExtensions Extensions diff --git a/src/VirtualClient/VirtualClient.UnitTests/VirtualClient.UnitTests.csproj b/src/VirtualClient/VirtualClient.UnitTests/VirtualClient.UnitTests.csproj index 7cec829e32..cc0d73539a 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/VirtualClient.UnitTests.csproj +++ b/src/VirtualClient/VirtualClient.UnitTests/VirtualClient.UnitTests.csproj @@ -32,6 +32,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/VirtualClient/VirtualClient.UnitTests/profiles/TEST-WORKLOAD-PROFILE-3.json b/src/VirtualClient/VirtualClient.UnitTests/profiles/TEST-WORKLOAD-PROFILE-3.json new file mode 100644 index 0000000000..cbe34169be --- /dev/null +++ b/src/VirtualClient/VirtualClient.UnitTests/profiles/TEST-WORKLOAD-PROFILE-3.json @@ -0,0 +1,46 @@ +{ + "Description": "Default profile scenario containing a workload to run but no explicit monitors.", + "Metadata": { + }, + "Parameters": { + "Parameter1": false, + "Parameter2": "base2", + "Parameter3": "base3", + "Parameter4": "base4" + }, + "ParametersOn": [ + { + "Condition": "{calculate({calculate(\"{Parameter2}\" == \"base2\")} ? \"true\" : \"false\")}", + "Parameter3": "conditional3", + "Parameter4": "conditional4" + }, + { + "Condition": "{calculate({calculate(\"{Parameter2}\" == \"base3\")} ? \"true\" : \"false\")}", + "Parameter3": "conditional5", + "Parameter4": "conditional6" + } + ], + "Actions": [ + { + "Type": "TestExecutor", + "Parameters": { + "Scenario": "Workload1", + "NewFlag": "$.Parameters.Parameter3" + } + }, + { + "Type": "TestExecutor", + "Parameters": { + "Scenario": "Workload2" + } + } + ], + "Dependencies": [ + { + "Type": "TestDependency", + "Parameters": { + "Scenario": "Dependency1" + } + } + ] +} \ No newline at end of file