From 61b01d9f5c7ae2be71dc76ce8b0338415588d810 Mon Sep 17 00:00:00 2001 From: Razvan 's Loghin Date: Mon, 25 Aug 2025 18:50:55 +0300 Subject: [PATCH 1/6] Implementation of HasStableAksVersion operator and it's unit tests --- .vscode/launch.json | 34 ++ docs/authoring-json-rules.md | 19 + .../DefaultStableAksVersionProviderTest.cs | 267 ++++++++++++++ .../HasStableAksVersionOperatorTests.cs | 334 ++++++++++++++++++ .../TestUtilities.cs | 31 ++ .../DefaultStableAksVersionProvider.cs | 135 +++++++ .../HasStableAksVersionOperator.cs | 85 +++++ .../IStableAksVersionProvider.cs | 21 ++ .../StableAksVersionProviderRegistry.cs | 35 ++ .../Schemas/LeafExpressionDefinition.cs | 10 + 10 files changed, 971 insertions(+) create mode 100644 src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs create mode 100644 src/Analyzer.JsonRuleEngine.UnitTests/HasStableAksVersionOperatorTests.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/IStableAksVersionProvider.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/StableAksVersionProviderRegistry.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index cfa319c6..03ecffe7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,6 +39,40 @@ "console": "internalConsole", "stopAtEntry": false }, + { + "name": "Debug Unit Tests", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", + "--logger", + "console;verbosity=detailed" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "justMyCode": false + }, + { + "name": "Debug Specific Test", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", + "--filter", + "TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly", + "--logger", + "console;verbosity=detailed" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "justMyCode": false + }, { "name": ".NET Core Attach", "type": "coreclr", diff --git a/docs/authoring-json-rules.md b/docs/authoring-json-rules.md index 5482ccd9..9b7fd199 100644 --- a/docs/authoring-json-rules.md +++ b/docs/authoring-json-rules.md @@ -259,6 +259,25 @@ 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 +{ + "path": "resources[*]", + "where": { + "path": "type", + "equals": "Microsoft.ContainerService/managedClusters" + }, + "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.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs new file mode 100644 index 00000000..b36c09a6 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +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 + { + [TestMethod] + public void Instance_WhenAccessed_ReturnsSameInstanceAlways() + { + // Test that Instance property returns the same singleton instance + var instance1 = DefaultStableAksVersionProvider.Instance; + var instance2 = DefaultStableAksVersionProvider.Instance; + + Assert.IsNotNull(instance1); + Assert.AreSame(instance1, instance2); + } + + [TestMethod] + public void Instance_WhenAccessedMultipleTimes_IsSingleton() + { + // Test singleton behavior across multiple accesses + var instances = new List(); + + for (int i = 0; i < 5; i++) + { + instances.Add(DefaultStableAksVersionProvider.Instance); + } + + // All instances should be the same reference + var firstInstance = instances.First(); + Assert.IsTrue(instances.All(instance => ReferenceEquals(instance, firstInstance))); + } + + [TestMethod] + public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults() + { + // This is an integration test that will actually call the API + // It tests the caching behavior and normalization + var provider = DefaultStableAksVersionProvider.Instance; + + // First call - should initialize cache + var result1 = provider.TryGetStableVersions("eastus", out var versions1); + + // Second call - should use cached data + var result2 = provider.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); + } + } + + [TestMethod] + public void TryGetStableVersions_WithDifferentCasing_NormalizesCorrectly() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test case normalization + var result1 = provider.TryGetStableVersions("eastus", out var versions1); + var result2 = provider.TryGetStableVersions("EASTUS", out var versions2); + var result3 = provider.TryGetStableVersions("EastUS", out var versions3); + + // All should return the same result due to normalization + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + + if (result1 && result2 && result3) + { + Assert.AreEqual(versions1.Count, versions2.Count); + Assert.AreEqual(versions2.Count, versions3.Count); + } + } + + [TestMethod] + public void TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test space normalization + var result1 = provider.TryGetStableVersions("westeurope", out var versions1); + var result2 = provider.TryGetStableVersions("west europe", out var versions2); + var result3 = provider.TryGetStableVersions("West Europe", out var versions3); + + // All should return the same result due to normalization + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + + if (result1 && result2 && result3) + { + Assert.AreEqual(versions1.Count, versions2.Count); + Assert.AreEqual(versions2.Count, versions3.Count); + } + } + + [TestMethod] + public void TryGetStableVersions_WithUnknownLocation_ReturnsFalse() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test with clearly non-existent regions + var result1 = provider.TryGetStableVersions("nonexistentregion", out var versions1); + var result2 = provider.TryGetStableVersions("mars-central", out var versions2); + var result3 = provider.TryGetStableVersions("atlantis-south", out var versions3); + + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsFalse(result3); + Assert.IsNull(versions1); + Assert.IsNull(versions2); + Assert.IsNull(versions3); + } + + [TestMethod] + public void TryGetStableVersions_WithNullLocation_ReturnsFalse() + { + var provider = DefaultStableAksVersionProvider.Instance; + + var result = provider.TryGetStableVersions(null, out var versions); + + Assert.IsFalse(result); + Assert.IsNull(versions); + } + + [TestMethod] + public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() + { + var provider = DefaultStableAksVersionProvider.Instance; + + var result1 = provider.TryGetStableVersions("", out var versions1); + var result2 = provider.TryGetStableVersions(" ", out var versions2); + + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsNull(versions1); + Assert.IsNull(versions2); + } + + [TestMethod] + public void TryGetStableVersions_CachingBehavior_OnlyInitializesOnce() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Multiple calls should use the same cached data + var startTime = DateTime.UtcNow; + + // First call + provider.TryGetStableVersions("eastus", out _); + var firstCallTime = DateTime.UtcNow - startTime; + + startTime = DateTime.UtcNow; + + // Second call should be much faster (cached) + provider.TryGetStableVersions("westus", out _); + var secondCallTime = DateTime.UtcNow - startTime; + + // The second call should be significantly faster than the first + // (this is a heuristic test - actual timing may vary) + Assert.IsTrue(secondCallTime < firstCallTime || secondCallTime.TotalMilliseconds < 100, + $"Second call ({secondCallTime.TotalMilliseconds}ms) should be faster than first call ({firstCallTime.TotalMilliseconds}ms)"); + } + + [TestMethod] + public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionSets() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test with a known region (assuming eastus exists) + var result = provider.TryGetStableVersions("eastus", out var versions); + + if (result) + { + Assert.IsNotNull(versions); + Assert.IsTrue(versions.Count > 0); + + // Verify versions are in expected format (semantic versioning) + foreach (var version in versions) + { + Assert.IsNotNull(version); + Assert.IsTrue(version.Contains("."), $"Version {version} should contain dots"); + + // Basic format check - should start with number + Assert.IsTrue(char.IsDigit(version[0]), $"Version {version} should start with a digit"); + } + } + } + + [TestMethod] + public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequests() + { + var provider = DefaultStableAksVersionProvider.Instance; + 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] = provider.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 + if (results.Any(r => r)) + { + Assert.IsTrue(results.All(r => r == results[0]), "All parallel calls should return the same result"); + Assert.IsTrue(versionCounts.All(c => c == versionCounts[0]), "All parallel calls should return the same version count"); + } + } + + [TestMethod] + public void NormalizeRegionName_Integration_WorksThroughPublicInterface() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test that normalization works through the public interface + // This indirectly tests the private NormalizeRegionName method + var testCases = new[] + { + ("East US", "eastus"), + ("WEST EUROPE", "westeurope"), + ("Central India", "centralindia"), + (" North Central US ", "northcentralus") + }; + + foreach (var (input, expected) in testCases) + { + var result1 = provider.TryGetStableVersions(input, out var versions1); + var result2 = provider.TryGetStableVersions(expected, out var versions2); + + // Both should return the same result + Assert.AreEqual(result1, result2, $"Results should be the same for '{input}' and '{expected}'"); + + if (result1 && result2) + { + Assert.AreEqual(versions1.Count, versions2.Count, + $"Version counts should be the same for '{input}' and '{expected}'"); + } + } + } + } +} \ 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/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..9b8abaad --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs @@ -0,0 +1,135 @@ +// 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) + { + 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..0fbfc442 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs @@ -0,0 +1,85 @@ +// 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 location = tokenToEvaluate?["location"]?.Value(); + var kubernetesVersion = tokenToEvaluate?["properties"]?["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); From 77f2281dd85185184f7c4f4d34b5d2c3a81958ed Mon Sep 17 00:00:00 2001 From: Razvan 's Loghin Date: Mon, 25 Aug 2025 21:13:02 +0300 Subject: [PATCH 2/6] Added integration tests for the HTTP request and Functional Tests. Ensured Unit Test Coverage is above 80%. --- .vscode/launch.json | 34 --- .../RuleParsingTests.cs | 12 + .../ScopeSelectionTests.cs | 213 ++++++++----- .../DefaultStableAksVersionProviderTest.cs | 281 ++++++++++-------- .../StableAksVersionProviderRegistryTests.cs | 178 +++++++++++ .../DefaultStableAksVersionProvider.cs | 6 + 6 files changed, 484 insertions(+), 240 deletions(-) create mode 100644 src/Analyzer.JsonRuleEngine.UnitTests/StableAksVersionProviderRegistryTests.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 03ecffe7..cfa319c6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,40 +39,6 @@ "console": "internalConsole", "stopAtEntry": false }, - { - "name": "Debug Unit Tests", - "type": "coreclr", - "request": "launch", - "program": "dotnet", - "args": [ - "test", - "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", - "--logger", - "console;verbosity=detailed" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "stopAtEntry": false, - "justMyCode": false - }, - { - "name": "Debug Specific Test", - "type": "coreclr", - "request": "launch", - "program": "dotnet", - "args": [ - "test", - "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", - "--filter", - "TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly", - "--logger", - "console;verbosity=detailed" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "stopAtEntry": false, - "justMyCode": false - }, { "name": ".NET Core Attach", "type": "coreclr", 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 index b36c09a6..192a825d 100644 --- a/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs +++ b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs @@ -4,6 +4,7 @@ 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; @@ -12,10 +13,74 @@ 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 that Instance property returns the same singleton instance + // Test the actual singleton behavior var instance1 = DefaultStableAksVersionProvider.Instance; var instance2 = DefaultStableAksVersionProvider.Instance; @@ -24,33 +89,20 @@ public void Instance_WhenAccessed_ReturnsSameInstanceAlways() } [TestMethod] - public void Instance_WhenAccessedMultipleTimes_IsSingleton() - { - // Test singleton behavior across multiple accesses - var instances = new List(); - - for (int i = 0; i < 5; i++) - { - instances.Add(DefaultStableAksVersionProvider.Instance); - } - - // All instances should be the same reference - var firstInstance = instances.First(); - Assert.IsTrue(instances.All(instance => ReferenceEquals(instance, firstInstance))); - } - - [TestMethod] + [TestCategory("Integration")] public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults() { - // This is an integration test that will actually call the API - // It tests the caching behavior and normalization - var provider = DefaultStableAksVersionProvider.Instance; + // 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); - // First call - should initialize cache - var result1 = provider.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 = provider.TryGetStableVersions("eastus", out var versions2); + var result2 = _testProvider.TryGetStableVersions("eastus", out var versions2); // Results should be consistent Assert.AreEqual(result1, result2); @@ -60,87 +112,90 @@ public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults 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() { - var provider = DefaultStableAksVersionProvider.Instance; - - // Test case normalization - var result1 = provider.TryGetStableVersions("eastus", out var versions1); - var result2 = provider.TryGetStableVersions("EASTUS", out var versions2); - var result3 = provider.TryGetStableVersions("EastUS", out var versions3); + // 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 && result2 && result3) + if (result1) { - Assert.AreEqual(versions1.Count, versions2.Count); - Assert.AreEqual(versions2.Count, versions3.Count); + Assert.IsTrue(versions1.Count > 0, "Should have versions for eastus"); + CollectionAssert.AreEquivalent(versions1.ToList(), versions2.ToList()); + CollectionAssert.AreEquivalent(versions2.ToList(), versions3.ToList()); } } [TestMethod] - public void TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly() + [TestCategory("Integration")] + public void TryGetStableVersions_WithSpacesInLocation_HandlesCorrectly() { - var provider = DefaultStableAksVersionProvider.Instance; + // 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); - // Test space normalization - var result1 = provider.TryGetStableVersions("westeurope", out var versions1); - var result2 = provider.TryGetStableVersions("west europe", out var versions2); - var result3 = provider.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)"); - // All should return the same result due to normalization - Assert.AreEqual(result1, result2); - Assert.AreEqual(result2, result3); - - if (result1 && result2 && result3) + if (result1) { - Assert.AreEqual(versions1.Count, versions2.Count); - Assert.AreEqual(versions2.Count, versions3.Count); + 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_WithUnknownLocation_ReturnsFalse() + public void TryGetStableVersions_WithNullLocation_ReturnsFalse() { - var provider = DefaultStableAksVersionProvider.Instance; + var result = _testProvider.TryGetStableVersions(null, out var versions); - // Test with clearly non-existent regions - var result1 = provider.TryGetStableVersions("nonexistentregion", out var versions1); - var result2 = provider.TryGetStableVersions("mars-central", out var versions2); - var result3 = provider.TryGetStableVersions("atlantis-south", out var versions3); + Assert.IsFalse(result); + Assert.IsNull(versions); - Assert.IsFalse(result1); - Assert.IsFalse(result2); - Assert.IsFalse(result3); - Assert.IsNull(versions1); - Assert.IsNull(versions2); - Assert.IsNull(versions3); + // Verify cache was not initialized for null input + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized for null input"); } [TestMethod] - public void TryGetStableVersions_WithNullLocation_ReturnsFalse() + public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() { - var provider = DefaultStableAksVersionProvider.Instance; + 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"); - var result = provider.TryGetStableVersions(null, out var versions); - - Assert.IsFalse(result); - Assert.IsNull(versions); + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsNull(versions1); + Assert.IsNull(versions2); } [TestMethod] - public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() + [TestCategory("Integration")] + public void TryGetStableVersions_WithUnknownLocation_ReturnsFalse() { - var provider = DefaultStableAksVersionProvider.Instance; + // This will initialize the cache with real data + var knownResult = _testProvider.TryGetStableVersions("eastus", out _); - var result1 = provider.TryGetStableVersions("", out var versions1); - var result2 = provider.TryGetStableVersions(" ", out var versions2); + // 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); @@ -149,58 +204,57 @@ public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() } [TestMethod] - public void TryGetStableVersions_CachingBehavior_OnlyInitializesOnce() + [TestCategory("Integration")] + public void EnsureInitialized_CalledMultipleTimes_OnlyFetchesOnce() { - var provider = DefaultStableAksVersionProvider.Instance; + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized"); - // Multiple calls should use the same cached data + // First call should fetch data var startTime = DateTime.UtcNow; - - // First call - provider.TryGetStableVersions("eastus", out _); + _testProvider.TryGetStableVersions("eastus", out _); var firstCallTime = DateTime.UtcNow - startTime; - startTime = DateTime.UtcNow; + Assert.IsTrue(_testProvider.IsCacheInitialized(), "Cache should be initialized after first call"); - // Second call should be much faster (cached) - provider.TryGetStableVersions("westus", out _); + // Second call should be much faster (no fetch) + startTime = DateTime.UtcNow; + _testProvider.TryGetStableVersions("westus", out _); var secondCallTime = DateTime.UtcNow - startTime; - // The second call should be significantly faster than the first - // (this is a heuristic test - actual timing may vary) - Assert.IsTrue(secondCallTime < firstCallTime || secondCallTime.TotalMilliseconds < 100, - $"Second call ({secondCallTime.TotalMilliseconds}ms) should be faster than first call ({firstCallTime.TotalMilliseconds}ms)"); + // 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] - public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionSets() + [TestCategory("Integration")] + public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionFormats() { - var provider = DefaultStableAksVersionProvider.Instance; - - // Test with a known region (assuming eastus exists) - var result = provider.TryGetStableVersions("eastus", out var versions); + var result = _testProvider.TryGetStableVersions("eastus", out var versions); if (result) { Assert.IsNotNull(versions); - Assert.IsTrue(versions.Count > 0); + Assert.IsTrue(versions.Count > 0, "Should have at least one version"); - // Verify versions are in expected format (semantic versioning) foreach (var version in versions) { - Assert.IsNotNull(version); - Assert.IsTrue(version.Contains("."), $"Version {version} should contain dots"); + // Verify semantic versioning format + Assert.IsTrue(System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+\.\d+"), + $"Version '{version}' should follow semantic versioning"); - // Basic format check - should start with number - Assert.IsTrue(char.IsDigit(version[0]), $"Version {version} should start with a digit"); + // 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 provider = DefaultStableAksVersionProvider.Instance; var results = new bool[10]; var versionCounts = new int[10]; var exceptions = new List(); @@ -210,7 +264,7 @@ public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequest { try { - results[i] = provider.TryGetStableVersions("eastus", out var versions); + results[i] = _testProvider.TryGetStableVersions("eastus", out var versions); versionCounts[i] = versions?.Count ?? 0; } catch (Exception ex) @@ -223,44 +277,17 @@ public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequest }); // Should not have any exceptions - Assert.AreEqual(0, exceptions.Count, $"Thread safety test failed with exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + 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 - if (results.Any(r => r)) - { - Assert.IsTrue(results.All(r => r == results[0]), "All parallel calls should return the same result"); - Assert.IsTrue(versionCounts.All(c => c == versionCounts[0]), "All parallel calls should return the same version count"); - } - } - - [TestMethod] - public void NormalizeRegionName_Integration_WorksThroughPublicInterface() - { - var provider = DefaultStableAksVersionProvider.Instance; - - // Test that normalization works through the public interface - // This indirectly tests the private NormalizeRegionName method - var testCases = new[] - { - ("East US", "eastus"), - ("WEST EUROPE", "westeurope"), - ("Central India", "centralindia"), - (" North Central US ", "northcentralus") - }; + var firstResult = results[0]; + Assert.IsTrue(results.All(r => r == firstResult), "All parallel calls should succeed/fail consistently"); - foreach (var (input, expected) in testCases) + if (firstResult) { - var result1 = provider.TryGetStableVersions(input, out var versions1); - var result2 = provider.TryGetStableVersions(expected, out var versions2); - - // Both should return the same result - Assert.AreEqual(result1, result2, $"Results should be the same for '{input}' and '{expected}'"); - - if (result1 && result2) - { - Assert.AreEqual(versions1.Count, versions2.Count, - $"Version counts should be the same for '{input}' and '{expected}'"); - } + var firstCount = versionCounts[0]; + Assert.IsTrue(versionCounts.All(c => c == firstCount), "All parallel calls should return same version count"); } } } 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/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs index 9b8abaad..8366a241 100644 --- a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs @@ -31,6 +31,12 @@ 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)) From 9dc6d405008cecf91a23ba10a4f9684708a53c77 Mon Sep 17 00:00:00 2001 From: Razvan 's Loghin Date: Mon, 25 Aug 2025 18:50:55 +0300 Subject: [PATCH 3/6] Implementation of HasStableAksVersion operator and it's unit tests --- .vscode/launch.json | 34 ++ docs/authoring-json-rules.md | 19 + .../DefaultStableAksVersionProviderTest.cs | 267 ++++++++++++++ .../HasStableAksVersionOperatorTests.cs | 334 ++++++++++++++++++ .../TestUtilities.cs | 31 ++ .../DefaultStableAksVersionProvider.cs | 135 +++++++ .../HasStableAksVersionOperator.cs | 85 +++++ .../IStableAksVersionProvider.cs | 21 ++ .../StableAksVersionProviderRegistry.cs | 35 ++ .../Schemas/LeafExpressionDefinition.cs | 10 + 10 files changed, 971 insertions(+) create mode 100644 src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs create mode 100644 src/Analyzer.JsonRuleEngine.UnitTests/HasStableAksVersionOperatorTests.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/IStableAksVersionProvider.cs create mode 100644 src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/StableAksVersionProviderRegistry.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 4b431fcc..28f7ae76 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,6 +39,40 @@ "console": "internalConsole", "stopAtEntry": false }, + { + "name": "Debug Unit Tests", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", + "--logger", + "console;verbosity=detailed" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "justMyCode": false + }, + { + "name": "Debug Specific Test", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", + "--filter", + "TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly", + "--logger", + "console;verbosity=detailed" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "justMyCode": false + }, { "name": ".NET Core Attach", "type": "coreclr", diff --git a/docs/authoring-json-rules.md b/docs/authoring-json-rules.md index 5482ccd9..9b7fd199 100644 --- a/docs/authoring-json-rules.md +++ b/docs/authoring-json-rules.md @@ -259,6 +259,25 @@ 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 +{ + "path": "resources[*]", + "where": { + "path": "type", + "equals": "Microsoft.ContainerService/managedClusters" + }, + "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.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs new file mode 100644 index 00000000..b36c09a6 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +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 + { + [TestMethod] + public void Instance_WhenAccessed_ReturnsSameInstanceAlways() + { + // Test that Instance property returns the same singleton instance + var instance1 = DefaultStableAksVersionProvider.Instance; + var instance2 = DefaultStableAksVersionProvider.Instance; + + Assert.IsNotNull(instance1); + Assert.AreSame(instance1, instance2); + } + + [TestMethod] + public void Instance_WhenAccessedMultipleTimes_IsSingleton() + { + // Test singleton behavior across multiple accesses + var instances = new List(); + + for (int i = 0; i < 5; i++) + { + instances.Add(DefaultStableAksVersionProvider.Instance); + } + + // All instances should be the same reference + var firstInstance = instances.First(); + Assert.IsTrue(instances.All(instance => ReferenceEquals(instance, firstInstance))); + } + + [TestMethod] + public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults() + { + // This is an integration test that will actually call the API + // It tests the caching behavior and normalization + var provider = DefaultStableAksVersionProvider.Instance; + + // First call - should initialize cache + var result1 = provider.TryGetStableVersions("eastus", out var versions1); + + // Second call - should use cached data + var result2 = provider.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); + } + } + + [TestMethod] + public void TryGetStableVersions_WithDifferentCasing_NormalizesCorrectly() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test case normalization + var result1 = provider.TryGetStableVersions("eastus", out var versions1); + var result2 = provider.TryGetStableVersions("EASTUS", out var versions2); + var result3 = provider.TryGetStableVersions("EastUS", out var versions3); + + // All should return the same result due to normalization + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + + if (result1 && result2 && result3) + { + Assert.AreEqual(versions1.Count, versions2.Count); + Assert.AreEqual(versions2.Count, versions3.Count); + } + } + + [TestMethod] + public void TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test space normalization + var result1 = provider.TryGetStableVersions("westeurope", out var versions1); + var result2 = provider.TryGetStableVersions("west europe", out var versions2); + var result3 = provider.TryGetStableVersions("West Europe", out var versions3); + + // All should return the same result due to normalization + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + + if (result1 && result2 && result3) + { + Assert.AreEqual(versions1.Count, versions2.Count); + Assert.AreEqual(versions2.Count, versions3.Count); + } + } + + [TestMethod] + public void TryGetStableVersions_WithUnknownLocation_ReturnsFalse() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test with clearly non-existent regions + var result1 = provider.TryGetStableVersions("nonexistentregion", out var versions1); + var result2 = provider.TryGetStableVersions("mars-central", out var versions2); + var result3 = provider.TryGetStableVersions("atlantis-south", out var versions3); + + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsFalse(result3); + Assert.IsNull(versions1); + Assert.IsNull(versions2); + Assert.IsNull(versions3); + } + + [TestMethod] + public void TryGetStableVersions_WithNullLocation_ReturnsFalse() + { + var provider = DefaultStableAksVersionProvider.Instance; + + var result = provider.TryGetStableVersions(null, out var versions); + + Assert.IsFalse(result); + Assert.IsNull(versions); + } + + [TestMethod] + public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() + { + var provider = DefaultStableAksVersionProvider.Instance; + + var result1 = provider.TryGetStableVersions("", out var versions1); + var result2 = provider.TryGetStableVersions(" ", out var versions2); + + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsNull(versions1); + Assert.IsNull(versions2); + } + + [TestMethod] + public void TryGetStableVersions_CachingBehavior_OnlyInitializesOnce() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Multiple calls should use the same cached data + var startTime = DateTime.UtcNow; + + // First call + provider.TryGetStableVersions("eastus", out _); + var firstCallTime = DateTime.UtcNow - startTime; + + startTime = DateTime.UtcNow; + + // Second call should be much faster (cached) + provider.TryGetStableVersions("westus", out _); + var secondCallTime = DateTime.UtcNow - startTime; + + // The second call should be significantly faster than the first + // (this is a heuristic test - actual timing may vary) + Assert.IsTrue(secondCallTime < firstCallTime || secondCallTime.TotalMilliseconds < 100, + $"Second call ({secondCallTime.TotalMilliseconds}ms) should be faster than first call ({firstCallTime.TotalMilliseconds}ms)"); + } + + [TestMethod] + public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionSets() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test with a known region (assuming eastus exists) + var result = provider.TryGetStableVersions("eastus", out var versions); + + if (result) + { + Assert.IsNotNull(versions); + Assert.IsTrue(versions.Count > 0); + + // Verify versions are in expected format (semantic versioning) + foreach (var version in versions) + { + Assert.IsNotNull(version); + Assert.IsTrue(version.Contains("."), $"Version {version} should contain dots"); + + // Basic format check - should start with number + Assert.IsTrue(char.IsDigit(version[0]), $"Version {version} should start with a digit"); + } + } + } + + [TestMethod] + public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequests() + { + var provider = DefaultStableAksVersionProvider.Instance; + 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] = provider.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 + if (results.Any(r => r)) + { + Assert.IsTrue(results.All(r => r == results[0]), "All parallel calls should return the same result"); + Assert.IsTrue(versionCounts.All(c => c == versionCounts[0]), "All parallel calls should return the same version count"); + } + } + + [TestMethod] + public void NormalizeRegionName_Integration_WorksThroughPublicInterface() + { + var provider = DefaultStableAksVersionProvider.Instance; + + // Test that normalization works through the public interface + // This indirectly tests the private NormalizeRegionName method + var testCases = new[] + { + ("East US", "eastus"), + ("WEST EUROPE", "westeurope"), + ("Central India", "centralindia"), + (" North Central US ", "northcentralus") + }; + + foreach (var (input, expected) in testCases) + { + var result1 = provider.TryGetStableVersions(input, out var versions1); + var result2 = provider.TryGetStableVersions(expected, out var versions2); + + // Both should return the same result + Assert.AreEqual(result1, result2, $"Results should be the same for '{input}' and '{expected}'"); + + if (result1 && result2) + { + Assert.AreEqual(versions1.Count, versions2.Count, + $"Version counts should be the same for '{input}' and '{expected}'"); + } + } + } + } +} \ 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/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..9b8abaad --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs @@ -0,0 +1,135 @@ +// 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) + { + 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..0fbfc442 --- /dev/null +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs @@ -0,0 +1,85 @@ +// 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 location = tokenToEvaluate?["location"]?.Value(); + var kubernetesVersion = tokenToEvaluate?["properties"]?["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); From 15d09a47bee46ef0fe321df8991b0be5889a6c2c Mon Sep 17 00:00:00 2001 From: Razvan 's Loghin Date: Mon, 25 Aug 2025 21:13:02 +0300 Subject: [PATCH 4/6] Added integration tests for the HTTP request and Functional Tests. Ensured Unit Test Coverage is above 80%. --- .vscode/launch.json | 34 --- .../RuleParsingTests.cs | 12 + .../ScopeSelectionTests.cs | 213 ++++++++----- .../DefaultStableAksVersionProviderTest.cs | 281 ++++++++++-------- .../StableAksVersionProviderRegistryTests.cs | 178 +++++++++++ .../DefaultStableAksVersionProvider.cs | 6 + 6 files changed, 484 insertions(+), 240 deletions(-) create mode 100644 src/Analyzer.JsonRuleEngine.UnitTests/StableAksVersionProviderRegistryTests.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 28f7ae76..4b431fcc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,40 +39,6 @@ "console": "internalConsole", "stopAtEntry": false }, - { - "name": "Debug Unit Tests", - "type": "coreclr", - "request": "launch", - "program": "dotnet", - "args": [ - "test", - "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", - "--logger", - "console;verbosity=detailed" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "stopAtEntry": false, - "justMyCode": false - }, - { - "name": "Debug Specific Test", - "type": "coreclr", - "request": "launch", - "program": "dotnet", - "args": [ - "test", - "${workspaceFolder}/src/Analyzer.JsonRuleEngine.UnitTests/Analyzer.JsonRuleEngine.UnitTests.csproj", - "--filter", - "TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly", - "--logger", - "console;verbosity=detailed" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "stopAtEntry": false, - "justMyCode": false - }, { "name": ".NET Core Attach", "type": "coreclr", 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 index b36c09a6..192a825d 100644 --- a/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs +++ b/src/Analyzer.JsonRuleEngine.UnitTests/DefaultStableAksVersionProviderTest.cs @@ -4,6 +4,7 @@ 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; @@ -12,10 +13,74 @@ 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 that Instance property returns the same singleton instance + // Test the actual singleton behavior var instance1 = DefaultStableAksVersionProvider.Instance; var instance2 = DefaultStableAksVersionProvider.Instance; @@ -24,33 +89,20 @@ public void Instance_WhenAccessed_ReturnsSameInstanceAlways() } [TestMethod] - public void Instance_WhenAccessedMultipleTimes_IsSingleton() - { - // Test singleton behavior across multiple accesses - var instances = new List(); - - for (int i = 0; i < 5; i++) - { - instances.Add(DefaultStableAksVersionProvider.Instance); - } - - // All instances should be the same reference - var firstInstance = instances.First(); - Assert.IsTrue(instances.All(instance => ReferenceEquals(instance, firstInstance))); - } - - [TestMethod] + [TestCategory("Integration")] public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults() { - // This is an integration test that will actually call the API - // It tests the caching behavior and normalization - var provider = DefaultStableAksVersionProvider.Instance; + // 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); - // First call - should initialize cache - var result1 = provider.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 = provider.TryGetStableVersions("eastus", out var versions2); + var result2 = _testProvider.TryGetStableVersions("eastus", out var versions2); // Results should be consistent Assert.AreEqual(result1, result2); @@ -60,87 +112,90 @@ public void TryGetStableVersions_WithNormalizedLocation_ReturnsConsistentResults 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() { - var provider = DefaultStableAksVersionProvider.Instance; - - // Test case normalization - var result1 = provider.TryGetStableVersions("eastus", out var versions1); - var result2 = provider.TryGetStableVersions("EASTUS", out var versions2); - var result3 = provider.TryGetStableVersions("EastUS", out var versions3); + // 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 && result2 && result3) + if (result1) { - Assert.AreEqual(versions1.Count, versions2.Count); - Assert.AreEqual(versions2.Count, versions3.Count); + Assert.IsTrue(versions1.Count > 0, "Should have versions for eastus"); + CollectionAssert.AreEquivalent(versions1.ToList(), versions2.ToList()); + CollectionAssert.AreEquivalent(versions2.ToList(), versions3.ToList()); } } [TestMethod] - public void TryGetStableVersions_WithSpacesInLocation_NormalizesCorrectly() + [TestCategory("Integration")] + public void TryGetStableVersions_WithSpacesInLocation_HandlesCorrectly() { - var provider = DefaultStableAksVersionProvider.Instance; + // 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); - // Test space normalization - var result1 = provider.TryGetStableVersions("westeurope", out var versions1); - var result2 = provider.TryGetStableVersions("west europe", out var versions2); - var result3 = provider.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)"); - // All should return the same result due to normalization - Assert.AreEqual(result1, result2); - Assert.AreEqual(result2, result3); - - if (result1 && result2 && result3) + if (result1) { - Assert.AreEqual(versions1.Count, versions2.Count); - Assert.AreEqual(versions2.Count, versions3.Count); + 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_WithUnknownLocation_ReturnsFalse() + public void TryGetStableVersions_WithNullLocation_ReturnsFalse() { - var provider = DefaultStableAksVersionProvider.Instance; + var result = _testProvider.TryGetStableVersions(null, out var versions); - // Test with clearly non-existent regions - var result1 = provider.TryGetStableVersions("nonexistentregion", out var versions1); - var result2 = provider.TryGetStableVersions("mars-central", out var versions2); - var result3 = provider.TryGetStableVersions("atlantis-south", out var versions3); + Assert.IsFalse(result); + Assert.IsNull(versions); - Assert.IsFalse(result1); - Assert.IsFalse(result2); - Assert.IsFalse(result3); - Assert.IsNull(versions1); - Assert.IsNull(versions2); - Assert.IsNull(versions3); + // Verify cache was not initialized for null input + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized for null input"); } [TestMethod] - public void TryGetStableVersions_WithNullLocation_ReturnsFalse() + public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() { - var provider = DefaultStableAksVersionProvider.Instance; + 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"); - var result = provider.TryGetStableVersions(null, out var versions); - - Assert.IsFalse(result); - Assert.IsNull(versions); + Assert.IsFalse(result1); + Assert.IsFalse(result2); + Assert.IsNull(versions1); + Assert.IsNull(versions2); } [TestMethod] - public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() + [TestCategory("Integration")] + public void TryGetStableVersions_WithUnknownLocation_ReturnsFalse() { - var provider = DefaultStableAksVersionProvider.Instance; + // This will initialize the cache with real data + var knownResult = _testProvider.TryGetStableVersions("eastus", out _); - var result1 = provider.TryGetStableVersions("", out var versions1); - var result2 = provider.TryGetStableVersions(" ", out var versions2); + // 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); @@ -149,58 +204,57 @@ public void TryGetStableVersions_WithEmptyLocation_ReturnsFalse() } [TestMethod] - public void TryGetStableVersions_CachingBehavior_OnlyInitializesOnce() + [TestCategory("Integration")] + public void EnsureInitialized_CalledMultipleTimes_OnlyFetchesOnce() { - var provider = DefaultStableAksVersionProvider.Instance; + Assert.IsFalse(_testProvider.IsCacheInitialized(), "Cache should not be initialized"); - // Multiple calls should use the same cached data + // First call should fetch data var startTime = DateTime.UtcNow; - - // First call - provider.TryGetStableVersions("eastus", out _); + _testProvider.TryGetStableVersions("eastus", out _); var firstCallTime = DateTime.UtcNow - startTime; - startTime = DateTime.UtcNow; + Assert.IsTrue(_testProvider.IsCacheInitialized(), "Cache should be initialized after first call"); - // Second call should be much faster (cached) - provider.TryGetStableVersions("westus", out _); + // Second call should be much faster (no fetch) + startTime = DateTime.UtcNow; + _testProvider.TryGetStableVersions("westus", out _); var secondCallTime = DateTime.UtcNow - startTime; - // The second call should be significantly faster than the first - // (this is a heuristic test - actual timing may vary) - Assert.IsTrue(secondCallTime < firstCallTime || secondCallTime.TotalMilliseconds < 100, - $"Second call ({secondCallTime.TotalMilliseconds}ms) should be faster than first call ({firstCallTime.TotalMilliseconds}ms)"); + // 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] - public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionSets() + [TestCategory("Integration")] + public void TryGetStableVersions_WhenDataExists_ReturnsValidVersionFormats() { - var provider = DefaultStableAksVersionProvider.Instance; - - // Test with a known region (assuming eastus exists) - var result = provider.TryGetStableVersions("eastus", out var versions); + var result = _testProvider.TryGetStableVersions("eastus", out var versions); if (result) { Assert.IsNotNull(versions); - Assert.IsTrue(versions.Count > 0); + Assert.IsTrue(versions.Count > 0, "Should have at least one version"); - // Verify versions are in expected format (semantic versioning) foreach (var version in versions) { - Assert.IsNotNull(version); - Assert.IsTrue(version.Contains("."), $"Version {version} should contain dots"); + // Verify semantic versioning format + Assert.IsTrue(System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+\.\d+"), + $"Version '{version}' should follow semantic versioning"); - // Basic format check - should start with number - Assert.IsTrue(char.IsDigit(version[0]), $"Version {version} should start with a digit"); + // 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 provider = DefaultStableAksVersionProvider.Instance; var results = new bool[10]; var versionCounts = new int[10]; var exceptions = new List(); @@ -210,7 +264,7 @@ public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequest { try { - results[i] = provider.TryGetStableVersions("eastus", out var versions); + results[i] = _testProvider.TryGetStableVersions("eastus", out var versions); versionCounts[i] = versions?.Count ?? 0; } catch (Exception ex) @@ -223,44 +277,17 @@ public void TryGetStableVersions_ThreadSafety_HandlesMultipleSimultaneousRequest }); // Should not have any exceptions - Assert.AreEqual(0, exceptions.Count, $"Thread safety test failed with exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + 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 - if (results.Any(r => r)) - { - Assert.IsTrue(results.All(r => r == results[0]), "All parallel calls should return the same result"); - Assert.IsTrue(versionCounts.All(c => c == versionCounts[0]), "All parallel calls should return the same version count"); - } - } - - [TestMethod] - public void NormalizeRegionName_Integration_WorksThroughPublicInterface() - { - var provider = DefaultStableAksVersionProvider.Instance; - - // Test that normalization works through the public interface - // This indirectly tests the private NormalizeRegionName method - var testCases = new[] - { - ("East US", "eastus"), - ("WEST EUROPE", "westeurope"), - ("Central India", "centralindia"), - (" North Central US ", "northcentralus") - }; + var firstResult = results[0]; + Assert.IsTrue(results.All(r => r == firstResult), "All parallel calls should succeed/fail consistently"); - foreach (var (input, expected) in testCases) + if (firstResult) { - var result1 = provider.TryGetStableVersions(input, out var versions1); - var result2 = provider.TryGetStableVersions(expected, out var versions2); - - // Both should return the same result - Assert.AreEqual(result1, result2, $"Results should be the same for '{input}' and '{expected}'"); - - if (result1 && result2) - { - Assert.AreEqual(versions1.Count, versions2.Count, - $"Version counts should be the same for '{input}' and '{expected}'"); - } + var firstCount = versionCounts[0]; + Assert.IsTrue(versionCounts.All(c => c == firstCount), "All parallel calls should return same version count"); } } } 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/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs index 9b8abaad..8366a241 100644 --- a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/DefaultStableAksVersionProvider.cs @@ -31,6 +31,12 @@ 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)) From aed7ff674a70f24fc8a6dbdad60d16e3824e302a Mon Sep 17 00:00:00 2001 From: RazvanLoghin1 Date: Tue, 26 Aug 2025 19:21:21 +0300 Subject: [PATCH 5/6] Update tokenToEvaluate to fetch correctly the resource information --- docs/authoring-json-rules.md | 9 +++------ .../HasStableAksVersionOperator.cs | 5 +++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/authoring-json-rules.md b/docs/authoring-json-rules.md index 9b7fd199..7c0edd60 100644 --- a/docs/authoring-json-rules.md +++ b/docs/authoring-json-rules.md @@ -267,12 +267,9 @@ The `HasStableAKSVersion` operator checks if an AKS cluster is using a stable Ku Example: ```json { - "path": "resources[*]", - "where": { - "path": "type", - "equals": "Microsoft.ContainerService/managedClusters" - }, - "hasStableAKSVersion": true + "resourceType": "Microsoft.ContainerService/managedClusters", + "path": "properties", + "hasStableAKSVersion": true } ``` diff --git a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs index 0fbfc442..d901e806 100644 --- a/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs +++ b/src/Analyzer.JsonRuleEngine/Operators/HasStableAKSVersionOperator/HasStableAksVersionOperator.cs @@ -50,8 +50,9 @@ public override bool EvaluateExpression(JToken tokenToEvaluate) { bool specifiedBoolValue = this.SpecifiedValue.Value(); - var location = tokenToEvaluate?["location"]?.Value(); - var kubernetesVersion = tokenToEvaluate?["properties"]?["kubernetesVersion"]?.Value(); + var resource = tokenToEvaluate?.Parent?.Parent; + var location = resource?["location"]?.Value(); + var kubernetesVersion = tokenToEvaluate?["kubernetesVersion"]?.Value(); if (string.IsNullOrEmpty(location) || string.IsNullOrEmpty(kubernetesVersion)) { From 3a3c2b7324a47941f9d04efcde2382f409b8c2b3 Mon Sep 17 00:00:00 2001 From: RazvanLoghin1 Date: Fri, 12 Sep 2025 14:27:34 +0300 Subject: [PATCH 6/6] Add the built in rule and a test --- .../Tests/TA-000039/AKSClusters.json | 156 ++++++++++++++++++ .../Tests/TA-000039/TA-000039.json | 41 +++++ src/Analyzer.Core/Rules/BuiltInRules.json | 14 ++ .../testFiles/shouldFail.bicep | 26 --- .../testFiles/shouldFail.json | 41 ----- .../testFiles/shouldPass.bicep | 25 --- .../testFiles/shouldPass.json | 40 ----- testHasStableAKSVersion/testRule.json | 16 -- 8 files changed, 211 insertions(+), 148 deletions(-) create mode 100644 src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/AKSClusters.json create mode 100644 src/Analyzer.Core.BuiltInRuleTests/Tests/TA-000039/TA-000039.json delete mode 100644 testHasStableAKSVersion/testFiles/shouldFail.bicep delete mode 100644 testHasStableAKSVersion/testFiles/shouldFail.json delete mode 100644 testHasStableAKSVersion/testFiles/shouldPass.bicep delete mode 100644 testHasStableAKSVersion/testFiles/shouldPass.json delete mode 100644 testHasStableAKSVersion/testRule.json 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/testHasStableAKSVersion/testFiles/shouldFail.bicep b/testHasStableAKSVersion/testFiles/shouldFail.bicep deleted file mode 100644 index f1b80a8b..00000000 --- a/testHasStableAKSVersion/testFiles/shouldFail.bicep +++ /dev/null @@ -1,26 +0,0 @@ -resource aksClusterTwo 'Microsoft.ContainerService/managedClusters@2025-06-02-preview' = { - name: 'aks-cluster-two' - location: 'northeurope' - sku: { - name: 'Standard' - tier: 'Paid' - } - 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' - } - } -} diff --git a/testHasStableAKSVersion/testFiles/shouldFail.json b/testHasStableAKSVersion/testFiles/shouldFail.json deleted file mode 100644 index 2e46430d..00000000 --- a/testHasStableAKSVersion/testFiles/shouldFail.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.36.177.2456", - "templateHash": "2494712498625137652" - } - }, - "resources": [ - { - "type": "Microsoft.ContainerService/managedClusters", - "apiVersion": "2025-06-02-preview", - "name": "aks-cluster-two", - "location": "northeurope", - "sku": { - "name": "Standard", - "tier": "Paid" - }, - "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" - } - } - } - ] -} \ No newline at end of file diff --git a/testHasStableAKSVersion/testFiles/shouldPass.bicep b/testHasStableAKSVersion/testFiles/shouldPass.bicep deleted file mode 100644 index 7a3bb52d..00000000 --- a/testHasStableAKSVersion/testFiles/shouldPass.bicep +++ /dev/null @@ -1,25 +0,0 @@ -resource aksClusterOne 'Microsoft.ContainerService/managedClusters@2025-06-02-preview' = { - name: 'aks-cluster-one' - location: 'westeurope' - sku: { - name: 'Basic' - tier: 'Free' - } - 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' - } - } -} diff --git a/testHasStableAKSVersion/testFiles/shouldPass.json b/testHasStableAKSVersion/testFiles/shouldPass.json deleted file mode 100644 index fd417a45..00000000 --- a/testHasStableAKSVersion/testFiles/shouldPass.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.36.177.2456", - "templateHash": "5931368711354706632" - } - }, - "resources": [ - { - "type": "Microsoft.ContainerService/managedClusters", - "apiVersion": "2025-06-02-preview", - "name": "aks-cluster-one", - "location": "westeurope", - "sku": { - "name": "Basic", - "tier": "Free" - }, - "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" - } - } - } - ] -} \ No newline at end of file diff --git a/testHasStableAKSVersion/testRule.json b/testHasStableAKSVersion/testRule.json deleted file mode 100644 index 834bf4b0..00000000 --- a/testHasStableAKSVersion/testRule.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "id": "TA-TEST-001", - "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://example.com", - "severity": 2, - "evaluation": { - "resourceType": "Microsoft.ContainerService/managedClusters", - "path": "properties", - "hasStableAKSVersion": true - } - } -] \ No newline at end of file