From 652444dffba12629cab0068cc725ef616b7d82da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:47:24 +0000 Subject: [PATCH 1/4] Initial plan From a531de57f8daec19d38666563bebcc6650c44b24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:04:42 +0000 Subject: [PATCH 2/4] Add extension bundle deprecation warning functionality Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> --- .../AzureActions/PublishFunctionAppAction.cs | 8 + .../ExtensionBundle/ExtensionBundleHelper.cs | 180 ++++++++++++++++++ .../HelperTests/ExtensionBundleHelperTests.cs | 51 +++++ 3 files changed, 239 insertions(+) diff --git a/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs b/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs index b5e26675f..8d5159071 100644 --- a/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs +++ b/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs @@ -7,6 +7,7 @@ using Azure.Functions.Cli.Actions.LocalActions; using Azure.Functions.Cli.Common; using Azure.Functions.Cli.Extensions; +using Azure.Functions.Cli.ExtensionBundle; using Azure.Functions.Cli.Helpers; using Azure.Functions.Cli.Interfaces; using Azure.Functions.Cli.StacksApi; @@ -378,6 +379,13 @@ private async Task> ValidateFunctionAppPublish(Site ColoredConsole.WriteLine(WarningColor(Constants.Errors.ProxiesNotSupported)); } + // Check for deprecated extension bundle version + var extensionBundleWarning = await ExtensionBundleHelper.GetDeprecatedExtensionBundleWarning(functionAppRoot); + if (!string.IsNullOrEmpty(extensionBundleWarning)) + { + ColoredConsole.WriteLine(WarningColor(extensionBundleWarning)); + } + return result; } diff --git a/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs b/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs index ba5332af0..7544af7d8 100644 --- a/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs +++ b/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs @@ -10,6 +10,7 @@ using Microsoft.Azure.WebJobs.Script.ExtensionBundle; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; namespace Azure.Functions.Cli.ExtensionBundle { @@ -75,5 +76,184 @@ await RetryHelper.Retry( // If Extension Bundle download fails again in the host then the host will return the appropriate customer facing error. } } + + /// + /// Checks if the extension bundle version in host.json is deprecated. + /// + /// The root directory of the function app + /// A warning message if deprecated, null otherwise + public static async Task GetDeprecatedExtensionBundleWarning(string functionAppRoot) + { + try + { + var hostJsonPath = Path.Combine(functionAppRoot, Constants.HostJsonFileName); + if (!FileSystemHelpers.FileExists(hostJsonPath)) + { + return null; + } + + var hostJsonContent = await FileSystemHelpers.ReadAllTextFromFileAsync(hostJsonPath); + var hostJson = JObject.Parse(hostJsonContent); + + var extensionBundle = hostJson[Constants.ExtensionBundleConfigPropertyName]; + if (extensionBundle == null) + { + return null; + } + + var version = extensionBundle["version"]?.ToString(); + if (string.IsNullOrEmpty(version)) + { + return null; + } + + // Fetch the default version range from Azure + string defaultVersionRange = await GetDefaultExtensionBundleVersionRange(); + if (string.IsNullOrEmpty(defaultVersionRange)) + { + return null; + } + + // Check if the current version range intersects with the default (recommended) range + if (!VersionRangesIntersect(version, defaultVersionRange)) + { + return $"Your app is using a deprecated version {version} of extension bundles. Upgrade to {defaultVersionRange}."; + } + + return null; + } + catch (Exception) + { + // If we can't determine deprecation status, don't block the publish + return null; + } + } + + /// + /// Fetches the default extension bundle version range from Azure. + /// + private static async Task GetDefaultExtensionBundleVersionRange() + { + try + { + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var response = await httpClient.GetStringAsync("https://aka.ms/funcStaticProperties"); + var json = JObject.Parse(response); + return json["defaultVersionRange"]?.ToString(); + } + catch (Exception) + { + // If we can't fetch the default range, return null to avoid blocking + return null; + } + } + + /// + /// Checks if two version ranges intersect. + /// Supports format: [major.*, major.minor.patch) or [major.minor.patch, major.minor.patch) + /// + internal static bool VersionRangesIntersect(string range1, string range2) + { + try + { + var parsed1 = ParseVersionRange(range1); + var parsed2 = ParseVersionRange(range2); + + if (parsed1 == null || parsed2 == null) + { + return true; // If we can't parse, assume they intersect (no warning) + } + + // Two ranges intersect if: start1 < end2 AND start2 < end1 + return CompareVersions(parsed1.Value.start, parsed2.Value.end) < 0 && + CompareVersions(parsed2.Value.start, parsed1.Value.end) < 0; + } + catch + { + return true; // If comparison fails, assume they intersect (no warning) + } + } + + /// + /// Parses a version range string like "[1.*, 2.0.0)" or "[1.0.0, 2.0.0)" + /// Returns (start, end) tuple where versions are normalized to "major.minor.patch" format + /// + internal static (string start, string end)? ParseVersionRange(string range) + { + if (string.IsNullOrEmpty(range)) + { + return null; + } + + // Match patterns like [1.*, 2.0.0) or [1.0.0, 2.0.0) + var match = System.Text.RegularExpressions.Regex.Match( + range, + @"\[(\d+(?:\.\d+(?:\.\d+)?)?|\d+)\.\*?,\s*(\d+\.\d+\.\d+)\)" + ); + + if (!match.Success) + { + // Try without wildcard: [1.0.0, 2.0.0) + match = System.Text.RegularExpressions.Regex.Match( + range, + @"\[(\d+\.\d+\.\d+),\s*(\d+\.\d+\.\d+)\)" + ); + } + + if (!match.Success) + { + return null; + } + + var lower = match.Groups[1].Value; + var upper = match.Groups[2].Value; + + // Normalize lower bound: if it contains *, replace with .0.0 + if (lower.Contains("*")) + { + lower = lower.Replace(".*", ".0.0"); + } + + // Ensure both versions are in major.minor.patch format + lower = NormalizeVersion(lower); + upper = NormalizeVersion(upper); + + return (lower, upper); + } + + /// + /// Normalizes a version string to major.minor.patch format + /// + private static string NormalizeVersion(string version) + { + var parts = version.Split('.'); + if (parts.Length == 1) + { + return $"{parts[0]}.0.0"; + } + else if (parts.Length == 2) + { + return $"{parts[0]}.{parts[1]}.0"; + } + return version; + } + + /// + /// Compares two version strings in major.minor.patch format + /// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 + /// + private static int CompareVersions(string v1, string v2) + { + var parts1 = v1.Split('.').Select(int.Parse).ToArray(); + var parts2 = v2.Split('.').Select(int.Parse).ToArray(); + + for (int i = 0; i < Math.Min(parts1.Length, parts2.Length); i++) + { + if (parts1[i] < parts2[i]) return -1; + if (parts1[i] > parts2[i]) return 1; + } + + return parts1.Length.CompareTo(parts2.Length); + } } } diff --git a/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs b/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs index dcae2cbd6..eb30795d0 100644 --- a/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs +++ b/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs @@ -15,5 +15,56 @@ public void GetBundleDownloadPath_ReturnCorrectPath() var expectedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azure-functions-core-tools", "Functions", "ExtensionBundles", "BundleId"); Assert.Equal(expectedPath, downloadPath); } + + [Theory] + [InlineData("[3.3.0, 4.0.0)", "3.3.0", "4.0.0")] + [InlineData("[4.*, 5.0.0)", "4.0.0", "5.0.0")] + [InlineData("[1.0.0, 2.0.0)", "1.0.0", "2.0.0")] + [InlineData("[2.*, 3.0.0)", "2.0.0", "3.0.0")] + public void ParseVersionRange_ValidRange_ReturnsCorrectBounds(string range, string expectedStart, string expectedEnd) + { + var result = ExtensionBundleHelper.ParseVersionRange(range); + + Assert.NotNull(result); + Assert.Equal(expectedStart, result.Value.start); + Assert.Equal(expectedEnd, result.Value.end); + } + + [Theory] + [InlineData("invalid")] + [InlineData("")] + [InlineData(null)] + public void ParseVersionRange_InvalidRange_ReturnsNull(string range) + { + var result = ExtensionBundleHelper.ParseVersionRange(range); + + Assert.Null(result); + } + + [Theory] + [InlineData("[4.*, 5.0.0)", "[4.*, 5.0.0)", true)] // Same ranges intersect + [InlineData("[3.3.0, 4.0.0)", "[4.*, 5.0.0)", false)] // No overlap: 3.3.0-4.0.0 vs 4.0.0-5.0.0 + [InlineData("[4.*, 5.0.0)", "[3.3.0, 4.0.0)", false)] // No overlap (reversed) + [InlineData("[3.*, 5.0.0)", "[4.*, 5.0.0)", true)] // Partial overlap: 3.0.0-5.0.0 vs 4.0.0-5.0.0 + [InlineData("[4.0.0, 4.5.0)", "[4.2.0, 5.0.0)", true)] // Partial overlap: 4.0.0-4.5.0 vs 4.2.0-5.0.0 + [InlineData("[1.*, 2.0.0)", "[3.*, 4.0.0)", false)] // Completely separate ranges + public void VersionRangesIntersect_VariousRanges_ReturnsExpectedResult(string range1, string range2, bool expectedIntersect) + { + var result = ExtensionBundleHelper.VersionRangesIntersect(range1, range2); + + Assert.Equal(expectedIntersect, result); + } + + [Theory] + [InlineData("[3.3.0, 4.0.0)", "[4.*, 5.0.0)", false)] // Deprecated: v3 doesn't intersect with v4 + [InlineData("[2.*, 3.0.0)", "[4.*, 5.0.0)", false)] // Deprecated: v2 doesn't intersect with v4 + [InlineData("[4.*, 5.0.0)", "[4.*, 5.0.0)", true)] // Not deprecated: same as default + [InlineData("[4.0.0, 4.5.0)", "[4.*, 5.0.0)", true)] // Not deprecated: within v4 range + public void VersionRangesIntersect_DeprecationScenarios_ReturnsExpectedResult(string localVersion, string defaultVersion, bool shouldIntersect) + { + var result = ExtensionBundleHelper.VersionRangesIntersect(localVersion, defaultVersion); + + Assert.Equal(shouldIntersect, result); + } } } From 8ac433a6d01cc2b5554e96c6754f0287e05cf929 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:10:40 +0000 Subject: [PATCH 3/4] Address code review feedback - improve HttpClient usage and error handling Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> --- .../ExtensionBundle/ExtensionBundleHelper.cs | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs b/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs index 7544af7d8..c1955c4b7 100644 --- a/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs +++ b/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs @@ -19,6 +19,13 @@ internal class ExtensionBundleHelper private const int MaxRetries = 3; private static readonly TimeSpan _retryDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan _httpTimeout = TimeSpan.FromMinutes(1); + private static readonly HttpClient _sharedHttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + + // Regex patterns for version range parsing + // Matches: [4.*, 5.0.0) or [1.*, 2.0.0) - with wildcard + private const string VersionRangeWithWildcardPattern = @"\[(\d+(?:\.\d+(?:\.\d+)?)?|\d+)\.\*?,\s*(\d+\.\d+\.\d+)\)"; + // Matches: [1.0.0, 2.0.0) - without wildcard + private const string VersionRangePattern = @"\[(\d+\.\d+\.\d+),\s*(\d+\.\d+\.\d+)\)"; public static ExtensionBundleOptions GetExtensionBundleOptions(ScriptApplicationHostOptions hostOptions = null) { @@ -136,8 +143,7 @@ private static async Task GetDefaultExtensionBundleVersionRange() { try { - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; - var response = await httpClient.GetStringAsync("https://aka.ms/funcStaticProperties"); + var response = await _sharedHttpClient.GetStringAsync("https://aka.ms/funcStaticProperties"); var json = JObject.Parse(response); return json["defaultVersionRange"]?.ToString(); } @@ -185,19 +191,13 @@ internal static (string start, string end)? ParseVersionRange(string range) return null; } - // Match patterns like [1.*, 2.0.0) or [1.0.0, 2.0.0) - var match = System.Text.RegularExpressions.Regex.Match( - range, - @"\[(\d+(?:\.\d+(?:\.\d+)?)?|\d+)\.\*?,\s*(\d+\.\d+\.\d+)\)" - ); + // Try to match with wildcard pattern first + var match = System.Text.RegularExpressions.Regex.Match(range, VersionRangeWithWildcardPattern); if (!match.Success) { - // Try without wildcard: [1.0.0, 2.0.0) - match = System.Text.RegularExpressions.Regex.Match( - range, - @"\[(\d+\.\d+\.\d+),\s*(\d+\.\d+\.\d+)\)" - ); + // Try without wildcard + match = System.Text.RegularExpressions.Regex.Match(range, VersionRangePattern); } if (!match.Success) @@ -244,16 +244,26 @@ private static string NormalizeVersion(string version) /// private static int CompareVersions(string v1, string v2) { - var parts1 = v1.Split('.').Select(int.Parse).ToArray(); - var parts2 = v2.Split('.').Select(int.Parse).ToArray(); + var parts1 = v1.Split('.'); + var parts2 = v2.Split('.'); + + // Validate that all parts are numeric + if (!parts1.All(p => int.TryParse(p, out _)) || !parts2.All(p => int.TryParse(p, out _))) + { + // If we can't parse, return 0 (equal) to be safe + return 0; + } + + var intParts1 = parts1.Select(int.Parse).ToArray(); + var intParts2 = parts2.Select(int.Parse).ToArray(); - for (int i = 0; i < Math.Min(parts1.Length, parts2.Length); i++) + for (int i = 0; i < Math.Min(intParts1.Length, intParts2.Length); i++) { - if (parts1[i] < parts2[i]) return -1; - if (parts1[i] > parts2[i]) return 1; + if (intParts1[i] < intParts2[i]) return -1; + if (intParts1[i] > intParts2[i]) return 1; } - return parts1.Length.CompareTo(parts2.Length); + return intParts1.Length.CompareTo(intParts2.Length); } } } From 1722d1360cec06ba89906da0eff01fe46612f1d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 22:28:25 +0000 Subject: [PATCH 4/4] Add support for exact version format and requested test cases Co-authored-by: manvkaur <67894494+manvkaur@users.noreply.github.com> --- .../ExtensionBundle/ExtensionBundleHelper.cs | 25 ++++++++++++++++--- .../HelperTests/ExtensionBundleHelperTests.cs | 4 +++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs b/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs index c1955c4b7..5f6ddaf66 100644 --- a/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs +++ b/src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs @@ -26,6 +26,8 @@ internal class ExtensionBundleHelper private const string VersionRangeWithWildcardPattern = @"\[(\d+(?:\.\d+(?:\.\d+)?)?|\d+)\.\*?,\s*(\d+\.\d+\.\d+)\)"; // Matches: [1.0.0, 2.0.0) - without wildcard private const string VersionRangePattern = @"\[(\d+\.\d+\.\d+),\s*(\d+\.\d+\.\d+)\)"; + // Matches: [1.2.3] - exact version (treated as point range) + private const string ExactVersionPattern = @"\[(\d+\.\d+\.\d+)\]"; public static ExtensionBundleOptions GetExtensionBundleOptions(ScriptApplicationHostOptions hostOptions = null) { @@ -181,8 +183,9 @@ internal static bool VersionRangesIntersect(string range1, string range2) } /// - /// Parses a version range string like "[1.*, 2.0.0)" or "[1.0.0, 2.0.0)" + /// Parses a version range string like "[1.*, 2.0.0)" or "[1.0.0, 2.0.0)" or "[1.2.3]" /// Returns (start, end) tuple where versions are normalized to "major.minor.patch" format + /// For exact versions like "[1.2.3]", treats as a point range [1.2.3, 1.2.4) /// internal static (string start, string end)? ParseVersionRange(string range) { @@ -191,8 +194,24 @@ internal static (string start, string end)? ParseVersionRange(string range) return null; } - // Try to match with wildcard pattern first - var match = System.Text.RegularExpressions.Regex.Match(range, VersionRangeWithWildcardPattern); + // Try to match exact version pattern first [X.Y.Z] + var match = System.Text.RegularExpressions.Regex.Match(range, ExactVersionPattern); + if (match.Success) + { + var version = match.Groups[1].Value; + var parts = version.Split('.'); + if (parts.Length == 3 && int.TryParse(parts[2], out int patch)) + { + // Treat [X.Y.Z] as a point range [X.Y.Z, X.Y.(Z+1)) + var lower = version; + var upper = $"{parts[0]}.{parts[1]}.{patch + 1}"; + return (lower, upper); + } + return null; + } + + // Try to match with wildcard pattern + match = System.Text.RegularExpressions.Regex.Match(range, VersionRangeWithWildcardPattern); if (!match.Success) { diff --git a/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs b/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs index eb30795d0..8187f05d3 100644 --- a/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs +++ b/test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs @@ -21,6 +21,8 @@ public void GetBundleDownloadPath_ReturnCorrectPath() [InlineData("[4.*, 5.0.0)", "4.0.0", "5.0.0")] [InlineData("[1.0.0, 2.0.0)", "1.0.0", "2.0.0")] [InlineData("[2.*, 3.0.0)", "2.0.0", "3.0.0")] + [InlineData("[3.40.0]", "3.40.0", "3.40.1")] // Exact version treated as point range + [InlineData("[4.28.0]", "4.28.0", "4.28.1")] // Exact version treated as point range public void ParseVersionRange_ValidRange_ReturnsCorrectBounds(string range, string expectedStart, string expectedEnd) { var result = ExtensionBundleHelper.ParseVersionRange(range); @@ -60,6 +62,8 @@ public void VersionRangesIntersect_VariousRanges_ReturnsExpectedResult(string ra [InlineData("[2.*, 3.0.0)", "[4.*, 5.0.0)", false)] // Deprecated: v2 doesn't intersect with v4 [InlineData("[4.*, 5.0.0)", "[4.*, 5.0.0)", true)] // Not deprecated: same as default [InlineData("[4.0.0, 4.5.0)", "[4.*, 5.0.0)", true)] // Not deprecated: within v4 range + [InlineData("[3.40.0]", "[4.*, 5.0.0)", false)] // Deprecated: exact v3 version doesn't intersect with v4 + [InlineData("[4.28.0]", "[4.*, 5.0.0)", true)] // Not deprecated: exact v4 version within v4 range public void VersionRangesIntersect_DeprecationScenarios_ReturnsExpectedResult(string localVersion, string defaultVersion, bool shouldIntersect) { var result = ExtensionBundleHelper.VersionRangesIntersect(localVersion, defaultVersion);