BenchPress/Generators/AzureDeploymentImporter.cs (195 lines of code) (raw):
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Generators;
public class AzureDeploymentImporter
{
private static Regex s_parametersOrVariablesRegex = new Regex(
"(?<paramOrVarType>parameters|variables)\\('(?<paramOrVarName>.*?)'\\)",
RegexOptions.Compiled
);
private static Regex s_resourceIdParametersRegex = new Regex(
"\\[resourceId\\((?<resourceIdParameters>.*)\\)\\]",
RegexOptions.Compiled
);
private static string s_resourceIdParametersKey = "resourceIdParameters";
private static string s_dependsOnKey = "dependsOn";
private static string s_squareBracketPattern = "\\[(.*?)\\]";
private static string s_squareBracketSubstituition = "$1";
private static string s_paramOrVarNameGroupKey = "paramOrVarName";
private static string s_paramOrVarTypeGroupKey = "paramOrVarType";
private static string s_defaultValueKey = "defaultValue";
public static IEnumerable<TestMetadata> Import(FileInfo inputFile, string outputFolderPath)
{
return Import(inputFile.FullName, outputFolderPath);
}
public static IEnumerable<TestMetadata> Import(
string inputFileFullPath,
string outputFolderPath
)
{
var jsonFileContent = "";
if (inputFileFullPath.EndsWith(".bicep"))
{
var tempFileFullPath = Path.GetTempFileName();
var buildArgs = new string[]
{
"build",
inputFileFullPath,
"--outfile",
tempFileFullPath
};
Bicep.Cli.Program.Main(buildArgs).Wait();
jsonFileContent = File.ReadAllText(tempFileFullPath);
var generateParamsArgs = new string[]
{
"generate-params",
inputFileFullPath,
"--outfile",
outputFolderPath + "\\generated"
};
Bicep.Cli.Program.Main(generateParamsArgs).Wait();
File.Delete(tempFileFullPath);
}
else if (inputFileFullPath.EndsWith(".json"))
{
jsonFileContent = File.ReadAllText(inputFileFullPath);
}
else
{
throw new FileFormatException();
}
var parsed = JsonNode.Parse(jsonFileContent)?.AsObject();
if (parsed == null)
{
throw new Exception("Failed to parse json file");
}
var list = new List<TestMetadata>();
foreach (var resource in (JsonArray)parsed["resources"]!)
{
if (resource == null)
{
throw new Exception("Failed to parse json file");
}
var resourceType = resource["type"]?.ToString().Trim();
var resourceName = resource["name"]?.ToString().Trim();
if (resourceName == null || resourceType == null)
{
throw new Exception("Failed to parse json file");
}
resourceName = ResolveParamsAndVariables(resourceName, parsed);
if (resourceName == null)
{
throw new Exception("Failed to parse json file");
}
var extraProperties = GetExtraProperties(resource, parsed);
try
{
list.Add(new TestMetadata(resourceType, resourceName, extraProperties));
}
catch (UnknownResourceTypeException)
{
// ignore
}
}
return list;
}
/// <summary>
/// Sets the extra properties for the test metadata by using information in the resource definition. When
/// the bicep file is transpiled to an ARM template, the dependsOn property for each resource will be in the form
/// of a resource unique identifier. The unique identifier can be used to determine any parent or dependent
/// resources. Any parent or dependent resources will be added to the extra properties dictionary with the resource
/// type as the key and the resource name as the value. This will allow ResourceTypes that need additional
/// parameters (i.e. SqlDatabase will need ServerName) to be able to get the value from the extra properties
/// dictionary.
/// <example>
/// For example, the following resource definition:
/// <code>
/// {
/// "type": "Microsoft.Sql/servers/databases",
/// "apiVersion": "2022-05-01-preview",
/// "name": "[format('{0}/{1}', parameters('serverName'), parameters('databaseName'))]",
/// "location": "[parameters('location')]",
/// "sku": {
/// "name": "Standard",
/// "tier": "Standard"
/// },
/// "dependsOn": [
/// "[resourceId('Microsoft.Sql/servers', parameters('serverName'))]"
/// ]
///}
/// </code>
/// Will result in the following extra properties dictionary:
/// <code>
/// {
/// "servers": "parameters('serverName')"
/// }
/// </code>
/// </summary>
private static Dictionary<string, string> GetExtraProperties(
JsonNode resource,
JsonObject armTemplateObject
)
{
var extraProperties = new Dictionary<string, string>();
var dependencies = (JsonArray?)resource[s_dependsOnKey];
if (dependencies != null)
{
foreach (var dependency in dependencies)
{
if (dependency != null)
{
// There is only one Capture value for the Group, which is the entire list of parameters that are
// passed to "[resourceId()]" as a single string. After the split, the first parameter in
// resourceIdParameters will be the path (e.g., "'Microsoft.xxx/yyy/zzz'"), and all further entries
// will be values for the path (e.g., "parameters('yyy')", "variables('zzz')").
var resourceIdParameters = s_resourceIdParametersRegex
.Match(dependency.ToString())
.Groups[s_resourceIdParametersKey].Captures[0].Value.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
);
// The number of entries in resourceIdParameters must be 2 or more, otherwise it's not valid.
if (resourceIdParameters.Length > 1)
{
// The first element is the path, so remove the leading/trailing single quotes from
// "'Microsft.xxx/yyy/zzz'", then split on the path separator: ["Microsoft.xxx", "yyy", "zzz"],
// and finally, remove the leading "Microsoft.xxx" by skipping (1).
var pathParts = resourceIdParameters[0]
.Trim('\'')
.Split('/')
.Skip(1)
.ToList();
// There should be one more Resource ID Parameter than path parts, otherwise it is not valid.
if (pathParts.Count() == (resourceIdParameters.Count() - 1))
{
// Skip the path parameter, counts and indexes match now.
var values = resourceIdParameters.Skip(1).ToList();
for (int index = 0; index < pathParts.Count(); index++)
{
// If the value is a "parameter" or "variable", then resolve it to the correct value
// of the parameter or variable. If the value is a hard coded value, then the
// value will be "'value'" so trim any single quotes.
var value = ResolveParamsAndVariables(
values[index],
armTemplateObject
)
.Trim('\'');
extraProperties.Add(pathParts[index], value);
}
}
}
}
}
}
extraProperties.Add("resourceGroup", "FAKE-RESOURCE-GROUP");
return extraProperties;
}
/// <summary>
/// Takes a string from an ARM template containing instances of <c>parameters('...')</c> and
/// <c>variables('...')</c> and resolves those instances to the correct values, if possible.
/// <example>
/// For example, if the following ARM template (<paramref name="armTemplateObject"/>) with the parameter block
/// below is passed in:
/// <code>
/// {...
/// "parameters": {
/// "demoParam": {
/// "type": "string",
/// "defaultValue": "Contoso"
/// }
/// }
/// ...}
/// </code>
/// and the following string (<paramref name ="stringToResolve"/>) is passed in:
/// <code>
/// "[parameters('demoParam')]"
/// </code>
/// The parameter will be resolved to the default value and result in the following return value:
/// <code>
/// "Contoso"
/// </code>
/// This method can also handle resolving ARM template variables. For example if the following ARM template
/// (<paramref name="armTemplateObject"/>) with the variable block below is passed in:
/// <code>
/// {...
/// "variables": {
/// "demoVar": "Contoso"
/// }
/// ...}
/// </code>
/// and the following string (<paramref name ="stringToResolve"/>) is passed in:
/// <code>
/// "[variables('demoVar')]"
/// </code>
/// The variable will be resolved to the correct value and result in the following return value:
/// <code>
/// "Contoso"
/// </code>
/// Finally, this method can also handle resolving a mixture of ARM template parameters and variables. For example
/// if the following ARM template (<paramref name="armTemplateObject"/>) with the parameter and variable block
/// below is passed in:
/// <code>
/// {...
/// "parameters": {
/// "demoParam": {
/// "type": "string",
/// "defaultValue": "ContosoParam"
/// }
/// }
/// "variables": {
/// "demoVar": "ContosoVar"
/// }
/// ...}
/// </code>
/// and the following string (<paramref name ="stringToResolve"/>) is passed in:
/// <code>
/// "[format('{0}{1}', variables('demoVar'), parameters('demoParam')]"
/// </code>
/// The variable and parameter will be resolved to the correct values and result in the following return value:
/// <code>
/// "format('{0}{1}', 'ContosoVar' , 'ContosoParam')"
/// </code>
/// </summary>
private static string ResolveParamsAndVariables(
string stringToResolve,
JsonObject armTemplateObject
)
{
// Find and remove square brackets from the parameter/variable string. Square brackets are specific to
// ARM template syntax and are not needed in generated tests.
stringToResolve = Regex.Replace(
stringToResolve,
s_squareBracketPattern,
s_squareBracketSubstituition
);
// Find all matches in the parameter/variable string that follows the pattern of "parameters('...')" or
// "variables('...')". The regular expression pattern defines two named subexpressions: paramOrVarType, which
// represents the type of parameter/variable (e.g., "parameters" or "variables"), and paramOrVarName, which
// represents the name of the parameter/variable.
var matches = s_parametersOrVariablesRegex.Matches(stringToResolve);
foreach (Match match in matches)
{
var name = match.Groups[s_paramOrVarNameGroupKey].Value;
var type = match.Groups[s_paramOrVarTypeGroupKey].Value;
var resolvedValue = System.String.Empty;
if (!string.IsNullOrWhiteSpace(name))
{
// ARM templates will contain a JSON Object representing parameters and variables for the ARM template.
// Get the correct JSON Object using the type from the regex match (e.g., "parameters" or "variables").
var parametersOrVariablesObj = armTemplateObject[type];
if (parametersOrVariablesObj != null)
{
// Get the value of the parameter/variable from the JSON Object. If the value is still a JSON
// Object, that means it is a parameter that has a default value, so get the default value (if it
// exists). Otherwise, the value can be converted to a string and assigned to resolveValue.
var resolvedValueNode = parametersOrVariablesObj[name];
if (resolvedValueNode != null)
{
if (resolvedValueNode is JsonObject)
{
resolvedValue = resolvedValueNode[s_defaultValueKey]?.ToString();
}
else
{
resolvedValue = resolvedValueNode.ToString();
}
}
}
}
if (!string.IsNullOrWhiteSpace(resolvedValue))
{
// If square brackets are present in the resolved value, remove them from the value. Square brackets
// are specific to ARM template syntax to represent expressions and are not needed in generated tests.
// If the resolved value does not have square brackets, then the resolved value does not contain ARM
// functions and should be wrapped in single quotes.
if (Regex.Match(resolvedValue, s_squareBracketPattern).Success)
{
resolvedValue = Regex.Replace(
resolvedValue,
s_squareBracketPattern,
s_squareBracketSubstituition
);
}
else
{
resolvedValue = "\'" + resolvedValue + "\'";
}
stringToResolve = stringToResolve.Replace(match.Value, resolvedValue);
}
}
return stringToResolve;
}
}