Skip to content

Commit 78afd8b

Browse files
authored
Fix bug with installing .NET templates (#4612)
1 parent c3972eb commit 78afd8b

File tree

11 files changed

+268
-129
lines changed

11 files changed

+268
-129
lines changed

release_notes.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
# Azure Functions CLI 4.2.1
1+
# Azure Functions CLI 4.2.2
22

33
#### Host Version
44

55
- Host Version: 4.1041.200
6-
- In-Proc Host Version: 4.41.100 (4.841.100, 4.641.100)
6+
- In-Proc Host Version: 4.41.100 (4.841.100, 4.641.100)
77

88
#### Changes
99

10-
- Add support for .NET 10 isolated model (#4589)
11-
- Update log streaming to support both connection string and instrumentation Key (#4586)
12-
- Remove content of workers dir from minified versions (#4609)
10+
- Fix .NET template install bug (#4612)

src/Cli/func/Common/FileSystemHelpers.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See LICENSE in the project root for license information.
33

44
using System.IO.Abstractions;
@@ -127,6 +127,12 @@ public static string EnsureDirectory(string path)
127127
return path;
128128
}
129129

130+
public static bool EnsureDirectoryNotEmpty(string path)
131+
{
132+
return DirectoryExists(path) &&
133+
Instance.Directory.EnumerateFileSystemEntries(path).Any();
134+
}
135+
130136
public static void DeleteDirectorySafe(string path, bool ignoreErrors = true)
131137
{
132138
DeleteFileSystemInfo(Instance.DirectoryInfo.FromDirectoryName(path), ignoreErrors);

src/Cli/func/Directory.Version.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project>
22

33
<PropertyGroup>
4-
<VersionPrefix>4.2.1</VersionPrefix>
4+
<VersionPrefix>4.2.2</VersionPrefix>
55
<VersionSuffix></VersionSuffix>
66
<UpdateBuildNumber>true</UpdateBuildNumber>
77
</PropertyGroup>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Azure.Functions.Cli.Common;
5+
6+
namespace Azure.Functions.Cli.Helpers
7+
{
8+
// Partial class to hold E2E test related helpers
9+
public static partial class DotnetHelpers
10+
{
11+
// Environment variable names to control custom hive usage in E2E tests
12+
internal const string CustomHiveFlag = "FUNC_E2E_USE_CUSTOM_HIVE";
13+
internal const string CustomHiveRoot = "FUNC_E2E_HIVE_ROOT";
14+
internal const string CustomHiveKey = "FUNC_E2E_HIVE_KEY";
15+
16+
private static bool UseCustomTemplateHive() => string.Equals(Environment.GetEnvironmentVariable(CustomHiveFlag), "1", StringComparison.Ordinal);
17+
18+
private static string GetHiveRoot()
19+
{
20+
string root = Environment.GetEnvironmentVariable(CustomHiveRoot);
21+
22+
if (!string.IsNullOrWhiteSpace(root))
23+
{
24+
return root;
25+
}
26+
27+
string coreToolsLocalDataPath = Utilities.EnsureCoreToolsLocalData();
28+
return Path.Combine(coreToolsLocalDataPath, "dotnet-templates-custom-hives");
29+
}
30+
31+
// By default, each worker runtime shares a hive. This can be overridden by setting the FUNC_E2E_HIVE_KEY
32+
// environment variable to a custom value, which will cause a separate hive to be used.
33+
private static string GetHivePath(WorkerRuntime workerRuntime)
34+
{
35+
string key = Environment.GetEnvironmentVariable(CustomHiveKey);
36+
string leaf = !string.IsNullOrWhiteSpace(key) ? key : $"{workerRuntime.ToString().ToLowerInvariant()}-hive";
37+
return Path.Combine(GetHiveRoot(), leaf);
38+
}
39+
40+
private static bool TryGetCustomHiveArg(WorkerRuntime workerRuntime, out string customHiveArg)
41+
{
42+
customHiveArg = string.Empty;
43+
44+
if (!UseCustomTemplateHive())
45+
{
46+
return false;
47+
}
48+
49+
string hive = GetHivePath(workerRuntime);
50+
FileSystemHelpers.EnsureDirectory(hive);
51+
52+
customHiveArg = $" --debug:custom-hive \"{hive}\"";
53+
return true;
54+
}
55+
}
56+
}

src/Cli/func/Helpers/DotnetHelpers.cs

Lines changed: 93 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See LICENSE in the project root for license information.
33

4-
using System.Reflection;
54
using System.Runtime.InteropServices;
65
using System.Text;
6+
using System.Text.RegularExpressions;
77
using Azure.Functions.Cli.Common;
88
using Colors.Net;
99
using Microsoft.Azure.WebJobs.Extensions.Http;
1010
using static Azure.Functions.Cli.Common.OutputTheme;
1111

1212
namespace Azure.Functions.Cli.Helpers
1313
{
14-
public static class DotnetHelpers
14+
public static partial class DotnetHelpers
1515
{
16-
private const string WebJobsTemplateBasePackId = "Microsoft.Azure.WebJobs";
16+
private const string InProcTemplateBasePackId = "Microsoft.Azure.WebJobs";
1717
private const string IsolatedTemplateBasePackId = "Microsoft.Azure.Functions.Worker";
18-
private const string TemplatesLockFileName = "func_dotnet_templates.lock";
19-
private static readonly Lazy<Task<HashSet<string>>> _installedTemplatesList = new(GetInstalledTemplatePackageIds);
18+
19+
/// <summary>
20+
/// Gets or sets test hook to intercept 'dotnet new' invocations for unit tests.
21+
/// If null, real process execution is used.
22+
/// </summary>
23+
internal static Func<string, Task<int>> RunDotnetNewFunc { get; set; } = null;
24+
25+
private static Task<int> RunDotnetNewAsync(string args)
26+
=> (RunDotnetNewFunc is not null)
27+
? RunDotnetNewFunc(args)
28+
: new Executable("dotnet", args).RunAsync();
2029

2130
public static void EnsureDotnet()
2231
{
@@ -64,7 +73,18 @@ public static async Task<string> DetermineTargetFramework(string projectDirector
6473
throw new CliException($"Can not determine target framework for dotnet project at ${projectDirectory}");
6574
}
6675

67-
return output.ToString();
76+
// Extract the target framework moniker (TFM) from the output using regex pattern matching
77+
var outputString = output.ToString();
78+
79+
// Look for a line that looks like a target framework moniker
80+
var tfm = TargetFrameworkHelper.TfmRegex.Match(outputString);
81+
82+
if (!tfm.Success)
83+
{
84+
throw new CliException($"Could not parse target framework from output: {outputString}");
85+
}
86+
87+
return tfm.Value;
6888
}
6989

7090
public static async Task DeployDotnetProject(string name, bool force, WorkerRuntime workerRuntime, string targetFramework = "")
@@ -78,7 +98,8 @@ await TemplateOperationAsync(
7898
var connectionString = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
7999
? $"--StorageConnectionStringValue \"{Constants.StorageEmulatorConnectionString}\""
80100
: string.Empty;
81-
var exe = new Executable("dotnet", $"new func {frameworkString} --AzureFunctionsVersion v4 --name {name} {connectionString} {(force ? "--force" : string.Empty)}");
101+
TryGetCustomHiveArg(workerRuntime, out string customHive);
102+
var exe = new Executable("dotnet", $"new func {frameworkString} --AzureFunctionsVersion v4 --name {name} {connectionString} {(force ? "--force" : string.Empty)}{customHive}");
82103
var exitCode = await exe.RunAsync(o => { }, e => ColoredConsole.Error.WriteLine(ErrorColor(e)));
83104
if (exitCode != 0)
84105
{
@@ -109,7 +130,8 @@ await TemplateOperationAsync(
109130
}
110131
}
111132

112-
var exe = new Executable("dotnet", exeCommandArguments);
133+
TryGetCustomHiveArg(workerRuntime, out string customHive);
134+
var exe = new Executable("dotnet", exeCommandArguments + customHive);
113135
string dotnetNewErrorMessage = string.Empty;
114136
var exitCode = await exe.RunAsync(o => { }, e =>
115137
{
@@ -277,119 +299,110 @@ public static string GetCsprojOrFsproj()
277299
}
278300
}
279301

280-
private static async Task TemplateOperationAsync(Func<Task> action, WorkerRuntime workerRuntime)
302+
internal static async Task TemplateOperationAsync(Func<Task> action, WorkerRuntime workerRuntime)
281303
{
282304
EnsureDotnet();
283305

306+
// If we have enabled custom hives (for E2E tests), install templates there and run the action
307+
if (UseCustomTemplateHive())
308+
{
309+
await EnsureTemplatesInCustomHiveAsync(action, workerRuntime);
310+
return;
311+
}
312+
313+
// Default CLI behaviour: Templates are installed globally, so we need to install/uninstall them around the action
284314
if (workerRuntime == WorkerRuntime.DotnetIsolated)
285315
{
286-
await EnsureIsolatedTemplatesInstalled();
316+
await EnsureIsolatedTemplatesInstalledAsync(action);
287317
}
288318
else
289319
{
290-
await EnsureWebJobsTemplatesInstalled();
320+
await EnsureInProcTemplatesInstalledAsync(action);
291321
}
292-
293-
await action();
294322
}
295323

296-
private static async Task EnsureIsolatedTemplatesInstalled()
324+
private static async Task EnsureTemplatesInCustomHiveAsync(Func<Task> action, WorkerRuntime workerRuntime)
297325
{
298-
if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, WebJobsTemplateBasePackId))
299-
{
300-
await UninstallWebJobsTemplates();
301-
}
302-
303-
if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, IsolatedTemplateBasePackId))
326+
// If the custom hive already has templates installed, just run the action and skip installation
327+
string hivePackagesDir = Path.Combine(GetHivePath(workerRuntime), "packages");
328+
if (FileSystemHelpers.EnsureDirectoryNotEmpty(hivePackagesDir))
304329
{
330+
await action();
305331
return;
306332
}
307333

308-
await FileLockHelper.WithFileLockAsync(TemplatesLockFileName, InstallIsolatedTemplates);
334+
// Install only, no need to uninstall as we are using a custom hive
335+
Func<Task> installTemplates = workerRuntime == WorkerRuntime.DotnetIsolated
336+
? InstallIsolatedTemplates
337+
: InstallInProcTemplates;
338+
339+
await installTemplates();
340+
await action();
309341
}
310342

311-
private static async Task EnsureWebJobsTemplatesInstalled()
343+
private static async Task EnsureIsolatedTemplatesInstalledAsync(Func<Task> action)
312344
{
313-
if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, IsolatedTemplateBasePackId))
345+
try
314346
{
315-
await UninstallIsolatedTemplates();
316-
}
347+
// Uninstall any existing webjobs templates, as they conflict with isolated templates
348+
await UninstallInProcTemplates();
317349

318-
if (AreDotnetTemplatePackagesInstalled(await _installedTemplatesList.Value, WebJobsTemplateBasePackId))
350+
// Install the latest isolated templates
351+
await InstallIsolatedTemplates();
352+
await action();
353+
}
354+
finally
319355
{
320-
return;
356+
await UninstallIsolatedTemplates();
321357
}
322-
323-
await FileLockHelper.WithFileLockAsync(TemplatesLockFileName, InstallWebJobsTemplates);
324-
}
325-
326-
internal static bool AreDotnetTemplatePackagesInstalled(HashSet<string> templates, string packageIdPrefix)
327-
{
328-
var hasProjectTemplates = templates.Contains($"{packageIdPrefix}.ProjectTemplates", StringComparer.OrdinalIgnoreCase);
329-
var hasItemTemplates = templates.Contains($"{packageIdPrefix}.ItemTemplates", StringComparer.OrdinalIgnoreCase);
330-
331-
return hasProjectTemplates && hasItemTemplates;
332358
}
333359

334-
private static async Task<HashSet<string>> GetInstalledTemplatePackageIds()
360+
private static async Task EnsureInProcTemplatesInstalledAsync(Func<Task> action)
335361
{
336-
var exe = new Executable("dotnet", "new uninstall", shareConsole: false);
337-
var output = new StringBuilder();
338-
var exitCode = await exe.RunAsync(o => output.AppendLine(o), e => output.AppendLine(e));
362+
try
363+
{
364+
// Uninstall any existing isolated templates, as they conflict with webjobs templates
365+
await UninstallIsolatedTemplates();
339366

340-
if (exitCode != 0)
367+
// Install the latest webjobs templates
368+
await InstallInProcTemplates();
369+
await action();
370+
}
371+
finally
341372
{
342-
throw new CliException("Failed to get list of installed template packages");
373+
await UninstallInProcTemplates();
343374
}
375+
}
344376

345-
var lines = output.ToString()
346-
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
347-
348-
var packageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
349-
350-
const string uninstallPrefix = "dotnet new uninstall ";
377+
private static string[] GetNupkgFiles(string templatesPath)
378+
{
379+
var templatesLocation = Path.Combine(
380+
Path.GetDirectoryName(AppContext.BaseDirectory),
381+
Path.Combine(templatesPath));
351382

352-
foreach (var line in lines)
383+
if (!FileSystemHelpers.DirectoryExists(templatesLocation))
353384
{
354-
var trimmed = line.Trim();
355-
356-
if (trimmed.StartsWith(uninstallPrefix, StringComparison.OrdinalIgnoreCase))
357-
{
358-
var packageId = trimmed.Substring(uninstallPrefix.Length).Trim();
359-
if (!string.IsNullOrWhiteSpace(packageId))
360-
{
361-
packageIds.Add(packageId);
362-
}
363-
}
385+
throw new CliException($"Can't find templates location. Looked under '{templatesLocation}'");
364386
}
365387

366-
return packageIds;
388+
return Directory.GetFiles(templatesLocation, "*.nupkg", SearchOption.TopDirectoryOnly);
367389
}
368390

369-
private static Task UninstallIsolatedTemplates() => DotnetTemplatesAction("uninstall", nugetPackageList: [$"{IsolatedTemplateBasePackId}.ProjectTemplates", $"{IsolatedTemplateBasePackId}.ItemTemplates"]);
391+
private static Task InstallIsolatedTemplates() => DotnetTemplatesAction("install", WorkerRuntime.DotnetIsolated, Path.Combine("templates", $"net-isolated"));
370392

371-
private static Task UninstallWebJobsTemplates() => DotnetTemplatesAction("uninstall", nugetPackageList: [$"{WebJobsTemplateBasePackId}.ProjectTemplates", $"{WebJobsTemplateBasePackId}.ItemTemplates"]);
393+
private static Task UninstallIsolatedTemplates() => DotnetTemplatesAction("uninstall", WorkerRuntime.DotnetIsolated, nugetPackageList: [$"{IsolatedTemplateBasePackId}.ProjectTemplates", $"{IsolatedTemplateBasePackId}.ItemTemplates"]);
372394

373-
private static Task InstallWebJobsTemplates() => DotnetTemplatesAction("install", "templates");
395+
private static Task InstallInProcTemplates() => DotnetTemplatesAction("install", WorkerRuntime.Dotnet, "templates");
374396

375-
private static Task InstallIsolatedTemplates() => DotnetTemplatesAction("install", Path.Combine("templates", $"net-isolated"));
397+
private static Task UninstallInProcTemplates() => DotnetTemplatesAction("uninstall", WorkerRuntime.Dotnet, nugetPackageList: [$"{InProcTemplateBasePackId}.ProjectTemplates", $"{InProcTemplateBasePackId}.ItemTemplates"]);
376398

377-
private static async Task DotnetTemplatesAction(string action, string templateDirectory = null, string[] nugetPackageList = null)
399+
private static async Task DotnetTemplatesAction(string action, WorkerRuntime workerRuntime, string templateDirectory = null, string[] nugetPackageList = null)
378400
{
379401
string[] list;
380402

381403
if (!string.IsNullOrEmpty(templateDirectory))
382404
{
383-
var templatesLocation = Path.Combine(
384-
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
385-
templateDirectory);
386-
387-
if (!FileSystemHelpers.DirectoryExists(templatesLocation))
388-
{
389-
throw new CliException($"Can't find templates location. Looked under '{templatesLocation}'");
390-
}
391-
392-
list = Directory.GetFiles(templatesLocation, "*.nupkg", SearchOption.TopDirectoryOnly);
405+
list = GetNupkgFiles(templateDirectory);
393406
}
394407
else
395408
{
@@ -398,8 +411,9 @@ private static async Task DotnetTemplatesAction(string action, string templateDi
398411

399412
foreach (var nupkg in list)
400413
{
401-
var exe = new Executable("dotnet", $"new {action} \"{nupkg}\"");
402-
await exe.RunAsync();
414+
TryGetCustomHiveArg(workerRuntime, out string customHive);
415+
var args = $"new {action} \"{nupkg}\" {customHive}";
416+
await RunDotnetNewAsync(args);
403417
}
404418
}
405419
}

0 commit comments

Comments
 (0)