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