From 663d71961eab22736ad5834f2df6a4644b814017 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Fri, 7 Nov 2025 17:07:23 -0800 Subject: [PATCH 1/5] Different command for tfm --- .../AzureActions/PublishFunctionAppAction.cs | 2 +- .../func/Actions/LocalActions/InitAction.cs | 2 +- src/Cli/func/Helpers/DotnetHelpers.cs | 57 ++++++++++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs b/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs index c52a033bb..b5e26675f 100644 --- a/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs +++ b/src/Cli/func/Actions/AzureActions/PublishFunctionAppAction.cs @@ -175,7 +175,7 @@ public override async Task RunAsync() string projectFilePath = ProjectHelpers.FindProjectFile(functionAppRoot); if (projectFilePath != null) { - var targetFramework = await DotnetHelpers.DetermineTargetFramework(Path.GetDirectoryName(projectFilePath)); + var targetFramework = await DotnetHelpers.DetermineTargetFrameworkAsync(Path.GetDirectoryName(projectFilePath)); var majorDotnetVersion = StacksApiHelper.GetMajorDotnetVersionFromDotnetVersionInProject(targetFramework); diff --git a/src/Cli/func/Actions/LocalActions/InitAction.cs b/src/Cli/func/Actions/LocalActions/InitAction.cs index cc8eba59e..f2aa9d526 100644 --- a/src/Cli/func/Actions/LocalActions/InitAction.cs +++ b/src/Cli/func/Actions/LocalActions/InitAction.cs @@ -419,7 +419,7 @@ private static async Task WriteDockerfile(WorkerRuntime workerRuntime, string la var functionAppRoot = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory); if (functionAppRoot != null) { - targetFramework = await DotnetHelpers.DetermineTargetFramework(functionAppRoot); + targetFramework = await DotnetHelpers.DetermineTargetFrameworkAsync(functionAppRoot); } } diff --git a/src/Cli/func/Helpers/DotnetHelpers.cs b/src/Cli/func/Helpers/DotnetHelpers.cs index f59c3178b..1c7ddec10 100644 --- a/src/Cli/func/Helpers/DotnetHelpers.cs +++ b/src/Cli/func/Helpers/DotnetHelpers.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. -using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using Azure.Functions.Cli.Common; @@ -87,6 +86,62 @@ public static async Task DetermineTargetFramework(string projectDirector return tfm.Value; } + public static async Task DetermineTargetFrameworkAsync(string workingDirectory) + { + EnsureDotnet(); + + var projectFilePath = ProjectHelpers.FindProjectFile(workingDirectory); + + var args = + $"msbuild \"{projectFilePath}\" " + + "-nologo -v:q -restore:false " + + "-getProperty:TargetFrameworks " + + "-getProperty:TargetFramework"; + + var exe = new Executable( + "dotnet", + args, + workingDirectory: workingDirectory, + environmentVariables: new Dictionary + { + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + }); + + var sb = new StringBuilder(); + var exit = await exe.RunAsync(o => sb.Append(o), e => sb.AppendLine(e)); + if (exit != 0) + { + throw new CliException($"Unable to evaluate target frameworks for '{projectFilePath}'."); + } + + var output = sb.ToString(); + + // Just match any valid TFM + var match = TargetFrameworkHelper.TfmRegex.Match(output); + if (!match.Success) + { + throw new CliException($"Could not parse target framework from msbuild output for '{projectFilePath}'. Output:\n{output}"); + } + + var tfm = match.Value; + + // If multiple are in the string, warn + var matches = TargetFrameworkHelper.TfmRegex.Matches(output) + .Cast() + .Select(m => m.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (matches.Length > 1) + { + ColoredConsole.WriteLine( + WarningColor($"[Warning] Multiple target frameworks detected in project '{projectFilePath}': {string.Join(";", matches)}. Using the first: {tfm}")); + } + + return tfm; + } + public static async Task DeployDotnetProject(string name, bool force, WorkerRuntime workerRuntime, string targetFramework = "") { await TemplateOperationAsync( From 4e279768e78d001dd3549aba84b5c1edf3f14b7b Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 12 Nov 2025 11:00:26 -0800 Subject: [PATCH 2/5] Refactor --- src/Cli/func/Helpers/DotnetHelpers.cs | 94 +++++---------------------- 1 file changed, 16 insertions(+), 78 deletions(-) diff --git a/src/Cli/func/Helpers/DotnetHelpers.cs b/src/Cli/func/Helpers/DotnetHelpers.cs index 1c7ddec10..72ed70098 100644 --- a/src/Cli/func/Helpers/DotnetHelpers.cs +++ b/src/Cli/func/Helpers/DotnetHelpers.cs @@ -38,108 +38,46 @@ public static void EnsureDotnet() /// Function that determines TargetFramework of a project even when it's defined outside of the .csproj file, /// e.g. in Directory.Build.props. /// - /// Directory containing the .csproj file. - /// Name of the .csproj file. + /// Directory containing the .csproj file. /// Target framework, e.g. net8.0. /// Unable to determine target framework. - public static async Task DetermineTargetFramework(string projectDirectory, string projectFilename = null) - { - EnsureDotnet(); - if (projectFilename == null) - { - var projectFilePath = ProjectHelpers.FindProjectFile(projectDirectory); - if (projectFilePath != null) - { - projectFilename = Path.GetFileName(projectFilePath); - } - } - - var exe = new Executable( - "dotnet", - $"build {projectFilename} -getproperty:TargetFramework", - workingDirectory: projectDirectory, - environmentVariables: new Dictionary - { - // https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables - ["DOTNET_NOLOGO"] = "1", // do not write disclaimer to stdout - ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", // just in case - }); - - StringBuilder output = new(); - var exitCode = await exe.RunAsync(o => output.Append(o), e => ColoredConsole.Error.WriteLine(ErrorColor(e))); - if (exitCode != 0) - { - throw new CliException($"Can not determine target framework for dotnet project at ${projectDirectory}"); - } - - // Extract the target framework moniker (TFM) from the output using regex pattern matching - var outputString = output.ToString(); - - // Look for a line that looks like a target framework moniker - var tfm = TargetFrameworkHelper.TfmRegex.Match(outputString); - - if (!tfm.Success) - { - throw new CliException($"Could not parse target framework from output: {outputString}"); - } - - return tfm.Value; - } - public static async Task DetermineTargetFrameworkAsync(string workingDirectory) { EnsureDotnet(); - var projectFilePath = ProjectHelpers.FindProjectFile(workingDirectory); + string projectFilePath = ProjectHelpers.FindProjectFile(workingDirectory); - var args = + string args = $"msbuild \"{projectFilePath}\" " + "-nologo -v:q -restore:false " + - "-getProperty:TargetFrameworks " + "-getProperty:TargetFramework"; var exe = new Executable( "dotnet", args, workingDirectory: workingDirectory, - environmentVariables: new Dictionary - { - ["DOTNET_NOLOGO"] = "1", - ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", - }); - - var sb = new StringBuilder(); - var exit = await exe.RunAsync(o => sb.Append(o), e => sb.AppendLine(e)); - if (exit != 0) - { - throw new CliException($"Unable to evaluate target frameworks for '{projectFilePath}'."); - } + environmentVariables: new Dictionary { ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" }); - var output = sb.ToString(); + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); - // Just match any valid TFM - var match = TargetFrameworkHelper.TfmRegex.Match(output); - if (!match.Success) + int exit = await exe.RunAsync(s => stdOut.Append(s), s => stdErr.Append(s)); + if (exit is not 0) { - throw new CliException($"Could not parse target framework from msbuild output for '{projectFilePath}'. Output:\n{output}"); + throw new CliException( + $"Unable to evaluate target framework for '{projectFilePath}'.\nError output:\n{stdErr}"); } - var tfm = match.Value; - - // If multiple are in the string, warn - var matches = TargetFrameworkHelper.TfmRegex.Matches(output) - .Cast() - .Select(m => m.Value) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + string output = stdOut.ToString(); - if (matches.Length > 1) + Match match = TargetFrameworkHelper.TfmRegex.Match(output); + if (!match.Success) { - ColoredConsole.WriteLine( - WarningColor($"[Warning] Multiple target frameworks detected in project '{projectFilePath}': {string.Join(";", matches)}. Using the first: {tfm}")); + throw new CliException( + $"Could not parse target framework from msbuild output for '{projectFilePath}'.\nStdout:\n{output}\nStderr:\n{stdErr}"); } - return tfm; + return match.Value; } public static async Task DeployDotnetProject(string name, bool force, WorkerRuntime workerRuntime, string targetFramework = "") From 089a8612dcc94936f793787ef20c047550d10aea Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 12 Nov 2025 11:02:02 -0800 Subject: [PATCH 3/5] Rename to tfm --- src/Cli/func/Helpers/DotnetHelpers.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Cli/func/Helpers/DotnetHelpers.cs b/src/Cli/func/Helpers/DotnetHelpers.cs index 72ed70098..a57bdcd2a 100644 --- a/src/Cli/func/Helpers/DotnetHelpers.cs +++ b/src/Cli/func/Helpers/DotnetHelpers.cs @@ -70,14 +70,14 @@ public static async Task DetermineTargetFrameworkAsync(string workingDir string output = stdOut.ToString(); - Match match = TargetFrameworkHelper.TfmRegex.Match(output); - if (!match.Success) + Match tfm = TargetFrameworkHelper.TfmRegex.Match(output); + if (!tfm.Success) { throw new CliException( $"Could not parse target framework from msbuild output for '{projectFilePath}'.\nStdout:\n{output}\nStderr:\n{stdErr}"); } - return match.Value; + return tfm.Value; } public static async Task DeployDotnetProject(string name, bool force, WorkerRuntime workerRuntime, string targetFramework = "") From 6e4e6b4af92598dfbe85808e8ef645146c08b931 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 12 Nov 2025 11:12:19 -0800 Subject: [PATCH 4/5] Uddate test --- .../FuncInit/DotnetIsolatedInitTests.cs | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/test/Cli/Func.E2ETests/Commands/FuncInit/DotnetIsolatedInitTests.cs b/test/Cli/Func.E2ETests/Commands/FuncInit/DotnetIsolatedInitTests.cs index defc47dd4..fbe5413b6 100644 --- a/test/Cli/Func.E2ETests/Commands/FuncInit/DotnetIsolatedInitTests.cs +++ b/test/Cli/Func.E2ETests/Commands/FuncInit/DotnetIsolatedInitTests.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using Azure.Functions.Cli.Common; using Azure.Functions.Cli.E2ETests.Traits; using Azure.Functions.Cli.TestFramework.Assertions; using Azure.Functions.Cli.TestFramework.Commands; @@ -127,28 +128,37 @@ public void Init_WithTargetFrameworkAndDockerFlag_GeneratesDockerFile(string tar [InlineData("net10.0")] public async void Init_DockerOnlyOnExistingProjectWithTargetFramework_GeneratesDockerfile(string targetFramework) { - var targetFrameworkstr = targetFramework.Replace("net", string.Empty); - var workingDir = WorkingDirectory; - var testName = nameof(Init_DockerOnlyOnExistingProjectWithTargetFramework_GeneratesDockerfile); - var funcInitCommand = new FuncInitCommand(FuncPath, testName, Log ?? throw new ArgumentNullException(nameof(Log))); - var dockerFilePath = Path.Combine(workingDir, "Dockerfile"); - var expectedDockerfileContent = new[] { $"FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated{targetFrameworkstr}" }; - var filesToValidate = new List<(string FilePath, string[] ExpectedContent)> - { - (dockerFilePath, expectedDockerfileContent) - }; - - // Initialize dotnet-isolated function app using retry helper - await FuncInitWithRetryAsync(testName, [".", "--worker-runtime", "dotnet-isolated", "--target-framework", targetFramework]); + var workingDir = Path.Combine(WorkingDirectory, targetFramework); - var funcInitResult = funcInitCommand - .WithWorkingDirectory(workingDir) - .Execute(["--docker-only"]); - - // Validate expected output content - funcInitResult.Should().ExitWith(0); - funcInitResult.Should().WriteDockerfile(); - funcInitResult.Should().FilesExistsWithExpectContent(filesToValidate); + try + { + var targetFrameworkstr = targetFramework.Replace("net", string.Empty); + FileSystemHelpers.EnsureDirectory(workingDir); + var testName = nameof(Init_DockerOnlyOnExistingProjectWithTargetFramework_GeneratesDockerfile); + var funcInitCommand = new FuncInitCommand(FuncPath, testName, Log ?? throw new ArgumentNullException(nameof(Log))); + var dockerFilePath = Path.Combine(workingDir, "Dockerfile"); + var expectedDockerfileContent = new[] { $"FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated{targetFrameworkstr}" }; + var filesToValidate = new List<(string FilePath, string[] ExpectedContent)> + { + (dockerFilePath, expectedDockerfileContent) + }; + + // Initialize dotnet-isolated function app using retry helper + await FuncInitWithRetryAsync(testName, [".", "--worker-runtime", "dotnet-isolated", "--target-framework", targetFramework]); + + var funcInitResult = funcInitCommand + .WithWorkingDirectory(workingDir) + .Execute(["--docker-only"]); + + // Validate expected output content + funcInitResult.Should().ExitWith(0); + funcInitResult.Should().WriteDockerfile(); + funcInitResult.Should().FilesExistsWithExpectContent(filesToValidate); + } + finally + { + Directory.Delete(workingDir, recursive: true); + } } } } From 220362b208ca2e5c2f3a8b002ca75393d51e82aa Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 12 Nov 2025 13:28:34 -0800 Subject: [PATCH 5/5] Prompt on multiple TFMs --- src/Cli/func/Helpers/DotnetHelpers.cs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Cli/func/Helpers/DotnetHelpers.cs b/src/Cli/func/Helpers/DotnetHelpers.cs index a57bdcd2a..70b789a87 100644 --- a/src/Cli/func/Helpers/DotnetHelpers.cs +++ b/src/Cli/func/Helpers/DotnetHelpers.cs @@ -50,6 +50,7 @@ public static async Task DetermineTargetFrameworkAsync(string workingDir string args = $"msbuild \"{projectFilePath}\" " + "-nologo -v:q -restore:false " + + "-getProperty:TargetFrameworks " + "-getProperty:TargetFramework"; var exe = new Executable( @@ -62,7 +63,7 @@ public static async Task DetermineTargetFrameworkAsync(string workingDir var stdErr = new StringBuilder(); int exit = await exe.RunAsync(s => stdOut.Append(s), s => stdErr.Append(s)); - if (exit is not 0) + if (exit != 0) { throw new CliException( $"Unable to evaluate target framework for '{projectFilePath}'.\nError output:\n{stdErr}"); @@ -70,14 +71,29 @@ public static async Task DetermineTargetFrameworkAsync(string workingDir string output = stdOut.ToString(); - Match tfm = TargetFrameworkHelper.TfmRegex.Match(output); - if (!tfm.Success) + var uniqueTfms = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match m in TargetFrameworkHelper.TfmRegex.Matches(output)) + { + if (m.Success && !string.IsNullOrEmpty(m.Value)) + { + uniqueTfms.Add(m.Value); + } + } + + if (uniqueTfms.Count == 0) { throw new CliException( $"Could not parse target framework from msbuild output for '{projectFilePath}'.\nStdout:\n{output}\nStderr:\n{stdErr}"); } - return tfm.Value; + if (uniqueTfms.Count == 1) + { + return uniqueTfms.First(); + } + + ColoredConsole.WriteLine("Multiple target frameworks detected."); + SelectionMenuHelper.DisplaySelectionWizardPrompt("target framework"); + return SelectionMenuHelper.DisplaySelectionWizard(uniqueTfms.ToArray()); } public static async Task DeployDotnetProject(string name, bool force, WorkerRuntime workerRuntime, string targetFramework = "")