From b662d0a9b6bda6ee84788c106b730484b498ba63 Mon Sep 17 00:00:00 2001 From: Johnathon Mohr Date: Fri, 8 Aug 2025 13:13:34 -0700 Subject: [PATCH 1/5] Support for template language version 2.0 --- src/Analyzer.Core/TemplateAnalyzer.cs | 3 +- .../PowerShellRuleEngine.cs | 5 +- .../ArmTemplateProcessor.cs | 22 +- src/Analyzer.Types/TemplateContext.cs | 5 + .../JsonPathResolverTests.cs | 302 +++++++++++++----- src/Analyzer.Utilities/JsonPathResolver.cs | 24 +- src/Analyzer.Utilities/TemplateDiscovery.cs | 9 + 7 files changed, 282 insertions(+), 88 deletions(-) diff --git a/src/Analyzer.Core/TemplateAnalyzer.cs b/src/Analyzer.Core/TemplateAnalyzer.cs index c9c147e3..3b742b99 100644 --- a/src/Analyzer.Core/TemplateAnalyzer.cs +++ b/src/Analyzer.Core/TemplateAnalyzer.cs @@ -166,7 +166,8 @@ private IEnumerable AnalyzeAllIncludedTemplates(string populatedTem IsBicep = parentContext.IsBicep, BicepMetadata = parentContext.BicepMetadata, PathPrefix = pathPrefix, - ParentContext = parentContext + ParentContext = parentContext, + LanguageVersion = armTemplateProcessor.LanguageVersion, }; try diff --git a/src/Analyzer.PowerShellRuleEngine/PowerShellRuleEngine.cs b/src/Analyzer.PowerShellRuleEngine/PowerShellRuleEngine.cs index cc62887f..7b39f4ed 100644 --- a/src/Analyzer.PowerShellRuleEngine/PowerShellRuleEngine.cs +++ b/src/Analyzer.PowerShellRuleEngine/PowerShellRuleEngine.cs @@ -128,7 +128,10 @@ public IEnumerable AnalyzeTemplate(TemplateContext templateContext) // placeholder value for location is westus2 optionsForFileAnalysis.Configuration["AZURE_RESOURCE_ALLOWED_LOCATIONS"] = new[] { "westus2" }; - var resources = templateContext.ExpandedTemplate.InsensitiveToken("resources").Values(); + string resourcesPath = templateContext.LanguageVersion == 2 + ? "resources.*" + : "resources[*]"; + var resources = templateContext.ExpandedTemplate.InsensitiveTokens(resourcesPath); var builder = CommandLineBuilder.Invoke(modules, optionsForFileAnalysis, hostContext); builder.InputPath(new string[] { tempTemplateFile }); diff --git a/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs b/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs index b3a36247..a9ea1d43 100644 --- a/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs +++ b/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs @@ -29,9 +29,9 @@ public class ArmTemplateProcessor private readonly string armTemplate; private readonly string apiVersion; private readonly ILogger logger; - private Dictionary> originalToExpandedMapping = new Dictionary>(); - private Dictionary expandedToOriginalMapping = new Dictionary(); - private Dictionary flattenedResources = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> originalToExpandedMapping = []; + private readonly Dictionary expandedToOriginalMapping = []; + private readonly Dictionary flattenedResources = new(StringComparer.OrdinalIgnoreCase); /// /// Mapping between resources in the expanded template to their original resource in @@ -39,7 +39,16 @@ public class ArmTemplateProcessor /// The key is the path in the expanded template with value being the path /// in the original template. /// - public Dictionary ResourceMappings = new Dictionary(); + public Dictionary ResourceMappings { get; } = []; + + /// + /// The language version of the ARM template processed. + /// This is used to determine the language features available in the template. + /// + /// + /// 2 if "languageVersion": "2.0" is specified in the template processed; otherwise, 1. + /// + public int LanguageVersion { get; private set; } = 1; /// /// Constructor for the ARM Template Processing library @@ -105,6 +114,11 @@ internal Template ParseAndValidateTemplate(InsensitiveDictionary paramet Template template = TemplateEngine.ParseTemplate(armTemplate); + if (template.LanguageVersion?.Value == "2.0") + { + LanguageVersion = 2; + } + TemplateEngine.ValidateTemplate(template, apiVersion, TemplateDeploymentScope.NotSpecified); SetOriginalResourceNames(template); diff --git a/src/Analyzer.Types/TemplateContext.cs b/src/Analyzer.Types/TemplateContext.cs index 12e5b0d4..518005b0 100644 --- a/src/Analyzer.Types/TemplateContext.cs +++ b/src/Analyzer.Types/TemplateContext.cs @@ -58,5 +58,10 @@ public class TemplateContext /// Template context for the immediate parent template /// public TemplateContext ParentContext { get; set; } + + /// + /// The language version of the template + /// + public int LanguageVersion { get; set; } = 1; } } \ No newline at end of file diff --git a/src/Analyzer.Utilities.UnitTests/JsonPathResolverTests.cs b/src/Analyzer.Utilities.UnitTests/JsonPathResolverTests.cs index 639d075b..06d81c51 100644 --- a/src/Analyzer.Utilities.UnitTests/JsonPathResolverTests.cs +++ b/src/Analyzer.Utilities.UnitTests/JsonPathResolverTests.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Azure.Templates.Analyzer.Types; namespace Microsoft.Azure.Templates.Analyzer.Utilities.UnitTests { @@ -68,18 +69,14 @@ public class JsonPathResolverTests [DataRow("", DisplayName = "Empty path")] public void Resolve_NullOrEmptyPath_ReturnsResolverWithOriginalJtoken(string path) { - var jtoken = JObject.Parse("{ \"Property\": \"Value\" }"); + var jobject = JObject.Parse("{ \"Property\": \"Value\" }"); - var resolver = new JsonPathResolver(jtoken, jtoken.Path); + var resolver = new JsonPathResolver(jobject, jobject.Path); - // Do twice to verify internal cache correctness - for (int i = 0; i < 2; i++) - { - var results = resolver.Resolve(path).ToList(); + var results = resolver.Resolve(path).ToList(); - Assert.AreEqual(1, results.Count); - Assert.AreEqual(jtoken, results[0].JToken); - } + Assert.AreEqual(1, results.Count); + Assert.AreEqual(jobject, results[0].JToken); } [DataTestMethod] @@ -89,7 +86,7 @@ public void Resolve_NullOrEmptyPath_ReturnsResolverWithOriginalJtoken(string pat [DataRow("twochildlevels.child2.lastprop", DisplayName = "Resolve three properties deep")] public void Resolve_JsonContainsPath_ReturnsResolverWithCorrectJtokenAndPath(string path) { - JToken jtoken = JObject.Parse( + JObject jobject = JObject.Parse( @"{ ""NoChildren"": true, ""OneChildLevel"": { @@ -104,19 +101,15 @@ public void Resolve_JsonContainsPath_ReturnsResolverWithCorrectJtokenAndPath(str }, }"); - var resolver = new JsonPathResolver(jtoken, jtoken.Path); + var resolver = new JsonPathResolver(jobject, jobject.Path); - // Do twice to verify internal cache correctness - for (int i = 0; i < 2; i++) - { - var results = resolver.Resolve(path).ToList(); + var results = resolver.Resolve(path).ToList(); - Assert.AreEqual(1, results.Count); + Assert.AreEqual(1, results.Count); - // Verify correct property was resolved and resolver returns correct path - Assert.AreEqual(path, results[0].JToken.Path, ignoreCase: true); - Assert.AreEqual(path, results[0].Path, ignoreCase: true); - } + // Verify correct property was resolved and resolver returns correct path + Assert.AreEqual(path, results[0].JToken.Path, ignoreCase: true); + Assert.AreEqual(path, results[0].Path, ignoreCase: true); } // Combinations of wildcards are tested more extensively in JTokenExtensionsTests.cs @@ -128,7 +121,7 @@ public void Resolve_JsonContainsPath_ReturnsResolverWithCorrectJtokenAndPath(str [DataRow("NoChildren.*", 0, DisplayName = "Wildcard matching nothing")] public void Resolve_JsonContainsWildcardPath_ReturnsResolverWithCorrectJtokensAndPath(string path, int expectedCount) { - JToken jtoken = JObject.Parse( + JObject jobject = JObject.Parse( @"{ ""NoChildren"": true, ""OneChildLevel"": { @@ -143,42 +136,38 @@ public void Resolve_JsonContainsWildcardPath_ReturnsResolverWithCorrectJtokensAn }, }"); - var resolver = new JsonPathResolver(jtoken, jtoken.Path); + var resolver = new JsonPathResolver(jobject, jobject.Path); var arrayRegex = new Regex(@"(?\w+)\[(\d|\*)\]"); - // Do twice to verify internal cache correctness - for (int i = 0; i < 2; i++) - { - var results = resolver.Resolve(path).ToList(); + var results = resolver.Resolve(path).ToList(); - Assert.AreEqual(expectedCount, results.Count); + Assert.AreEqual(expectedCount, results.Count); - foreach (var resolved in results) + foreach (var resolved in results) + { + // Verify path on each segment + var expectedPath = path.Split('.'); + var actualPath = resolved.JToken.Path.Split('.'); + Assert.AreEqual(expectedPath.Length, actualPath.Length); + for (int j = 0; j < expectedPath.Length; j++) { - // Verify path on each segment - var expectedPath = path.Split('.'); - var actualPath = resolved.JToken.Path.Split('.'); - Assert.AreEqual(expectedPath.Length, actualPath.Length); - for (int j = 0; j < expectedPath.Length; j++) - { - var expectedSegment = expectedPath[j]; - var actualSegment = actualPath[j]; - var arrayMatch = arrayRegex.Match(expectedSegment); + var expectedSegment = expectedPath[j]; + var actualSegment = actualPath[j]; + var arrayMatch = arrayRegex.Match(expectedSegment); - if (arrayMatch.Success) - { - Assert.AreEqual(arrayMatch.Groups["property"].Value, arrayRegex.Match(actualSegment).Groups["property"].Value, ignoreCase: true); - } - else - { - Assert.IsTrue(expectedSegment.Equals("*") || expectedSegment.Equals(actualPath[j], StringComparison.OrdinalIgnoreCase)); - } + if (arrayMatch.Success) + { + Assert.AreEqual(arrayMatch.Groups["property"].Value, arrayRegex.Match(actualSegment).Groups["property"].Value, ignoreCase: true); + } + else + { + Assert.IsTrue(expectedSegment.Equals("*") || expectedSegment.Equals(actualPath[j], StringComparison.OrdinalIgnoreCase)); } - - // Verify returned path matches JToken path - Assert.AreEqual(resolved.JToken.Path, resolved.Path, ignoreCase: true); } + + // Verify returned path matches JObject path + Assert.AreEqual(resolved.JToken.Path, resolved.Path, ignoreCase: true); } } @@ -190,20 +179,15 @@ public void Resolve_JsonContainsWildcardPath_ReturnsResolverWithCorrectJtokensAn [DataRow("Not.Existing.Property", DisplayName = "Non-existant path (multi-level, top level doesn't exist)")] public void Resolve_InvalidPath_ReturnsResolverWithNullJtokenAndCorrectResolvedPath(string path) { - var jtoken = JObject.Parse("{ \"Property\": \"Value\" }"); + var jobject = JObject.Parse("{ \"Property\": \"Value\" }"); - var resolver = new JsonPathResolver(jtoken, jtoken.Path); - var expectedPath = $"{jtoken.Path}.{path}"; + var resolver = new JsonPathResolver(jobject, jobject.Path); - // Do twice to verify internal cache correctness - for (int i = 0; i < 2; i++) - { - var results = resolver.Resolve(path).ToList(); + var results = resolver.Resolve(path).ToList(); - Assert.AreEqual(1, results.Count); - Assert.AreEqual(null, results[0].JToken); - Assert.AreEqual(expectedPath, results[0].Path); - } + Assert.AreEqual(1, results.Count); + Assert.AreEqual(null, results[0].JToken); + Assert.AreEqual(path, results[0].Path); } [DataTestMethod] @@ -213,30 +197,26 @@ public void ResolveResourceType_JObjectWithExpectedResourcesArray_ReturnsResourc var jToken = JObject.Parse(template); var resolver = new JsonPathResolver(jToken, jToken.Path); - // Do twice to verify internal cache correctness - for (int i = 0; i < 2; i++) + var resources = resolver.ResolveResourceType(resourceType).ToList(); + Assert.AreEqual(matchingResourceIndexes.Length, resources.Count); + + // Verify resources of correct type were returned + for (int numOfResourceMatched = 0; numOfResourceMatched < matchingResourceIndexes.Length; numOfResourceMatched++) { - var resources = resolver.ResolveResourceType(resourceType).ToList(); - Assert.AreEqual(matchingResourceIndexes.Length, resources.Count); + var resource = resources[numOfResourceMatched]; + var resourceIndexes = matchingResourceIndexes[numOfResourceMatched]; + var expectedPath = ""; - // Verify resources of correct type were returned - for (int numOfResourceMatched = 0; numOfResourceMatched < matchingResourceIndexes.Length; numOfResourceMatched++) + for (int numOfResourceIndex = 0; numOfResourceIndex < resourceIndexes.Length; numOfResourceIndex++) { - var resource = resources[numOfResourceMatched]; - var resourceIndexes = matchingResourceIndexes[numOfResourceMatched]; - var expectedPath = ""; - - for (int numOfResourceIndex = 0; numOfResourceIndex < resourceIndexes.Length; numOfResourceIndex++) + if (numOfResourceIndex != 0) { - if (numOfResourceIndex != 0) - { - expectedPath += "."; - } - expectedPath += $"resources[{resourceIndexes[numOfResourceIndex]}]"; + expectedPath += "."; } - - Assert.AreEqual(expectedPath, resource.JToken.Path); + expectedPath += $"resources[{resourceIndexes[numOfResourceIndex]}]"; } + + Assert.AreEqual(expectedPath, resource.JToken.Path); } } @@ -255,6 +235,174 @@ public void ResolveResourceType_JObjectWithResourcesNotArrayOfObjects_ReturnsEmp Assert.AreEqual(0, new JsonPathResolver(jToken, jToken.Path).ResolveResourceType("anything").Count()); } + [TestMethod] + public void ResolveResourceType_LanguageVersion2SymbolicNaming_ReturnsResourcesOfCorrectType() + { + // Test template with language version 2.0 symbolic naming (resources as object) + var template = @"{ + ""languageVersion"": ""2.0"", + ""resources"": { + ""storageAccount"": { + ""type"": ""Microsoft.Storage/storageAccounts"", + ""apiVersion"": ""2021-04-01"", + ""name"": ""mystorageaccount"" + }, + ""virtualNetwork"": { + ""type"": ""Microsoft.Network/virtualNetworks"", + ""apiVersion"": ""2021-02-01"", + ""name"": ""myvnet"" + }, + ""anotherStorage"": { + ""type"": ""Microsoft.Storage/storageAccounts"", + ""apiVersion"": ""2021-04-01"", + ""name"": ""anotherstorageaccount"" + } + } + }"; + + var jToken = JObject.Parse(template); + var resolver = new JsonPathResolver(jToken, jToken.Path); + + // Test 1: Find all storage accounts (should return 2) + var storageAccounts = resolver.ResolveResourceType("Microsoft.Storage/storageAccounts").ToList(); + Assert.AreEqual(2, storageAccounts.Count); + + // Verify the correct resources were found + foreach (var account in storageAccounts) + { + Assert.AreEqual("Microsoft.Storage/storageAccounts", + account.JToken.InsensitiveToken("type")?.Value()); + } + + // Test 2: Find virtual networks (should return 1) + var virtualNetworks = resolver.ResolveResourceType("Microsoft.Network/virtualNetworks").ToList(); + Assert.AreEqual(1, virtualNetworks.Count); + Assert.AreEqual("Microsoft.Network/virtualNetworks", + virtualNetworks[0].JToken.InsensitiveToken("type")?.Value()); + + // Test 3: Find non-existent resource type (should return 0) + var nonExistent = resolver.ResolveResourceType("Microsoft.Compute/virtualMachines").ToList(); + Assert.AreEqual(0, nonExistent.Count); + + // Test 4: Verify caching works correctly (run twice) + var storageAccountsAgain = resolver.ResolveResourceType("Microsoft.Storage/storageAccounts").ToList(); + Assert.AreEqual(2, storageAccountsAgain.Count); + } + + [TestMethod] + public void ResolveResourceType_SymbolicNamingWithNestedResources_ReturnsCorrectResources() + { + // Test template with nested resources in language version 2.0 format. + // Child resources are specified using both object and array formats. + var template = @"{ + ""languageVersion"": ""2.0"", + ""resources"": { + ""parentSite"": { + ""type"": ""Microsoft.Web/sites"", + ""apiVersion"": ""2021-02-01"", + ""name"": ""mywebsite"", + ""resources"": { + ""siteExtension"": { + ""type"": ""Microsoft.Web/sites/siteextensions"", + ""apiVersion"": ""2021-02-01"", + ""name"": ""myextension"" + } + } + }, + ""parentSite2"": { + ""type"": ""Microsoft.Web/sites"", + ""apiVersion"": ""2021-02-01"", + ""name"": ""mywebsite2"", + ""resources"": [ + { + ""type"": ""Microsoft.Web/sites/siteextensions"", + ""apiVersion"": ""2021-02-01"", + ""name"": ""myextension2"" + } + ] + } + } + }"; + + var jToken = JObject.Parse(template); + var resolver = new JsonPathResolver(jToken, jToken.Path); + + // Test finding parent resource + var sites = resolver.ResolveResourceType("Microsoft.Web/sites").ToList(); + Assert.AreEqual(2, sites.Count); + + // Test finding child resource + var extensions = resolver.ResolveResourceType("Microsoft.Web/sites/siteextensions").ToList(); + Assert.AreEqual(2, extensions.Count); + } + + [TestMethod] + public void Resolve_RepeatLookupForPath_UsesInternalCache() + { + JObject jobject = JObject.Parse( + @"{ + ""NoChildren"": true, + ""OneChildLevel"": { + ""Child"": ""aValue"", + ""Child2"": 2 + }, + ""TwoChildLevels"": { + ""Child"": [ 0, 1, 2 ], + ""Child2"": { + ""LastProp"": true + } + }, + }"); + + var resolver = new JsonPathResolver(jobject, jobject.Path); + + // Resolve root and compare to confirm that resolver is using the passed JObject directly (not copied). + var rootResult = resolver.Resolve("").ToList(); + Assert.AreEqual(1, rootResult.Count); + Assert.AreEqual(jobject, rootResult[0].JToken, "Root token does not match original JObject - a copy may have been returned."); + + // Setup multiple paths to resolve to test cache retrieval + string[] pathsToResolve = ["NoChildren", "OneChildLevel.Child", "twochildlevels.child2.lastprop"]; + + // Create a 2D array to hold resolved results for each path and each resolution attempt + List[,] resolvedResults = new List[2, pathsToResolve.Length]; + + // Resolve each path twice. Track each result in a 2D array and compare later to confirm the resolver is using the internal cache correctly. + for (int i = 0; i < 2; i++) + { + for (int j = 0; j < pathsToResolve.Length; j++) + { + // Resolve the specific path and confirm the correct property was resolved and resolver returns correct path + resolvedResults[i, j] = resolver.Resolve(pathsToResolve[j]).ToList(); + + Assert.AreEqual(1, resolvedResults[i, j].Count); + Assert.AreEqual(pathsToResolve[j], resolvedResults[i, j][0].JToken.Path, ignoreCase: true); + Assert.AreEqual(pathsToResolve[j], resolvedResults[i, j][0].Path, ignoreCase: true); + } + + // Remove all properties to confirm cached paths are used for repeat lookup. + jobject.RemoveAll(); + + // Create a new resolver to confirm paths are now unavailable + var resolverWithEmptyJObject = new JsonPathResolver(jobject, jobject.Path); + + for (int j = 0; j < pathsToResolve.Length; j++) + { + var emptyResults = resolverWithEmptyJObject.Resolve(pathsToResolve[j]).ToList(); + Assert.AreEqual(1, emptyResults.Count); + Assert.IsNull(emptyResults[0].JToken); + } + } + + // Validate that the results from the first and second resolution of each path are the same. + for (int i = 0; i < pathsToResolve.Length; i++) + { + Assert.AreEqual(1, resolvedResults[0, i].Count); + Assert.AreEqual(1, resolvedResults[1, i].Count); + Assert.AreEqual(resolvedResults[0, i][0].JToken, resolvedResults[1, i][0].JToken); + } + } + [TestMethod] [ExpectedException(typeof(ArgumentNullException))] public void Constructor_NullJToken_ThrowsException() diff --git a/src/Analyzer.Utilities/JsonPathResolver.cs b/src/Analyzer.Utilities/JsonPathResolver.cs index e64a917b..44675220 100644 --- a/src/Analyzer.Utilities/JsonPathResolver.cs +++ b/src/Analyzer.Utilities/JsonPathResolver.cs @@ -52,7 +52,7 @@ private JsonPathResolver(JToken jToken, string path, DictionaryThe JToken(s) at the path. If the path does not exist, returns a JToken with a null value. public IEnumerable Resolve(string jsonPath) { - string fullPath = string.IsNullOrEmpty(jsonPath) ? currentPath : string.Join('.', currentPath, jsonPath); + string fullPath = CombineJsonPaths(currentPath, jsonPath); if (!resolvedPaths.TryGetValue(fullPath, out var resolvedTokens)) { @@ -69,16 +69,25 @@ public IEnumerable Resolve(string jsonPath) /// /// Retrieves the JTokens for resources of the specified type - /// in a "resources" property array at the current scope. + /// in a "resources" property array or object at the current scope. + /// Supports both traditional array format and language version 2.0 object format (symbolic naming). /// /// The type of resource to find. /// An enumerable of resolvers with a scope of a resource of the specified type. public IEnumerable ResolveResourceType(string resourceType) { - string fullPath = this.currentPath + ".resources[*]"; - if (!resolvedPaths.TryGetValue(fullPath, out var resolvedTokens)) + // Traditional ARM JSON templates use an array for resources, + // while language version 2.0 uses an object. + string arrayPath = "resources[*]"; + string objectPath = "resources.*"; + + if (!resolvedPaths.TryGetValue(CombineJsonPaths(this.currentPath, arrayPath), out var resolvedTokens) && + !resolvedPaths.TryGetValue(CombineJsonPaths(this.currentPath, objectPath), out resolvedTokens)) { - var resources = this.currentScope.InsensitiveTokens("resources[*]"); + string resourcesPath = this.currentScope.InsensitiveToken("resources") is JObject ? objectPath : arrayPath; + string fullPath = CombineJsonPaths(this.currentPath, resourcesPath); + + var resources = this.currentScope.InsensitiveTokens(resourcesPath); resolvedTokens = resources.Select(r => (FieldContent)r).ToList(); resolvedPaths[fullPath] = resolvedTokens; } @@ -119,5 +128,10 @@ static bool resourceTypesAreEqual(FieldContent jTokenResourceType, string string /// public string Path => this.currentPath; + + private string CombineJsonPaths(params string[] paths) => + paths == null + ? string.Empty + : string.Join(".", paths.Where(p => !string.IsNullOrEmpty(p))); } } diff --git a/src/Analyzer.Utilities/TemplateDiscovery.cs b/src/Analyzer.Utilities/TemplateDiscovery.cs index ed90f054..ea4d04d3 100644 --- a/src/Analyzer.Utilities/TemplateDiscovery.cs +++ b/src/Analyzer.Utilities/TemplateDiscovery.cs @@ -27,7 +27,12 @@ public static class TemplateDiscovery new Regex(@"https?://schema\.management\.azure\.com/schemas/\d{4}-\d{2}-\d{2}/(subscription|tenant|managementGroup)?deploymentTemplate\.json\#?", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// + /// List of valid template properties that are checked for in the template. + /// Only referenced if the JSON schema property is not the first line of the template (rare). + /// private static readonly IReadOnlyList validTemplateProperties = new List { + // Standard properties "contentVersion", "apiProfile", "parameters", @@ -35,6 +40,10 @@ public static class TemplateDiscovery "functions", "resources", "outputs", + + // Added in language version 2.0 + "languageVersion", + "definitions", }.AsReadOnly(); /// From 91a91c2970dc5f1af5cba0b8f56c522579259798 Mon Sep 17 00:00:00 2001 From: Johnathon Mohr Date: Fri, 8 Aug 2025 15:29:42 -0700 Subject: [PATCH 2/5] Improve top-level Analyze function to better handle lang version 2.0 templates --- src/Analyzer.Core/TemplateAnalyzer.cs | 102 +++++++++++++------------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/src/Analyzer.Core/TemplateAnalyzer.cs b/src/Analyzer.Core/TemplateAnalyzer.cs index 3b742b99..d2cd7213 100644 --- a/src/Analyzer.Core/TemplateAnalyzer.cs +++ b/src/Analyzer.Core/TemplateAnalyzer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; @@ -136,20 +136,20 @@ public IEnumerable AnalyzeTemplate(string template, string template /// /// Analyzes ARM templates, recursively going through the nested templates /// - /// The ARM Template JSON with inherited parameters, variables, and functions, if applicable + /// The ARM Template JSON with inherited parameters, variables, and functions, if applicable /// The parameters for the ARM Template JSON /// The ARM Template file path /// Template context for the immediate parent template /// Prefix for resources' path used for line number mapping in nested templates /// An enumerable of TemplateAnalyzer evaluations. - private IEnumerable AnalyzeAllIncludedTemplates(string populatedTemplate, string parameters, string templateFilePath, TemplateContext parentContext, string pathPrefix) + private IEnumerable AnalyzeAllIncludedTemplates(string template, string parameters, string templateFilePath, TemplateContext parentContext, string pathPrefix) { - JToken templatejObject; - var armTemplateProcessor = new ArmTemplateProcessor(populatedTemplate, logger: this.logger); + JToken expandedTemplate; + var armTemplateProcessor = new ArmTemplateProcessor(template, logger: this.logger); try { - templatejObject = armTemplateProcessor.ProcessTemplate(parameters); + expandedTemplate = armTemplateProcessor.ProcessTemplate(parameters); } catch (Exception e) { @@ -158,8 +158,8 @@ private IEnumerable AnalyzeAllIncludedTemplates(string populatedTem var templateContext = new TemplateContext { - OriginalTemplate = JObject.Parse(populatedTemplate), - ExpandedTemplate = templatejObject, + OriginalTemplate = JObject.Parse(template), + ExpandedTemplate = expandedTemplate, IsMainTemplate = parentContext.OriginalTemplate == null, // Even the top level context will have a parent defined, but it won't represent a processed template ResourceMappings = armTemplateProcessor.ResourceMappings, TemplateIdentifier = templateFilePath, @@ -179,62 +179,58 @@ private IEnumerable AnalyzeAllIncludedTemplates(string populatedTem evaluations = evaluations.Concat(this.powerShellRuleEngine.AnalyzeTemplate(templateContext)); } - // Recursively handle nested templates - var jsonTemplate = JObject.Parse(populatedTemplate); - var processedTemplateResources = templatejObject.InsensitiveToken("resources"); + var pathResolver = new JsonPathResolver(expandedTemplate, expandedTemplate.Path); + var deploymentResources = pathResolver.ResolveResourceType("Microsoft.Resources/deployments"); - for (int i = 0; i < processedTemplateResources.Count(); i++) + // Recursively handle nested templates + foreach (var deploymentResource in deploymentResources) { - var currentProcessedResource = processedTemplateResources[i]; - - if (currentProcessedResource.InsensitiveToken("type")?.ToString().Equals("Microsoft.Resources/deployments", StringComparison.OrdinalIgnoreCase) ?? false) + var currentProcessedResource = deploymentResource.JToken; + var nestedTemplate = currentProcessedResource.InsensitiveToken("properties.template"); + if (nestedTemplate == null) { - var nestedTemplate = currentProcessedResource.InsensitiveToken("properties.template"); - if (nestedTemplate == null) - { - this.logger?.LogWarning($"A linked template was found on: {templateFilePath}, linked templates are currently not supported"); - - continue; - } - var populatedNestedTemplate = nestedTemplate.DeepClone(); + this.logger?.LogWarning($"A linked template was found on: {templateFilePath}, linked templates are currently not supported"); - // Check whether scope is set to inner or outer - var scope = currentProcessedResource.InsensitiveToken("properties.expressionEvaluationOptions.scope")?.ToString(); + continue; + } + var populatedNestedTemplate = nestedTemplate.DeepClone(); - if (scope == null || scope.Equals("outer", StringComparison.OrdinalIgnoreCase)) - { - // Variables, parameters and functions inherited from parent template - string functionsKey = populatedNestedTemplate.InsensitiveToken("functions")?.Parent.Path ?? "functions"; - string variablesKey = populatedNestedTemplate.InsensitiveToken("variables")?.Parent.Path ?? "variables"; - string parametersKey = populatedNestedTemplate.InsensitiveToken("parameters")?.Parent.Path ?? "parameters"; + // Check whether scope is set to inner or outer + var scope = currentProcessedResource.InsensitiveToken("properties.expressionEvaluationOptions.scope")?.ToString(); - populatedNestedTemplate[functionsKey] = jsonTemplate.InsensitiveToken("functions"); - populatedNestedTemplate[variablesKey] = jsonTemplate.InsensitiveToken("variables"); - populatedNestedTemplate[parametersKey] = jsonTemplate.InsensitiveToken("parameters"); - } - else // scope is inner - { - // Pass variables and functions to child template - (populatedNestedTemplate.InsensitiveToken("variables") as JObject)?.Merge(currentProcessedResource.InsensitiveToken("properties.variables")); - (populatedNestedTemplate.InsensitiveToken("functions") as JObject)?.Merge(currentProcessedResource.InsensitiveToken("properties.functions)")); + if (scope == null || scope.Equals("outer", StringComparison.OrdinalIgnoreCase)) + { + // Variables, parameters and functions inherited from parent template + string functionsKey = populatedNestedTemplate.InsensitiveToken("functions")?.Parent.Path ?? "functions"; + string variablesKey = populatedNestedTemplate.InsensitiveToken("variables")?.Parent.Path ?? "variables"; + string parametersKey = populatedNestedTemplate.InsensitiveToken("parameters")?.Parent.Path ?? "parameters"; + + populatedNestedTemplate[functionsKey] = templateContext.OriginalTemplate.InsensitiveToken("functions"); + populatedNestedTemplate[variablesKey] = templateContext.OriginalTemplate.InsensitiveToken("variables"); + populatedNestedTemplate[parametersKey] = templateContext.OriginalTemplate.InsensitiveToken("parameters"); + } + else // scope is inner + { + // Pass variables and functions to child template + (populatedNestedTemplate.InsensitiveToken("variables") as JObject)?.Merge(currentProcessedResource.InsensitiveToken("properties.variables")); + (populatedNestedTemplate.InsensitiveToken("functions") as JObject)?.Merge(currentProcessedResource.InsensitiveToken("properties.functions)")); - // Pass parameters to child template as the 'parameters' argument - var parametersToPass = currentProcessedResource.InsensitiveToken("properties.parameters"); + // Pass parameters to child template as the 'parameters' argument + var parametersToPass = currentProcessedResource.InsensitiveToken("properties.parameters"); - if (parametersToPass != null) - { - parametersToPass["parameters"] = parametersToPass; - parametersToPass["$schema"] = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#"; - parametersToPass["contentVersion"] = "1.0.0.0"; - parameters = JsonConvert.SerializeObject(parametersToPass); - } + if (parametersToPass != null) + { + parametersToPass["parameters"] = parametersToPass; + parametersToPass["$schema"] = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#"; + parametersToPass["contentVersion"] = "1.0.0.0"; + parameters = JsonConvert.SerializeObject(parametersToPass); } + } - string jsonPopulatedNestedTemplate = JsonConvert.SerializeObject(populatedNestedTemplate); + string jsonPopulatedNestedTemplate = JsonConvert.SerializeObject(populatedNestedTemplate); - IEnumerable result = AnalyzeAllIncludedTemplates(jsonPopulatedNestedTemplate, parameters, templateFilePath, templateContext, nestedTemplate.Path); - evaluations = evaluations.Concat(result); - } + IEnumerable result = AnalyzeAllIncludedTemplates(jsonPopulatedNestedTemplate, parameters, templateFilePath, templateContext, nestedTemplate.Path); + evaluations = evaluations.Concat(result); } return evaluations; } From 246e4b23e965eb904041a9eb4be55168008da7bc Mon Sep 17 00:00:00 2001 From: Johnathon Mohr Date: Fri, 8 Aug 2025 15:36:01 -0700 Subject: [PATCH 3/5] Update Bicep and ARM deployment packages --- .../BicepTemplateProcessor.cs | 18 ++++++++++----- .../SourceMapFeatureProvider.cs | 22 +++++++++---------- src/Analyzer.Core.NuGet/Analyzer.Core.nuspec | 12 +++++----- .../Analyzer.Reports.nuspec | 4 ++-- .../Analyzer.TemplateProcessing.nuspec | 10 ++++----- .../ArmTemplateProcessor.cs | 4 +++- src/Directory.Packages.props | 10 ++++----- 7 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/Analyzer.BicepProcessor/BicepTemplateProcessor.cs b/src/Analyzer.BicepProcessor/BicepTemplateProcessor.cs index 5ba5e290..98be61bf 100644 --- a/src/Analyzer.BicepProcessor/BicepTemplateProcessor.cs +++ b/src/Analyzer.BicepProcessor/BicepTemplateProcessor.cs @@ -6,6 +6,7 @@ using System.IO; using System.IO.Abstractions; using System.Linq; +using System.Net.Http; using System.Text.RegularExpressions; using Bicep.Core; using Bicep.Core.Analyzers.Interfaces; @@ -19,13 +20,14 @@ using Bicep.Core.Navigation; using Bicep.Core.Registry; using Bicep.Core.Registry.Auth; -using Bicep.Core.Registry.PublicRegistry; +using Bicep.Core.Registry.Catalog; +using Bicep.Core.Registry.Catalog.Implementation.PublicRegistries; using Bicep.Core.Semantics.Namespaces; +using Bicep.Core.SourceGraph; using Bicep.Core.Syntax; using Bicep.Core.Text; using Bicep.Core.TypeSystem.Providers; using Bicep.Core.Utils; -using Bicep.Core.Workspaces; using Bicep.IO.Abstraction; using Bicep.IO.FileSystem; using Microsoft.Extensions.DependencyInjection; @@ -46,18 +48,22 @@ public static class BicepTemplateProcessor /// /// public static IServiceCollection AddBicepCore(this IServiceCollection services) => services + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -74,6 +80,7 @@ public static IServiceCollection AddBicepCore(this IServiceCollection services) /// /// The Bicep template file path. /// The compiled template as a JSON string and its source map. +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits public static (string, BicepMetadata) ConvertBicepToJson(string bicepPath) { if (BicepCompiler == null) @@ -113,7 +120,7 @@ moduleDeclaration.Path is StringSyntax moduleDeclarationPath && // Group by the source file path to allow for easy construction of SourceFileModuleInfo. var moduleInfo = compilation.SourceFileGrouping.ArtifactLookup .Where(IsResolvedLocalModuleReference) - .GroupBy(artifact => artifact.Value.Origin) + .GroupBy(artifact => artifact.Value.ReferencingFile) .Select(grouping => { var bicepSourceFile = grouping.Key; @@ -127,7 +134,7 @@ moduleDeclaration.Path is StringSyntax moduleDeclarationPath && { var module = artifactRefAndUriResult.Key as ModuleDeclarationSyntax; var moduleLine = TextCoordinateConverter.GetPosition(bicepSourceFile.LineStarts, module.Span.Position).line; - var modulePath = new FileInfo(artifactRefAndUriResult.Value.Result.Unwrap().AbsolutePath).FullName; // converts path to current platform + var modulePath = new FileInfo(artifactRefAndUriResult.Value.Result.Unwrap().Uri).FullName; // converts path to current platform // Use relative paths for bicep to match file paths used in bicep modules and source map if (modulePath.EndsWith(".bicep")) @@ -150,5 +157,6 @@ moduleDeclaration.Path is StringSyntax moduleDeclarationPath && return (stringWriter.ToString(), bicepMetadata); } +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits } } diff --git a/src/Analyzer.BicepProcessor/SourceMapFeatureProvider.cs b/src/Analyzer.BicepProcessor/SourceMapFeatureProvider.cs index 74bd8a73..abbecece 100644 --- a/src/Analyzer.BicepProcessor/SourceMapFeatureProvider.cs +++ b/src/Analyzer.BicepProcessor/SourceMapFeatureProvider.cs @@ -45,9 +45,6 @@ public SourceMapFeatureProvider(IFeatureProvider features) /// public bool SymbolicNameCodegenEnabled => features.SymbolicNameCodegenEnabled; - /// - public bool ExtensibilityEnabled => features.ExtensibilityEnabled; - /// public bool ResourceTypedParamsAndOutputsEnabled => features.ResourceTypedParamsAndOutputsEnabled; @@ -61,27 +58,30 @@ public SourceMapFeatureProvider(IFeatureProvider features) public bool AssertsEnabled => features.AssertsEnabled; /// - public bool OptionalModuleNamesEnabled => features.OptionalModuleNamesEnabled; + public bool LegacyFormatterEnabled => features.LegacyFormatterEnabled; /// - public bool ResourceDerivedTypesEnabled => features.ResourceDerivedTypesEnabled; + public bool LocalDeployEnabled => features.LocalDeployEnabled; /// - public bool LegacyFormatterEnabled => features.LegacyFormatterEnabled; + public bool ExtendableParamFilesEnabled => features.ExtendableParamFilesEnabled; /// - public bool LocalDeployEnabled => features.LocalDeployEnabled; + public bool ResourceInfoCodegenEnabled => features.ResourceInfoCodegenEnabled; /// - public bool ExtendableParamFilesEnabled => features.ExtendableParamFilesEnabled; + public bool WaitAndRetryEnabled => features.WaitAndRetryEnabled; /// - public bool SecureOutputsEnabled => features.SecureOutputsEnabled; + public bool OnlyIfNotExistsEnabled => features.OnlyIfNotExistsEnabled; /// - public bool ResourceInfoCodegenEnabled => features.ResourceInfoCodegenEnabled; + public bool ModuleExtensionConfigsEnabled => features.ModuleExtensionConfigsEnabled; + + /// + public bool DesiredStateConfigurationEnabled => features.DesiredStateConfigurationEnabled; /// - public bool ExtensibilityV2EmittingEnabled => features.ExtensibilityV2EmittingEnabled; + public bool ModuleIdentityEnabled => features.ModuleIdentityEnabled; } } diff --git a/src/Analyzer.Core.NuGet/Analyzer.Core.nuspec b/src/Analyzer.Core.NuGet/Analyzer.Core.nuspec index 52d9fc1d..031c7d37 100644 --- a/src/Analyzer.Core.NuGet/Analyzer.Core.nuspec +++ b/src/Analyzer.Core.NuGet/Analyzer.Core.nuspec @@ -12,17 +12,17 @@ - - - - + + + + - + - + diff --git a/src/Analyzer.Reports.NuGet/Analyzer.Reports.nuspec b/src/Analyzer.Reports.NuGet/Analyzer.Reports.nuspec index d346959f..cf1ff2d3 100644 --- a/src/Analyzer.Reports.NuGet/Analyzer.Reports.nuspec +++ b/src/Analyzer.Reports.NuGet/Analyzer.Reports.nuspec @@ -14,8 +14,8 @@ - - + + diff --git a/src/Analyzer.TemplateProcessing.NuGet/Analyzer.TemplateProcessing.nuspec b/src/Analyzer.TemplateProcessing.NuGet/Analyzer.TemplateProcessing.nuspec index 98d48616..d6c6ed26 100644 --- a/src/Analyzer.TemplateProcessing.NuGet/Analyzer.TemplateProcessing.nuspec +++ b/src/Analyzer.TemplateProcessing.NuGet/Analyzer.TemplateProcessing.nuspec @@ -13,12 +13,12 @@ - - - - + + + + - + diff --git a/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs b/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs index a9ea1d43..e99ecbc5 100644 --- a/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs +++ b/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs @@ -141,7 +141,7 @@ internal Template ParseAndValidateTemplate(InsensitiveDictionary paramet try { - TemplateEngine.ProcessTemplateLanguageExpressions(managementGroupName, subscriptionId, resourceGroupName, template, apiVersion, parameters, metadata, null); + TemplateEngine.ProcessTemplateLanguageExpressions(managementGroupName, subscriptionId, resourceGroupName, template, apiVersion, parameters, metadata, null, null); } catch (Exception ex) { @@ -451,6 +451,8 @@ private TemplateExpressionEvaluationHelper GetTemplateFunctionEvaluationHelper(T functionsLookup: functionsLookup, parametersLookup: parametersLookup, variablesLookup: variablesLookup, + templateExtensionAliases: template.Extensions?.Keys, + extensionConfigLookup: null, validationContext: null, metricsRecorder: null); diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 56f8f467..b1675711 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,10 +5,10 @@ $(NoWarn);NU1507 - - - - + + + + @@ -28,7 +28,7 @@ - + From e6c28ef08f3813e25b04ca5d1870a4a10f4106ae Mon Sep 17 00:00:00 2001 From: Johnathon Mohr Date: Mon, 11 Aug 2025 18:51:14 -0700 Subject: [PATCH 4/5] Reference symbolic name too when copying resource dependents --- src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs b/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs index e99ecbc5..c7ce11d7 100644 --- a/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs +++ b/src/Analyzer.TemplateProcessor/ArmTemplateProcessor.cs @@ -264,7 +264,10 @@ internal void CopyResourceDependants(TemplateResource templateResource) else { parentResourceName = parentResourceIds.Value; - var matchingResources = this.flattenedResources.Where(k => k.Key.StartsWith($"{parentResourceName} ", StringComparison.OrdinalIgnoreCase)).ToList(); + var matchingResources = this.flattenedResources.Where(k => + k.Key.StartsWith($"{parentResourceName} ", StringComparison.OrdinalIgnoreCase) || + string.Equals(k.Value.resource.SymbolicName, parentResourceName, StringComparison.OrdinalIgnoreCase)) + .ToList(); if (matchingResources.Count == 1) { parentResourceInfo = matchingResources.First().Value; From 32120a1f059af8b89ff48e3dd93b8c5f4e0eaf92 Mon Sep 17 00:00:00 2001 From: Johnathon Mohr Date: Mon, 11 Aug 2025 18:54:14 -0700 Subject: [PATCH 5/5] Update version. Fix VSCode launch profiles --- .vscode/launch.json | 8 ++++---- src/Directory.Build.props | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index cfa319c6..4b431fcc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net6.0/TemplateAnalyzer.dll", + "program": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net8.0/TemplateAnalyzer.dll", // Runs the analyzer CLI, analyzing the file currently active/being viewed in VS Code. // Alternatively, change ${file} to the path of a template to analyze that file. @@ -17,7 +17,7 @@ "${file}" //"-p", "path-to-parameters" ], - "cwd": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net6.0/", + "cwd": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net8.0/", "console": "internalConsole", "stopAtEntry": false }, @@ -26,7 +26,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net6.0/TemplateAnalyzer.dll", + "program": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net8.0/TemplateAnalyzer.dll", // Runs the analyzer CLI, analyzing the directory of the file currently active/being // viewed in VS Code. Alternatively, change ${fileDirname} to the path of a directory to @@ -35,7 +35,7 @@ "analyze-directory", "${fileDirname}", ], - "cwd": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net6.0/", + "cwd": "${workspaceFolder}/src/Analyzer.Cli/bin/Debug/net8.0/", "console": "internalConsole", "stopAtEntry": false }, diff --git a/src/Directory.Build.props b/src/Directory.Build.props index cb253c8f..ddfce865 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.8.5 + 0.8.6 Microsoft © Microsoft Corporation. All rights reserved.