diff --git a/release_notes.md b/release_notes.md index 812cbd88f..c4dd31bf9 100644 --- a/release_notes.md +++ b/release_notes.md @@ -13,3 +13,4 @@ - Refactor to use msbuild for determining .NET target framework & add support multiple TFMs (#4715) - When using `func init --docker-only` on a .NET project with multiple target frameworks, the CLI will now prompt the user to select which target framework to use for the Dockerfile. +- Enhanced dotnet installation discovery by adopting the same `Muxer` logic used by the .NET SDK itself (#4732) \ No newline at end of file diff --git a/src/Cli/func/Helpers/DotnetHelpers.cs b/src/Cli/func/Helpers/DotnetHelpers.cs index 70b789a87..49d14fab6 100644 --- a/src/Cli/func/Helpers/DotnetHelpers.cs +++ b/src/Cli/func/Helpers/DotnetHelpers.cs @@ -28,9 +28,15 @@ private static Task RunDotnetNewAsync(string args) public static void EnsureDotnet() { - if (!CommandChecker.CommandExists("dotnet")) + try + { + _ = DotnetMuxer.GetMuxerPath(); + } + catch (InvalidOperationException ex) { - throw new CliException("dotnet sdk is required for dotnet based functions. Please install https://microsoft.com/net"); + throw new CliException( + "Unable to locate the .NET SDK. Install the .NET SDK from https://aka.ms/dotnet-download or configure PATH | DOTNET_ROOT | DOTNET_HOST_PATH so that 'dotnet' is discoverable.", + ex); } } diff --git a/src/Cli/func/Helpers/DotnetMuxer.cs b/src/Cli/func/Helpers/DotnetMuxer.cs new file mode 100644 index 000000000..59dd80c16 --- /dev/null +++ b/src/Cli/func/Helpers/DotnetMuxer.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +// Adapted from: https://github.com/dotnet/sdk/blob/main/src/Cli/Microsoft.DotNet.Cli.Utils/Muxer.cs +using System.Runtime.InteropServices; + +namespace Azure.Functions.Cli.Helpers; + +internal static class DotnetMuxer +{ + private static readonly string _muxerName = "dotnet"; + private static readonly string _exeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; + + /// + /// Locates the dotnet muxer (dotnet executable). + /// + /// The full path to the dotnet executable. + /// Thrown when the dotnet executable cannot be located. + public static string GetMuxerPath() + { + string muxerPath; + + // Most scenarios are running dotnet.dll as the app + // Root directory with muxer should be two above app base: /sdk/ + string rootPath = Path.GetDirectoryName(Path.GetDirectoryName(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar))); + if (rootPath is not null) + { + muxerPath = Path.Combine(rootPath, $"{_muxerName}{_exeSuffix}"); + if (File.Exists(muxerPath)) + { + return muxerPath; + } + } + + // Best-effort search for muxer. + muxerPath = Environment.ProcessPath; + + // The current process should be dotnet in most normal scenarios except when dotnet.dll is loaded in a custom host like the testhost + if (muxerPath is not null && !Path.GetFileNameWithoutExtension(muxerPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + // SDK sets DOTNET_HOST_PATH as absolute path to current dotnet executable + muxerPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + if (muxerPath is null) + { + // fallback to DOTNET_ROOT which typically holds some dotnet executable + string root = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + if (root is not null) + { + muxerPath = Path.Combine(root, $"dotnet{_exeSuffix}"); + } + } + } + + if (muxerPath is null) + { + throw new InvalidOperationException("Unable to locate dotnet multiplexer"); + } + + return muxerPath; + } +} diff --git a/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs b/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs index 625e26c79..ae869a46b 100644 --- a/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs +++ b/test/Cli/Func.UnitTests/HelperTests/DotnetHelpersTests.cs @@ -8,6 +8,14 @@ namespace Azure.Functions.Cli.UnitTests.HelperTests { public class DotnetHelpersTests { + [Fact] + public void EnsureDotnet_DoesNotThrow_WhenDotnetExists() + { + // dotnet is always installed in the test environment + var exception = Record.Exception(() => DotnetHelpers.EnsureDotnet()); + Assert.Null(exception); + } + [Theory] [InlineData("BlobTrigger", "blob")] [InlineData("HttpTrigger", "http")] diff --git a/test/Cli/Func.UnitTests/HelperTests/DotnetMuxerTests.cs b/test/Cli/Func.UnitTests/HelperTests/DotnetMuxerTests.cs new file mode 100644 index 000000000..4bbc9c048 --- /dev/null +++ b/test/Cli/Func.UnitTests/HelperTests/DotnetMuxerTests.cs @@ -0,0 +1,76 @@ +// 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.Helpers; +using Xunit; + +namespace Azure.Functions.Cli.UnitTests.HelperTests +{ + public class DotnetMuxerTests + { + [Fact] + public void GetMuxerPath_ReturnsMuxerPath_WhenDotnetExists() + { + // Act + var path = DotnetMuxer.GetMuxerPath(); + + // Assert + Assert.NotNull(path); + Assert.False(string.IsNullOrWhiteSpace(path)); + } + + [Fact] + public void GetMuxerPath_ContainsDotnetExecutable() + { + // Act + var path = DotnetMuxer.GetMuxerPath(); + + // Assert + Assert.Contains("dotnet", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetMuxerPath_PointsToExecutableFile() + { + // Act + var path = DotnetMuxer.GetMuxerPath(); + + // Assert + // The path should exist as a file + Assert.True( + File.Exists(path) || File.Exists(path + ".exe"), + $"Expected muxer path '{path}' to exist"); + } + + [Fact] + public void GetMuxerPath_PointsToFunctionalDotnetExecutable() + { + // Act + var path = DotnetMuxer.GetMuxerPath(); + + // Assert - invoke dotnet --version to verify it's functional + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = path, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + Assert.NotNull(process); + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + // Assert + Assert.Equal(0, process.ExitCode); + Assert.False(string.IsNullOrWhiteSpace(output), "Expected dotnet --version to produce output"); + + // Verify output looks like a version number (e.g., "8.0.100") + Assert.Matches(@"^\d+\.\d+\.\d+", output.Trim()); + } + } +}