Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -378,6 +379,13 @@ private async Task<IDictionary<string, string>> 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;
}

Expand Down
190 changes: 190 additions & 0 deletions src/Cli/func/ExtensionBundle/ExtensionBundleHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -18,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)
{
Expand Down Expand Up @@ -75,5 +83,187 @@ await RetryHelper.Retry(
// If Extension Bundle download fails again in the host then the host will return the appropriate customer facing error.
}
}

/// <summary>
/// Checks if the extension bundle version in host.json is deprecated.
/// </summary>
/// <param name="functionAppRoot">The root directory of the function app</param>
/// <returns>A warning message if deprecated, null otherwise</returns>
public static async Task<string> 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;
}
}

/// <summary>
/// Fetches the default extension bundle version range from Azure.
/// </summary>
private static async Task<string> GetDefaultExtensionBundleVersionRange()
{
try
{
var response = await _sharedHttpClient.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;
}
}

/// <summary>
/// Checks if two version ranges intersect.
/// Supports format: [major.*, major.minor.patch) or [major.minor.patch, major.minor.patch)
/// </summary>
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)
}
}

/// <summary>
/// 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
/// </summary>
internal static (string start, string end)? ParseVersionRange(string range)
{
if (string.IsNullOrEmpty(range))
{
return null;
}

// Try to match with wildcard pattern first
var match = System.Text.RegularExpressions.Regex.Match(range, VersionRangeWithWildcardPattern);

if (!match.Success)
{
// Try without wildcard
match = System.Text.RegularExpressions.Regex.Match(range, VersionRangePattern);
}

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);
}

/// <summary>
/// Normalizes a version string to major.minor.patch format
/// </summary>
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;
}

/// <summary>
/// Compares two version strings in major.minor.patch format
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
/// </summary>
private static int CompareVersions(string v1, string v2)
{
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(intParts1.Length, intParts2.Length); i++)
{
if (intParts1[i] < intParts2[i]) return -1;
if (intParts1[i] > intParts2[i]) return 1;
}

return intParts1.Length.CompareTo(intParts2.Length);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}