diff --git a/docs/authoring-json-rules.md b/docs/authoring-json-rules.md index 5482ccd9..7c0edd60 100644 --- a/docs/authoring-json-rules.md +++ b/docs/authoring-json-rules.md @@ -259,6 +259,22 @@ Example: } ``` +#### **HasStableAKSVersion** +*Type: boolean* + +The `HasStableAKSVersion` operator checks if an AKS cluster is using a stable Kubernetes version for its region. This operator fetches the list of stable AKS versions from the official AKS releases API. Checks if the cluster's `kubernetesVersion` is in the stable list for its `location`. Returns true if `hasStableAKSVersion: true` and the version is stable or if `hasStableAKSVersion: false` and the version is NOT stable. + +Example: +```json +{ + "resourceType": "Microsoft.ContainerService/managedClusters", + "path": "properties", + "hasStableAKSVersion": true +} +``` + + + ### Structured Operators These operators build up a structure of child `Evaluation`s, and therefore contain additional operators inside them. These operators are not required to include a `path`. If `resourceType` or `path` are specified, that becomes the scope for all `Evaluation`s nested inside the operator. More information on [Scopes](#scopes) can be found below. diff --git a/src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/AKSClusters.json b/src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/AKSClusters.json new file mode 100644 index 00000000..b4e682f5 --- /dev/null +++ b/src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/AKSClusters.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "2022-03-01", + "name": "aks-cluster-good", + "location": "westeurope", + "properties": { + "kubernetesVersion": "1.32.5", + "dnsPrefix": "aksclusterone", + "agentPoolProfiles": [ + { + "name": "nodepool1", + "count": 3, + "vmSize": "Standard_DS2_v2", + "osType": "Linux", + "mode": "System" + } + ], + "networkProfile": { + "networkPlugin": "azure", + "loadBalancerSku": "standard" + } + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "name": "storagenotaks", + "location": "invalid-region", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2" + }, + { + "type": "Microsoft.CustomProviders/resourceProviders", + "apiVersion": "2018-09-01-preview", + "name": "custom-resource", + "location": "westeurope", + "properties": { + "kubernetesVersion": "1.28.5", + "description": "Custom resource simulating invalid version property" + } + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "2022-03-01", + "name": "aks-cluster-invalid-version", + "location": "northeurope", + "properties": { + "kubernetesVersion": "1.28.5", + "dnsPrefix": "aksclustertwo", + "agentPoolProfiles": [ + { + "name": "nodepool2", + "count": 2, + "vmSize": "Standard_B4ms", + "osType": "Linux", + "mode": "User" + } + ], + "enableRBAC": true, + "networkProfile": { + "networkPlugin": "kubenet", + "outboundType": "loadBalancer" + } + } + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "2022-03-01", + "name": "aks-cluster-invalid-location", + "location": "invalid-region", + "properties": { + "kubernetesVersion": "1.32.2", + "dnsPrefix": "aksclusterinvalid", + "agentPoolProfiles": [ + { + "name": "nodepool3", + "count": 1, + "vmSize": "Standard_B2s", + "osType": "Linux", + "mode": "System" + } + ], + "enableRBAC": true, + "networkProfile": { + "networkPlugin": "azure", + "outboundType": "loadBalancer" + } + } + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "2022-03-01", + "name": "aks-cluster-old-version", + "location": "westeurope", + "properties": { + "kubernetesVersion": "1.18.0", + "dnsPrefix": "aksclusterold", + "agentPoolProfiles": [ + { + "name": "nodepool4", + "count": 1, + "vmSize": "Standard_B2s", + "osType": "Linux", + "mode": "System" + } + ], + "enableRBAC": true, + "networkProfile": { + "networkPlugin": "azure", + "outboundType": "loadBalancer" + } + } + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "2022-03-01", + "name": "aks-cluster-nolocation-noversion", + "properties": { + "dnsPrefix": "aksnolocnoversion", + "agentPoolProfiles": [ + { + "name": "nodepool5", + "count": 1, + "vmSize": "Standard_B2s", + "osType": "Linux", + "mode": "System" + } + ] + } + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "2022-03-01", + "name": "aks-cluster-noversion", + "location": "northeurope", + "properties": { + "dnsPrefix": "aksnoversion", + "agentPoolProfiles": [ + { + "name": "nodepool6", + "count": 2, + "vmSize": "Standard_B4ms", + "osType": "Linux", + "mode": "User" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/TA-000039.json b/src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/TA-000039.json new file mode 100644 index 00000000..e7947a32 --- /dev/null +++ b/src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/TA-000039.json @@ -0,0 +1,41 @@ +[ + { + "Template": "AKSClusters.json", + "ReportedFailures": [ + { + "LineNumber": 46, + "Description": "AKS cluster using unstable Kubernetes version" + }, + { + "LineNumber": 70, + "Description": "AKS cluster using invalid location" + }, + { + "LineNumber": 94, + "Description": "AKS cluster using very old Kubernetes version" + }, + { + "LineNumber": 118, + "Description": "AKS cluster missing Kubernetes version" + }, + { + "LineNumber": 134, + "Description": "AKS cluster missing Kubernetes version" + } + ], + "PassingSections": [ + { + "ResourceName": "aks-cluster-good", + "Explanation": "AKS cluster using stable Kubernetes version 1.32.5 in supported region westeurope" + }, + { + "ResourceName": "storagenotaks", + "Explanation": "Not an AKS resource, rule does not apply" + }, + { + "ResourceName": "custom-resource", + "Explanation": "Not an AKS resource, rule does not apply" + } + ] + } +] \ No newline at end of file diff --git a/src/Analyzer.Core/Rules/BuiltInRules.json b/src/Analyzer.Core/Rules/BuiltInRules.json index 4ac1b61b..13e712f4 100644 --- a/src/Analyzer.Core/Rules/BuiltInRules.json +++ b/src/Analyzer.Core/Rules/BuiltInRules.json @@ -1096,5 +1096,19 @@ } ] } + }, + { + "id": "TA-000039", + "name": "AKS.UseStableKubernetesVersion", + "shortDescription": "AKS clusters should use stable Kubernetes versions", + "fullDescription": "AKS clusters should use stable Kubernetes versions for their region to ensure security and support.", + "recommendation": "Use a stable Kubernetes version for your AKS cluster", + "helpUri": "https://eng.ms/docs/microsoft-security/digital-security-and-resilience/azure-security/security-health-analytics/sha-intelligence/threat-vulnerability-management/remediate/actions/upgradeaksclusterversion", + "severity": 2, + "evaluation": { + "resourceType": "Microsoft.ContainerService/managedClusters", + "path": "properties", + "hasStableAKSVersion": true + } } ] \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine.FunctionalTests/RuleParsingTests.cs b/src/Analyzer.JsonRuleEngine.FunctionalTests/RuleParsingTests.cs index 57d85c62..76815aac 100644 --- a/src/Analyzer.JsonRuleEngine.FunctionalTests/RuleParsingTests.cs +++ b/src/Analyzer.JsonRuleEngine.FunctionalTests/RuleParsingTests.cs @@ -9,6 +9,7 @@ using Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Schemas; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.FunctionalTests { @@ -29,6 +30,8 @@ public class RuleParsingTests [DataTestMethod] [DataRow("hasValue", false, typeof(HasValueOperator), DisplayName = "HasValue: false")] [DataRow("exists", true, typeof(ExistsOperator), DisplayName = "Exists: true")] + [DataRow("hasStableAKSVersion", true, typeof(HasStableAksVersionOperator), DisplayName = "HasStableAKSVersion: true")] + [DataRow("hasStableAKSVersion", false, typeof(HasStableAksVersionOperator), DisplayName = "HasStableAKSVersion: false")] [DataRow("greater", "2021-02-28", typeof(InequalityOperator), DisplayName = "Greater: 2021-02-28")] [DataRow("greater", "2021-02-28T18:17:16Z", typeof(InequalityOperator), DisplayName = "Greater: 2021-02-28T18:17:16Z")] [DataRow("greater", "2021-02-28T18:17:16+00:00", typeof(InequalityOperator), DisplayName = "Greater: 2021-02-28T18:17:16+00:00")] @@ -107,6 +110,15 @@ private static void InequalityValidation(InequalityOperator inequalityOperator, Assert.IsTrue(parsedDate.Second == 0 || parsedDate.Second == 16); } + [OperatorSpecificValidator(typeof(HasStableAksVersionOperator))] + private static void HasStableAksVersionValidation(HasStableAksVersionOperator hasStableAksVersionOperator, bool operatorValue) + { + var actualValue = hasStableAksVersionOperator.SpecifiedValue.ToObject(); + Assert.AreEqual(operatorValue, actualValue); + Assert.IsFalse(hasStableAksVersionOperator.IsNegative); + Assert.AreEqual("HasStableAksVersion", hasStableAksVersionOperator.Name); + } + private const string TestResourceType = "Namespace/ResourceType"; private const string TestPath = "json.path"; } diff --git a/src/Analyzer.JsonRuleEngine.FunctionalTests/ScopeSelectionTests.cs b/src/Analyzer.JsonRuleEngine.FunctionalTests/ScopeSelectionTests.cs index 7b499e99..95e67edb 100644 --- a/src/Analyzer.JsonRuleEngine.FunctionalTests/ScopeSelectionTests.cs +++ b/src/Analyzer.JsonRuleEngine.FunctionalTests/ScopeSelectionTests.cs @@ -142,6 +142,43 @@ public class ScopeSelectionTests ] } } + }, + { + ""apiVersion"": ""2023-05-01"", + ""type"": ""Microsoft.ContainerService/managedClusters"", + ""name"": ""myAKSCluster1"", + ""location"": ""[parameters('location')]"", + ""properties"": { + ""kubernetesVersion"": ""1.28.9"", + ""dnsPrefix"": ""myaks1"", + ""agentPoolProfiles"": [ + { + ""name"": ""nodepool1"", + ""count"": 3, + ""vmSize"": ""Standard_DS2_v2"" + } + ] + } + }, + { + ""apiVersion"": ""2023-05-01"", + ""type"": ""Microsoft.ContainerService/managedClusters"", + ""name"": ""myAKSCluster2"", + ""location"": ""[parameters('location')]"", + ""properties"": { + ""kubernetesVersion"": ""1.29.4"", + ""dnsPrefix"": ""myaks2"", + ""agentPoolProfiles"": [ + { + ""name"": ""nodepool1"", + ""count"": 1, + ""vmSize"": ""Standard_B2s"" + } + ], + ""networkProfile"": { + ""networkPlugin"": ""azure"" + } + } } ] }"; @@ -168,88 +205,106 @@ public override IEnumerable Evaluate(IJsonPathResolver jsonS } } - [DataTestMethod] - [DataRow(null, "outputs", - null, // Unresolved static path returns null - DisplayName = "No resource type, path not resolved")] - [DataRow(null, "$schema", - "$schema", - DisplayName = "No resource type, path resolved")] - [DataRow(null, "params.*", - // No scopes expected evaluated (unresolved wildcard returns empty) - DisplayName = "No resource type, wildcard path does not resolve")] - [DataRow(null, "parameters.*", - "parameters.location", - DisplayName = "No resource type, wildcard path resolves single path")] - [DataRow(null, "resources[*]", - "resources[0]", "resources[1]", "resources[2]", "resources[3]", "resources[4]", - DisplayName = "No resource type, wildcard path resolves multiple paths")] - [DataRow("Microsoft.Storage/storageAccounts", null, - // No scopes expected evaluated (no resources to evaluate) - DisplayName = "Resource type matches none, no path")] - [DataRow("Microsoft.Network/virtualNetworks", null, - "resources[0]", - DisplayName = "Resource type matches 1, no path")] - [DataRow("Microsoft.Compute/virtualMachines", null, - "resources[3]", "resources[4]", - DisplayName = "Resource type matches multiple, no path")] - [DataRow("Microsoft.Storage/storageAccounts", "name", - // No scopes expected evaluated (no resources to evaluate) - DisplayName = "Resource type matches none, path not resolved")] - [DataRow("Microsoft.Network/virtualNetworks", "properties.addressSpace", - null, // Unresolved static path returns null - DisplayName = "Resource type matches 1, path not resolved")] - [DataRow("Microsoft.Network/virtualNetworks", "location", - "resources[0].location", - DisplayName = "Resource type matches 1, path resolved")] - [DataRow("Microsoft.Network/virtualNetworks", "dependsOn[*]", - // No scopes expected evaluated (unresolved wildcard returns empty) - DisplayName = "Resource type matches 1, wildcard path does not resolve")] - [DataRow("Microsoft.Network/virtualNetworks", "properties.subnets[*]", - "resources[0].properties.subnets[0]", - DisplayName = "Resource type matches 1, wildcard path resolves single path")] - [DataRow("Microsoft.Network/virtualNetworks", "properties.*", - "resources[0].properties.subnets[0]", "resources[0].properties.enableDdosProtection", - DisplayName = "Resource type matches 1, wildcard path resolves multiple paths")] - [DataRow("Microsoft.Network/networkInterfaces", "properties.dnsSettings", - null, null, // Unresolved static paths return null - DisplayName = "Resource type matches multiple, path not resolved")] - [DataRow("Microsoft.Network/networkInterfaces", "properties.networkSecurityGroup", - "resources[1].properties.networkSecurityGroup", null, - DisplayName = "Resource type matches multiple, path resolves in 1")] - [DataRow("Microsoft.Network/networkInterfaces", "properties.ipConfigurations[0]", - "resources[1].properties.ipConfigurations[0]", "resources[2].properties.ipConfigurations[0]", - DisplayName = "Resource type matches multiple, path resolves in all")] - [DataRow("Microsoft.Compute/virtualMachines", "properties.hardwareProfile.*", - // No scopes expected evaluated (unresolved wildcard returns empty) - DisplayName = "Resource type matches multiple, wildcard path does not resolve")] - [DataRow("Microsoft.Compute/virtualMachines", "properties.*.customData", - "resources[4].properties.osProfile.customData", - DisplayName = "Resource type matches multiple, wildcard path resolves single path in 1")] - [DataRow("Microsoft.Compute/virtualMachines", "properties.networkProfile.networkInterfaces[*]", - "resources[3].properties.networkProfile.networkInterfaces[0]", "resources[4].properties.networkProfile.networkInterfaces[0]", - DisplayName = "Resource type matches multiple, wildcard path resolves single path in all")] - [DataRow("Microsoft.Compute/virtualMachines", "tags.*", - "resources[3].tags.Tag1", "resources[3].tags.Tag2", "resources[4].tags.Tag1", "resources[4].tags.Tag2", - DisplayName = "Resource type matches multiple, wildcard path resolves multiple paths")] + [DataTestMethod] + [DataRow(null, "outputs", + null, // Unresolved static path returns null + DisplayName = "No resource type, path not resolved")] + [DataRow(null, "$schema", + "$schema", + DisplayName = "No resource type, path resolved")] + [DataRow(null, "params.*", + // No scopes expected evaluated (unresolved wildcard returns empty) + DisplayName = "No resource type, wildcard path does not resolve")] + [DataRow(null, "parameters.*", + "parameters.location", + DisplayName = "No resource type, wildcard path resolves single path")] + [DataRow(null, "resources[*]", + "resources[0]", "resources[1]", "resources[2]", "resources[3]", "resources[4]", "resources[5]", "resources[6]", + DisplayName = "No resource type, wildcard path resolves multiple paths")] + [DataRow("Microsoft.Storage/storageAccounts", null, + // No scopes expected evaluated (no resources to evaluate) + DisplayName = "Resource type matches none, no path")] + [DataRow("Microsoft.Network/virtualNetworks", null, + "resources[0]", + DisplayName = "Resource type matches 1, no path")] + [DataRow("Microsoft.Compute/virtualMachines", null, + "resources[3]", "resources[4]", + DisplayName = "Resource type matches multiple, no path")] + [DataRow("Microsoft.Storage/storageAccounts", "name", + // No scopes expected evaluated (no resources to evaluate) + DisplayName = "Resource type matches none, path not resolved")] + [DataRow("Microsoft.Network/virtualNetworks", "properties.addressSpace", + null, // Unresolved static path returns null + DisplayName = "Resource type matches 1, path not resolved")] + [DataRow("Microsoft.Network/virtualNetworks", "location", + "resources[0].location", + DisplayName = "Resource type matches 1, path resolved")] + [DataRow("Microsoft.Network/virtualNetworks", "dependsOn[*]", + // No scopes expected evaluated (unresolved wildcard returns empty) + DisplayName = "Resource type matches 1, wildcard path does not resolve")] + [DataRow("Microsoft.Network/virtualNetworks", "properties.subnets[*]", + "resources[0].properties.subnets[0]", + DisplayName = "Resource type matches 1, wildcard path resolves single path")] + [DataRow("Microsoft.Network/virtualNetworks", "properties.*", + "resources[0].properties.subnets[0]", "resources[0].properties.enableDdosProtection", + DisplayName = "Resource type matches 1, wildcard path resolves multiple paths")] + [DataRow("Microsoft.Network/networkInterfaces", "properties.dnsSettings", + null, null, // Unresolved static paths return null + DisplayName = "Resource type matches multiple, path not resolved")] + [DataRow("Microsoft.Network/networkInterfaces", "properties.networkSecurityGroup", + "resources[1].properties.networkSecurityGroup", null, + DisplayName = "Resource type matches multiple, path resolves in 1")] + [DataRow("Microsoft.Network/networkInterfaces", "properties.ipConfigurations[0]", + "resources[1].properties.ipConfigurations[0]", "resources[2].properties.ipConfigurations[0]", + DisplayName = "Resource type matches multiple, path resolves in all")] + [DataRow("Microsoft.Compute/virtualMachines", "properties.hardwareProfile.*", + // No scopes expected evaluated (unresolved wildcard returns empty) + DisplayName = "Resource type matches multiple, wildcard path does not resolve")] + [DataRow("Microsoft.Compute/virtualMachines", "properties.*.customData", + "resources[4].properties.osProfile.customData", + DisplayName = "Resource type matches multiple, wildcard path resolves single path in 1")] + [DataRow("Microsoft.Compute/virtualMachines", "properties.networkProfile.networkInterfaces[*]", + "resources[3].properties.networkProfile.networkInterfaces[0]", "resources[4].properties.networkProfile.networkInterfaces[0]", + DisplayName = "Resource type matches multiple, wildcard path resolves single path in all")] + [DataRow("Microsoft.Compute/virtualMachines", "tags.*", + "resources[3].tags.Tag1", "resources[3].tags.Tag2", "resources[4].tags.Tag1", "resources[4].tags.Tag2", + DisplayName = "Resource type matches multiple, wildcard path resolves multiple paths")] + [DataRow("Microsoft.ContainerService/managedClusters", null, + "resources[5]", "resources[6]", + DisplayName = "Resource type matches multiple AKS clusters, no path")] + [DataRow("Microsoft.ContainerService/managedClusters", "properties.kubernetesVersion", + "resources[5].properties.kubernetesVersion", "resources[6].properties.kubernetesVersion", + DisplayName = "Resource type matches multiple AKS clusters, path resolves in all")] + [DataRow("Microsoft.ContainerService/managedClusters", "properties.dnsPrefix", + "resources[5].properties.dnsPrefix", "resources[6].properties.dnsPrefix", + DisplayName = "Resource type matches multiple AKS clusters, path resolves in all")] + [DataRow("Microsoft.ContainerService/managedClusters", "properties.networkProfile.networkPlugin", + null, "resources[6].properties.networkProfile.networkPlugin", + DisplayName = "Resource type matches multiple AKS clusters, path resolves in 1")] + [DataRow("Microsoft.ContainerService/managedClusters", "properties.agentPoolProfiles[*]", + "resources[5].properties.agentPoolProfiles[0]", "resources[6].properties.agentPoolProfiles[0]", + DisplayName = "Resource type matches multiple AKS clusters, wildcard path resolves single path in all")] + [DataRow("Microsoft.ContainerService/managedClusters", "properties.invalidProperty", + null, null, // Unresolved static paths return null + DisplayName = "Resource type matches multiple AKS clusters, path not resolved")] public void EvaluateTemplate_ExpressionsWithVariousScopes_CorrectScopesAreEvaluated(string resourceType, string path, params string[] expectedPaths) - { - var scopesEvaluated = new List(); - var expression = new MockExpression(new ExpressionCommonProperties { ResourceType = resourceType, Path = path }) - { - // Track what scopes were called to evaluate with - EvaluationCallback = scope => scopesEvaluated.Add(scope) - }; + { + var scopesEvaluated = new List(); + var expression = new MockExpression(new ExpressionCommonProperties { ResourceType = resourceType, Path = path }) + { + // Track what scopes were called to evaluate with + EvaluationCallback = scope => scopesEvaluated.Add(scope) + }; - expression.Evaluate(new JsonPathResolver(JToken.Parse(mockTemplate), "")); + expression.Evaluate(new JsonPathResolver(JToken.Parse(mockTemplate), "")); - Assert.AreEqual(expectedPaths.Length, scopesEvaluated.Count); + Assert.AreEqual(expectedPaths.Length, scopesEvaluated.Count); - // Verify all scopes evaluated (if any) were the expected scopes - for (int i = 0; i < expectedPaths.Length; i++) - { - Assert.AreEqual(expectedPaths[i], scopesEvaluated[i].JToken?.Path, ignoreCase: true); - } - } + // Verify all scopes evaluated (if any) were the expected scopes + for (int i = 0; i < expectedPaths.Length; i++) + { + Assert.AreEqual(expectedPaths[i], scopesEvaluated[i].JToken?.Path, ignoreCase: true); + } + } } } diff --git a/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs new file mode 100644 index 00000000..192a825d --- /dev/null +++ b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.UnitTests +{ + [TestClass] + public class DefaultStableAksVersionProviderTests + { + /// + /// Test-specific provider that creates new instances for each test + /// + private class TestableDefaultStableAksVersionProvider : IStableAksVersionProvider + { + private readonly DefaultStableAksVersionProvider _inner; + private readonly FieldInfo _cacheField; + private readonly FieldInfo _lockField; + + public TestableDefaultStableAksVersionProvider() + { + // Use reflection to create a new instance bypassing the singleton + var constructorInfo = typeof(DefaultStableAksVersionProvider) + .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null); + + _inner = (DefaultStableAksVersionProvider)constructorInfo.Invoke(null); + + _cacheField = typeof(DefaultStableAksVersionProvider) + .GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance); + _lockField = typeof(DefaultStableAksVersionProvider) + .GetField("_lock", BindingFlags.NonPublic | BindingFlags.Instance); + } + + public bool TryGetStableVersions(string normalizedLocation, out ISet versions) + { + return _inner.TryGetStableVersions(normalizedLocation, out versions); + } + + public void ResetCache() + { + var lockObject = _lockField.GetValue(_inner); + lock (lockObject) + { + _cacheField.SetValue(_inner, null); + } + } + + public bool IsCacheInitialized() + { + return _cacheField.GetValue(_inner) != null; + } + + public DefaultStableAksVersionProvider GetInnerProvider() => _inner; + } + + private TestableDefaultStableAksVersionProvider _testProvider; + + [TestInitialize] + public void TestInitialize() + { + // Create a fresh provider for each test + _testProvider = new TestableDefaultStableAksVersionProvider(); + + // Set it in the registry so HasStableAksVersionOperator will use it + StableAksVersionProviderRegistry.SetProvider(_testProvider); + } + + [TestCleanup] + public void TestCleanup() + { + // Reset to default provider after each test + StableAksVersionProviderRegistry.ResetToDefault(); + } + + [TestMethod] + public void Instance_WhenAccessed_ReturnsSameInstanceAlways() + { + // Test the actual singleton behavior + var instance1 = DefaultStableAksVersionProvider.Instance; + var instance2 = DefaultStableAksVersionProvider.Instance; + + Assert.IsNotNull(instance1); + Assert.AreSame(instance1, instance2); + } + + [TestMethod] + [TestCategory("Integration")] + public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults() + { + // Verify cache is initially empty + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized at start"); + + // First call - should initialize cache and fetch from API + var result1 = _testProvider.TryGetStableVersions("eastus", out var versions1); + + // Verify cache is now populated + Assert.IsTrue(_testProvider.IsCacheInitialized(), "Cache should be initialized after first call"); + + // Second call - should use cached data + var result2 = _testProvider.TryGetStableVersions("eastus", out var versions2); + + // Results should be consistent + Assert.AreEqual(result1, result2); + + if (result1) + { + Assert.IsNotNull(versions1); + Assert.IsNotNull(versions2); + Assert.AreEqual(versions1.Count, versions2.Count); + CollectionAssert.AreEquivalent(versions1.ToList(), versions2.ToList()); + } + } + + [TestMethod] + [TestCategory("Integration")] + public void TryGetStableVersions_WithDifferentCasing_NormalizesCorrectly() + { + // This test will fetch fresh data since we have a new provider instance + var result1 = _testProvider.TryGetStableVersions("eastus", out var versions1); + var result2 = _testProvider.TryGetStableVersions("EASTUS", out var versions2); + var result3 = _testProvider.TryGetStableVersions("EastUS", out var versions3); + + // All should return the same result due to normalization + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + + if (result1) + { + Assert.IsTrue(versions1.Count > 0, "Should have versions for eastus"); + CollectionAssert.AreEquivalent(versions1.ToList(), versions2.ToList()); + CollectionAssert.AreEquivalent(versions2.ToList(), versions3.ToList()); + } + } + + [TestMethod] + [TestCategory("Integration")] + public void TryGetStableVersions_WithSpacesInLocation_HandlesCorrectly() + { + // Test the actual behavior based on implementation + var result1 = _testProvider.TryGetStableVersions("westeurope", out var versions1); + var result2 = _testProvider.TryGetStableVersions("west europe", out var versions2); + var result3 = _testProvider.TryGetStableVersions("West Europe", out var versions3); + + Assert.IsTrue(result1, "Should find westeurope (exact match with cache key)"); + Assert.IsFalse(result2, "Should NOT find 'west europe' (doesn't match normalized cache key)"); + Assert.IsFalse(result3, "Should NOT find 'West Europe' (doesn't match normalized cache key)"); + + if (result1) + { + Assert.IsNotNull(versions1); + Assert.IsTrue(versions1.Count > 0, "Should have versions for westeurope"); + } + + Assert.IsNull(versions2, "Should be null when not found"); + Assert.IsNull(versions3, "Should be null when not found"); + } + + [TestMethod] + public void TryGetStableVersions_WithNullLocation_ReturnsFalse() + { + var result = _testProvider.TryGetStableVersions(null, out var versions); + + Assert.IsFalse(result); + Assert.IsNull(versions); + + // Verify cache was not initialized for null input + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized for null input"); + } + + [TestMethod] + public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() + { + var result1 = _testProvider.TryGetStableVersions("", out var versions1); + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized for empty input"); + var result2 = _testProvider.TryGetStableVersions(" ", out var versions2); + Assert.IsTrue(_testProvider.IsCacheInitialized(), "Cache is initialized now"); + + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsNull(versions1); + Assert.IsNull(versions2); + } + + [TestMethod] + [TestCategory("Integration")] + public void TryGetStableVersions_WithUnknownLocation_ReturnsFalse() + { + // This will initialize the cache with real data + var knownResult = _testProvider.TryGetStableVersions("eastus", out _); + + // Now test with unknown locations + var result1 = _testProvider.TryGetStableVersions("nonexistentregion", out var versions1); + var result2 = _testProvider.TryGetStableVersions("marscentral", out var versions2); + + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsNull(versions1); + Assert.IsNull(versions2); + } + + [TestMethod] + [TestCategory("Integration")] + public void EnsureInitialized_CalledMultipleTimes_OnlyFetchesOnce() + { + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized"); + + // First call should fetch data + var startTime = DateTime.UtcNow; + _testProvider.TryGetStableVersions("eastus", out _); + var firstCallTime = DateTime.UtcNow - startTime; + + Assert.IsTrue(_testProvider.IsCacheInitialized(), "Cache should be initialized after first call"); + + // Second call should be much faster (no fetch) + startTime = DateTime.UtcNow; + _testProvider.TryGetStableVersions("westus", out _); + var secondCallTime = DateTime.UtcNow - startTime; + + // Second call should be significantly faster + Assert.IsTrue(secondCallTime.TotalMilliseconds < firstCallTime.TotalMilliseconds / 10 || + secondCallTime.TotalMilliseconds < 50, + $"Second call ({secondCallTime.TotalMilliseconds}ms) should be much faster than first ({firstCallTime.TotalMilliseconds}ms)"); + } + + [TestMethod] + [TestCategory("Integration")] + public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionFormats() + { + var result = _testProvider.TryGetStableVersions("eastus", out var versions); + + if (result) + { + Assert.IsNotNull(versions); + Assert.IsTrue(versions.Count > 0, "Should have at least one version"); + + foreach (var version in versions) + { + // Verify semantic versioning format + Assert.IsTrue(System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+\.\d+"), + $"Version '{version}' should follow semantic versioning"); + + // Verify no HTML or markers + Assert.IsFalse(version.Contains("<"), $"Version '{version}' should not contain HTML"); + Assert.IsFalse(version.Contains("(LTS)"), $"Version '{version}' should not contain LTS marker"); + } + } + } + + [TestMethod] + [TestCategory("Integration")] + public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequests() + { + var results = new bool[10]; + var versionCounts = new int[10]; + var exceptions = new List(); + + // Test thread safety with parallel requests + System.Threading.Tasks.Parallel.For(0, 10, i => + { + try + { + results[i] = _testProvider.TryGetStableVersions("eastus", out var versions); + versionCounts[i] = versions?.Count ?? 0; + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + + // Should not have any exceptions + Assert.AreEqual(0, exceptions.Count, + $"Thread safety test failed with exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + + // All calls should return the same result + var firstResult = results[0]; + Assert.IsTrue(results.All(r => r == firstResult), "All parallel calls should succeed/fail consistently"); + + if (firstResult) + { + var firstCount = versionCounts[0]; + Assert.IsTrue(versionCounts.All(c => c == firstCount), "All parallel calls should return same version count"); + } + } + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine.UnitTests/HasStableAksVersionOperatorTests.cs b/src/Analyzer.JsonRuleEngine.UnitTests/HasStableAksVersionOperatorTests.cs new file mode 100644 index 00000000..6de7ff1e --- /dev/null +++ b/src/Analyzer.JsonRuleEngine.UnitTests/HasStableAksVersionOperatorTests.cs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.UnitTests +{ + [TestClass] + public class HasStableAksVersionOperatorTests + { + private InMemoryStableAksVersionProvider _mockProvider; + + private static readonly Dictionary> MockData = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["eastus"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "1.30.12", "1.29.15", "1.28.101" }, + ["westus"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "1.30.12", "1.29.14", "1.28.100" }, + ["qatarcentral"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "1.33.2", "1.32.6", "1.31.10" }, + ["westeurope"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "1.30.12", "1.29.15" } + }; + + [TestInitialize] + public void TestInitialize() + { + _mockProvider = new InMemoryStableAksVersionProvider(MockData); + StableAksVersionProviderRegistry.SetProvider(_mockProvider); + } + + [TestCleanup] + public void TestCleanup() + { + StableAksVersionProviderRegistry.ResetToDefault(); + } + + [DataTestMethod] + [DataRow("eastus", "1.30.12", DisplayName = "Stable version in East US")] + [DataRow("westus", "1.29.14", DisplayName = "Stable version in West US")] + [DataRow("Qatar Central", "1.33.2", DisplayName = "Stable version in Qatar Central with spaces")] + [DataRow("WESTEUROPE", "1.30.12", DisplayName = "Stable version with uppercase location")] + [DataRow("WestEurope", "1.29.15", DisplayName = "Stable version with mixed case location")] + public void EvaluateExpression_StableVersion_HasStableAksVersionIsTrue(string location, string version) + { + var aksResource = TestUtilities.CreateAksResource(location, version); + + // Test using default constructor (uses registry) + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false); + Assert.IsTrue(hasStableOperator.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false); + Assert.IsFalse(doesNotHaveStableOperator.EvaluateExpression(aksResource)); + + // Test using dependency injection constructor + var hasStableOperatorDI = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsTrue(hasStableOperatorDI.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperatorDI = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsFalse(doesNotHaveStableOperatorDI.EvaluateExpression(aksResource)); + } + + [DataTestMethod] + [DataRow("eastus", "1.31.0", DisplayName = "Unstable version in East US")] + [DataRow("westus", "1.27.100", DisplayName = "Unstable version in West US")] + [DataRow("qatarcentral", "1.30.12", DisplayName = "Version not available in Qatar Central")] + [DataRow("West Europe", "1.28.100", DisplayName = "Version not available in West Europe")] + public void EvaluateExpression_UnstableVersion_HasStableAksVersionIsFalse(string location, string version) + { + var aksResource = TestUtilities.CreateAksResource(location, version); + + // Test using default constructor (uses registry) + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false); + Assert.IsFalse(hasStableOperator.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(aksResource)); + + // Test using dependency injection constructor + var hasStableOperatorDI = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperatorDI.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperatorDI = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperatorDI.EvaluateExpression(aksResource)); + } + + [DataTestMethod] + [DataRow("unknownregion", "1.30.12", DisplayName = "Unknown region")] + [DataRow("mars-central", "1.29.15", DisplayName = "Non-existent region")] + public void EvaluateExpression_UnknownLocation_TreatedAsUnstable(string location, string version) + { + var aksResource = TestUtilities.CreateAksResource(location, version); + + // Test using default constructor (uses registry) + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false); + Assert.IsFalse(hasStableOperator.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(aksResource)); + + // Test using dependency injection constructor + var hasStableOperatorDI = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperatorDI.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperatorDI = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperatorDI.EvaluateExpression(aksResource)); + } + + [TestMethod] + public void EvaluateExpression_MissingLocation_TreatedAsUnstable() + { + var resource = new + { + type = "Microsoft.ContainerService/managedClusters", + properties = new + { + kubernetesVersion = "1.30.2", + } + }; + + var jObjectResource = JObject.FromObject(resource); + + // Test using dependency injection constructor + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(jObjectResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(jObjectResource)); + } + + [TestMethod] + public void EvaluateExpression_MissingKubernetesVersion_TreatedAsUnstable() + { + var resource = new + { + type = "Microsoft.ContainerService/managedClusters", + location = "eastus", + properties = new + { + linuxProfile = new + { + adminUsername = "azureuser", + ssh = new + { + publicKeys = new[] + { + new { keyData = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..." } + } + } + } + } + }; + + var jObjectResource = JObject.FromObject(resource); + + // Test using dependency injection constructor + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(jObjectResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(jObjectResource)); + } + + [TestMethod] + public void EvaluateExpression_MissingProperties_TreatedAsUnstable() + { + var resource = new + { + type = "Microsoft.ContainerService/managedClusters", + apiVersion = "2025-06-02-preview", + name = "test-aks-cluster", + location = "eastus" + }; + + var jObjectResource = JObject.FromObject(resource); + + // Test using dependency injection constructor + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(jObjectResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(jObjectResource)); + } + + [TestMethod] + public void EvaluateExpression_NullToken_TreatedAsUnstable() + { + // Test using dependency injection constructor + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(null)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(null)); + } + + [DataTestMethod] + [DataRow("", "1.30.12", DisplayName = "Empty location string")] + [DataRow("eastus", "", DisplayName = "Empty version string")] + [DataRow("", "", DisplayName = "Both empty strings")] + public void EvaluateExpression_EmptyStrings_TreatedAsUnstable(string location, string version) + { + var aksResource = TestUtilities.CreateAksResource(location, version); + + // Test using dependency injection constructor + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, _mockProvider); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(aksResource)); + } + + [TestMethod] + public void EvaluateExpression_WithIsNegative_InvertsResult() + { + var aksResource = TestUtilities.CreateAksResource("eastus", "1.30.12"); + + // Stable version with isNegative: true should invert the result + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: true, _mockProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(aksResource)); + + // Unstable version + aksResource = TestUtilities.CreateAksResource("eastus", "1.31.0"); + hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: true, _mockProvider); + Assert.IsTrue(hasStableOperator.EvaluateExpression(aksResource)); + } + + [TestMethod] + public void Name_WhenAccessed_ReturnsHasStableAksVersion() + { + // Test both constructors return the same name + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(true, false).Name); + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(true, true).Name); + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(false, false).Name); + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(false, true).Name); + + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(true, false, _mockProvider).Name); + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(true, true, _mockProvider).Name); + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(false, false, _mockProvider).Name); + Assert.AreEqual("HasStableAksVersion", new HasStableAksVersionOperator(false, true, _mockProvider).Name); + } + + [TestMethod] + public void Constructor_WhenCalledWithValidParameters_SetsPropertiesCorrectly() + { + // Test default constructor + var operatorTrue = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false); + Assert.AreEqual(true, operatorTrue.SpecifiedValue.Value()); + Assert.AreEqual(false, operatorTrue.IsNegative); + + var operatorFalse = new HasStableAksVersionOperator(specifiedValue: false, isNegative: true); + Assert.AreEqual(false, operatorFalse.SpecifiedValue.Value()); + Assert.AreEqual(true, operatorFalse.IsNegative); + + // Test dependency injection constructor + var operatorTrueDI = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, _mockProvider); + Assert.AreEqual(true, operatorTrueDI.SpecifiedValue.Value()); + Assert.AreEqual(false, operatorTrueDI.IsNegative); + + var operatorFalseDI = new HasStableAksVersionOperator(specifiedValue: false, isNegative: true, _mockProvider); + Assert.AreEqual(false, operatorFalseDI.SpecifiedValue.Value()); + Assert.AreEqual(true, operatorFalseDI.IsNegative); + } + + [TestMethod] + public void Constructor_ThrowsArgumentNullException_WhenProviderIsNull() + { + Assert.ThrowsException(() => + new HasStableAksVersionOperator(true, false, null)); + } + + [TestMethod] + public void EvaluateExpression_WithEmptyProvider_TreatsAllAsUnstable() + { + var emptyProvider = new InMemoryStableAksVersionProvider(new Dictionary>()); + var aksResource = TestUtilities.CreateAksResource("eastus", "1.30.12"); + + // With empty provider, all versions are treated as unstable + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false, emptyProvider); + Assert.IsFalse(hasStableOperator.EvaluateExpression(aksResource)); + + var doesNotHaveStableOperator = new HasStableAksVersionOperator(specifiedValue: false, isNegative: false, emptyProvider); + Assert.IsTrue(doesNotHaveStableOperator.EvaluateExpression(aksResource)); + } + + [TestMethod] + public void Registry_WhenSetAndResetProvider_ChangesProviderCorrectly() + { + var customProvider = new InMemoryStableAksVersionProvider( + new Dictionary> + { + ["testregion"] = new HashSet { "1.0.0" } + }); + + // Set custom provider + StableAksVersionProviderRegistry.SetProvider(customProvider); + + var aksResource = TestUtilities.CreateAksResource("testregion", "1.0.0"); + var hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false); + Assert.IsTrue(hasStableOperator.EvaluateExpression(aksResource)); + + // Reset to default + StableAksVersionProviderRegistry.ResetToDefault(); + + // After reset, should not find the test region (since default provider won't have it) + hasStableOperator = new HasStableAksVersionOperator(specifiedValue: true, isNegative: false); + Assert.IsFalse(hasStableOperator.EvaluateExpression(aksResource)); + } + } + + /// + /// In-memory implementation of IStableAksVersionProvider for testing. + /// + internal class InMemoryStableAksVersionProvider : IStableAksVersionProvider + { + private readonly Dictionary> _data; + + public InMemoryStableAksVersionProvider(Dictionary> data) + { + _data = data ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + public bool TryGetStableVersions(string normalizedLocation, out ISet versions) + { + if (_data.TryGetValue(normalizedLocation, out var set)) + { + versions = set; + return true; + } + + versions = null; + return false; + } + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine.UnitTests/StableAksVersionProviderRegistryTests.cs b/src/Analyzer.JsonRuleEngine.UnitTests/StableAksVersionProviderRegistryTests.cs new file mode 100644 index 00000000..dd7d156f --- /dev/null +++ b/src/Analyzer.JsonRuleEngine.UnitTests/StableAksVersionProviderRegistryTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.UnitTests +{ + [TestClass] + public class StableAksVersionProviderRegistryTests + { + private InMemoryStableAksVersionProvider _testProvider; + + [TestInitialize] + public void TestInitialize() + { + // Create a test provider with known data + var testData = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["testregion"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "1.0.0", "1.1.0" } + }; + _testProvider = new InMemoryStableAksVersionProvider(testData); + } + + [TestCleanup] + public void TestCleanup() + { + // Reset to default provider after each test to avoid test interference + StableAksVersionProviderRegistry.ResetToDefault(); + } + + [TestMethod] + public void Provider_ByDefault_ReturnsDefaultStableAksVersionProvider() + { + // Reset to ensure we're testing the default state + StableAksVersionProviderRegistry.ResetToDefault(); + + var provider = StableAksVersionProviderRegistry.Provider; + + Assert.IsNotNull(provider); + Assert.IsInstanceOfType(provider, typeof(DefaultStableAksVersionProvider)); + Assert.AreSame(DefaultStableAksVersionProvider.Instance, provider); + } + + [TestMethod] + public void Provider_AfterSettingCustomProvider_ReturnsCustomProvider() + { + StableAksVersionProviderRegistry.SetProvider(_testProvider); + + var provider = StableAksVersionProviderRegistry.Provider; + + Assert.IsNotNull(provider); + Assert.AreSame(_testProvider, provider); + Assert.IsInstanceOfType(provider, typeof(InMemoryStableAksVersionProvider)); + } + + [TestMethod] + public void SetProvider_WithValidProvider_SetsProviderCorrectly() + { + // Verify initial state + Assert.IsInstanceOfType(StableAksVersionProviderRegistry.Provider, typeof(DefaultStableAksVersionProvider)); + + // Set custom provider + StableAksVersionProviderRegistry.SetProvider(_testProvider); + + // Verify the provider was set + Assert.AreSame(_testProvider, StableAksVersionProviderRegistry.Provider); + } + + [TestMethod] + public void SetProvider_WithNull_SetsToDefaultProvider() + { + // First set to a custom provider + StableAksVersionProviderRegistry.SetProvider(_testProvider); + Assert.AreSame(_testProvider, StableAksVersionProviderRegistry.Provider); + + // Then set to null + StableAksVersionProviderRegistry.SetProvider(null); + + // Should revert to default provider + Assert.IsInstanceOfType(StableAksVersionProviderRegistry.Provider, typeof(DefaultStableAksVersionProvider)); + Assert.AreSame(DefaultStableAksVersionProvider.Instance, StableAksVersionProviderRegistry.Provider); + } + + [TestMethod] + public void ResetToDefault_AfterSettingCustomProvider_RestoresDefaultProvider() + { + // Set custom provider + StableAksVersionProviderRegistry.SetProvider(_testProvider); + Assert.AreSame(_testProvider, StableAksVersionProviderRegistry.Provider); + + // Reset to default + StableAksVersionProviderRegistry.ResetToDefault(); + + // Should be back to default provider + Assert.IsInstanceOfType(StableAksVersionProviderRegistry.Provider, typeof(DefaultStableAksVersionProvider)); + Assert.AreSame(DefaultStableAksVersionProvider.Instance, StableAksVersionProviderRegistry.Provider); + } + + [TestMethod] + public void ResetToDefault_WhenAlreadyDefault_RemainsDefault() + { + // Ensure we start with default + StableAksVersionProviderRegistry.ResetToDefault(); + var providerBefore = StableAksVersionProviderRegistry.Provider; + + // Reset again + StableAksVersionProviderRegistry.ResetToDefault(); + var providerAfter = StableAksVersionProviderRegistry.Provider; + + // Should still be the same default provider + Assert.AreSame(providerBefore, providerAfter); + Assert.IsInstanceOfType(providerAfter, typeof(DefaultStableAksVersionProvider)); + Assert.AreSame(DefaultStableAksVersionProvider.Instance, providerAfter); + } + + [TestMethod] + public void Provider_MultipleAccesses_ReturnsSameInstance() + { + StableAksVersionProviderRegistry.SetProvider(_testProvider); + + var provider1 = StableAksVersionProviderRegistry.Provider; + var provider2 = StableAksVersionProviderRegistry.Provider; + var provider3 = StableAksVersionProviderRegistry.Provider; + + Assert.AreSame(provider1, provider2); + Assert.AreSame(provider2, provider3); + Assert.AreSame(_testProvider, provider1); + } + + [TestMethod] + public void ProviderChanges_AreReflectedImmediately() + { + // Start with default + var defaultProvider = StableAksVersionProviderRegistry.Provider; + Assert.IsInstanceOfType(defaultProvider, typeof(DefaultStableAksVersionProvider)); + + // Change to custom provider + StableAksVersionProviderRegistry.SetProvider(_testProvider); + var customProvider = StableAksVersionProviderRegistry.Provider; + Assert.AreSame(_testProvider, customProvider); + Assert.AreNotSame(defaultProvider, customProvider); + + // Change back to default + StableAksVersionProviderRegistry.ResetToDefault(); + var backToDefaultProvider = StableAksVersionProviderRegistry.Provider; + Assert.IsInstanceOfType(backToDefaultProvider, typeof(DefaultStableAksVersionProvider)); + Assert.AreNotSame(customProvider, backToDefaultProvider); + } + + [TestMethod] + public void SetProvider_WithDifferentCustomProviders_UpdatesCorrectly() + { + var firstTestData = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["region1"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "1.0.0" } + }; + var firstProvider = new InMemoryStableAksVersionProvider(firstTestData); + + var secondTestData = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["region2"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "2.0.0" } + }; + var secondProvider = new InMemoryStableAksVersionProvider(secondTestData); + + // Set first provider + StableAksVersionProviderRegistry.SetProvider(firstProvider); + Assert.AreSame(firstProvider, StableAksVersionProviderRegistry.Provider); + + // Set second provider + StableAksVersionProviderRegistry.SetProvider(secondProvider); + Assert.AreSame(secondProvider, StableAksVersionProviderRegistry.Provider); + Assert.AreNotSame(firstProvider, StableAksVersionProviderRegistry.Provider); + } + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine.UnitTests/TestUtilities.cs b/src/Analyzer.JsonRuleEngine.UnitTests/TestUtilities.cs index 3d2f590c..6a8256bd 100644 --- a/src/Analyzer.JsonRuleEngine.UnitTests/TestUtilities.cs +++ b/src/Analyzer.JsonRuleEngine.UnitTests/TestUtilities.cs @@ -48,5 +48,36 @@ public MockExpression(ExpressionCommonProperties commonProperties) public override IEnumerable Evaluate(IJsonPathResolver jsonScope, ISourceLocationResolver lineNumberResolver) => base.EvaluateInternal(jsonScope, EvaluationCallback); } + + /// + /// Creates a JToken representing an AKS managed cluster resource. + /// + public static JToken CreateAksResource(string location, string kubernetesVersion) + { + var resource = new + { + type = "Microsoft.ContainerService/managedClusters", + apiVersion = "2025-06-02-preview", + name = "test-aks-cluster", + location = location, + properties = new + { + kubernetesVersion = kubernetesVersion, + linuxProfile = new + { + adminUsername = "azureuser", + ssh = new + { + publicKeys = new[] + { + new { keyData = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..." } + } + } + } + } + }; + + return JObject.FromObject(resource); + } } } \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs new file mode 100644 index 00000000..8366a241 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators +{ + /// + /// Default implementation that fetches stable AKS versions from the Azure API. + /// + internal sealed class DefaultStableAksVersionProvider : IStableAksVersionProvider + { + private static readonly Lazy _instance = + new Lazy(() => new DefaultStableAksVersionProvider()); + + private readonly object _lock = new object(); + private volatile Dictionary> _cache; + + /// + /// Gets the singleton instance of the provider. + /// + public static DefaultStableAksVersionProvider Instance => _instance.Value; + + private DefaultStableAksVersionProvider() { } + + /// + public bool TryGetStableVersions(string normalizedLocation, out ISet versions) + { + if (string.IsNullOrEmpty(normalizedLocation)) + { + versions = null; + return false; + } + + EnsureInitialized(); + + if (_cache != null && _cache.TryGetValue(normalizedLocation, out var set)) + { + versions = set; + return true; + } + + versions = null; + return false; + } + + /// + /// Ensures the cache is initialized with stable version data. + /// + private void EnsureInitialized() + { + if (_cache != null) return; + + lock (_lock) + { + if (_cache != null) return; + _cache = FetchStableVersions(); + } + } + + /// + /// Fetches stable AKS versions from the Azure API. + /// + /// A dictionary mapping normalized region names to stable version sets. + private Dictionary> FetchStableVersions() + { + try + { + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + + var response = httpClient.GetAsync("https://releases.aks.azure.com/webpage/parsed_data.json") + .GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + + var jsonString = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var token = JToken.Parse(jsonString); + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var regionalStatuses = token.SelectToken( + "Sections.KubernetesSupportedVersions.Components.KubernetesVersions.RegionalStatuses") as JObject; + + if (regionalStatuses != null) + { + foreach (var continent in regionalStatuses.Properties()) + { + var regions = continent.Value as JArray; + if (regions == null) continue; + + foreach (var region in regions.Children()) + { + var regionName = region.Value("RegionName"); + if (string.IsNullOrEmpty(regionName)) continue; + + var normalizedRegionName = NormalizeRegionName(regionName); + var html = region.SelectToken("Current.Version")?.ToString() ?? ""; + + var versions = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match match in Regex.Matches(html, "]*>(.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline)) + { + var version = System.Net.WebUtility.HtmlDecode(match.Groups[1].Value).Trim(); + if (!string.IsNullOrEmpty(version)) + { + var cleanVersion = Regex.Replace(version, @"\s*\(LTS\)\s*$", "", + RegexOptions.IgnoreCase); + versions.Add(cleanVersion); + } + } + + result[normalizedRegionName] = versions; + } + } + } + + return result; + } + catch + { + // On failure, return empty dictionary + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// Normalizes a region name by converting to lowercase and removing spaces. + /// + /// The region name to normalize. + /// The normalized region name. + private static string NormalizeRegionName(string regionName) + { + return regionName.ToLowerInvariant().Replace(" ", ""); + } + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs new file mode 100644 index 00000000..d901e806 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators +{ + /// + /// An operator that evaluates whether an Aks cluster is using a stable Kubernetes version for its region. + /// + internal class HasStableAksVersionOperator : LeafExpressionOperator + { + private readonly IStableAksVersionProvider _provider; + + /// + /// Gets the name of this operator. + /// + public override string Name => "HasStableAksVersion"; + + /// + /// Creates a HasStableAksVersionOperator using the default provider from the registry. + /// + /// The value specified in the JSON rule. + /// Whether the result should be negated. + public HasStableAksVersionOperator(bool specifiedValue, bool isNegative) + : this(specifiedValue, isNegative, StableAksVersionProviderRegistry.Provider) + { + } + + /// + /// Creates a HasStableAksVersionOperator with a specific provider. + /// + /// The value specified in the JSON rule. + /// Whether the result should be negated. + /// The provider to use for getting stable versions. + internal HasStableAksVersionOperator(bool specifiedValue, bool isNegative, IStableAksVersionProvider provider) + { + this.SpecifiedValue = JToken.FromObject(specifiedValue); + this.IsNegative = isNegative; + this._provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + /// Evaluates whether the Aks cluster is using a stable version. + /// + /// The JToken representing the Aks cluster resource. + /// True if the evaluation passes, false otherwise. + public override bool EvaluateExpression(JToken tokenToEvaluate) + { + bool specifiedBoolValue = this.SpecifiedValue.Value(); + + var resource = tokenToEvaluate?.Parent?.Parent; + var location = resource?["location"]?.Value(); + var kubernetesVersion = tokenToEvaluate?["kubernetesVersion"]?.Value(); + + if (string.IsNullOrEmpty(location) || string.IsNullOrEmpty(kubernetesVersion)) + { + // Missing data is treated as not having stable version + return !specifiedBoolValue ^ this.IsNegative; + } + + var normalizedLocation = NormalizeRegionName(location); + + if (!_provider.TryGetStableVersions(normalizedLocation, out var stableVersions)) + { + // Unknown location is treated as not having stable version + return !specifiedBoolValue ^ this.IsNegative; + } + + var isStableVersion = stableVersions.Contains(kubernetesVersion); + return (isStableVersion == specifiedBoolValue) ^ this.IsNegative; + } + + + /// + /// Normalizes a region name by converting to lowercase and removing spaces. + /// + /// The region name to normalize. + /// The normalized region name. + private static string NormalizeRegionName(string regionName) + { + return regionName.ToLowerInvariant().Replace(" ", ""); + } + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/IStableAksVersionProvider.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/IStableAksVersionProvider.cs new file mode 100644 index 00000000..5433d804 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/IStableAksVersionProvider.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators +{ + /// + /// Provides stable AKS versions by location. + /// + internal interface IStableAksVersionProvider + { + /// + /// Attempts to get stable AKS versions for a normalized location. + /// + /// The normalized location name (lowercase, no spaces). + /// The set of stable versions if found. + /// True if versions were found for the location, false otherwise. + bool TryGetStableVersions(string normalizedLocation, out ISet versions); + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/StableAksVersionProviderRegistry.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/StableAksVersionProviderRegistry.cs new file mode 100644 index 00000000..507f12ff --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/StableAksVersionProviderRegistry.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.Templates.Analyzer.RuleEngines.JsonEngine.Operators +{ + /// + /// Registry for the stable AKS version provider. + /// + internal static class StableAksVersionProviderRegistry + { + private static IStableAksVersionProvider _provider = DefaultStableAksVersionProvider.Instance; + + /// + /// Gets the current provider. + /// + public static IStableAksVersionProvider Provider => _provider; + + /// + /// Sets a custom provider (mainly for testing). + /// + /// The provider to use. + public static void SetProvider(IStableAksVersionProvider provider) + { + _provider = provider ?? DefaultStableAksVersionProvider.Instance; + } + + /// + /// Resets to the default provider. + /// + public static void ResetToDefault() + { + _provider = DefaultStableAksVersionProvider.Instance; + } + } +} \ No newline at end of file diff --git a/src/Analyzer.JsonRuleEngine/Schemas/LeafExpressionDefinition.cs b/src/Analyzer.JsonRuleEngine/Schemas/LeafExpressionDefinition.cs index 62dc96ed..7bb12807 100644 --- a/src/Analyzer.JsonRuleEngine/Schemas/LeafExpressionDefinition.cs +++ b/src/Analyzer.JsonRuleEngine/Schemas/LeafExpressionDefinition.cs @@ -78,6 +78,12 @@ internal class LeafExpressionDefinition : ExpressionDefinition [JsonProperty] public JToken GreaterOrEquals { get; set; } + /// + /// Gets or sets the HasStableAKSVersion property + /// + [JsonProperty] + public bool? HasStableAKSVersion { get; set; } + /// /// Creates a capable of evaluating JSON using the operator specified in the JSON rule. /// @@ -91,6 +97,10 @@ public override Expression ToExpression(bool isNegative = false) { leafOperator = new ExistsOperator(Exists.Value, isNegative); } + else if (this.HasStableAKSVersion != null) + { + leafOperator = new HasStableAksVersionOperator(this.HasStableAKSVersion.Value, isNegative); + } else if (this.HasValue != null) { leafOperator = new HasValueOperator(HasValue.Value, isNegative);