Skip to content

Commit de27dbd

Browse files
Copilotliliankasemmanvkaur
authored
Add deprecation warning for extension bundles during publish (#4746)
* Initial plan * Add extension bundle deprecation warning functionality Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> * Address code review feedback - improve HttpClient usage and error handling Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> * Add support for exact version format and requested test cases Co-authored-by: manvkaur <67894494+manvkaur@users.noreply.github.com> * Use direct CDN URL for extension bundle static properties Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> * Add fallback default extension bundle version range Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> * Update release notes with extension bundle deprecation warning Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> * Cleanup --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: liliankasem <2198905+liliankasem@users.noreply.github.com> Co-authored-by: manvkaur <67894494+manvkaur@users.noreply.github.com> Co-authored-by: Lilian Kasem <likasem@microsoft.com>
1 parent 23eb3b9 commit de27dbd

4 files changed

Lines changed: 288 additions & 1 deletion

File tree

release_notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Enhanced dotnet installation discovery by adopting the same `Muxer` logic used by the .NET SDK itself (#4732)
1717
- Update .NET templates package version to 4.0.5337 (#4728)
1818
- Fix `func pack --build-native-deps` failure on Windows for Python 3.13+ (#4742)
19+
- Add deprecation warning for extension bundles during function app publish (#4700)
1920
- Update the TypeScript project template to improve interoperability (#4739)
2021
- Upgrade `typescript` from `^4.0.0` to `^5.0.0`
2122
- Add `"esModuleInterop": true` option to `tsconfig.json`

src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Net.Http.Headers;
77
using Azure.Functions.Cli.Actions.LocalActions;
88
using Azure.Functions.Cli.Common;
9+
using Azure.Functions.Cli.ExtensionBundle;
910
using Azure.Functions.Cli.Extensions;
1011
using Azure.Functions.Cli.Helpers;
1112
using Azure.Functions.Cli.Interfaces;
@@ -378,6 +379,13 @@ private async Task<IDictionary<string, string>> ValidateFunctionAppPublish(Site
378379
ColoredConsole.WriteLine(WarningColor(Constants.Errors.ProxiesNotSupported));
379380
}
380381

382+
// Check for deprecated extension bundle version
383+
var extensionBundleWarning = await ExtensionBundleHelper.GetDeprecatedExtensionBundleWarning(functionAppRoot);
384+
if (!string.IsNullOrEmpty(extensionBundleWarning))
385+
{
386+
ColoredConsole.WriteLine(WarningColor(extensionBundleWarning));
387+
}
388+
381389
return result;
382390
}
383391

src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,35 @@
33

44
using Azure.Functions.Cli.Common;
55
using Azure.Functions.Cli.Helpers;
6-
using Colors.Net;
76
using Microsoft.Azure.WebJobs.Script;
87
using Microsoft.Azure.WebJobs.Script.Config;
98
using Microsoft.Azure.WebJobs.Script.Configuration;
109
using Microsoft.Azure.WebJobs.Script.ExtensionBundle;
1110
using Microsoft.Extensions.Configuration;
1211
using Microsoft.Extensions.Logging.Abstractions;
12+
using Newtonsoft.Json.Linq;
1313

1414
namespace Azure.Functions.Cli.ExtensionBundle
1515
{
1616
internal class ExtensionBundleHelper
1717
{
1818
private const int MaxRetries = 3;
19+
private const string ExtensionBundleStaticPropertiesUrl = "https://cdn.functions.azure.com/public/ExtensionBundles/Microsoft.Azure.Functions.ExtensionBundle/staticProperties.json";
20+
private const string DefaultExtensionBundleVersionRange = "[4.*, 5.0.0)";
21+
22+
// Regex patterns for version range parsing
23+
// Matches: [4.*, 5.0.0) or [1.*, 2.0.0) - with wildcard
24+
private const string VersionRangeWithWildcardPattern = @"\[(\d+(?:\.\d+(?:\.\d+)?)?|\d+)\.\*?,\s*(\d+\.\d+\.\d+)\)";
25+
26+
// Matches: [1.0.0, 2.0.0) - without wildcard
27+
private const string VersionRangePattern = @"\[(\d+\.\d+\.\d+),\s*(\d+\.\d+\.\d+)\)";
28+
29+
// Matches: [1.2.3] - exact version (treated as point range)
30+
private const string ExactVersionPattern = @"\[(\d+\.\d+\.\d+)\]";
31+
1932
private static readonly TimeSpan _retryDelay = TimeSpan.FromSeconds(2);
2033
private static readonly TimeSpan _httpTimeout = TimeSpan.FromMinutes(1);
34+
private static readonly HttpClient _sharedHttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
2135

2236
public static ExtensionBundleOptions GetExtensionBundleOptions(ScriptApplicationHostOptions hostOptions = null)
2337
{
@@ -75,5 +89,214 @@ await RetryHelper.Retry(
7589
// If Extension Bundle download fails again in the host then the host will return the appropriate customer facing error.
7690
}
7791
}
92+
93+
/// <summary>
94+
/// Checks if the extension bundle version in host.json is deprecated.
95+
/// </summary>
96+
/// <param name="functionAppRoot">The root directory of the function app.</param>
97+
/// <returns>A warning message if deprecated, null otherwise.</returns>
98+
public static async Task<string> GetDeprecatedExtensionBundleWarning(string functionAppRoot)
99+
{
100+
try
101+
{
102+
var hostJsonPath = Path.Combine(functionAppRoot, Constants.HostJsonFileName);
103+
if (!FileSystemHelpers.FileExists(hostJsonPath))
104+
{
105+
return null;
106+
}
107+
108+
var hostJsonContent = await FileSystemHelpers.ReadAllTextFromFileAsync(hostJsonPath);
109+
var hostJson = JObject.Parse(hostJsonContent);
110+
111+
var extensionBundle = hostJson[Constants.ExtensionBundleConfigPropertyName];
112+
if (extensionBundle == null)
113+
{
114+
return null;
115+
}
116+
117+
var version = extensionBundle["version"]?.ToString();
118+
if (string.IsNullOrEmpty(version))
119+
{
120+
return null;
121+
}
122+
123+
// Fetch the default version range from Azure
124+
string defaultVersionRange = await GetDefaultExtensionBundleVersionRange();
125+
if (string.IsNullOrEmpty(defaultVersionRange))
126+
{
127+
return null;
128+
}
129+
130+
// Check if the current version range intersects with the default (recommended) range
131+
if (!VersionRangesIntersect(version, defaultVersionRange))
132+
{
133+
return $"Your app is using a deprecated version {version} of extension bundles. Upgrade to {defaultVersionRange}.";
134+
}
135+
136+
return null;
137+
}
138+
catch (Exception)
139+
{
140+
// If we can't determine deprecation status, don't block the publish
141+
return null;
142+
}
143+
}
144+
145+
/// <summary>
146+
/// Fetches the default extension bundle version range from Azure.
147+
/// Falls back to a default range if the URL cannot be reached.
148+
/// </summary>
149+
private static async Task<string> GetDefaultExtensionBundleVersionRange()
150+
{
151+
try
152+
{
153+
var response = await _sharedHttpClient.GetStringAsync(ExtensionBundleStaticPropertiesUrl);
154+
var json = JObject.Parse(response);
155+
return json["defaultVersionRange"]?.ToString() ?? DefaultExtensionBundleVersionRange;
156+
}
157+
catch (Exception)
158+
{
159+
// If we can't fetch the default range, use the fallback default
160+
return DefaultExtensionBundleVersionRange;
161+
}
162+
}
163+
164+
/// <summary>
165+
/// Checks if two version ranges intersect.
166+
/// Supports format: [major.*, major.minor.patch) or [major.minor.patch, major.minor.patch).
167+
/// </summary>
168+
internal static bool VersionRangesIntersect(string range1, string range2)
169+
{
170+
try
171+
{
172+
var parsed1 = ParseVersionRange(range1);
173+
var parsed2 = ParseVersionRange(range2);
174+
175+
if (parsed1 == null || parsed2 == null)
176+
{
177+
return true; // If we can't parse, assume they intersect (no warning)
178+
}
179+
180+
// Two ranges intersect if: start1 < end2 AND start2 < end1
181+
return CompareVersions(parsed1.Value.Start, parsed2.Value.End) < 0 &&
182+
CompareVersions(parsed2.Value.Start, parsed1.Value.End) < 0;
183+
}
184+
catch
185+
{
186+
return true; // If comparison fails, assume they intersect (no warning)
187+
}
188+
}
189+
190+
/// <summary>
191+
/// Parses a version range string like "[1.*, 2.0.0)" or "[1.0.0, 2.0.0)" or "[1.2.3]"
192+
/// Returns (start, end) tuple where versions are normalized to "major.minor.patch" format
193+
/// For exact versions like "[1.2.3]", treats as a point range [1.2.3, 1.2.4).
194+
/// </summary>
195+
internal static (string Start, string End)? ParseVersionRange(string range)
196+
{
197+
if (string.IsNullOrEmpty(range))
198+
{
199+
return null;
200+
}
201+
202+
// Try to match exact version pattern first [X.Y.Z]
203+
var match = System.Text.RegularExpressions.Regex.Match(range, ExactVersionPattern);
204+
if (match.Success)
205+
{
206+
var version = match.Groups[1].Value;
207+
var parts = version.Split('.');
208+
if (parts.Length == 3 && int.TryParse(parts[2], out int patch))
209+
{
210+
// Treat [X.Y.Z] as a point range [X.Y.Z, X.Y.(Z+1))
211+
var start = version;
212+
var end = $"{parts[0]}.{parts[1]}.{patch + 1}";
213+
return (start, end);
214+
}
215+
216+
return null;
217+
}
218+
219+
// Try to match with wildcard pattern
220+
match = System.Text.RegularExpressions.Regex.Match(range, VersionRangeWithWildcardPattern);
221+
222+
if (!match.Success)
223+
{
224+
// Try without wildcard
225+
match = System.Text.RegularExpressions.Regex.Match(range, VersionRangePattern);
226+
}
227+
228+
if (!match.Success)
229+
{
230+
return null;
231+
}
232+
233+
var lower = match.Groups[1].Value;
234+
var upper = match.Groups[2].Value;
235+
236+
// Normalize lower bound: if it contains *, replace with .0.0
237+
if (lower.Contains("*"))
238+
{
239+
lower = lower.Replace(".*", ".0.0");
240+
}
241+
242+
// Ensure both versions are in major.minor.patch format
243+
lower = NormalizeVersion(lower);
244+
upper = NormalizeVersion(upper);
245+
246+
return (lower, upper);
247+
}
248+
249+
/// <summary>
250+
/// Normalizes a version string to major.minor.patch format.
251+
/// </summary>
252+
private static string NormalizeVersion(string version)
253+
{
254+
var parts = version.Split('.');
255+
if (parts.Length == 1)
256+
{
257+
return $"{parts[0]}.0.0";
258+
}
259+
else if (parts.Length == 2)
260+
{
261+
return $"{parts[0]}.{parts[1]}.0";
262+
}
263+
264+
return version;
265+
}
266+
267+
/// <summary>
268+
/// Compares two version strings in major.minor.patch format
269+
/// Returns: -1 if v1 is less than v2, 0 if equal, 1 if v1 is greater than v2.
270+
/// </summary>
271+
private static int CompareVersions(string v1, string v2)
272+
{
273+
var parts1 = v1.Split('.');
274+
var parts2 = v2.Split('.');
275+
276+
// Validate that all parts are numeric
277+
if (!parts1.All(p => int.TryParse(p, out _)) || !parts2.All(p => int.TryParse(p, out _)))
278+
{
279+
// If we can't parse, return 0 (equal) to be safe
280+
return 0;
281+
}
282+
283+
var intParts1 = parts1.Select(int.Parse).ToArray();
284+
var intParts2 = parts2.Select(int.Parse).ToArray();
285+
286+
for (int i = 0; i < Math.Min(intParts1.Length, intParts2.Length); i++)
287+
{
288+
if (intParts1[i] < intParts2[i])
289+
{
290+
return -1;
291+
}
292+
293+
if (intParts1[i] > intParts2[i])
294+
{
295+
return 1;
296+
}
297+
}
298+
299+
return intParts1.Length.CompareTo(intParts2.Length);
300+
}
78301
}
79302
}

test/Cli/Func.UnitTests/HelperTests/ExtensionBundleHelperTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,60 @@ public void GetBundleDownloadPath_ReturnCorrectPath()
1515
var expectedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azure-functions-core-tools", "Functions", "ExtensionBundles", "BundleId");
1616
Assert.Equal(expectedPath, downloadPath);
1717
}
18+
19+
[Theory]
20+
[InlineData("[3.3.0, 4.0.0)", "3.3.0", "4.0.0")]
21+
[InlineData("[4.*, 5.0.0)", "4.0.0", "5.0.0")]
22+
[InlineData("[1.0.0, 2.0.0)", "1.0.0", "2.0.0")]
23+
[InlineData("[2.*, 3.0.0)", "2.0.0", "3.0.0")]
24+
[InlineData("[3.40.0]", "3.40.0", "3.40.1")] // Exact version treated as point range
25+
[InlineData("[4.28.0]", "4.28.0", "4.28.1")] // Exact version treated as point range
26+
public void ParseVersionRange_ValidRange_ReturnsCorrectBounds(string range, string expectedStart, string expectedEnd)
27+
{
28+
var result = ExtensionBundleHelper.ParseVersionRange(range);
29+
30+
Assert.NotNull(result);
31+
Assert.Equal(expectedStart, result.Value.Start);
32+
Assert.Equal(expectedEnd, result.Value.End);
33+
}
34+
35+
[Theory]
36+
[InlineData("invalid")]
37+
[InlineData("")]
38+
[InlineData(null)]
39+
public void ParseVersionRange_InvalidRange_ReturnsNull(string range)
40+
{
41+
var result = ExtensionBundleHelper.ParseVersionRange(range);
42+
43+
Assert.Null(result);
44+
}
45+
46+
[Theory]
47+
[InlineData("[4.*, 5.0.0)", "[4.*, 5.0.0)", true)] // Same ranges intersect
48+
[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
49+
[InlineData("[4.*, 5.0.0)", "[3.3.0, 4.0.0)", false)] // No overlap (reversed)
50+
[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
51+
[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
52+
[InlineData("[1.*, 2.0.0)", "[3.*, 4.0.0)", false)] // Completely separate ranges
53+
public void VersionRangesIntersect_VariousRanges_ReturnsExpectedResult(string range1, string range2, bool expectedIntersect)
54+
{
55+
var result = ExtensionBundleHelper.VersionRangesIntersect(range1, range2);
56+
57+
Assert.Equal(expectedIntersect, result);
58+
}
59+
60+
[Theory]
61+
[InlineData("[3.3.0, 4.0.0)", "[4.*, 5.0.0)", false)] // Deprecated: v3 doesn't intersect with v4
62+
[InlineData("[2.*, 3.0.0)", "[4.*, 5.0.0)", false)] // Deprecated: v2 doesn't intersect with v4
63+
[InlineData("[4.*, 5.0.0)", "[4.*, 5.0.0)", true)] // Not deprecated: same as default
64+
[InlineData("[4.0.0, 4.5.0)", "[4.*, 5.0.0)", true)] // Not deprecated: within v4 range
65+
[InlineData("[3.40.0]", "[4.*, 5.0.0)", false)] // Deprecated: exact v3 version doesn't intersect with v4
66+
[InlineData("[4.28.0]", "[4.*, 5.0.0)", true)] // Not deprecated: exact v4 version within v4 range
67+
public void VersionRangesIntersect_DeprecationScenarios_ReturnsExpectedResult(string localVersion, string defaultVersion, bool shouldIntersect)
68+
{
69+
var result = ExtensionBundleHelper.VersionRangesIntersect(localVersion, defaultVersion);
70+
71+
Assert.Equal(shouldIntersect, result);
72+
}
1873
}
1974
}

0 commit comments

Comments
 (0)