|
3 | 3 |
|
4 | 4 | using Azure.Functions.Cli.Common; |
5 | 5 | using Azure.Functions.Cli.Helpers; |
6 | | -using Colors.Net; |
7 | 6 | using Microsoft.Azure.WebJobs.Script; |
8 | 7 | using Microsoft.Azure.WebJobs.Script.Config; |
9 | 8 | using Microsoft.Azure.WebJobs.Script.Configuration; |
10 | 9 | using Microsoft.Azure.WebJobs.Script.ExtensionBundle; |
11 | 10 | using Microsoft.Extensions.Configuration; |
12 | 11 | using Microsoft.Extensions.Logging.Abstractions; |
| 12 | +using Newtonsoft.Json.Linq; |
13 | 13 |
|
14 | 14 | namespace Azure.Functions.Cli.ExtensionBundle |
15 | 15 | { |
16 | 16 | internal class ExtensionBundleHelper |
17 | 17 | { |
18 | 18 | 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 | + |
19 | 32 | private static readonly TimeSpan _retryDelay = TimeSpan.FromSeconds(2); |
20 | 33 | private static readonly TimeSpan _httpTimeout = TimeSpan.FromMinutes(1); |
| 34 | + private static readonly HttpClient _sharedHttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; |
21 | 35 |
|
22 | 36 | public static ExtensionBundleOptions GetExtensionBundleOptions(ScriptApplicationHostOptions hostOptions = null) |
23 | 37 | { |
@@ -75,5 +89,214 @@ await RetryHelper.Retry( |
75 | 89 | // If Extension Bundle download fails again in the host then the host will return the appropriate customer facing error. |
76 | 90 | } |
77 | 91 | } |
| 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 | + } |
78 | 301 | } |
79 | 302 | } |
0 commit comments